diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a9cc8760..77a01a4fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: enable-cache: true - name: Install system libraries - run: bash scripts/linux/build/dependencies.sh + run: python3 scripts/system_dependencies.py - name: Install the development environment run: uv sync --group dev @@ -52,6 +52,8 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] python: ["3.12", "3.13"] + env: + PYTHON: ${{ matrix.os == 'windows-latest' && 'python' || 'python3' }} steps: - uses: actions/checkout@v7 @@ -63,19 +65,57 @@ jobs: - name: Install system libraries (Linux) if: runner.os == 'Linux' - run: bash scripts/linux/build/dependencies.sh + run: python3 scripts/system_dependencies.py - name: Install system libraries (macOS) if: runner.os == 'macOS' run: | - bash scripts/macos/build/dependencies.sh - bash scripts/macos/build/build_env.sh >> "$GITHUB_ENV" + python3 scripts/system_dependencies.py + python3 scripts/build_environment.py >> "$GITHUB_ENV" - name: Install the development environment + id: environment run: uv sync --group dev - - name: Run doctests - run: uv run python -m pytest src/ --doctest-modules --no-cov + - name: Run the doctests + shell: bash + run: $PYTHON scripts/run_tests.py doctests - - name: Run the unit and integration suites with coverage - run: uv run python -m pytest -n auto --cov + - name: Run the test suite with coverage + if: ${{ !cancelled() && steps.environment.outcome == 'success' }} + shell: bash + run: $PYTHON scripts/run_tests.py suite --workers auto + + screens: + name: Screen scenarios (part ${{ matrix.shard }} of 3, py${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.12", "3.13"] + shard: [1, 2, 3] + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: ${{ matrix.python }} + enable-cache: true + + - name: Install system libraries + run: python3 scripts/system_dependencies.py + + - name: Install the development environment + run: uv sync --group dev + + - name: Run this part of the screen scenarios + id: screens + run: python3 scripts/run_tests.py screens --screen-workers 2 --shard ${{ matrix.shard }}/3 + + - name: Keep what the failed screen scenarios left + if: ${{ failure() && steps.screens.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: screens-py${{ matrix.python }}-part-${{ matrix.shard }} + path: build/screens/ diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index f94d55538..bd22d7aa4 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -32,10 +32,7 @@ jobs: - name: Verify tag matches project version if: startsWith(github.ref, 'refs/tags/v') - run: | - python3 scripts/ci/checks/version_tag.py \ - --tag "$GITHUB_REF_NAME" \ - --project-version "$(uv version --short)" + run: python3 scripts/verify_version_tag.py --tag "$GITHUB_REF_NAME" - name: Build sdist and wheel run: uv build @@ -78,8 +75,8 @@ jobs: - name: Install PortAudio (macOS) if: runner.os == 'macOS' run: | - bash repository/scripts/macos/build/dependencies.sh - bash repository/scripts/macos/build/build_env.sh >> "$GITHUB_ENV" + python3 repository/scripts/system_dependencies.py + python3 repository/scripts/build_environment.py >> "$GITHUB_ENV" - name: Install the wheel and check the entry point shell: bash @@ -87,10 +84,19 @@ jobs: python -m pip install --upgrade pip python -m pip install dist/*.whl sampletones --version + sampletones --help - name: Check the wheel carries every resource it needs at startup shell: bash - run: sampletones --self-check + run: sampletones self-check + + - name: Check a developer command refuses to run outside a checkout + shell: bash + run: | + if refusal=$(sampletones check import-boundary --all 2>&1); then + exit 1 + fi + grep "runs from a checkout" <<< "$refusal" bundle: name: Standalone bundle (${{ matrix.platform }}) @@ -114,38 +120,19 @@ jobs: - name: Install system libraries (Linux) if: runner.os == 'Linux' - run: bash scripts/linux/build/dependencies.sh + run: python scripts/system_dependencies.py - - name: Create the build environment the bundle scripts expect + - name: Build the bundle shell: bash - run: | - python -m venv .venv-build - if [ "$RUNNER_OS" = "Windows" ]; then - venv_python=.venv-build/Scripts/python.exe - else - venv_python=.venv-build/bin/python - fi - "$venv_python" -m pip install --upgrade pip - "$venv_python" -m pip install ".[build]" --group assets - - - name: Build the bundle (Linux) - if: runner.os == 'Linux' - run: bash scripts/linux/build/build.sh --release - - - name: Build the bundle (Windows) - if: runner.os == 'Windows' - shell: cmd - run: scripts\windows\build\build.bat --release + run: python scripts/bundle.py --release - name: Check the bundle runs and carries its notices shell: bash - run: python scripts/ci/checks/bundle.py bin/sampletones + run: python scripts/verify_bundle.py - name: Zip the bundle shell: bash - run: | - name="sampletones-${GITHUB_REF_NAME}-${{ matrix.platform }}" - python scripts/ci/zip_bundle.py bin/sampletones "bundles/${name}.zip" --root "${name}" + run: python scripts/archive_bundle.py --label "${{ matrix.platform }}" - uses: actions/upload-artifact@v7 with: diff --git a/.gitignore b/.gitignore index 585ea73dc..f9ba5ecf8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,15 +8,17 @@ __pycache__/ .venv/ .venv-build/ .vscode/ +.worktrees/ dist/ wheels/ /bin/ /build/ +/bundles/ sampletones !src/sampletones -!tests/sampletones +!tests/unit/sampletones *.pyc *.pyo @@ -49,3 +51,6 @@ sampletones *.exe *.dll *.so + +!tests/data/compatibility/**/*.ins +!tests/data/compatibility/**/*.stn diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0f67e8714..0f642af4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,9 +33,27 @@ repos: - repo: local hooks: + - id: mypy + name: mypy + entry: uv run mypy + language: system + types: [python] + verbose: True + require_serial: true + exclude: ^(tests/) + + - id: pylint + name: pylint + entry: uv run pylint + language: system + types: [python] + verbose: True + require_serial: true + exclude: ^(tests/) + - id: import-boundary name: import boundary - entry: uv run scripts/checks/import_boundary.py --all + entry: uv run sampletones check import-boundary --all language: system types: [python] pass_filenames: false @@ -43,15 +61,23 @@ repos: - id: unused-tags name: unused tags - entry: uv run scripts/checks/unused_tags.py + entry: uv run sampletones check unused-tags language: system types: [python] pass_filenames: false verbose: true + - id: rendered-literals + name: rendered literals + entry: uv run sampletones check rendered-literals + language: system + files: ^tests/.*\.py$ + pass_filenames: false + verbose: true + - id: tag-names name: tag names - entry: uv run scripts/checks/tag_names.py --all + entry: uv run sampletones check tag-names --all language: system types: [python] pass_filenames: false @@ -59,7 +85,7 @@ repos: - id: palette-colors name: palette colors - entry: uv run scripts/checks/palette_colors.py + entry: uv run sampletones check palette-colors language: system files: (^src/sampletones_application/.*\.py|^src/sampletones_config/.*\.yaml)$ pass_filenames: false @@ -67,46 +93,26 @@ repos: - id: language-keys name: language keys - entry: uv run scripts/checks/language_keys.py + entry: uv run sampletones check language-keys language: system files: (\.py|^src/sampletones_config/lang/.*\.yaml)$ pass_filenames: false verbose: true - - id: mypy - name: mypy - entry: uv run mypy - language: system - types: [python] - verbose: True - require_serial: true - exclude: ^(tests/) - - - id: pylint - name: pylint - entry: uv run pylint + - id: shortcut-actions + name: shortcut actions + entry: uv run sampletones check shortcut-actions language: system - types: [python] - verbose: True - require_serial: true - exclude: ^(tests/) + files: (\.py|^src/sampletones_config/keybindings/.*\.yaml)$ + pass_filenames: false + verbose: true - id: icons name: icons - entry: uv run python scripts/assets/icons.py + entry: uv run python -m assets.icons language: system - files: ^src/sampletones_assets/(icons|mark)/ + files: ^(src/sampletones_assets/icons|assets/icons)/ pass_filenames: false verbose: true stages: - pre-push - - - id: pytest - name: pytest - entry: make test - language: system - types: - - python - stages: - - pre-push - pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 607071e29..876af936f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,71 +1,85 @@ -# SampleToNES - -## 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 export. +* Added support for pitch and hi-pitch envelopes. +* Added stem conversion: mix several recordings into one reconstruction, with in-reconstruction stem separation. +* Added voices: samples or pure instruments. +* Improved reconstruction clarity. +* Optimized reconstruction size. +* Optimized algorithm memory usage. +* Improved FamiTracker/Bitphase compatibility. +* Bumped the reconstruction data- version to `2.2`, with backward compatibility for `2.1`. +* Bumped the project data version to `1.1`, with backward compatibility. +* Bumped the library data version to `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/LICENSE b/LICENSE index 5918803cf..119ae7fc2 100644 --- a/LICENSE +++ b/LICENSE @@ -24,7 +24,7 @@ SOFTWARE. The MIT license above covers the SampleToNES source code and the application icons under `src/sampletones_assets/icons/`, which are drawn from the mark -declared in `src/sampletones_assets/mark/`. +declared in `src/sampletones_tools/assets/mark/`. Font files bundled under `src/sampletones_assets/fonts/` are the work of third parties and remain under their own licenses (SIL Open Font License 1.1 and the diff --git a/Makefile b/Makefile index 7ab6b93d5..10a16227d 100644 --- a/Makefile +++ b/Makefile @@ -1,145 +1,98 @@ -.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 system-deps build release run calibration tracker-playback icons demo pictures clean pre-commit test test-docs benchmarks screens lint format ifeq ($(OS),Windows_NT) -ifeq ($(MSYSTEM),) -UNAME_S := Windows +PYTHON := python else -UNAME_S := $(shell uname -s) -endif -else -UNAME_S := $(shell uname -s) -endif - -ifeq ($(UNAME_S),Windows) - SCRIPTS_DIR := scripts/windows - SCRIPT_EXT := .bat - RUN_SCRIPT := - BUILD_SCRIPT := install.bat - EXECUTABLE := sampletones.exe - PYTHON := python -else - SCRIPTS_DIR := scripts/linux - SCRIPT_EXT := .sh - RUN_SCRIPT := bash - BUILD_SCRIPT := ./install.sh - EXECUTABLE := sampletones - PYTHON := python3 -endif - -ifeq ($(UNAME_S),Windows) -script = $(subst /,\,$(SCRIPTS_DIR)/$(1)$(SCRIPT_EXT)) -else -script = $(RUN_SCRIPT) $(SCRIPTS_DIR)/$(1)$(SCRIPT_EXT) +PYTHON := python3 endif -ifeq ($(UNAME_S),Windows) +ifeq ($(OS)$(MSYSTEM),Windows_NT) Q := else Q := " endif -BUILD_COMMAND := $(RUN_SCRIPT) $(BUILD_SCRIPT) -RELEASE_COMMAND := $(RUN_SCRIPT) $(BUILD_SCRIPT) --release -SYSTEM_DEPS_COMMAND := bash scripts/linux/build/dependencies.sh -SETUP_ENV := - -ifeq ($(UNAME_S),Darwin) - MACOS_NO_BUNDLE := bash scripts/macos/build/no_bundle.sh - BUILD_COMMAND := $(MACOS_NO_BUNDLE) 'make build' - RELEASE_COMMAND := $(MACOS_NO_BUNDLE) 'make release' - SYSTEM_DEPS_COMMAND := bash scripts/macos/build/dependencies.sh - SETUP_ENV := ARCHFLAGS="-arch $(shell uname -m)" -endif - GPU ?= auto -GPU_EXTRA := -ifeq ($(filter 0,$(GPU)),) -ifneq ($(filter setup,$(MAKECMDGOALS)),) - GPU_EXTRA := $(shell $(PYTHON) scripts/detect_cuda.py --extra) -endif -endif +DEMO ?= build/demo +PROJECT_OPTIONS = $(foreach project,$(PROJECT),--project $(project)) help: @echo $(Q)Available targets:$(Q) @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 test - Run unit tests with coverage$(Q) - @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) - @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) - @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) + @echo $(Q) make system-deps - Install system packages required to build and run (apt on Debian-based Linux, Homebrew on macOS)$(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 the test suite with coverage$(Q) + @echo $(Q) make test-docs - Run the doctests$(Q) + @echo $(Q) make benchmarks - Run the measured-duration suite$(Q) + @echo $(Q) make screens - Run the screen scenarios: the application driven on a virtual display (needs Xvfb)$(Q) + @echo $(Q) make calibration - Measure reconstruction on the reference sounds; writes the renders and a report$(Q) + @echo $(Q) make tracker-playback BITPHASE=folder FAMITRACKER=FamiTracker.exe [PROJECT=song.stp] - Play exported projects (the corpus by default) in Bitphase, FamiTracker or both; reports every tick that differs from the app$(Q) + @echo $(Q) make icons - Write the application icon suite from the mark into the package$(Q) + @echo $(Q) make demo - Make the demo tree (recordings, a library, reconstructions, a project) in DEMO=build/demo$(Q) + @echo $(Q) make pictures - Draw the guide's and the README's pictures of the application from the demo tree (needs Xvfb)$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) - @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) + @echo $(Q) make lint - Run mypy and pylint (ARGS=--mypy or ARGS=--pylint for one of them)$(Q) @echo $(Q) make format - Auto-format code (isort, black)$(Q) @echo $(Q) make run - Run SampleToNES application$(Q) setup: - $(SETUP_ENV) uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),) - $(MAKE) icons - $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) + $(PYTHON) scripts/setup_environment.py --gpu $(GPU) install: $(MAKE) setup $(MAKE) build build: - $(BUILD_COMMAND) + $(PYTHON) scripts/bundle.py release: - $(RELEASE_COMMAND) + $(PYTHON) scripts/bundle.py --release system-deps: - $(SYSTEM_DEPS_COMMAND) + $(PYTHON) scripts/system_dependencies.py run: uv run sampletones -clean: - $(call script,build/clean) - -pre-commit: - $(call script,dev/pre_commit) - -test: - $(call script,dev/tests) +calibration: + uv run sampletones calibration -ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm -ftm-samples: - uv run python -m pytest tests/integration/famitracker +tracker-playback: + $(if $(BITPHASE)$(FAMITRACKER),,$(error Give BITPHASE=folder, FAMITRACKER=path/to/FamiTracker.exe, or both)) + $(if $(BITPHASE),uv run sampletones tracker-playback bitphase --directory $(BITPHASE) $(PROJECT_OPTIONS)) + $(if $(FAMITRACKER),uv run sampletones tracker-playback famitracker --executable $(FAMITRACKER) $(PROJECT_OPTIONS)) icons: - uv run --group assets python scripts/assets/icons.py + uv run python -m assets.icons -check-import-boundary: - uv run scripts/checks/import_boundary.py --all +demo: + uv run python -m assets.demo -o $(DEMO) -check-tag-names: - uv run scripts/checks/tag_names.py --all +pictures: + uv run python -m assets.pictures -check-unused-tags: - uv run scripts/checks/unused_tags.py +clean: + $(PYTHON) scripts/clean.py -check-language-keys: - uv run scripts/checks/language_keys.py +pre-commit: + $(PYTHON) scripts/hooks.py -check-palette-colors: - uv run scripts/checks/palette_colors.py +test: + $(PYTHON) scripts/run_tests.py suite -calibration: - uv run scripts/calibration.py +test-docs: + $(PYTHON) scripts/run_tests.py doctests -lint: - $(call script,dev/lint) +benchmarks: + $(PYTHON) scripts/run_tests.py benchmarks -pylint: - $(call script,dev/pylint) +screens: + $(PYTHON) scripts/run_tests.py screens -mypy: - $(call script,dev/mypy) +lint: + $(PYTHON) scripts/lint.py $(ARGS) format: - $(call script,dev/format) + $(PYTHON) scripts/formatting.py diff --git a/README.md b/README.md index 9d505bfb6..6fbec4e20 100644 --- a/README.md +++ b/README.md @@ -6,133 +6,68 @@
SampleToNES +

SampleToNES

## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). -SampleToNES +
+ SampleToNES +
The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. A built-in sequencer lets you arrange the reconstructed samples into patterns and play them back inside the application, so you can experiment with the results before exporting the instruments into FamiTracker. -It supports: +With it you can: -* loading common audio formats: WAV, MP3, FLAC, OGG, AIFF, and AU -* a wide range of NES frequencies, from 15 Hz to 300 Hz, including the two most common standards: - * NTSC (60 Hz) - * PAL (50 Hz) -* various sample rates, from 8000 Hz to 192,000 Hz -* restricting the reconstruction to a chosen subset of oscillators: - * `pulse1` - * `pulse2` - * `triangle` - * `noise` -* exporting reconstructed audio as FamiTracker `.fti` instruments, Bitphase `.json` instrument presets, or `.wav` +* convert your own recordings — WAV, MP3, FLAC, OGG, AIFF or AU — into NES instruments +* choose which of the four channels each recording may use, and how loud it plays on them +* arrange the results into a song and play it back in the app +* export instruments and songs for: + * [_FamiTracker_](http://famitracker.com/) + * [_Bitphase_](https://bitphase.app/) + * `.nsf` program + * audio `.wav` file ## Installation -### Standalone bundle - -The easiest way to use _SampleToNES_ is to download the release package for your platform from the -[releases page](https://github.com/JakimPL/SampleToNES/releases), extract it, and run -`sampletones` from inside the extracted folder. On Linux you may need `chmod +x sampletones`. - -### Requirements - -- Windows, macOS, or Linux -- Python 3.12 or newer (https://www.python.org/downloads/) - -### From PyPI - -The quickest way to get the `sampletones` command. Because _SampleToNES_ is an application -rather than a library, installing it into its own isolated environment is recommended: - -```sh -uv tool install sampletones # or: pipx install sampletones -sampletones # launch the GUI -``` - -A plain `pip install sampletones` into an active virtual environment works too. - -To install with GPU support, request the `gpu` extra (see [GPU acceleration](#gpu-acceleration)): - -```sh -uv tool install "sampletones[gpu]" -``` - -On Linux and macOS, audio playback and file dialogs rely on system libraries that come from -the platform's package manager. Install them first: - -```sh -sudo apt-get install libportaudio2 libasound2 python3-tk # Debian/Ubuntu -brew install portaudio # macOS -``` -### Building the executable yourself +You can install _SampleToNES_ in three ways: -You only need Python 3.12. +- **Download a release** for Windows or Linux from the [releases page](https://github.com/JakimPL/SampleToNES/releases), extract it, and start `sampletones`. +- **Install from PyPI** on Windows, macOS or Linux. You need Python 3.12 or newer. On Linux and + macOS, install the audio and file dialog libraries first: -#### Windows + ```sh + sudo apt-get install libportaudio2 libasound2 python3-tk # Debian and Ubuntu + brew install portaudio # macOS + ``` -1. Install Python 3.12. -2. Double-click `install.bat`. It builds `bin\sampletones.exe`. -3. Double-click `bin\sampletones.exe` to start. + Then install the app and start it: -#### Linux + ```sh + uv tool install sampletones # or: pipx install sampletones + sampletones + ``` -1. Install the audio and file-dialog system packages: `make system-deps` (or run `./scripts/linux/build/dependencies.sh`). -2. Install Python 3.12, then run `./install.sh` in a terminal. It builds a `bin/sampletones` executable. -3. Run `./bin/sampletones` to start. +- **Run from source** with Python 3.12 or newer and [uv](https://docs.astral.sh/uv/): `make system-deps`, `make setup`, then `make run`. -### Run from source - -For development. Requires [uv](https://docs.astral.sh/uv/) (and, on Linux, the system packages from the Linux steps above): - -```sh -make setup # create the environment and install the sampletones command -make run # run the app -``` - -To update the global command after pulling new changes, re-run `make setup` (or `uv tool install --force .`). - -### GPU acceleration - -_SampleToNES_ can use an NVIDIA GPU (via [_CuPy_](https://cupy.dev/) and CUDA) to speed up instruction-library generation and reconstruction. `make setup` detects your NVIDIA driver and installs the matching CuPy build automatically: - -```sh -make setup # installs GPU support when a supported driver is present -make setup GPU=0 # forces the CPU (NumPy) backend -``` - -A current NVIDIA driver is all you need — the CUDA components ship with the CuPy build, on Linux and Windows alike. On macOS the app runs on the CPU. +An NVIDIA graphics card can speed up conversion. The [installation guide](https://github.com/JakimPL/SampleToNES/blob/main/docs/guide/installation.md) covers the system libraries each way needs, GPU support, and building a standalone app yourself. ## Usage -### Where your files are stored - -Your configuration, instruction libraries (`.ins`), and reconstructions (`.stn`) live under your documents folder, in `SampleToNES/`: - -- Windows: `C:\Users\\Documents\SampleToNES` -- Linux: `/home//Documents/SampleToNES` -- macOS: `/Users//Documents/SampleToNES` - -### Command line - -You can run without the GUI to use a custom config, generate an instruction library, or reconstruct a file: - -```sh -sampletones --config # run with a custom config -sampletones --generate --config # generate an instruction library -sampletones --config --output # reconstruct an audio file -``` +`sampletones` starts the app. Your work is saved in a `SampleToNES` folder inside your documents +folder. -Run `sampletones --help` for all options. +Every operation is also a named command — `sampletones convert ` reconstructs a +recording without the interface, for example. Run `sampletones --help` for the list, and see the +[command-line guide](https://github.com/JakimPL/SampleToNES/blob/main/docs/guide/command-line.md). ## Documentation -Internals — the reconstruction algorithms, file formats, the Python API, and developer notes — live in [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs). +The [user guide](https://github.com/JakimPL/SampleToNES/tree/main/docs/guide) explains how to use the app, step by step. The rest of [`docs/`](https://github.com/JakimPL/SampleToNES/tree/main/docs) covers the reconstruction algorithms, the file formats, the Python API and the developer notes. ## License diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index ad4a8c25d..5e81183d2 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -4931,6 +4931,46 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +==================================================================================================== +py65 1.2.0 +License: BSD-3-Clause +Project: https://pypi.org/project/py65/ +==================================================================================================== + +---------------------------------------------------------------------------------------------------- +[py65-1.2.0.dist-info/LICENSE.txt] +---------------------------------------------------------------------------------------------------- +BSD 3-Clause License + +Copyright (c) 2008-2024, Mike Naberezny and contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ==================================================================================================== PyAudio 0.2.14 License: MIT License diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 99cd24b32..0d147c3d5 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -112,7 +112,7 @@ CUDA components from their publishers straight to your machine. ## Build-time tooling -The application icons are drawn by `sampletones_assets.mark` and rasterized with +The application icons are drawn by `assets.icons.mark`, a checkout unit, and rasterized with [Pillow](https://pypi.org/project/Pillow/), which is under the [MIT-CMU license](https://github.com/python-pillow/Pillow/blob/main/LICENSE). Pillow belongs to the `assets` dependency group alone, so `pip`/`uv` installs it on the machine that diff --git a/src/sampletones_assets/mark/__init__.py b/assets/__init__.py similarity index 100% rename from src/sampletones_assets/mark/__init__.py rename to assets/__init__.py diff --git a/src/sampletones_core/scripts/__init__.py b/assets/demo/__init__.py similarity index 100% rename from src/sampletones_core/scripts/__init__.py rename to assets/demo/__init__.py diff --git a/assets/demo/__main__.py b/assets/demo/__main__.py new file mode 100644 index 000000000..ec417086a --- /dev/null +++ b/assets/demo/__main__.py @@ -0,0 +1,38 @@ +import sys +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Sequence + +from assets.demo.tree import build_demo + +PROGRAM: Final[str] = "python -m assets.demo" +DESCRIPTION: Final[str] = "make the demo tree: recordings, a library, reconstructions and a project" +OUTPUT_HELP: Final[str] = "the empty folder the tree is written into, created when missing" + + +@dataclass(frozen=True) +class DemoArguments: + """What a demo run is given: the folder the tree is written into.""" + + output: Path + + @classmethod + def parse(cls, argv: Sequence[str]) -> "DemoArguments": + """The arguments ``argv`` states, read by the maker's own parser.""" + parser = ArgumentParser(prog=PROGRAM, description=DESCRIPTION) + parser.add_argument("--output", "-o", type=Path, required=True, help=OUTPUT_HELP) + return cls(output=parser.parse_args(list(argv)).output) + + +def main(argv: Sequence[str]) -> int: + """Writes the demo tree and reports each file produced.""" + given = DemoArguments.parse(argv) + for path in build_demo(given.output): + print(f"Wrote {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones_core/trackers/__init__.py b/assets/demo/config/__init__.py similarity index 100% rename from src/sampletones_core/trackers/__init__.py rename to assets/demo/config/__init__.py diff --git a/assets/demo/config/conversion.yaml b/assets/demo/config/conversion.yaml new file mode 100644 index 000000000..d00a0f420 --- /dev/null +++ b/assets/demo/config/conversion.yaml @@ -0,0 +1,13 @@ +hits: + Kick: [pulse1, triangle, noise] + Snare: [pulse1, noise] +piece: + mode: round_robin + levels: + - [Lead] + - [Drums] + - [Bass] + stems: + Lead: [pulse1, pulse2] + Drums: [triangle, noise] + Bass: [triangle] diff --git a/assets/demo/config/project.yaml b/assets/demo/config/project.yaml new file mode 100644 index 000000000..c5ba0b87b --- /dev/null +++ b/assets/demo/config/project.yaml @@ -0,0 +1,92 @@ +created: 2026-10-01T12:00:00Z +module: + title: Demo song + author: SampleToNES + tempo: 150 + speed: 6 + nes_frequency: 60 +instruments: + Lead: + volume: [12, 11, 10, 9, 8, 7, 7, 6, 6, 5] + arpeggio: [] + duty_cycle: [1] + initial_pitch: 69 + Bass: + volume: [15, 13, 11, 9, 7, 5, 3, 2] + arpeggio: [] + duty_cycle: [] + initial_pitch: 45 +song: + rows_per_pattern: 16 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + - { pulse1: 1, pulse2: 0, triangle: 1, noise: 0 } + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 1 } + - { pulse1: 1, pulse2: 1, triangle: 1, noise: 1 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: Lead, transpose: 3, volume: 12 } + - { row: 2, voice: Lead, transpose: 7 } + - { row: 4, voice: Lead, transpose: 10 } + - { row: 7, voice: Lead, transpose: 7 } + - { row: 8, voice: Lead, transpose: 7 } + - { row: 10, voice: Lead, transpose: 3 } + - { row: 12, voice: Lead, transpose: 0 } + - { row: 15, "off": true } + 1: + - { row: 0, voice: Lead, transpose: 8, volume: 12 } + - { row: 2, voice: Lead, transpose: 12 } + - { row: 4, voice: Lead, transpose: 15 } + - { row: 7, voice: Lead, transpose: 12 } + - { row: 8, voice: Lead, transpose: 10 } + - { row: 10, voice: Lead, transpose: 7 } + - { row: 12, voice: Lead, transpose: 5 } + - { row: 14, voice: Lead, transpose: 3 } + pulse2: + patterns: + 0: + - { row: 0, voice: Lead, transpose: -5, volume: 8 } + - { row: 4, voice: Lead, transpose: -2 } + - { row: 8, voice: Lead, transpose: -5 } + - { row: 12, voice: Lead, transpose: -9 } + 1: + - { row: 0, voice: Lead, transpose: 0, volume: 8 } + - { row: 4, voice: Lead, transpose: 3 } + - { row: 8, voice: Lead, transpose: -2 } + - { row: 12, voice: Lead, transpose: -5 } + triangle: + patterns: + 0: + - { row: 0, voice: Bass, transpose: -9 } + - { row: 2, voice: Bass, transpose: -9 } + - { row: 4, voice: Bass, transpose: -9 } + - { row: 6, voice: Bass, transpose: -2 } + - { row: 8, voice: Bass, transpose: -12 } + - { row: 10, voice: Bass, transpose: -12 } + - { row: 12, voice: Bass, transpose: -12 } + - { row: 14, voice: Bass, transpose: -5 } + 1: + - { row: 0, voice: Bass, transpose: -4 } + - { row: 2, voice: Bass, transpose: -4 } + - { row: 4, voice: Bass, transpose: -4 } + - { row: 6, voice: Bass, transpose: 3 } + - { row: 8, voice: Bass, transpose: -2 } + - { row: 10, voice: Bass, transpose: -2 } + - { row: 12, voice: Bass, transpose: 2 } + - { row: 14, voice: Bass, transpose: 5 } + noise: + patterns: + 0: + - { row: 0, voice: Kick, volume: 15 } + - { row: 4, voice: Snare, volume: 12 } + - { row: 8, voice: Kick, volume: 15 } + - { row: 12, voice: Snare, volume: 12 } + 1: + - { row: 0, voice: Kick, volume: 15 } + - { row: 4, voice: Snare, volume: 12 } + - { row: 6, voice: Kick, volume: 10 } + - { row: 8, voice: Kick, volume: 15 } + - { row: 12, voice: Snare, volume: 12 } + - { row: 14, voice: Snare, volume: 9 } diff --git a/assets/demo/config/recordings.yaml b/assets/demo/config/recordings.yaml new file mode 100644 index 000000000..4b4b50eb0 --- /dev/null +++ b/assets/demo/config/recordings.yaml @@ -0,0 +1,95 @@ +sample_rate: 44100 +tempo: 120.0 +amplitude: 0.8 +hits: + - { name: Kick, voice: kick } + - { name: Snare, voice: snare } +piece: + name: Demo song + beats: 16.0 + stems: + - name: Bass + notes: + - { voice: bass, pitch: 36, start: 0.0, length: 0.5 } + - { voice: bass, pitch: 36, start: 0.5, length: 0.5 } + - { voice: bass, pitch: 36, start: 1.0, length: 0.5 } + - { voice: bass, pitch: 43, start: 1.5, length: 0.5 } + - { voice: bass, pitch: 36, start: 2.0, length: 0.5 } + - { voice: bass, pitch: 36, start: 2.5, length: 0.5 } + - { voice: bass, pitch: 48, start: 3.0, length: 0.5 } + - { voice: bass, pitch: 46, start: 3.5, length: 0.5 } + - { voice: bass, pitch: 33, start: 4.0, length: 0.5 } + - { voice: bass, pitch: 33, start: 4.5, length: 0.5 } + - { voice: bass, pitch: 33, start: 5.0, length: 0.5 } + - { voice: bass, pitch: 40, start: 5.5, length: 0.5 } + - { voice: bass, pitch: 33, start: 6.0, length: 0.5 } + - { voice: bass, pitch: 33, start: 6.5, length: 0.5 } + - { voice: bass, pitch: 45, start: 7.0, length: 0.5 } + - { voice: bass, pitch: 43, start: 7.5, length: 0.5 } + - { voice: bass, pitch: 41, start: 8.0, length: 0.5 } + - { voice: bass, pitch: 41, start: 8.5, length: 0.5 } + - { voice: bass, pitch: 41, start: 9.0, length: 0.5 } + - { voice: bass, pitch: 48, start: 9.5, length: 0.5 } + - { voice: bass, pitch: 41, start: 10.0, length: 0.5 } + - { voice: bass, pitch: 41, start: 10.5, length: 0.5 } + - { voice: bass, pitch: 53, start: 11.0, length: 0.5 } + - { voice: bass, pitch: 51, start: 11.5, length: 0.5 } + - { voice: bass, pitch: 43, start: 12.0, length: 0.5 } + - { voice: bass, pitch: 43, start: 12.5, length: 0.5 } + - { voice: bass, pitch: 43, start: 13.0, length: 0.5 } + - { voice: bass, pitch: 50, start: 13.5, length: 0.5 } + - { voice: bass, pitch: 43, start: 14.0, length: 0.5 } + - { voice: bass, pitch: 43, start: 14.5, length: 0.5 } + - { voice: bass, pitch: 47, start: 15.0, length: 0.5 } + - { voice: bass, pitch: 50, start: 15.5, length: 0.5 } + - name: Lead + notes: + - { voice: lead, pitch: 72, start: 0.0, length: 1.0 } + - { voice: lead, pitch: 76, start: 1.0, length: 1.0 } + - { voice: lead, pitch: 79, start: 2.0, length: 1.5 } + - { voice: lead, pitch: 76, start: 3.5, length: 0.5 } + - { voice: lead, pitch: 76, start: 4.0, length: 1.0 } + - { voice: lead, pitch: 72, start: 5.0, length: 1.0 } + - { voice: lead, pitch: 69, start: 6.0, length: 2.0 } + - { voice: lead, pitch: 77, start: 8.0, length: 1.0 } + - { voice: lead, pitch: 81, start: 9.0, length: 1.0 } + - { voice: lead, pitch: 84, start: 10.0, length: 1.5 } + - { voice: lead, pitch: 81, start: 11.5, length: 0.5 } + - { voice: lead, pitch: 79, start: 12.0, length: 1.0 } + - { voice: lead, pitch: 76, start: 13.0, length: 1.0 } + - { voice: lead, pitch: 74, start: 14.0, length: 1.0 } + - { voice: lead, pitch: 72, start: 15.0, length: 1.0 } + - name: Drums + notes: + - { voice: kick, start: 0.0 } + - { voice: hat, start: 0.5 } + - { voice: snare, start: 1.0 } + - { voice: hat, start: 1.5 } + - { voice: kick, start: 2.0 } + - { voice: hat, start: 2.5 } + - { voice: snare, start: 3.0 } + - { voice: hat, start: 3.5 } + - { voice: kick, start: 4.0 } + - { voice: hat, start: 4.5 } + - { voice: snare, start: 5.0 } + - { voice: hat, start: 5.5 } + - { voice: kick, start: 6.0 } + - { voice: kick, start: 6.5 } + - { voice: snare, start: 7.0 } + - { voice: hat, start: 7.5 } + - { voice: kick, start: 8.0 } + - { voice: hat, start: 8.5 } + - { voice: snare, start: 9.0 } + - { voice: hat, start: 9.5 } + - { voice: kick, start: 10.0 } + - { voice: hat, start: 10.5 } + - { voice: snare, start: 11.0 } + - { voice: hat, start: 11.5 } + - { voice: kick, start: 12.0 } + - { voice: hat, start: 12.5 } + - { voice: snare, start: 13.0 } + - { voice: hat, start: 13.5 } + - { voice: kick, start: 14.0 } + - { voice: kick, start: 14.5 } + - { voice: snare, start: 15.0 } + - { voice: snare, start: 15.5 } diff --git a/assets/demo/config/voices.yaml b/assets/demo/config/voices.yaml new file mode 100644 index 000000000..2fe089b69 --- /dev/null +++ b/assets/demo/config/voices.yaml @@ -0,0 +1,41 @@ +seed: 2026 +voices: + kick: + duration_seconds: 0.45 + layers: + - oscillator: { kind: geometric_sweep, frequency_start: 160.0, frequency_end: 45.0 } + envelopes: [{ kind: exponential_decay, time_constant_seconds: 0.12 }] + gain: 1.0 + filters: [] + snare: + duration_seconds: 0.35 + layers: + - oscillator: { kind: white_noise } + envelopes: [{ kind: exponential_decay, time_constant_seconds: 0.08 }] + gain: 0.7 + - oscillator: { kind: sine, frequency: 180.0 } + envelopes: [{ kind: exponential_decay, time_constant_seconds: 0.05 }] + gain: 0.6 + filters: [] + hat: + duration_seconds: 0.15 + layers: + - oscillator: { kind: white_noise } + envelopes: [{ kind: exponential_decay, time_constant_seconds: 0.03 }] + gain: 0.5 + filters: + - { kind: butterworth_highpass, cutoff_hz: 6000.0, order: 4 } + bass: + duration_seconds: 0.5 + layers: + - oscillator: { kind: pulse, frequency: 36, duty_cycle: 0.5 } + envelopes: [{ kind: exponential_decay, time_constant_seconds: 0.35 }] + gain: 0.8 + filters: [] + lead: + duration_seconds: 0.5 + layers: + - oscillator: { kind: pulse, frequency: 69, duty_cycle: 0.25 } + envelopes: [{ kind: exponential_decay, time_constant_seconds: 0.25 }] + gain: 0.8 + filters: [] diff --git a/assets/demo/paths.py b/assets/demo/paths.py new file mode 100644 index 000000000..1f1226f67 --- /dev/null +++ b/assets/demo/paths.py @@ -0,0 +1,22 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.user import ( + CONFIG_PATH, + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, +) + +CONFIG_DIRECTORY: Final[Path] = package_directory("assets.demo.config") +VOICES_PATH: Final[Path] = CONFIG_DIRECTORY / "voices.yaml" +RECORDINGS_PATH: Final[Path] = CONFIG_DIRECTORY / "recordings.yaml" +CONVERSION_PATH: Final[Path] = CONFIG_DIRECTORY / "conversion.yaml" +PROJECT_PATH: Final[Path] = CONFIG_DIRECTORY / "project.yaml" + +RECORDINGS_FOLDER: Final[str] = "recordings" +LIBRARY_FOLDER: Final[str] = LIBRARY_DIRECTORY.name +RECONSTRUCTIONS_FOLDER: Final[str] = RECONSTRUCTIONS_DIRECTORY.name +PROJECTS_FOLDER: Final[str] = PROJECTS_DIRECTORY.name +CONFIG_FILE: Final[str] = CONFIG_PATH.name diff --git a/assets/demo/render.py b/assets/demo/render.py new file mode 100644 index 000000000..ef92d2317 --- /dev/null +++ b/assets/demo/render.py @@ -0,0 +1,90 @@ +from typing import Final, Mapping + +import numpy as np + +from assets.demo.specification import Note, Stem +from sampletones_core.audio.processing import clip_audio +from sampletones_tools.synthesis.oscillators.pulse import PulseOscillator +from sampletones_tools.synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.oscillators.types import OscillatorUnion +from sampletones_tools.synthesis.voice.voice import Voice + +SECONDS_PER_MINUTE: Final[float] = 60.0 + + +def render_stem( + stem: Stem, + voices: Mapping[str, Voice], + *, + tempo: float, + beats: float, + sample_rate: int, + generator: np.random.Generator, +) -> np.ndarray: + """The stem rendered over the piece's length: every note struck where its beat falls. + + A note sounds its voice at the note's pitch for the note's length, and what runs past the end + of the piece is cut there. Notes draw from the generator in order, so a seeded generator + renders the stem the same every time. + + Args: + stem: The part to render. + voices: The voices the notes strike, by name. + tempo: Beats per minute. + beats: The length of the piece, in beats. + sample_rate: Samples per second. + generator: The random source the voices' noise draws from. + + Returns: + np.ndarray: The unit-scale waveform, float64. + """ + seconds_per_beat = SECONDS_PER_MINUTE / tempo + audio = np.zeros(round(beats * seconds_per_beat * sample_rate), dtype=np.float64) + for note in stem.notes: + rendered = sounding(voices[note.voice], note, seconds_per_beat).render( + sample_rate=sample_rate, + generator=generator, + ) + start = round(note.start * seconds_per_beat * sample_rate) + end = min(audio.shape[0], start + rendered.shape[0]) + audio[start:end] += rendered[: end - start] + + return audio + + +def sounding( + voice: Voice, + note: Note, + seconds_per_beat: float, +) -> Voice: + """The voice as ``note`` strikes it: lasting the note's length, its pitched oscillators at the note's pitch. + + A note stating no length sounds the voice for the voice's own duration, and one stating no + pitch leaves every oscillator at the frequency the voice declares. + """ + duration = voice.duration_seconds if note.length is None else note.length * seconds_per_beat + layers = voice.layers + if note.pitch is not None: + pitch = note.pitch + layers = tuple(layer.model_copy(update={"oscillator": pitched(layer.oscillator, pitch)}) for layer in layers) + + return voice.model_copy(update={"duration_seconds": duration, "layers": layers}) + + +def pitched(oscillator: OscillatorUnion, pitch: int) -> OscillatorUnion: + """The oscillator sounding at the MIDI pitch ``pitch``, where its kind sounds a pitch at all.""" + match oscillator: + case SineOscillator() | PulseOscillator(): + return oscillator.model_copy(update={"frequency": pitch}) + case _: + return oscillator + + +def leveled(audio: np.ndarray, amplitude: float) -> np.ndarray: + """The waveform scaled so its peak reaches ``amplitude``, as float32 ready to write. + + Silence stays silence, since it has no peak to scale by. + """ + peak = float(np.abs(audio).max()) if audio.shape[0] else 0.0 + scaled = audio * (amplitude / peak) if peak > 0.0 else audio + return clip_audio(scaled).astype(np.float32) diff --git a/assets/demo/specification.py b/assets/demo/specification.py new file mode 100644 index 000000000..d7556305c --- /dev/null +++ b/assets/demo/specification.py @@ -0,0 +1,226 @@ +from datetime import datetime +from typing import Dict, Optional, Self, Tuple + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from assets.demo.paths import ( + CONVERSION_PATH, + PROJECT_PATH, + RECORDINGS_PATH, + VOICES_PATH, +) +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import SongSpec +from sampletones_tools.corpus.synth import SynthConfig +from sampletones_tools.synthesis.frequency import PitchSpec + + +class Note(BaseModel): + """One sound in a stem: which voice strikes, when, for how long and at which pitch. + + Attributes: + voice: The voice that sounds, by its name in the voices. + start: When the note starts, in beats from the start of the piece. + length: How long the note lasts, in beats, or ``None`` for the voice's own length. + pitch: The MIDI pitch the voice's pitched oscillators sound at, or ``None`` for their own. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + voice: str + start: float = Field(ge=0.0) + length: Optional[float] = Field(default=None, gt=0.0) + pitch: Optional[PitchSpec] = None + + +class Stem(BaseModel): + """One recording of the piece: a named part made of notes.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + notes: Tuple[Note, ...] = Field(min_length=1) + + +class Hit(BaseModel): + """One recording of a single voice struck once, named as the file it becomes.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + voice: str + + +class Piece(BaseModel): + """The short piece the stems play together: its name, its length and its parts. + + Attributes: + name: The name of the folder the stems are written into, which names their mix. + beats: The length every stem is rendered to, in beats. + stems: The parts, in the order the mix converts them. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + beats: float = Field(gt=0.0) + stems: Tuple[Stem, ...] = Field(min_length=1) + + @model_validator(mode="after") + def _every_note_ends_within_the_piece(self) -> Self: + """Raises: + ValueError: If a note starts past the end of the piece. + """ + late = [note for stem in self.stems for note in stem.notes if note.start >= self.beats] + if late: + raise ValueError(f"{len(late)} notes start past the piece's {self.beats} beats") + + return self + + +class RecordingsSpec(BaseModel): + """What the demo records: the hits, the piece, and the rate, tempo and level they are rendered at. + + Attributes: + sample_rate: The sample rate of every recording. + tempo: The beats per minute the piece's notes are placed at. + amplitude: The peak every recording is scaled to, as a fraction of full scale. + hits: The recordings of one voice struck once. + piece: The piece the stems play together. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + sample_rate: int = Field(gt=0) + tempo: float = Field(gt=0.0) + amplitude: float = Field(gt=0.0, le=1.0) + hits: Tuple[Hit, ...] = Field(min_length=1) + piece: Piece + + +class PieceConversion(BaseModel): + """How the piece's stems are converted together: the channels each may take, and who picks first. + + Attributes: + mode: Whether the levels take channels in turns or one level at a time. + levels: The stems by name, level by level, the first level picking first. + stems: The channels each stem may take, by the stem's name. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + mode: HierarchyMode + levels: Tuple[Tuple[str, ...], ...] = Field(min_length=1) + stems: Dict[str, Tuple[ChannelName, ...]] + + @model_validator(mode="after") + def _every_stem_stands_on_one_level(self) -> Self: + """Raises: + ValueError: If a stem is missing from the levels, or named on more than one. + """ + named = [name for level in self.levels for name in level] + if sorted(named) != sorted(self.stems): + raise ValueError(f"The levels name {named}, where the stems are {sorted(self.stems)}") + + return self + + +class ConversionSpec(BaseModel): + """What each recording is converted with: the channels of each hit, and the setup of the piece.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + hits: Dict[str, Tuple[ChannelName, ...]] + piece: PieceConversion + + +class InstrumentSpec(BaseModel): + """An instrument of the project, as the envelopes it writes and the pitch it measures them from.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + volume: Tuple[int, ...] + arpeggio: Tuple[int, ...] + duty_cycle: Tuple[int, ...] + initial_pitch: int + + +class ProjectSpec(BaseModel): + """The project the demo arranges: its identity, its instruments and the song that plays them. + + Attributes: + created: When the project reads as created and last changed, so a tree made today carries + the dates a tree made any other day carries. + module: The song's title, author and timing. + instruments: The instruments the song plays, by name. + song: The patterns and the order. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + created: datetime + module: ModuleConfig + instruments: Dict[str, InstrumentSpec] + song: SongSpec + + +class DemoSpecification(BaseModel): + """Everything the demo tree is made from, read from the four files the package ships. + + Each file is validated by the model that reads it, and the four are held to each other here: + every note strikes a voice the voices declare, every recording is converted under a setup of + its own, and every voice the song plays is a hit or an instrument. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + voices: SynthConfig + recordings: RecordingsSpec + conversion: ConversionSpec + project: ProjectSpec + + @classmethod + def load(cls) -> Self: + """The specification the package ships.""" + return cls( + voices=load_yaml_model(VOICES_PATH, SynthConfig), + recordings=load_yaml_model(RECORDINGS_PATH, RecordingsSpec), + conversion=load_yaml_model(CONVERSION_PATH, ConversionSpec), + project=load_yaml_model(PROJECT_PATH, ProjectSpec), + ) + + @model_validator(mode="after") + def _every_name_reaches_what_it_names(self) -> Self: + """Raises: + ValueError: If a note or a hit strikes a voice the voices leave out, a recording lacks a + conversion setup, or the song plays a voice the project leaves out. + """ + struck = {hit.voice for hit in self.recordings.hits} | { + note.voice for stem in self.recordings.piece.stems for note in stem.notes + } + unknown = sorted(struck - set(self.voices.voices)) + if unknown: + raise ValueError(f"The recordings strike voices the voices leave out: {unknown}") + + hits = [hit.name for hit in self.recordings.hits] + if sorted(hits) != sorted(self.conversion.hits): + raise ValueError(f"The hits are {hits}, where the conversion names {sorted(self.conversion.hits)}") + + stems = [stem.name for stem in self.recordings.piece.stems] + if sorted(stems) != sorted(self.conversion.piece.stems): + raise ValueError(f"The stems are {stems}, where the conversion names {sorted(self.conversion.piece.stems)}") + + played = { + row.voice + for channel in self.project.song.channels.values() + for rows in channel.patterns.values() + for row in rows + if row.voice is not None + } + unknown = sorted(played - set(hits) - set(self.project.instruments)) + if unknown: + raise ValueError(f"The song plays voices the project leaves out: {unknown}") + + return self diff --git a/assets/demo/tree.py b/assets/demo/tree.py new file mode 100644 index 000000000..5afc0755d --- /dev/null +++ b/assets/demo/tree.py @@ -0,0 +1,226 @@ +from pathlib import Path +from typing import Dict, Iterable, List, Mapping, Tuple + +import numpy as np + +from assets.demo.paths import ( + CONFIG_FILE, + LIBRARY_FOLDER, + PROJECTS_FOLDER, + RECONSTRUCTIONS_FOLDER, + RECORDINGS_FOLDER, +) +from assets.demo.render import leveled, render_stem +from assets.demo.specification import ( + DemoSpecification, + InstrumentSpec, + RecordingsSpec, +) +from sampletones_core.audio.io import write_wave +from sampletones_core.configs import Config +from sampletones_core.features.envelope import Envelope +from sampletones_core.headless.conversion.request import classic_setup +from sampletones_core.headless.conversion.runners import reconstruct_sources +from sampletones_core.headless.library import ensure_library +from sampletones_core.project import ProjectContainer +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.reconstructions import Reconstruction +from sampletones_core.reconstructions.converter.paths.utils import ( + group_output_path, +) +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.configs.settings import ( + StemSettings, +) +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT, EXT_FILE_WAVE +from sampletones_tools.corpus.build import build_project + + +def build_demo(destination: Path) -> Tuple[Path, ...]: + """Writes the demo tree under ``destination`` and returns every file written, in writing order. + + The tree is a documents folder with the recordings beside it: the hits and the stems rendered + from the voices, a configuration whose library and reconstructions folders lie in the tree, the + library that configuration asks for, one reconstruction per hit and one of the whole piece, and + the project arranging the hits and the instruments into a song. Each reconstruction names its + recordings relative to the tree, so the tree reads the same wherever it is put, with the + application run from inside it. + + Raises: + FileExistsError: If the destination already holds files, which a fresh tree would mix with. + """ + specification = DemoSpecification.load() + root = destination.resolve() + _require_empty(root) + written: List[Path] = [] + recordings = _write_recordings(root, specification, written) + config = _write_config(root, written) + ensure_library(config) + written.extend(sorted(config.library_directory.iterdir())) + documents = _convert(root, specification, config, recordings) + _relocate(root, documents.values()) + written.extend(documents.values()) + written.append(_write_project(root, specification, documents)) + return tuple(written) + + +def _require_empty(root: Path) -> None: + if root.exists() and any(root.iterdir()): + raise FileExistsError(f"{root} already holds files; the demo tree is written into an empty folder") + + +def _write_recordings( + root: Path, + specification: DemoSpecification, + written: List[Path], +) -> Dict[str, Path]: + """Renders every hit and every stem to a WAV file, and returns each recording's path by its name.""" + recordings = specification.recordings + voices = specification.voices.voices + generator = np.random.default_rng(specification.voices.seed) + folder = root / RECORDINGS_FOLDER + paths: Dict[str, Path] = {} + for hit in recordings.hits: + audio = voices[hit.voice].render(sample_rate=recordings.sample_rate, generator=generator) + paths[hit.name] = _write_recording(folder / f"{hit.name}{EXT_FILE_WAVE}", audio, recordings) + + piece = recordings.piece + for stem in piece.stems: + audio = render_stem( + stem, + voices, + tempo=recordings.tempo, + beats=piece.beats, + sample_rate=recordings.sample_rate, + generator=generator, + ) + paths[stem.name] = _write_recording( + folder / piece.name / f"{stem.name}{EXT_FILE_WAVE}", + audio, + recordings, + ) + + written.extend(paths.values()) + return paths + + +def _write_recording(path: Path, audio: np.ndarray, recordings: RecordingsSpec) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + write_wave(path, recordings.sample_rate, leveled(audio, recordings.amplitude)) + return path + + +def _write_config(root: Path, written: List[Path]) -> Config: + """Writes the default configuration pointed at the tree's own library and reconstructions folders.""" + config = Config() + general = config.general.model_copy( + update={ + "library_directory": str(root / LIBRARY_FOLDER), + "reconstructions_directory": str(root / RECONSTRUCTIONS_FOLDER), + } + ) + config = config.model_copy(update={"general": general}) + path = root / CONFIG_FILE + config.save(path) + written.append(path) + return config + + +def _convert( + root: Path, + specification: DemoSpecification, + config: Config, + recordings: Mapping[str, Path], +) -> Dict[str, Path]: + """Converts each hit alone and the stems together, and returns each document's path by its name.""" + del root + conversion = specification.conversion + documents: Dict[str, Path] = {} + for hit in specification.recordings.hits: + sources: Tuple[Path, ...] = (recordings[hit.name],) + stems = classic_setup(list(conversion.hits[hit.name])) + reconstruct_sources(sources, config, stems, None) + documents[hit.name] = group_output_path(config, sources, stems.covered_channels) + + piece = specification.recordings.piece + sources = tuple(recordings[stem.name] for stem in piece.stems) + stems = _piece_setup(specification) + reconstruct_sources(sources, config, stems, None) + documents[piece.name] = group_output_path(config, sources, stems.covered_channels) + return documents + + +def _piece_setup(specification: DemoSpecification) -> StemsConfig: + """The setup converting the stems together: one entry per stem in piece order, on the declared levels.""" + conversion = specification.conversion.piece + names = [stem.name for stem in specification.recordings.piece.stems] + entries = tuple( + StemEntry( + id=index, + settings=StemSettings.covering(list(conversion.stems[name])), + ) + for index, name in enumerate(names) + ) + levels = tuple(tuple(names.index(name) for name in level) for level in conversion.levels) + return StemsConfig( + entries=entries, + hierarchy=StemsHierarchy(levels=levels, mode=conversion.mode), + ) + + +def _relocate(root: Path, documents: Iterable[Path]) -> None: + """Rewrites each document to name its recordings relative to the tree, so the tree can be moved whole.""" + for path in documents: + reconstruction = Reconstruction.load(path) + relative = tuple(recording.relative_to(root) for recording in reconstruction.audio_filepath) + relocated = reconstruction.model_copy(update={"stems_data": reconstruction.stems_data.with_sources(relative)}) + relocated.save(path) + + +def _write_project( + root: Path, + specification: DemoSpecification, + documents: Mapping[str, Path], +) -> Path: + """Arranges the hits and the instruments into the song and saves the project, returning its path.""" + catalog: Dict[str, VoiceUnion] = { + hit.name: Sample( + name=hit.name, + reconstruction=Reconstruction.load(documents[hit.name]), + ) + for hit in specification.recordings.hits + } + for name, instrument in specification.project.instruments.items(): + catalog[name] = _instrument(name, instrument) + + module = specification.project.module + project = build_project(catalog, module, specification.project.song) + project.info.created = specification.project.created + project.info.modified = specification.project.created + path = root / PROJECTS_FOLDER / f"{module.title}{EXT_FILE_PROJECT}" + path.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, path) + return path + + +def _instrument(name: str, specification: InstrumentSpec) -> Instrument: + return Instrument( + name=name, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=specification.volume), + arpeggio=Envelope[int](items=specification.arpeggio), + duty_cycle=Envelope[int](items=specification.duty_cycle), + ), + initial_pitch=specification.initial_pitch, + ) diff --git a/src/sampletones_core/trackers/implementation/__init__.py b/assets/icons/__init__.py similarity index 100% rename from src/sampletones_core/trackers/implementation/__init__.py rename to assets/icons/__init__.py diff --git a/assets/icons/__main__.py b/assets/icons/__main__.py new file mode 100644 index 000000000..d1c530dce --- /dev/null +++ b/assets/icons/__main__.py @@ -0,0 +1,50 @@ +import sys +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Sequence + +from assets.icons.mark.specification import Mark +from assets.icons.mark.suite import write_icon_suite +from assets.icons.paths import ICONS_DIRECTORY + +PROGRAM: Final[str] = "python -m assets.icons" +DESCRIPTION: Final[str] = "write the application icon suite from the mark" +OUTPUT_HELP: Final[str] = "the directory receiving the icon files; without it, the icons the package ships" + + +@dataclass(frozen=True) +class IconsArguments: + """What an icon suite run is given: where the files go, if anywhere but the package.""" + + output: Optional[Path] + + @classmethod + def parse(cls, argv: Sequence[str]) -> "IconsArguments": + """The arguments ``argv`` states, read by the maker's own parser.""" + arguments = cls._parser().parse_args(list(argv)) + return cls(output=arguments.output) + + @staticmethod + def _parser() -> ArgumentParser: + parser = ArgumentParser(prog=PROGRAM, description=DESCRIPTION) + parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP) + return parser + + @property + def directory(self) -> Path: + """The directory the suite is written into: the one given, or the one the icons ship from.""" + return self.output if self.output is not None else ICONS_DIRECTORY + + +def main(argv: Sequence[str]) -> int: + """Writes the icon suite and reports each file produced.""" + given = IconsArguments.parse(argv) + for path in write_icon_suite(given.directory, Mark.load()): + print(f"Wrote {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones_shared/meta/source/__init__.py b/assets/icons/mark/__init__.py similarity index 100% rename from src/sampletones_shared/meta/source/__init__.py rename to assets/icons/mark/__init__.py diff --git a/src/sampletones_shared/meta/source/bindings/__init__.py b/assets/icons/mark/config/__init__.py similarity index 100% rename from src/sampletones_shared/meta/source/bindings/__init__.py rename to assets/icons/mark/config/__init__.py diff --git a/src/sampletones_assets/mark/mark.yaml b/assets/icons/mark/config/mark.yaml similarity index 100% rename from src/sampletones_assets/mark/mark.yaml rename to assets/icons/mark/config/mark.yaml diff --git a/src/sampletones_assets/mark/template.svg b/assets/icons/mark/config/template.svg similarity index 100% rename from src/sampletones_assets/mark/template.svg rename to assets/icons/mark/config/template.svg diff --git a/assets/icons/mark/geometry.py b/assets/icons/mark/geometry.py new file mode 100644 index 000000000..5c6646ead --- /dev/null +++ b/assets/icons/mark/geometry.py @@ -0,0 +1,140 @@ +import itertools +from dataclasses import dataclass +from typing import List + +from assets.icons.mark.specification.point import CubicCurve, Point +from assets.icons.mark.specification.waves import MarkSine, MarkSquare + + +@dataclass(frozen=True) +class Rectangle: + """An axis-aligned box in grid units, the shape one segment of the stepped half fills.""" + + left: float + top: float + right: float + bottom: float + + +def _cubic_coordinate( + start: float, + control_start: float, + control_end: float, + end: float, + progress: float, +) -> float: + remainder = 1.0 - progress + return ( + remainder**3 * start + + 3 * remainder**2 * progress * control_start + + 3 * remainder * progress**2 * control_end + + progress**3 * end + ) + + +def _cubic_point( + start: Point, + curve: CubicCurve, + progress: float, +) -> Point: + return Point( + x=_cubic_coordinate( + start.x, + curve.control_start.x, + curve.control_end.x, + curve.end.x, + progress, + ), + y=_cubic_coordinate( + start.y, + curve.control_start.y, + curve.control_end.y, + curve.end.y, + progress, + ), + ) + + +def sine_points(sine: MarkSine, samples: int) -> List[Point]: + """The smooth half as a polyline, sampled evenly along every segment. + + Each segment contributes ``samples`` points, ending on its own end point, so the next + segment starts where the previous one arrived and the polyline runs unbroken from the + wave's start to its handover. + """ + points = [sine.start] + position = sine.start + for curve in sine.curves: + for step in range(1, samples + 1): + points.append(_cubic_point(position, curve, step / samples)) + + position = curve.end + + return points + + +def _direction(delta: float) -> float: + if delta > 0: + return 1.0 + + if delta < 0: + return -1.0 + + return 0.0 + + +def _segment_rectangle( + start: Point, + end: Point, + *, + half_width: float, + joined_start: bool, + joined_end: bool, +) -> Rectangle: + """The stroke rectangle of one axis-aligned segment. + + A joined end reaches half the stroke width past its corner, so consecutive rectangles + fill their right-angle miter; an open end keeps a butt cap. + """ + direction_x = _direction(end.x - start.x) + direction_y = _direction(end.y - start.y) + start_reach = half_width if joined_start else 0.0 + end_reach = half_width if joined_end else 0.0 + + reached_start = ( + start.x - direction_x * start_reach, + start.y - direction_y * start_reach, + ) + reached_end = ( + end.x + direction_x * end_reach, + end.y + direction_y * end_reach, + ) + across_x = half_width * abs(direction_y) + across_y = half_width * abs(direction_x) + + return Rectangle( + left=min(reached_start[0], reached_end[0]) - across_x, + top=min(reached_start[1], reached_end[1]) - across_y, + right=max(reached_start[0], reached_end[0]) + across_x, + bottom=max(reached_start[1], reached_end[1]) + across_y, + ) + + +def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]: + """The stepped half as filled rectangles, one per segment between its corners. + + The rectangles meet at every corner the wave turns at, so the sequence covers the + stroke a vector renderer draws with square joins. + """ + segments = list(itertools.pairwise(square.points)) + final_segment = len(segments) - 1 + return [ + _segment_rectangle( + start, + end, + half_width=width / 2, + joined_start=index > 0, + joined_end=index < final_segment, + ) + for index, (start, end) in enumerate(segments) + ] diff --git a/assets/icons/mark/paths.py b/assets/icons/mark/paths.py new file mode 100644 index 000000000..12f2d7406 --- /dev/null +++ b/assets/icons/mark/paths.py @@ -0,0 +1,8 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory + +MARK_CONFIG_DIRECTORY: Final[Path] = package_directory("assets.icons.mark.config") +MARK_PATH: Final[Path] = MARK_CONFIG_DIRECTORY / "mark.yaml" +TEMPLATE_PATH: Final[Path] = MARK_CONFIG_DIRECTORY / "template.svg" diff --git a/src/sampletones_assets/mark/raster.py b/assets/icons/mark/raster.py similarity index 85% rename from src/sampletones_assets/mark/raster.py rename to assets/icons/mark/raster.py index 7c437fe08..f2ac8c25d 100644 --- a/src/sampletones_assets/mark/raster.py +++ b/assets/icons/mark/raster.py @@ -2,8 +2,8 @@ from PIL import Image, ImageDraw -from sampletones_assets.mark.geometry import sine_points, square_rectangles -from sampletones_assets.mark.specification import Mark +from assets.icons.mark.geometry import sine_points, square_rectangles +from assets.icons.mark.specification import Mark from sampletones_shared.types.application import ColorRGBA from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction @@ -51,11 +51,15 @@ 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) - shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size)) + shaded = Image.composite( + bottom, + top, + Image.linear_gradient("L").resize(size), + ) background = Image.new("RGBA", size, TRANSPARENT) background.paste(shaded, mask=self._frame_mask()) @@ -77,7 +81,10 @@ def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None: what holds the outline smooth along its whole sweep. """ radius = self.mark.waves.width * self.scale / 2 - for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples): + for point in sine_points( + self.mark.waves.sine, + self.mark.render.curve_samples, + ): center_x, center_y = point.x * self.scale, point.y * self.scale draw.ellipse( ( @@ -90,7 +97,10 @@ def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None: ) def _draw_square(self, draw: ImageDraw.ImageDraw) -> None: - for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width): + for rectangle in square_rectangles( + self.mark.waves.square, + self.mark.waves.width, + ): draw.rectangle( ( round(rectangle.left * self.scale), @@ -113,4 +123,7 @@ def _rim(self) -> Image.Image: return overlay def _rim_color(self) -> ColorRGBA: - return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity) + return with_alpha_fraction( + parse_hex_color(self.mark.colors.rim), + self.mark.frame.rim.opacity, + ) diff --git a/assets/icons/mark/specification/__init__.py b/assets/icons/mark/specification/__init__.py new file mode 100644 index 000000000..c29d4d18c --- /dev/null +++ b/assets/icons/mark/specification/__init__.py @@ -0,0 +1,38 @@ +from typing import Self + +from pydantic import BaseModel, Field + +from assets.icons.mark.paths import MARK_PATH +from assets.icons.mark.specification.colors import MarkColors +from assets.icons.mark.specification.frame import MarkFrame +from assets.icons.mark.specification.render import MarkRender +from assets.icons.mark.specification.waves import MarkWaves +from sampletones_shared.utils.serialization import load_yaml_model + + +class Mark(BaseModel, extra="forbid", frozen=True): + """The design definition of the application mark. + + Every shipped icon derives from this one definition — the vector, the raster the + application loads, and the multi-resolution Windows icon — so the mark is drawn from a + single source and stays the same shape at every size. Coordinates are written on the + frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an + icon size. + """ + + frame: MarkFrame = Field(description="The rounded square the mark sits on.") + 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.") + + @classmethod + def load(cls) -> Self: + """Load the packaged mark definition. + + Returns: + The mark validated from `assets/icons/mark/config/mark.yaml`. + + Raises: + TypeError: If the definition file holds anything other than a mapping. + """ + return load_yaml_model(MARK_PATH, cls) diff --git a/assets/icons/mark/specification/colors.py b/assets/icons/mark/specification/colors.py new file mode 100644 index 000000000..8f9820420 --- /dev/null +++ b/assets/icons/mark/specification/colors.py @@ -0,0 +1,29 @@ +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, Field + +from sampletones_shared.utils.color import parse_hex_color + + +def _validate_hex_color(value: str) -> str: + parse_hex_color(value) + return value + + +HexColor = Annotated[str, AfterValidator(_validate_hex_color)] + + +class MarkBackground(BaseModel, extra="forbid", frozen=True): + """The vertical gradient filling 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 colors, written as the hex strings the vector carries.""" + + background: MarkBackground = Field(description="Gradient behind the wave.") + 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/assets/icons/mark/specification/frame.py b/assets/icons/mark/specification/frame.py new file mode 100644 index 000000000..256c0aa9c --- /dev/null +++ b/assets/icons/mark/specification/frame.py @@ -0,0 +1,53 @@ +from typing import Self + +from pydantic import ( + BaseModel, + Field, + PositiveFloat, + PositiveInt, + model_validator, +) + + +class MarkRim(BaseModel, extra="forbid", frozen=True): + """The hairline drawn just inside the frame's edge, lifting it off a dark desktop.""" + + inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.") + width: PositiveFloat = Field(description="Stroke width of the hairline.") + opacity: float = Field( + gt=0.0, + le=1.0, + description="Share of full opacity the hairline is drawn at.", + ) + + +class MarkFrame(BaseModel, extra="forbid", frozen=True): + """The rounded square the mark sits on. + + ``grid`` is the edge length every other coordinate is expressed in, so the whole design + follows from this one number and scales to any icon size. + """ + + grid: PositiveInt = Field(description="Edge length of the design grid.") + corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.") + rim: MarkRim = Field(description="The hairline inside the frame's edge.") + + @property + def rim_radius(self) -> float: + """Corner radius the rim follows, keeping it concentric with the frame.""" + return self.corner_radius - self.rim.inset + + @property + def rim_extent(self) -> float: + """Edge length of the rim's square, inset on both sides.""" + return self.grid - 2 * self.rim.inset + + @model_validator(mode="after") + def _validate_rounding(self) -> Self: + if 2 * self.corner_radius > self.grid: + raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}") + + if self.rim.inset >= self.corner_radius: + raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}") + + return self diff --git a/src/sampletones_assets/mark/specification/point.py b/assets/icons/mark/specification/point.py similarity index 100% rename from src/sampletones_assets/mark/specification/point.py rename to assets/icons/mark/specification/point.py diff --git a/src/sampletones_assets/mark/specification/render.py b/assets/icons/mark/specification/render.py similarity index 100% rename from src/sampletones_assets/mark/specification/render.py rename to assets/icons/mark/specification/render.py diff --git a/src/sampletones_assets/mark/specification/waves.py b/assets/icons/mark/specification/waves.py similarity index 96% rename from src/sampletones_assets/mark/specification/waves.py rename to assets/icons/mark/specification/waves.py index b97a1c35f..005f2c653 100644 --- a/src/sampletones_assets/mark/specification/waves.py +++ b/assets/icons/mark/specification/waves.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, PositiveFloat, model_validator -from sampletones_assets.mark.specification.point import CubicCurve, Point +from assets.icons.mark.specification.point import CubicCurve, Point class MarkSine(BaseModel, extra="forbid", frozen=True): diff --git a/assets/icons/mark/suite.py b/assets/icons/mark/suite.py new file mode 100644 index 000000000..5e32b4fa1 --- /dev/null +++ b/assets/icons/mark/suite.py @@ -0,0 +1,75 @@ +from pathlib import Path +from typing import List, Tuple + +from PIL import Image + +from assets.icons.mark.raster import MarkRaster +from assets.icons.mark.specification import Mark +from assets.icons.mark.vector import render_vector +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + + +def _resized(master: Image.Image, size: int) -> Image.Image: + return master.resize((size, size), Image.Resampling.LANCZOS) + + +def _write_vector(path: Path, mark: Mark) -> Path: + path.write_text(render_vector(mark), encoding="utf-8") + return path + + +def _write_raster(path: Path, master: Image.Image, size: int) -> Path: + _resized(master, size).save(path) + return path + + +def _write_windows_icon( + path: Path, + master: Image.Image, + sizes: Tuple[int, ...], +) -> Path: + """Writes the multi-resolution icon, rendering one frame per declared size. + + Every frame is resampled from the supersampled master, so a 16 px frame carries the + detail the design grid puts there. + """ + primary, *appended = (_resized(master, size) for size in sizes) + primary.save( + path, + format="ICO", + sizes=[(size, size) for size in sizes], + append_images=appended, + ) + return path + + +def write_icon_suite(directory: Path, mark: Mark) -> List[Path]: + """Writes the vector, the raster and the Windows icon the application ships. + + Args: + directory (Path): Directory receiving the icon files, created where it is missing. + mark (Mark): Design definition every file is drawn from. + + Returns: + List[Path]: The files written, in the order they were produced. + """ + directory.mkdir(parents=True, exist_ok=True) + master = MarkRaster(mark).render() + + return [ + _write_vector(directory / ICON_VECTOR_FILENAME, mark), + _write_raster( + directory / ICON_UNIX_FILENAME, + master, + mark.render.raster_size, + ), + _write_windows_icon( + directory / ICON_WIN_FILENAME, + master, + mark.render.windows_sizes, + ), + ] diff --git a/src/sampletones_assets/mark/vector.py b/assets/icons/mark/vector.py similarity index 90% rename from src/sampletones_assets/mark/vector.py rename to assets/icons/mark/vector.py index f9ba412c9..1097b942a 100644 --- a/src/sampletones_assets/mark/vector.py +++ b/assets/icons/mark/vector.py @@ -2,10 +2,10 @@ from string import Template from typing import Dict -from sampletones_assets.mark.paths import TEMPLATE_PATH -from sampletones_assets.mark.specification import Mark -from sampletones_assets.mark.specification.point import Point -from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare +from assets.icons.mark.paths import TEMPLATE_PATH +from assets.icons.mark.specification import Mark +from assets.icons.mark.specification.point import Point +from assets.icons.mark.specification.waves import MarkSine, MarkSquare def _number(value: float) -> str: diff --git a/assets/icons/paths.py b/assets/icons/paths.py new file mode 100644 index 000000000..4d4c72e04 --- /dev/null +++ b/assets/icons/paths.py @@ -0,0 +1,7 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.resources import ICON_DIRECTORY +from sampletones_shared.paths.source import SOURCE_ROOT + +ICONS_DIRECTORY: Final[Path] = SOURCE_ROOT / "sampletones_assets" / ICON_DIRECTORY diff --git a/tests/unit/sampletones_assets/__init__.py b/assets/pictures/__init__.py similarity index 100% rename from tests/unit/sampletones_assets/__init__.py rename to assets/pictures/__init__.py diff --git a/assets/pictures/__main__.py b/assets/pictures/__main__.py new file mode 100644 index 000000000..3d8b43128 --- /dev/null +++ b/assets/pictures/__main__.py @@ -0,0 +1,136 @@ +import os +import sys +import time +from argparse import ArgumentParser +from pathlib import Path +from typing import Dict, Final, List, Optional, Sequence + +import pytest + +from assets.demo.tree import build_demo +from assets.pictures.paths import ( + DEMO_DIRECTORY, + HOMES_DIRECTORY, + IMAGES_DIRECTORY, + KEPT_DIRECTORY, + PICTURE_SUFFIX, + SCENES_DIRECTORY, +) +from assets.pictures.writer import written_since +from automation.environment import ( + HOMES_VARIABLE, + KEPT_VARIABLE, + SCREEN_VARIABLE, +) +from sampletones_shared.paths.source import REPOSITORY_ROOT + +PROGRAM: Final[str] = "python -m assets.pictures" +DESCRIPTION: Final[str] = "draw the guide's and the README's pictures of the application from the demo tree" +SCREEN: Final[str] = "1700x1300" +WORKERS: Final[str] = "4" +HIDDEN_PREFIX: Final[str] = "." +NAMING: Final[Dict[str, str]] = { + "python_files": "*_scenes.py", + "python_classes": "*Scenes", + "python_functions": "picture_*", +} +ALONE_MARKER: Final[str] = "alone" +ALONE_MARKER_WORDS: Final[str] = "a scene drawn with no other scene's home beside its own" +NOTHING_TO_RUN: Final[int] = int(pytest.ExitCode.NO_TESTS_COLLECTED) + + +def pytest_arguments( + scenes: Path, + naming: Dict[str, str], + *, + workers: Optional[str], + selection: str, +) -> List[str]: + """The arguments the scenes run under: their folder, their naming, the marker expression ``selection`` + picks them by, and ``workers`` workers, or one process at a time for ``None``, without coverage. + """ + overrides = [argument for key, value in naming.items() for argument in ("-o", f"{key}={value}")] + spread = ["-n", workers] if workers is not None else [] + return [ + str(scenes), + *overrides, + "-o", + f"markers={ALONE_MARKER}: {ALONE_MARKER_WORDS}", + "-m", + selection, + *spread, + "--no-cov", + "-p", + "no:cacheprovider", + "-q", + ] + + +def run_environment(base: Dict[str, str], *, kept: Path, homes: Path, repository: Path) -> Dict[str, str]: + """The environment the scenes run under: a screen large enough for every picture, and records kept apart. + + The scenario homes go under the repository when its path holds no hidden folder, so the paths the + pictures show read as a home under ``/home``; a checkout under a hidden folder leaves the homes in + the system's temporary folder, which the application's browsers can reach. + """ + environment = dict(base) + environment[SCREEN_VARIABLE] = SCREEN + environment[KEPT_VARIABLE] = str(kept) + if not any(part.startswith(HIDDEN_PREFIX) for part in repository.parts): + environment[HOMES_VARIABLE] = str(homes) + + return environment + + +def main(argv: Sequence[str]) -> int: + """Makes the demo tree where it is missing, draws every scene, and reports each picture written. + + The scenes run on a few workers, and the scenes marked as drawn alone run one at a time after + them, once every other worker has let its homes go: a picture of the file browser shows the + folders beside the scene's home, and a home beside it is named after the run that made it. + """ + ArgumentParser(prog=PROGRAM, description=DESCRIPTION).parse_args(list(argv)) + if not DEMO_DIRECTORY.is_dir(): + build_demo(DEMO_DIRECTORY) + + HOMES_DIRECTORY.mkdir(parents=True, exist_ok=True) + os.environ.update( + run_environment( + dict(os.environ), + kept=KEPT_DIRECTORY, + homes=HOMES_DIRECTORY, + repository=REPOSITORY_ROOT, + ) + ) + started = time.time() + status = int( + pytest.main( + pytest_arguments( + SCENES_DIRECTORY, + NAMING, + workers=WORKERS, + selection=f"not {ALONE_MARKER}", + ) + ) + ) + if status == 0: + alone = int( + pytest.main( + pytest_arguments( + SCENES_DIRECTORY, + NAMING, + workers=None, + selection=ALONE_MARKER, + ) + ) + ) + status = 0 if alone == NOTHING_TO_RUN else alone + + for path in written_since(IMAGES_DIRECTORY, started, PICTURE_SUFFIX): + print(f"Wrote {path}") + + return status + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/assets/pictures/boxes.py b/assets/pictures/boxes.py new file mode 100644 index 000000000..0856f00d9 --- /dev/null +++ b/assets/pictures/boxes.py @@ -0,0 +1,47 @@ +from typing import Sequence, Tuple + +from automation.dearpygui.geometry import Rect + + +def union(rects: Sequence[Rect]) -> Rect: + """The smallest box holding every box in ``rects``. + + Raises: + ValueError: If ``rects`` is empty. + """ + if not rects: + raise ValueError("A union takes at least one box") + + left = min(rect.x for rect in rects) + top = min(rect.y for rect in rects) + right = max(rect.x + rect.width for rect in rects) + bottom = max(rect.y + rect.height for rect in rects) + return Rect(x=left, y=top, width=right - left, height=bottom - top) + + +def corner_box(rects: Sequence[Rect]) -> Rect: + """The box from the frame's top left corner down and across to the farthest edge of ``rects``. + + Raises: + ValueError: If ``rects`` is empty. + """ + held = union(rects) + return Rect(x=0, y=0, width=held.x + held.width, height=held.y + held.height) + + +def crop_box( + rect: Rect, + margin: int, + *, + width: int, + height: int, +) -> Tuple[int, int, int, int]: + """The pixel box ``margin`` pixels around ``rect``, kept inside a frame of ``width`` by ``height``. + + The box reads as an image library takes it: left, top, right and bottom, the last two exclusive. + """ + left = max(0, int(rect.x) - margin) + top = max(0, int(rect.y) - margin) + right = min(width, int(rect.x + rect.width) + margin) + bottom = min(height, int(rect.y + rect.height) + margin) + return left, top, right, bottom diff --git a/assets/pictures/demo.py b/assets/pictures/demo.py new file mode 100644 index 000000000..1b36c1136 --- /dev/null +++ b/assets/pictures/demo.py @@ -0,0 +1,49 @@ +from pathlib import Path +from typing import Final + +from assets.demo.paths import ( + PROJECTS_FOLDER, + RECONSTRUCTIONS_FOLDER, + RECORDINGS_FOLDER, +) +from assets.demo.specification import DemoSpecification +from sampletones_shared.paths.extensions import ( + EXT_FILE_PROJECT, + EXT_FILE_RECONSTRUCTION, + EXT_FILE_WAVE, +) +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS + +SPECIFICATION: Final[DemoSpecification] = DemoSpecification.load() +PIECE: Final[str] = SPECIFICATION.recordings.piece.name +PROJECT: Final[str] = SPECIFICATION.project.module.title + + +def recordings_folder() -> Path: + """The demo's recordings, laid beside the working directory the application was started in.""" + return Path.cwd() / RECORDINGS_FOLDER + + +def piece_folder() -> Path: + """The folder holding the piece's stems, inside the recordings.""" + return recordings_folder() / PIECE + + +def hit_recording(name: str) -> Path: + """The recording of the hit ``name``.""" + return recordings_folder() / f"{name}{EXT_FILE_WAVE}" + + +def stem_recording(name: str) -> Path: + """The recording of the piece's stem ``name``.""" + return piece_folder() / f"{name}{EXT_FILE_WAVE}" + + +def piece_document() -> Path: + """The piece's reconstruction in the home's documents folder, under its configuration folder.""" + return next((USER_PATH_DOCUMENTS / RECONSTRUCTIONS_FOLDER).rglob(f"{PIECE}{EXT_FILE_RECONSTRUCTION}")) + + +def project_document() -> Path: + """The demo project in the home's documents folder.""" + return USER_PATH_DOCUMENTS / PROJECTS_FOLDER / f"{PROJECT}{EXT_FILE_PROJECT}" diff --git a/assets/pictures/frames.py b/assets/pictures/frames.py new file mode 100644 index 000000000..4fe50b10e --- /dev/null +++ b/assets/pictures/frames.py @@ -0,0 +1,18 @@ +from typing import Final + +import numpy as np +from PIL import Image + +RGB_CHANNELS: Final[int] = 3 +FULL_SCALE: Final[float] = 255.0 +IMAGE_MODE: Final[str] = "RGB" + + +def to_image(pixels: np.ndarray) -> Image.Image: + """The frame as an image: the red, green and blue fractions of every pixel rounded to eight bits. + + Args: + pixels: The frame as DearPyGui hands it over, rows of red, green, blue and alpha fractions. + """ + levels = np.clip(np.rint(pixels[:, :, :RGB_CHANNELS] * FULL_SCALE), 0, FULL_SCALE).astype(np.uint8) + return Image.fromarray(levels, IMAGE_MODE) diff --git a/assets/pictures/jitter.py b/assets/pictures/jitter.py new file mode 100644 index 000000000..cffde630c --- /dev/null +++ b/assets/pictures/jitter.py @@ -0,0 +1,48 @@ +from typing import Final + +import numpy as np +from PIL import Image + +JITTER_FRACTION: Final[float] = 1e-3 +BLOCK: Final[int] = 3 +JITTER_BLOCK_PIXELS: Final[int] = 3 + + +def is_jitter(kept: Image.Image, drawn: Image.Image) -> bool: + """Whether ``drawn`` differs from ``kept`` only by the renderer's jitter, so the kept picture stands. + + A software renderer lands an anti-aliased edge a fraction of a pixel differently from one process + to the next, which flips a scatter of single pixels along text and plot edges. A change to the + interface alters a region instead: a glyph, a control or a line moves as a block. The two are told + apart by density: jitter leaves every 3 by 3 block of the frame with a few changed pixels at most, + and keeps the changed pixels a small fraction of the whole. + """ + if kept.size != drawn.size: + return False + + changed = np.any( + np.asarray(kept.convert("RGB")) != np.asarray(drawn.convert("RGB")), + axis=2, + ) + if not changed.any(): + return True + + if changed.mean() > JITTER_FRACTION: + return False + + return int(block_counts(changed).max()) <= JITTER_BLOCK_PIXELS + + +def block_counts(changed: np.ndarray) -> np.ndarray: + """How many changed pixels each 3 by 3 block centered on a pixel holds, at every pixel of the frame.""" + padded = np.pad(changed.astype(np.int32), BLOCK // 2) + height, width = changed.shape + counts = np.zeros((height, width), dtype=np.int32) + for row_offset in range(BLOCK): + for column_offset in range(BLOCK): + counts += padded[ + row_offset : row_offset + height, + column_offset : column_offset + width, + ] + + return counts diff --git a/assets/pictures/paths.py b/assets/pictures/paths.py new file mode 100644 index 000000000..4d2f1f97c --- /dev/null +++ b/assets/pictures/paths.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.source import REPOSITORY_ROOT + +PICTURE_SUFFIX: Final[str] = ".webp" +IMAGES_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "docs" / "images" +GUIDE_DIRECTORY: Final[Path] = IMAGES_DIRECTORY / "guide" +README_PICTURE: Final[Path] = IMAGES_DIRECTORY / f"sampletones{PICTURE_SUFFIX}" +DEMO_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "demo" +KEPT_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "pictures" +HOMES_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "worlds" / "homes" +SCENES_DIRECTORY: Final[Path] = package_directory("assets.pictures.scenes") + + +def guide_picture(page: str, name: str) -> Path: + """Where the picture ``name`` of the guide page ``page`` is kept.""" + return GUIDE_DIRECTORY / page / f"{name}{PICTURE_SUFFIX}" diff --git a/tests/unit/sampletones_assets/mark/__init__.py b/assets/pictures/scenes/__init__.py similarity index 100% rename from tests/unit/sampletones_assets/mark/__init__.py rename to assets/pictures/scenes/__init__.py diff --git a/assets/pictures/scenes/configuration_scenes.py b/assets/pictures/scenes/configuration_scenes.py new file mode 100644 index 000000000..419e30ddc --- /dev/null +++ b/assets/pictures/scenes/configuration_scenes.py @@ -0,0 +1,35 @@ +from typing import Final + +from assets.pictures.paths import guide_picture +from assets.pictures.writer import Pictures +from automation.screen import Screen +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.main import ( + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_CONFIG_PANEL, +) + +PAGE: Final[str] = "configuration" + + +class ConfigurationScenes: + """The settings cards of the Main tab: General settings as the tab opens, and Advanced settings once shown.""" + + def picture_general_settings(self, screen: Screen, pictures: Pictures) -> None: + pictures.rest() + + pictures.around(guide_picture(PAGE, "general-settings"), TAG_MAIN_CONFIG_PANEL) + + def picture_advanced_settings(self, screen: Screen, pictures: Pictures) -> None: + screen.menu.choose( + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS, + ) + screen.expect( + screen.main.advanced.is_shown, + bool, + description="the Advanced settings card", + ) + pictures.rest() + + pictures.around(guide_picture(PAGE, "advanced-settings"), TAG_MAIN_ADVANCED_PANEL) diff --git a/assets/pictures/scenes/conftest.py b/assets/pictures/scenes/conftest.py new file mode 100644 index 000000000..6378d2bd8 --- /dev/null +++ b/assets/pictures/scenes/conftest.py @@ -0,0 +1,63 @@ +import pytest + +from assets.pictures.worlds import GUIDE_VIEWPORT, demo_world +from assets.pictures.writer import Pictures +from automation.plugin.fixtures import ( + output_device_fixture, + screen_application_fixture, + screen_boundaries_fixture, + screen_bridge_fixture, + screen_fixture, + screen_render_thread_fixture, + startup_fixture, +) +from automation.plugin.hold_fixtures import ( + conversion_hold_fixture, + export_hold_fixture, + regeneration_hold_fixture, + scan_hold_fixture, + screen_holds_fixture, +) +from automation.plugin.hooks import ( + pytest_collection_modifyitems, + pytest_configure, + pytest_pyfunc_call, + pytest_runtest_protocol, + pytest_unconfigure, +) +from automation.screen import Screen +from automation.worlds.home import World + +__all__ = [ + "conversion_hold_fixture", + "export_hold_fixture", + "output_device_fixture", + "pictures_fixture", + "pytest_collection_modifyitems", + "pytest_configure", + "pytest_pyfunc_call", + "pytest_runtest_protocol", + "pytest_unconfigure", + "regeneration_hold_fixture", + "scan_hold_fixture", + "screen_application_fixture", + "screen_boundaries_fixture", + "screen_bridge_fixture", + "screen_fixture", + "screen_holds_fixture", + "screen_render_thread_fixture", + "startup_fixture", + "world_fixture", +] + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """A home holding the demo tree, at the window size the guide's pictures are drawn at.""" + return demo_world(GUIDE_VIEWPORT) + + +@pytest.fixture(name="pictures") +def pictures_fixture(screen: Screen) -> Pictures: + """What a scene writes its pictures with.""" + return Pictures(screen) diff --git a/assets/pictures/scenes/converting_scenes.py b/assets/pictures/scenes/converting_scenes.py new file mode 100644 index 000000000..cf7af00aa --- /dev/null +++ b/assets/pictures/scenes/converting_scenes.py @@ -0,0 +1,211 @@ +from functools import partial +from pathlib import Path +from typing import Final + +import pytest + +from assets.pictures.demo import ( + SPECIFICATION, + hit_recording, + piece_folder, + recordings_folder, + stem_recording, +) +from assets.pictures.paths import guide_picture +from assets.pictures.writer import Pictures +from automation.screen import Screen +from automation.steps.instructions import load_library +from automation.steps.main import ( + RUN_TIMEOUT_SECONDS, + choose_from_the_row_menu, + explorer_row, + gather, +) +from automation.views.prompts import MissingPromptError +from automation.worlds.home import one_worker_config +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.general import TAG_GLOBAL_CONTEXT_WINDOW +from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_SPECTRUM, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM, + TAG_INSTRUCTIONS_LIBRARY_PANEL, +) +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_SOURCE_PANEL, +) +from sampletones_core.constants.enums import ChannelName, GeneratorName +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS + +PAGE: Final[str] = "converting" +ISOLATE: Final[str] = "main.converter.label.context_isolate" +KICK: Final[str] = "Kick" +DOCUMENTS: Final[str] = USER_PATH_DOCUMENTS.parent.name +SNARE: Final[str] = "Snare" +LEAD: Final[str] = "Lead" + + +def open_folder(screen: Screen, path: Path) -> None: + """Opens the explorer's row of the folder at ``path`` where it stands closed.""" + set_folder(screen, path, open=True) + + +def close_folder(screen: Screen, path: Path) -> None: + """Closes the explorer's row of the folder at ``path`` where it stands open.""" + set_folder(screen, path, open=False) + + +def set_folder(screen: Screen, path: Path, *, open: bool) -> None: + """Clicks the explorer's row of the folder at ``path`` where it stands other than ``open`` asks.""" + row = explorer_row(screen, path) + if screen.explorer.is_open(row) != open: + screen.explorer.open_by_click(row) + screen.expect( + partial(screen.explorer.is_open, row), + open.__eq__, + description=f"{path.name} {'open' if open else 'closed'}", + ) + + +def gather_the_stems(screen: Screen) -> None: + """Gathers the piece's stems into one mix, on the levels the demo's conversion gives them. + + The stems are gathered in the order their levels pick, which puts them all on the first level, + and each later level's stem is then put on a level of its own, last level first, so the levels + stand in the demo's order. Each level of the demo holds one stem. + """ + levels = SPECIFICATION.conversion.piece.levels + open_folder(screen, recordings_folder()) + open_folder(screen, piece_folder()) + gather(screen, *(stem_recording(name) for level in levels for name in level)) + screen.main.choose_output(OutputKind.MIXED) + screen.expect( + screen.main.converter.list.has_levels, + bool, + description="the levels drawn", + ) + for level in reversed(levels[1:]): + for name in level: + choose_from_the_row_menu(screen, stem_recording(name), screen.words(ISOLATE)) + + screen.expect( + screen.main.converter.list.level_count, + len(levels).__eq__, + description="a level per stem", + ) + + +class ConvertingScenes: + """The Main tab at work: the Filesystem browser with a recording's menu, the Converter card holding a mix, + the Source settings card, a finished conversion, and the Instructions tab with a tone drawn. + + The browser lists the folders beside the home as well, under the home's own rows, and their names + belong to the machine, so the browser's picture keeps them out: the home's last recording stands at + the bottom of the view, which leaves at most a few rows below it, all of them folders of the checkout + whose short names end before the picture's left edge, and the scene is drawn alone, once every other + scene's home, named after the run that made it, has gone. The piece's folder stands open so the + home's rows reach above the menu, which the window's bottom pushes up. + + The finished conversion takes the hit on every channel, which keeps its result apart from the one + the demo tree holds, so the run ends with the question about loading it and no question about + replacing a file. + """ + + @pytest.mark.alone + def picture_filesystem_menu(self, screen: Screen, pictures: Pictures) -> None: + close_folder(screen, Path.cwd() / DOCUMENTS) + open_folder(screen, recordings_folder()) + open_folder(screen, piece_folder()) + home = explorer_row(screen, Path.cwd()) + kick = explorer_row(screen, hit_recording(KICK)) + snare = explorer_row(screen, hit_recording(SNARE)) + screen.hand.scroll_into_view(home) + screen.hand.scroll_to_bottom(snare) + screen.explorer.right_click_beside(kick) + screen.expect( + screen.context_menu.is_shown, + bool, + description="the recording's menu", + ) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "filesystem-menu"), + home, + snare, + TAG_GLOBAL_CONTEXT_WINDOW, + ) + + def picture_converter_levels(self, screen: Screen, pictures: Pictures) -> None: + gather_the_stems(screen) + screen.hand.scroll_into_view(TAG_MAIN_CONVERTER_PANEL) + pictures.rest() + + pictures.around(guide_picture(PAGE, "converter-levels"), TAG_MAIN_CONVERTER_PANEL) + + def picture_source_settings(self, screen: Screen, pictures: Pictures) -> None: + listing = screen.main.converter.list + open_folder(screen, recordings_folder()) + open_folder(screen, piece_folder()) + lead = stem_recording(LEAD) + gather(screen, lead) + listing.pick(lead) + screen.expect(partial(listing.is_picked, lead), bool, description=f"{LEAD} picked") + screen.hand.scroll_into_view(TAG_MAIN_SOURCE_PANEL) + pictures.rest() + + pictures.around(guide_picture(PAGE, "source-settings"), TAG_MAIN_SOURCE_PANEL) + + def picture_converter_done(self, screen: Screen, pictures: Pictures) -> None: + converter = screen.main.converter + open_folder(screen, recordings_folder()) + kick = hit_recording(KICK) + gather(screen, kick) + converter.list.tick(kick, ChannelName.PULSE2) + screen.expect( + partial(converter.list.channel_ticked, kick, ChannelName.PULSE2), + bool, + description=f"{KICK} on every channel", + ) + converter.press_action() + screen.bridge.expect( + converter.end_prompt.is_shown, + bool, + description="the run's end", + timeout=RUN_TIMEOUT_SECONDS, + ) + prompt = converter.end_prompt.window() + if prompt is None: + raise MissingPromptError("The run's end asks nothing") + + screen.hand.scroll_into_view(TAG_MAIN_CONVERTER_PANEL) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "converter-done"), + TAG_MAIN_CONVERTER_PANEL, + prompt.alias, + ) + converter.end_prompt.cancel() + + def picture_instructions(self, screen: Screen, pictures: Pictures) -> None: + load_library(screen, one_worker_config()) + tree = screen.instructions.library.tree + row = screen.expect_item( + partial(tree.generator_row, GeneratorName.PULSE), + description="the pulse row", + ) + tree.click(row) + screen.expect( + screen.instructions.waveform.series, + bool, + description="the pulse drawn", + ) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "instructions"), + TAG_INSTRUCTIONS_LIBRARY_PANEL, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_SPECTRUM, + ) diff --git a/assets/pictures/scenes/interface_scenes.py b/assets/pictures/scenes/interface_scenes.py new file mode 100644 index 000000000..08f3e3204 --- /dev/null +++ b/assets/pictures/scenes/interface_scenes.py @@ -0,0 +1,26 @@ +from typing import Final + +from assets.pictures.paths import guide_picture +from assets.pictures.writer import Pictures +from automation.screen import Screen +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + TAG_GLOBAL_PANEL_PLAYER, + TAG_GLOBAL_TAB_INSTRUCTIONS, +) +from sampletones_application.tags.player import SUF_PLAYER_STOP + +PAGE: Final[str] = "interface" +STOP_BUTTON: Final[str] = compose_tag(TAG_GLOBAL_PANEL_PLAYER, SUF_PLAYER_STOP) + + +class InterfaceScenes: + """The strip along the top of the window: the menus, the play controls beside them, and the four tabs.""" + + def picture_tabs(self, screen: Screen, pictures: Pictures) -> None: + pictures.rest() + pictures.corner( + guide_picture(PAGE, "tabs"), + STOP_BUTTON, + TAG_GLOBAL_TAB_INSTRUCTIONS, + ) diff --git a/assets/pictures/scenes/readme_scenes.py b/assets/pictures/scenes/readme_scenes.py new file mode 100644 index 000000000..d2a05bb30 --- /dev/null +++ b/assets/pictures/scenes/readme_scenes.py @@ -0,0 +1,43 @@ +from typing import Final + +import pytest + +from assets.pictures.demo import piece_document +from assets.pictures.paths import README_PICTURE +from assets.pictures.worlds import README_VIEWPORT, demo_world +from assets.pictures.writer import Pictures +from automation.screen import Screen +from automation.steps.reconstructions import expect_open, load_from_the_browser +from automation.worlds.home import World +from sampletones_core.constants.enums import ChannelName + +WAVEFORM_POINT: Final[float] = 0.6 +SETTLE_FRAMES: Final[int] = 10 + + +class ReadmeScenes: + """The picture the README opens with: the piece open on the Reconstruction tab, its stems with their levels + folded away so every recording shows, its instruments beside the waveform, and the pointer resting on the + waveform so the status bar reads its hint. + """ + + @pytest.fixture(name="world") + def world_fixture(self) -> World: + """A home holding the demo tree, at the window size the README shows at half scale.""" + return demo_world(README_VIEWPORT) + + def picture_window(self, screen: Screen, pictures: Pictures) -> None: + document = piece_document() + load_from_the_browser(screen, document) + expect_open(screen, document) + screen.reconstructions.instruments.bring_forward(ChannelName.PULSE1) + screen.reconstructions.stems.toggle_levels() + screen.expect( + screen.reconstructions.stems.levels_collapsed, + bool, + description="the levels collapsed", + ) + screen.hand.move_to(screen.reconstructions.waveform.point(WAVEFORM_POINT)) + screen.frames(SETTLE_FRAMES) + + pictures.window(README_PICTURE) diff --git a/assets/pictures/scenes/reconstruction_scenes.py b/assets/pictures/scenes/reconstruction_scenes.py new file mode 100644 index 000000000..d6f2652fe --- /dev/null +++ b/assets/pictures/scenes/reconstruction_scenes.py @@ -0,0 +1,101 @@ +from typing import Final + +from assets.pictures.demo import piece_document +from assets.pictures.paths import guide_picture +from assets.pictures.writer import Pictures +from automation.screen import Screen +from automation.steps.reconstructions import expect_open, load_from_the_browser +from automation.views.instruments import tab +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_BROWSER_PANEL, + TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS, +) +from sampletones_core.constants.enums import ChannelName + +PAGE: Final[str] = "reconstruction" +BY_SAMPLE: Final[str] = "global.browser.label.by_sample" + + +def open_the_piece(screen: Screen) -> None: + """Opens the piece's reconstruction from the browser and waits for it to stand open.""" + document = piece_document() + load_from_the_browser(screen, document) + expect_open(screen, document) + + +class ReconstructionScenes: + """The Reconstruction tab with the piece open: the browser, each card, and the menu the piece is exported + from. + """ + + def picture_browser(self, screen: Screen, pictures: Pictures) -> None: + open_the_piece(screen) + browser = screen.reconstructions.browser + last_heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_SAMPLE)), + description="the heading listing reconstructions by sample", + ) + card = screen.main.card(TAG_RECONSTRUCTIONS_BROWSER_PANEL) + pictures.rest() + + pictures.around(guide_picture(PAGE, "browser"), card.strip, last_heading) + + def picture_source(self, screen: Screen, pictures: Pictures) -> None: + open_the_piece(screen) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "source"), + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, + ) + + def picture_waveform(self, screen: Screen, pictures: Pictures) -> None: + open_the_piece(screen) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "waveform"), + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, + ) + + def picture_stems(self, screen: Screen, pictures: Pictures) -> None: + open_the_piece(screen) + screen.hand.scroll_into_view(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "stems"), + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS, + ) + + def picture_instruments(self, screen: Screen, pictures: Pictures) -> None: + open_the_piece(screen) + instruments = screen.reconstructions.instruments + instruments.bring_forward(ChannelName.PULSE1) + screen.expect( + instruments.front, + tab(ChannelName.PULSE1).__eq__, + description="Pulse 1 in front", + ) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "instruments"), + TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, + ) + + def picture_export_menu(self, screen: Screen, pictures: Pictures) -> None: + open_the_piece(screen) + pictures.rest() + + pictures.popup( + guide_picture(PAGE, "export-menu"), + lambda: screen.menu.open_submenu( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.GROUP_RECONSTRUCTION_EXPORT_INSTRUMENTS, + ), + ) diff --git a/assets/pictures/scenes/sequencer_scenes.py b/assets/pictures/scenes/sequencer_scenes.py new file mode 100644 index 000000000..7b27dbdf4 --- /dev/null +++ b/assets/pictures/scenes/sequencer_scenes.py @@ -0,0 +1,139 @@ +from typing import Final + +from assets.pictures.demo import project_document +from assets.pictures.paths import guide_picture +from assets.pictures.writer import Pictures +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.project import leave_letting_the_project_go +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import TAG_GLOBAL_PANEL_PLAYER +from sampletones_application.tags.player import ( + SUF_PLAYER_PAUSE, + SUF_PLAYER_PLAY, + SUF_PLAYER_STOP, +) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_HISTORY_PANEL, + TAG_SEQUENCER_MODULE_PANEL, + TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, + TAG_SEQUENCER_TRACKER_PANEL, + TAG_SEQUENCER_VOICES_PANEL, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_NSF_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName + +PAGE: Final[str] = "sequencer" +TEMPO: Final[int] = 140 +SPEED: Final[int] = 5 +CARET_ROW: Final[int] = 7 +PLAY_BUTTONS: Final[tuple[str, ...]] = tuple( + compose_tag(TAG_GLOBAL_PANEL_PLAYER, suffix) for suffix in (SUF_PLAYER_PLAY, SUF_PLAYER_PAUSE, SUF_PLAYER_STOP) +) + + +def open_the_project(screen: Screen) -> None: + """Opens the demo project from File ▸ Open project and waits for its voices to be listed.""" + screen.answer_next_dialog(DialogKind.OPEN, project_document()) + screen.project.open() + screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") + screen.tabs.bring_to_front(Tab.SEQUENCER) + + +class SequencerScenes: + """The Sequencer tab with the demo project open: its cards, the play controls, and the windows the song is + rendered, exported and described in. + + A window opening with a field in focus shows the caret blinking in it, so the window's title bar is + clicked first, which takes the focus and keeps the picture the same from one run to the next. + """ + + def picture_voices(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + pictures.rest() + + pictures.around(guide_picture(PAGE, "voices"), TAG_SEQUENCER_VOICES_PANEL) + + def picture_tracker(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + screen.sequencer.tracker.click(CARET_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + pictures.rest() + + pictures.around(guide_picture(PAGE, "tracker"), TAG_SEQUENCER_TRACKER_PANEL) + + def picture_order(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + pictures.rest() + + pictures.around(guide_picture(PAGE, "order"), TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD) + + def picture_playback(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + pictures.rest() + + pictures.around(guide_picture(PAGE, "playback"), *PLAY_BUTTONS) + + def picture_module_options(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + pictures.rest() + + pictures.around(guide_picture(PAGE, "module-options"), TAG_SEQUENCER_MODULE_PANEL) + + def picture_history(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + module = screen.sequencer.module + module.retype_tempo(TEMPO) + screen.expect(module.tempo, TEMPO.__eq__, description="the tempo retyped") + module.retype_speed(SPEED) + screen.expect(module.speed, SPEED.__eq__, description="the speed retyped") + pictures.rest() + + pictures.around(guide_picture(PAGE, "history"), TAG_SEQUENCER_HISTORY_PANEL) + leave_letting_the_project_go(screen) + + def picture_nsf_window(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + screen.expect(screen.exports.nsf.is_shown, bool, description="the NSF window") + screen.hand.click_title_bar(TAG_SETTINGS_NSF_WINDOW) + pictures.rest() + + pictures.around(guide_picture(PAGE, "nsf-window"), TAG_SETTINGS_NSF_WINDOW) + screen.exports.nsf.cancel() + + def picture_render(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + screen.exports.render_song() + screen.expect( + screen.exports.render.is_shown, + bool, + description="the render window", + ) + pictures.rest() + + pictures.around(guide_picture(PAGE, "render"), TAG_SETTINGS_RENDER_WINDOW) + screen.exports.render.cancel() + + def picture_project_properties(self, screen: Screen, pictures: Pictures) -> None: + open_the_project(screen) + screen.project.properties.open() + screen.expect( + screen.project.properties.is_shown, + bool, + description="the properties dialog", + ) + screen.hand.click_title_bar(TAG_SETTINGS_PROPERTIES_WINDOW) + pictures.rest() + + pictures.around( + guide_picture(PAGE, "project-properties"), + TAG_SETTINGS_PROPERTIES_WINDOW, + ) + screen.project.properties.cancel() diff --git a/assets/pictures/worlds.py b/assets/pictures/worlds.py new file mode 100644 index 000000000..6bd2a6e20 --- /dev/null +++ b/assets/pictures/worlds.py @@ -0,0 +1,100 @@ +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +from assets.demo.paths import ( + LIBRARY_FOLDER, + PROJECTS_FOLDER, + RECONSTRUCTIONS_FOLDER, + RECORDINGS_FOLDER, +) +from assets.pictures.paths import DEMO_DIRECTORY +from automation.worlds.home import World, one_worker_config +from sampletones_application.config.session.application.config import ( + ApplicationConfig, +) +from sampletones_application.config.session.application.display import ( + DisplayConfig, +) +from sampletones_application.config.session.state.paths import LastPaths +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS + +GUIDE_VIEWPORT: Final[ViewportState] = ViewportState(width=1280, height=960, x=0, y=0) +README_VIEWPORT: Final[ViewportState] = ViewportState(width=1520, height=1140, x=0, y=0) +DOCUMENT_FOLDERS: Final[tuple[str, ...]] = ( + LIBRARY_FOLDER, + RECONSTRUCTIONS_FOLDER, + PROJECTS_FOLDER, +) + + +class MissingDemoError(FileNotFoundError): + """Raised when the demo tree the pictures are drawn from has not been made.""" + + +@dataclass(frozen=True) +class DemoTree: + """The demo tree laid into a home: the recordings beside the working directory, the documents in the + documents folder. + + The reconstructions name their recordings relative to the tree, so the recordings go where the + application resolves a relative path from, its working directory, and the documents go where the + application keeps them. + + Attributes: + source: The tree the demo maker wrote. + working_directory: Where the application is started, which the recordings folder is laid beside. + documents: The application's documents folder, which takes the library, the reconstructions + and the projects. + """ + + source: Path + working_directory: Path + documents: Path + + def write(self) -> None: + """Copies the tree into place. + + Raises: + MissingDemoError: If the source holds no tree. + """ + if not (self.source / RECORDINGS_FOLDER).is_dir(): + raise MissingDemoError(f"No demo tree at {self.source}; make demo writes one") + + shutil.copytree( + self.source / RECORDINGS_FOLDER, + self.working_directory / RECORDINGS_FOLDER, + dirs_exist_ok=True, + ) + for folder in DOCUMENT_FOLDERS: + shutil.copytree( + self.source / folder, + self.documents / folder, + dirs_exist_ok=True, + ) + + +def demo_world(viewport: ViewportState) -> World: + """A home holding the demo tree, opening a window of ``viewport`` with the frame-rate reading off. + + The session remembers the documents folder as where audio was last written, so a window proposing + a file for a render names a place under the documents. + """ + return World( + state=ApplicationState( + viewport=viewport, + last_paths=LastPaths(audio=USER_PATH_DOCUMENTS), + ), + application_config=ApplicationConfig(display=DisplayConfig(show_frame_rate=False)), + config=one_worker_config(), + files=( + DemoTree( + source=DEMO_DIRECTORY, + working_directory=Path.cwd(), + documents=USER_PATH_DOCUMENTS, + ), + ), + ) diff --git a/assets/pictures/writer.py b/assets/pictures/writer.py new file mode 100644 index 000000000..01e341eb1 --- /dev/null +++ b/assets/pictures/writer.py @@ -0,0 +1,131 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional + +import numpy as np +from PIL import Image + +from assets.pictures.boxes import corner_box, crop_box, union +from assets.pictures.frames import to_image +from assets.pictures.jitter import is_jitter +from automation.dearpygui.geometry import Point, Rect +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.types import Item +from automation.dearpygui.items.viewport import read_viewport +from automation.frames import changed_box, settled_frame +from automation.screen import Screen +from sampletones_application.tags.general import TAG_GLOBAL_TAB_INSTRUCTIONS + +MARGIN: Final[int] = 8 +OPENING_FRAMES: Final[int] = 5 +FAR_END_INSET: Final[int] = 30 +STATUS_SECONDS: Final[float] = 15.0 +FORMAT: Final[str] = "WEBP" +LOSSLESS: Final[bool] = True +QUALITY: Final[int] = 100 +METHOD: Final[int] = 6 + + +class BoxlessItemError(AssertionError): + """Raised when an item a picture is cropped around reports no box.""" + + +class Pictures: + """Keeps pictures of what the screen shows, cropped to what a page names and written where it is kept. + + The first frame DearPyGui hands over after a readback is asked for is the bare clear color, so + the first reading is taken and discarded. A picture is taken once the screen holds still, since a + card fills and a status line clears a frame or two after the gesture that brought them. Every + picture is written lossless, so what the guide shows is what the screen drew. + """ + + def __init__(self, screen: Screen) -> None: + self._screen = screen + self._warm = False + + def window(self, target: Path) -> Path: + """Writes the whole window to ``target``.""" + return write(to_image(self.pixels()), target) + + def around(self, target: Path, *items: Item) -> Path: + """Writes the part of the window holding every one of ``items``, with a margin around them, to ``target``. + + Raises: + BoxlessItemError: If one of the items reports no box. + """ + rects = [self._rect(item) for item in items] + return write(cropped(to_image(self.pixels()), union(rects)), target) + + def corner(self, target: Path, *items: Item) -> Path: + """Writes the window's top left corner, reaching down and across to the farthest of ``items``, to + ``target``. + + Raises: + BoxlessItemError: If one of the items reports no box. + """ + rects = [self._rect(item) for item in items] + return write(cropped(to_image(self.pixels()), corner_box(rects)), target) + + def popup(self, target: Path, opening: Callable[[], None]) -> Path: + """Writes the popup that ``opening`` opens, and the control that opened it, to ``target``. + + The frame before and the frame after the popup opened are compared, and the box around what + changed is what is kept. + """ + before = self.pixels() + opening() + self._screen.frames(OPENING_FRAMES) + after = self.pixels() + return write(cropped(to_image(after), changed_box(before, after)), target) + + def rest(self) -> None: + """Parks the pointer on the bare far end of the tab bar, where it lights nothing, and waits for the status + bar to let go of the hint of whatever the pointer left, which it keeps for a while. + """ + last_tab = self._rect(TAG_GLOBAL_TAB_INSTRUCTIONS) + viewport = self._screen.bridge.ask(read_viewport) + self._screen.hand.move_to(Point(x=round(viewport.width) - FAR_END_INSET, y=last_tab.center.y)) + self._screen.bridge.expect( + self._screen.status, + "".__eq__, + description="the status bar empty", + timeout=STATUS_SECONDS, + ) + + def pixels(self) -> np.ndarray: + """The frame drawn once the screen holds still, after the first readback of the process is discarded.""" + if not self._warm: + self._screen.frame_pixels() + self._warm = True + + return settled_frame(self._screen.bridge) + + def _rect(self, item: Item) -> Rect: + rect: Optional[Rect] = self._screen.bridge.ask(lambda: read_item(item).rect) + if rect is None: + raise BoxlessItemError(f"{item} reports no box to picture") + + return rect + + +def cropped(image: Image.Image, rect: Rect) -> Image.Image: + """The part of ``image`` holding ``rect`` with the margin around it.""" + return image.crop(crop_box(rect, MARGIN, width=image.width, height=image.height)) + + +def write(image: Image.Image, target: Path) -> Path: + """Writes ``image`` lossless to ``target``, making the folders on the way, and returns the target. + + A picture already kept at the target stands when the drawing differs from it by the renderer's + jitter alone, so a run after no change to the interface leaves the repository as it was. + """ + if target.is_file() and is_jitter(Image.open(target), image): + return target + + target.parent.mkdir(parents=True, exist_ok=True) + image.save(target, FORMAT, lossless=LOSSLESS, quality=QUALITY, method=METHOD) + return target + + +def written_since(directory: Path, moment: float, suffix: str) -> List[Path]: + """Every file with ``suffix`` under ``directory`` written at or after ``moment``, in path order.""" + return sorted(path for path in directory.rglob(f"*{suffix}") if path.stat().st_mtime >= moment) diff --git a/tests/unit/sampletones_core/calibration/__init__.py b/automation/__init__.py similarity index 100% rename from tests/unit/sampletones_core/calibration/__init__.py rename to automation/__init__.py diff --git a/tests/unit/sampletones_core/calibration/config/__init__.py b/automation/application/__init__.py similarity index 100% rename from tests/unit/sampletones_core/calibration/config/__init__.py rename to automation/application/__init__.py diff --git a/automation/application/boundaries.py b/automation/application/boundaries.py new file mode 100644 index 000000000..872868e8a --- /dev/null +++ b/automation/application/boundaries.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + +from automation.boundaries.audio import OutputRecord +from automation.boundaries.dialogs import ScriptedFileDialogs +from automation.boundaries.errors import ErrorRecords +from automation.boundaries.highlights import TableHighlights +from automation.boundaries.reveals import FileManagerStandIn +from automation.boundaries.spawns import SpawnGuard +from automation.holds.base import Holds + + +@dataclass(frozen=True) +class Boundaries: + """What stands between a scenario's application and the desktop around it.""" + + dialogs: ScriptedFileDialogs + errors: ErrorRecords + spawns: SpawnGuard + holds: Holds + file_manager: FileManagerStandIn + highlights: TableHighlights + output: OutputRecord diff --git a/automation/application/running.py b/automation/application/running.py new file mode 100644 index 000000000..7ed0382f1 --- /dev/null +++ b/automation/application/running.py @@ -0,0 +1,168 @@ +import threading +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg + +from automation.application.boundaries import Boundaries +from automation.checks import ( + AfterCheckError, + contained_findings, + quiet_findings, + read_screen_state, + screen_findings, + surviving_thread_findings, +) +from automation.dearpygui.bridge import Bridge, RenderThreadStoppedError +from automation.dearpygui.hosting import SCENARIO_THREAD_NAME +from automation.dearpygui.screenshot import capture +from automation.keyboard import primary_combination +from automation.render_thread import QueueRenderThread +from sampletones_application.application import Application +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.utils.gui.keyboard import KeyEvent +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager + +EXIT_TIMEOUT_SECONDS: Final[float] = 15.0 + + +class ScreenApplication: + """SampleToNES running for one scenario: its loop on the main thread, its ending read by the after-checks. + + A scenario that ends well leaves through the Exit shortcut, so every question the application asks + about unsaved work runs as it would for a user, and the application must then stop at once. A + scenario that failed, or a screen the after-checks object to, stops the loop outright once a + screenshot of it is kept. Either way, the work the scenario held is let go first. + """ + + def __init__( + self, + application: Application, + *, + render_thread: QueueRenderThread, + bridge: Bridge, + boundaries: Boundaries, + failure_screenshot: Path, + state_path: Path, + ) -> None: + self._application = application + self._render_thread = render_thread + self._bridge = bridge + self._boundaries = boundaries + self._failure_screenshot = failure_screenshot + self._state_path = state_path + self._reported_errors = 0 + + @property + def language(self) -> LanguageManager: + """The application's language manager, which turns a language key into the words a user reads.""" + return self._application.language_manager + + @property + def shortcuts(self) -> ShortcutManager: + """The application's shortcut manager, which says what keys each action has under the scheme in + place. + """ + return self._application.shortcut_manager + + def run(self) -> None: + """Starts the render thread and runs the application's loop on the calling thread until the loop + ends. + """ + self._boundaries.spawns.arm() + self._render_thread.start() + try: + self._application.run() + finally: + self._render_thread.stop() + + def finish(self, *, failed: bool) -> None: + """Ends the scenario: lets go of its holds, reads the after-checks and stops the application. + + A scenario that ended well leaves through the Exit shortcut; a failed one, or a screen the + after-checks object to, stops the loop outright after a screenshot is kept. + + Raises: + AfterCheckError: If the application broke a promise every scenario holds it to. + """ + self._boundaries.holds.release_all() + findings = self._boundary_findings() + if self._render_thread.is_running(): + findings.extend(self._screen_findings()) + if failed or findings: + findings.extend(self._capture_failure()) + self._halt() + elif not self._exit(): + findings.append("Leaving the application asked a question the scenario never answered") + findings.extend(self._capture_failure()) + self._halt() + + if findings: + raise AfterCheckError("\n".join(findings)) + + def close(self) -> None: + """Reads the checks that apply once the application has stopped. + + They cover late errors, surviving threads and the session state the application writes. + + Raises: + AfterCheckError: If an error came late, a thread outlived the exit or the session state is + missing. + """ + findings = quiet_findings(self._boundaries.errors, self._reported_errors, None) + findings.extend(surviving_thread_findings(_application_threads())) + if not self._state_path.exists(): + findings.append(f"The application stopped without writing its session state to {self._state_path}") + + if findings: + raise AfterCheckError("\n".join(findings)) + + def _boundary_findings(self) -> List[str]: + self._reported_errors = self._boundaries.errors.count + quiet = quiet_findings(self._boundaries.errors, 0, self._reported_errors) + return [ + *quiet, + *contained_findings(self._boundaries.spawns, self._boundaries.dialogs), + ] + + def _screen_findings(self) -> List[str]: + try: + state = self._bridge.ask(read_screen_state) + except RenderThreadStoppedError: + return [] + + return screen_findings(state) + + def _exit(self) -> bool: + """Presses the Exit shortcut and says whether the application stopped within the exit's time.""" + combination = primary_combination(self._application.shortcut_manager.shortcut(ShortcutId.EXIT)) + event = KeyEvent(key=combination.key, modifiers=combination.modifiers) + self._bridge.ask(lambda: self._application.key_router.route(event)) + return self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) + + def _halt(self) -> None: + """Stops the loop outright and waits for it to end.""" + try: + self._bridge.ask(dpg.stop_dearpygui) + except RenderThreadStoppedError: + return + + self._render_thread.wait_stopped(EXIT_TIMEOUT_SECONDS) + + def _capture_failure(self) -> List[str]: + try: + capture(self._bridge, self._failure_screenshot) + except AssertionError as error: + return [f"The failure screenshot could not be taken: {error}"] + + return [] + + +def _application_threads() -> List[threading.Thread]: + """The threads running beside the main one, the scenario's own aside.""" + return [ + thread + for thread in threading.enumerate() + if thread is not threading.main_thread() and thread.name != SCENARIO_THREAD_NAME + ] diff --git a/automation/application/startup.py b/automation/application/startup.py new file mode 100644 index 000000000..fe2f0363f --- /dev/null +++ b/automation/application/startup.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +@dataclass(frozen=True) +class Startup: + """The documents a scenario's application opens as it starts, as ``sampletones open`` hands them over. + + Attributes: + reconstruction: The reconstruction open on the Reconstructions tab, if any. + project: The project open in the Sequencer, if any. + """ + + reconstruction: Optional[Path] + project: Optional[Path] diff --git a/tests/unit/sampletones_core/calibration/corpus/__init__.py b/automation/boundaries/__init__.py similarity index 100% rename from tests/unit/sampletones_core/calibration/corpus/__init__.py rename to automation/boundaries/__init__.py diff --git a/automation/boundaries/audio.py b/automation/boundaries/audio.py new file mode 100644 index 000000000..95e1bc61c --- /dev/null +++ b/automation/boundaries/audio.py @@ -0,0 +1,197 @@ +import threading +import time +from enum import StrEnum +from pathlib import Path +from typing import Final, Optional +from weakref import WeakKeyDictionary + +import numpy as np +import pyaudio +import pytest + +ALSA_CONFIGURATION_FILE: Final[str] = ".asound.conf" +ALSA_CONFIGURATION_VARIABLE: Final[str] = "ALSA_CONFIG_PATH" +DEVICE_BUFFER_SECONDS: Final[float] = 0.05 +SILENT_DEFAULT_DEVICE: Final[str] = "pcm.!default {\n type null\n}\n" +REFUSED_STREAM: Final[str] = "A screen scenario's device refuses every stream" +JACK_SERVER_VARIABLE: Final[str] = "JACK_DEFAULT_SERVER" +JACK_NO_START_VARIABLE: Final[str] = "JACK_NO_START_SERVER" +ABSENT_JACK_SERVER: Final[str] = "sampletones-screens-absent" +JACK_STAYS_DOWN: Final[str] = "1" + + +class OutputDevice(StrEnum): + """The output a scenario's machine offers: a device that plays into silence, one that refuses every + stream, or none at all.""" + + SILENT = "silent" + REFUSING = "refusing" + NONE = "none" + + +class OutputRecord: + """What the application wrote to its output device: how many of the samples it played carry sound. + + It wraps the write of every output stream, so a sound counts once it reaches the device, however briefly it + plays and however many frames pass between two readings of it. The playback thread writes while a + scenario reads, so the count stands behind a lock. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._sounding_samples = 0 + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Puts the record in front of every output stream's write before the application starts. + + ``PyAudio.open`` builds its streams from ``PyAudio.Stream``, the class whose write is wrapped. + """ + write = pyaudio.PyAudio.Stream.write + + def recorded( + stream: pyaudio.PyAudio.Stream, + frames: bytes, + num_frames: Optional[int] = None, + exception_on_underflow: bool = False, + ) -> None: + self._count(frames) + write(stream, frames, num_frames, exception_on_underflow) + + monkeypatch.setattr(pyaudio.PyAudio.Stream, "write", recorded) + + def sounding_samples(self) -> int: + """How many samples carrying sound the application has written to its output so far.""" + with self._lock: + return self._sounding_samples + + def _count(self, frames: bytes) -> None: + sounding = int(np.count_nonzero(np.frombuffer(frames, dtype=np.float32))) + with self._lock: + self._sounding_samples += sounding + + +class DeviceClock: + """Plays a stream's sound out at its rate: a write returns once what it hands over fits the buffer, and a + stop once the buffer has played out. + + The buffer keeps ``DEVICE_BUFFER_SECONDS`` of sound waiting, as a device's does. Sound written once the + buffer has run dry starts playing as it arrives, as it does on a device that underran. + """ + + def __init__(self, bytes_per_second: int) -> None: + self._bytes_per_second = bytes_per_second + self._played_out_at = time.monotonic() + + def write(self, frames: bytes) -> None: + """Returns once ``frames`` fit in the buffer, the moment a device's blocking write returns.""" + now = time.monotonic() + self._played_out_at = max(self._played_out_at, now) + len(frames) / self._bytes_per_second + time.sleep(max(0.0, self._played_out_at - DEVICE_BUFFER_SECONDS - now)) + + def drain(self) -> None: + """Returns once the sound written has played out, the moment a device's stop returns.""" + time.sleep(max(0.0, self._played_out_at - time.monotonic())) + + +class SilentOutputDevice: + """The one output device a scenario's application finds: ALSA's null sink, played out in real time. + + The scenario's ALSA reads a configuration of its own, which names the null sink its default device and + leaves the machine's sound cards and sound server out, so every machine offers the same device and plays + into silence. The null sink takes sound as fast as it comes, so a clock behind each output stream returns + a write and a stop when a device would. + """ + + def __init__(self) -> None: + self._clocks: WeakKeyDictionary[pyaudio.PyAudio.Stream, DeviceClock] = WeakKeyDictionary() + + @staticmethod + def configure(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Points the scenario's ALSA at a configuration of its own, whose one device is the null sink.""" + configuration = home / ALSA_CONFIGURATION_FILE + configuration.write_text(SILENT_DEFAULT_DEVICE, encoding="utf-8") + monkeypatch.setenv(ALSA_CONFIGURATION_VARIABLE, str(configuration)) + + def keep_time(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Puts a clock behind every stream before the application starts, so the null sink plays in real time.""" + open_stream = pyaudio.PyAudio.open + write = pyaudio.PyAudio.Stream.write + stop_stream = pyaudio.PyAudio.Stream.stop_stream + + def opened( + audio: pyaudio.PyAudio, + *, + rate: int, + channels: int, + format: int, # pylint: disable=redefined-builtin + **options: object, + ) -> pyaudio.PyAudio.Stream: + stream = open_stream(audio, rate=rate, channels=channels, format=format, **options) + self._clocks[stream] = DeviceClock(rate * channels * pyaudio.get_sample_size(format)) + return stream + + def played( + stream: pyaudio.PyAudio.Stream, + frames: bytes, + num_frames: Optional[int] = None, + exception_on_underflow: bool = False, + ) -> None: + write(stream, frames, num_frames, exception_on_underflow) + self._clocks[stream].write(frames) + + def stopped(stream: pyaudio.PyAudio.Stream) -> None: + self._clocks[stream].drain() + stop_stream(stream) + + monkeypatch.setattr(pyaudio.PyAudio, "open", opened) + monkeypatch.setattr(pyaudio.PyAudio.Stream, "write", played) + monkeypatch.setattr(pyaudio.PyAudio.Stream, "stop_stream", stopped) + + +def provide_output_device( + device: OutputDevice, + *, + home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Gives the application the output ``device`` before it starts, with the machine's own sound kept out. + + Whatever the device, the scenario's ALSA reads its own configuration and JACK looks for a server no + one runs, so neither the sound cards nor a sound server running on the machine reach the run. + """ + silent = SilentOutputDevice() + silent.configure(home, monkeypatch) + _away_from_jack(monkeypatch) + match device: + case OutputDevice.SILENT: + silent.keep_time(monkeypatch) + case OutputDevice.REFUSING: + silent.keep_time(monkeypatch) + _refusing_output_device(monkeypatch) + case OutputDevice.NONE: + _no_output_device(monkeypatch) + + +def _away_from_jack(monkeypatch: pytest.MonkeyPatch) -> None: + """Points JACK at a server no one runs, and keeps it from starting one.""" + monkeypatch.setenv(JACK_SERVER_VARIABLE, ABSENT_JACK_SERVER) + monkeypatch.setenv(JACK_NO_START_VARIABLE, JACK_STAYS_DOWN) + + +def _refusing_output_device(monkeypatch: pytest.MonkeyPatch) -> None: + """Offers the silent device and refuses every stream opened on it, as a device another program holds.""" + monkeypatch.setattr(pyaudio.PyAudio, "open", _refused_stream) + + +def _refused_stream(_: pyaudio.PyAudio, **__: object) -> pyaudio.PyAudio.Stream: + raise OSError(REFUSED_STREAM) + + +def _no_output_device(monkeypatch: pytest.MonkeyPatch) -> None: + """Starts the application on a machine offering no output device at all.""" + monkeypatch.setattr(pyaudio.PyAudio, "get_device_count", lambda _: 0) + monkeypatch.setattr(pyaudio.PyAudio, "get_default_output_device_info", _no_default_device) + + +def _no_default_device(_: pyaudio.PyAudio) -> None: + raise OSError("A screen scenario offers no output device") diff --git a/automation/boundaries/dialogs.py b/automation/boundaries/dialogs.py new file mode 100644 index 000000000..6743b85e8 --- /dev/null +++ b/automation/boundaries/dialogs.py @@ -0,0 +1,221 @@ +import threading +from collections import deque +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Deque, Dict, List, Optional, Tuple + +import pytest + +import sampletones_application.utils.file_dialogs.api as file_dialogs_api +from sampletones_application.utils.file_dialogs.destination import ( + SaveDestination, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter + + +class DialogKind(StrEnum): + """What a file dialog asks the user for: a file to open, a path to save to or a folder.""" + + OPEN = "open" + SAVE = "save" + DIRECTORY = "directory" + + +@dataclass(frozen=True) +class DialogAnswer: + """What a scenario queued for a dialog to answer: a path, and for a save dialog the type picked in its + selector. + + Attributes: + path: The path the dialog answers with. + type_name: The name of the offered file type to pick, or ``None`` to leave the selector as it was. + """ + + path: Path + type_name: Optional[str] + + +@dataclass(frozen=True) +class DialogRequest: + """One file dialog the application opened during a scenario. + + Attributes: + kind: What the dialog asked for. + title: The title the dialog carried. + initial_directory: The folder the dialog opened in, if the application named one. + suggested_name: The file name a save dialog offered, if any. + filters: The kinds of file the dialog offered to show. + answered: Whether the scenario had queued an answer for it. + answer: The path the dialog answered with, or ``None`` for a dismissed dialog. + """ + + kind: DialogKind + title: str + initial_directory: Optional[Path] + suggested_name: Optional[str] + filters: Tuple[FileFilter, ...] + answered: bool + answer: Optional[Path] + + +class ScriptedFileDialogs: + """The file dialogs a scenario stands behind: each answers with what the scenario queued for its kind. + + A native dialog stops the queue the render thread drains while it stands, so a dialog here answers + at once from the answers queued before the gesture that opens it. A dialog opening with nothing + queued is dismissed, and the after-checks report it as a question the scenario left unanswered. + """ + + def __init__(self) -> None: + self._answers: Dict[DialogKind, Deque[Optional[DialogAnswer]]] = {kind: deque() for kind in DialogKind} + self._requests: List[DialogRequest] = [] + self._lock = threading.Lock() + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Stands in for the native backend every file dialog of the application asks for.""" + monkeypatch.setattr(file_dialogs_api, "select_file_dialog_backend", lambda: self) + + def answer( + self, + kind: DialogKind, + path: Optional[Path], + ) -> None: + """Queues the answer the next dialog of ``kind`` gives: ``path``, or ``None`` to dismiss it.""" + with self._lock: + self._answers[kind].append(None if path is None else DialogAnswer(path=path, type_name=None)) + + def answer_save_as( + self, + path: Path, + type_name: str, + ) -> None: + """Queues the answer the next save dialog gives: ``path``, with the type named ``type_name`` picked. + + A dialog offering a type of another name stays unanswered, which the after-checks report. + """ + with self._lock: + self._answers[DialogKind.SAVE].append(DialogAnswer(path=path, type_name=type_name)) + + @property + def requests(self) -> Tuple[DialogRequest, ...]: + """Every dialog the application opened so far, in order, answered or not.""" + with self._lock: + return tuple(self._requests) + + @property + def unanswered(self) -> Tuple[DialogRequest, ...]: + """The dialogs that opened while no answer was queued for them.""" + return tuple(request for request in self.requests if not request.answered) + + def open_file( + self, + *, + title: str, + initial_directory: Optional[Path], + filters: Tuple[FileFilter, ...], + ) -> Optional[Path]: + """Answers an open dialog with the queued path, or ``None`` when it is dismissed.""" + return _path( + self._take( + DialogKind.OPEN, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=filters, + ) + ) + + def save_file( + self, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[SaveDestination]: + """Answers a save dialog with the queued path and the file type picked, or ``None`` when it is + dismissed. + """ + answer = self._take( + DialogKind.SAVE, + title=title, + initial_directory=initial_directory, + suggested_name=suggested_name, + filters=filters, + ) + if answer is None: + return None + + return SaveDestination(path=answer.path, file_type=_picked(answer, filters)) + + def select_directory( + self, + *, + title: str, + initial_directory: Optional[Path], + ) -> Optional[Path]: + """Answers a folder dialog with the queued path, or ``None`` when it is dismissed.""" + return _path( + self._take( + DialogKind.DIRECTORY, + title=title, + initial_directory=initial_directory, + suggested_name=None, + filters=(), + ) + ) + + def _take( + self, + kind: DialogKind, + *, + title: str, + initial_directory: Optional[Path], + suggested_name: Optional[str], + filters: Tuple[FileFilter, ...], + ) -> Optional[DialogAnswer]: + """Takes the answer queued for ``kind``, notes the request and returns the answer, if any.""" + with self._lock: + queued = self._answers[kind] + answered = bool(queued) + answer = queued.popleft() if answered else None + if answer is not None and answer.type_name is not None and not _offers(filters, answer.type_name): + answered = False + answer = None + + self._requests.append( + DialogRequest( + kind=kind, + title=title, + initial_directory=initial_directory, + suggested_name=suggested_name, + filters=filters, + answered=answered, + answer=_path(answer), + ) + ) + + return answer + + +def _path(answer: Optional[DialogAnswer]) -> Optional[Path]: + return None if answer is None else answer.path + + +def _offers( + filters: Tuple[FileFilter, ...], + type_name: str, +) -> bool: + return any(file_filter.name == type_name for file_filter in filters) + + +def _picked( + answer: DialogAnswer, + filters: Tuple[FileFilter, ...], +) -> Optional[FileFilter]: + """The offered type the answer picks, or ``None`` for an answer leaving the selector alone.""" + return next( + (file_filter for file_filter in filters if file_filter.name == answer.type_name), + None, + ) diff --git a/automation/boundaries/errors.py b/automation/boundaries/errors.py new file mode 100644 index 000000000..a58e03f37 --- /dev/null +++ b/automation/boundaries/errors.py @@ -0,0 +1,101 @@ +import inspect +import logging +import threading +from dataclasses import dataclass +from types import CodeType +from typing import Final, List, Optional, Tuple + +from sampletones_application.utils.parallelization.thread import ( + SingleThreadExecutor, +) +from sampletones_shared.application import SAMPLETONES_NAME + +EXIT_JOIN_CODE: Final[CodeType] = SingleThreadExecutor.join_all.__code__ + + +@dataclass +class _ErrorRecord: + message: str + claimed: bool + + +class ErrorRecords(logging.Handler): + """Every error the application logs, and every exception one of its threads lets escape. + + A gesture's callback that raises is reported and the interface keeps running. The report is + logged, and the error dialog shows it unless a report already stands, so this record holds every + failure, the ones the screen shows once among them. The warning that background work outlived + the exit's deadline counts as an error too, since the teardown destroys what that work still + reaches. + + A scenario that provokes a failure claims the error it expects, and every error left unclaimed + is one the application reported on its own. + """ + + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self._records: List[_ErrorRecord] = [] + self._lock = threading.Lock() + + def install(self) -> None: + """Starts recording what the application logs and what its threads let escape.""" + logging.getLogger(SAMPLETONES_NAME).addHandler(self) + threading.excepthook = self._escaped + + @property + def count(self) -> int: + """How many errors have been recorded, claimed or not.""" + with self._lock: + return len(self._records) + + def unclaimed( + self, + start: int, + stop: Optional[int], + ) -> Tuple[str, ...]: + """The errors recorded from the ``start``-th up to the ``stop``-th, or on, that no scenario claimed.""" + with self._lock: + return tuple(record.message for record in self._records[start:stop] if not record.claimed) + + def claim(self, naming: str) -> bool: + """Takes the first unclaimed error whose message holds ``naming`` as provoked, and says whether one + did. + """ + with self._lock: + for record in self._records: + if not record.claimed and naming in record.message: + record.claimed = True + return True + + return False + + def emit(self, record: logging.LogRecord) -> None: + """Records a logged error, and a warning logged from within the exit's join on background work.""" + if record.levelno < logging.ERROR and not _logged_by_the_exit_join(): + return + + self._record(f"{record.levelname} {self.format(record)}") + + def _escaped(self, arguments: threading.ExceptHookArgs) -> None: + thread = arguments.thread.name if arguments.thread is not None else "a thread" + self._record(f"{thread} let {arguments.exc_type.__name__} escape: {arguments.exc_value}") + + def _record(self, message: str) -> None: + with self._lock: + self._records.append(_ErrorRecord(message=message, claimed=False)) + + +def _logged_by_the_exit_join() -> bool: + """Whether the record being handled was logged from within the join that awaits background work. + + The application logs through a wrapper, so a record names the wrapper as its origin, and the + call stack is what still holds the join. + """ + frame = inspect.currentframe() + while frame is not None: + if frame.f_code is EXIT_JOIN_CODE: + return True + + frame = frame.f_back + + return False diff --git a/automation/boundaries/highlights.py b/automation/boundaries/highlights.py new file mode 100644 index 000000000..52bed02eb --- /dev/null +++ b/automation/boundaries/highlights.py @@ -0,0 +1,118 @@ +import threading +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, Final, Optional, Sequence, Tuple, Union + +import dearpygui.dearpygui as dpg +import pytest + +TableItem = Union[int, str] +Color = Tuple[float, ...] +WHOLE_LINE: Final[None] = None + + +class HighlightKind(StrEnum): + """The stretch of a table one highlight covers.""" + + ROW = "row" + CELL = "cell" + COLUMN = "column" + + +@dataclass(frozen=True) +class HighlightPlace: + """Where a table highlight stands: the table, the stretch it covers, and the row and the column it covers. + + Attributes: + table: The table's tag. + kind: Whether the highlight covers a row, a cell or a column. + row: The row it covers, or ``None`` for a column. + column: The column it covers, or ``None`` for a row. + """ + + table: str + kind: HighlightKind + row: Optional[int] + column: Optional[int] + + +class TableHighlights: + """Notes every highlight the application lays on a table and lifts from one, with its color. + + DearPyGui holds a table's row, cell and column highlights as the table's own state, and answers + whether one stands but never its color. The stand-in wraps the calls that lay and lift them, + which run as they did, so the highlights standing and their colors are known as the + application left them, from the first frame on. + """ + + def __init__(self) -> None: + self._standing: Dict[HighlightPlace, Color] = {} + self._lock = threading.Lock() + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Wraps DearPyGui's calls that lay and lift table highlights, so each one is noted as it runs.""" + lay_row = dpg.highlight_table_row + lay_cell = dpg.highlight_table_cell + lay_column = dpg.highlight_table_column + lift_row = dpg.unhighlight_table_row + lift_cell = dpg.unhighlight_table_cell + lift_column = dpg.unhighlight_table_column + + def highlight_row(table: TableItem, row: int, color: Sequence[float]) -> None: + lay_row(table, row, color) + self._lay(_place(table, HighlightKind.ROW, row, WHOLE_LINE), color) + + def highlight_cell(table: TableItem, row: int, column: int, color: Sequence[float]) -> None: + lay_cell(table, row, column, color) + self._lay(_place(table, HighlightKind.CELL, row, column), color) + + def highlight_column(table: TableItem, column: int, color: Sequence[float]) -> None: + lay_column(table, column, color) + self._lay(_place(table, HighlightKind.COLUMN, WHOLE_LINE, column), color) + + def unhighlight_row(table: TableItem, row: int) -> None: + lift_row(table, row) + self._lift(_place(table, HighlightKind.ROW, row, WHOLE_LINE)) + + def unhighlight_cell(table: TableItem, row: int, column: int) -> None: + lift_cell(table, row, column) + self._lift(_place(table, HighlightKind.CELL, row, column)) + + def unhighlight_column(table: TableItem, column: int) -> None: + lift_column(table, column) + self._lift(_place(table, HighlightKind.COLUMN, WHOLE_LINE, column)) + + monkeypatch.setattr(dpg, "highlight_table_row", highlight_row) + monkeypatch.setattr(dpg, "highlight_table_cell", highlight_cell) + monkeypatch.setattr(dpg, "highlight_table_column", highlight_column) + monkeypatch.setattr(dpg, "unhighlight_table_row", unhighlight_row) + monkeypatch.setattr(dpg, "unhighlight_table_cell", unhighlight_cell) + monkeypatch.setattr(dpg, "unhighlight_table_column", unhighlight_column) + + def standing(self) -> Dict[HighlightPlace, Color]: + """Every highlight standing, with the color it was laid in.""" + with self._lock: + return dict(self._standing) + + def _lay(self, place: HighlightPlace, color: Sequence[float]) -> None: + with self._lock: + self._standing[place] = tuple(float(part) for part in color) + + def _lift(self, place: HighlightPlace) -> None: + with self._lock: + self._standing.pop(place, None) + + +def _place( + table: TableItem, + kind: HighlightKind, + row: Optional[int], + column: Optional[int], +) -> HighlightPlace: + """The place a call names, its table known by its tag. Runs on the render thread.""" + return HighlightPlace( + table=str(dpg.get_item_alias(table) or table), + kind=kind, + row=row, + column=column, + ) diff --git a/automation/boundaries/reveals.py b/automation/boundaries/reveals.py new file mode 100644 index 000000000..76516d47e --- /dev/null +++ b/automation/boundaries/reveals.py @@ -0,0 +1,58 @@ +import os +import stat +from pathlib import Path +from typing import Final, Mapping, Tuple + +import pytest + +PATH_VARIABLE: Final[str] = "PATH" +FILE_MANAGER: Final[str] = "nemo" +FILE_MANAGER_ENTRY: Final[str] = f"{FILE_MANAGER}.desktop" +EXECUTABLE: Final[int] = stat.S_IRWXU + + +class FileManagerStandIn: + """Stands in for the desktop's file manager, noting every path the application asks it to show. + + The application asks ``xdg-mime`` which file manager the desktop uses and then asks that file + manager, or ``xdg-open``, to show a path. Programs of those names lie in ``folder``, which leads + the search path, so the application's own reveal code runs and reaches them: the first names a + file manager they stand for, and the others write the path they were given to ``log``, one line + each. + """ + + def __init__( + self, + folder: Path, + log: Path, + ) -> None: + self.folder = folder + self._log = log + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Lays the programs in the folder and puts the folder first on the search path.""" + self.folder.mkdir(parents=True, exist_ok=True) + for name, script in self._programs().items(): + program = self.folder / name + program.write_text(script) + program.chmod(EXECUTABLE) + + monkeypatch.setenv( + PATH_VARIABLE, + os.pathsep.join((str(self.folder), os.environ[PATH_VARIABLE])), + ) + + def shown(self) -> Tuple[Path, ...]: + """Every path the file manager was asked to show, in order.""" + if not self._log.exists(): + return () + + return tuple(Path(line) for line in self._log.read_text().splitlines()) + + def _programs(self) -> Mapping[str, str]: + noting = f'#!/bin/sh\nfor path in "$@"; do printf \'%s\\n\' "$path" >> "{self._log}"; done\n' + return { + "xdg-mime": f"#!/bin/sh\necho {FILE_MANAGER_ENTRY}\n", + FILE_MANAGER: noting, + "xdg-open": noting, + } diff --git a/automation/boundaries/spawns.py b/automation/boundaries/spawns.py new file mode 100644 index 000000000..8156b6cfa --- /dev/null +++ b/automation/boundaries/spawns.py @@ -0,0 +1,94 @@ +import shutil +import sys +import threading +from pathlib import Path +from typing import Any, Final, FrozenSet, List, Optional, Tuple + +LIBRARY_PROBES: Final[FrozenSet[str]] = frozenset({"cc", "gcc", "ld", "ldconfig", "objdump"}) +PROGRAM_ARGUMENT: Final[int] = 0 +ARGUMENTS_ARGUMENT: Final[int] = 1 + +SPAWN_EVENTS: Final[FrozenSet[str]] = frozenset( + { + "os.exec", + "os.posix_spawn", + "os.spawn", + "os.startfile", + "os.system", + "subprocess.Popen", + "webbrowser.open", + } +) + + +class SpawnRefusedError(PermissionError): + """Raised inside the application when it tries to start a program during a scenario.""" + + +class SpawnGuard: + """Refuses every program the application tries to start once a scenario runs, and remembers each try. + + A file manager, a browser or a dialog tool would open on the desktop around the scenario's display. + The guard is an audit hook, so it stands in front of every way Python starts a program, however + a module imported it. It lets through the application's own worker processes, which start through + multiprocessing, the tools ``ctypes.util.find_library`` asks where a shared library lies, and the + stand-in programs of the folder it is told to allow. A refused try is remembered by its program and + arguments alone, since the environment it carried can hold the secrets of the machine the run is on. + """ + + def __init__(self) -> None: + self._refused: List[str] = [] + self._armed = False + self._allowed: Optional[Path] = None + self._lock = threading.Lock() + + def install(self) -> None: + """Puts the guard in front of the process for good; it lets every try through until armed.""" + sys.addaudithook(self._audit) + + def allow(self, folder: Path) -> None: + """Lets the programs lying in ``folder`` start, which is where the scenario's stand-ins lie.""" + self._allowed = folder + + def arm(self) -> None: + """Starts refusing the programs the application tries to start.""" + self._armed = True + + @property + def refused(self) -> Tuple[str, ...]: + """The programs refused so far, each with its event and arguments, in order.""" + with self._lock: + return tuple(self._refused) + + def _audit( + self, + event: str, + arguments: Tuple[Any, ...], + ) -> None: + if not self._armed or event not in SPAWN_EVENTS: + return + + program = Path(str(arguments[PROGRAM_ARGUMENT])).name if arguments else "" + if program in LIBRARY_PROBES or self._is_allowed(arguments): + return + + attempt = f"{event} {program} {_described(arguments)}" + with self._lock: + self._refused.append(attempt) + + raise SpawnRefusedError(f"A screen scenario refuses to start a program: {attempt}") + + def _is_allowed(self, arguments: Tuple[Any, ...]) -> bool: + """Whether the program a try names lies in the folder of stand-ins the guard allows.""" + if self._allowed is None or not arguments: + return False + + located = shutil.which(str(arguments[PROGRAM_ARGUMENT])) + return located is not None and Path(located).parent == self._allowed + + +def _described(arguments: Tuple[Any, ...]) -> str: + if len(arguments) > ARGUMENTS_ARGUMENT: + return repr(arguments[ARGUMENTS_ARGUMENT]) + + return "" diff --git a/automation/checks.py b/automation/checks.py new file mode 100644 index 000000000..fd968e857 --- /dev/null +++ b/automation/checks.py @@ -0,0 +1,87 @@ +import threading +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +from automation.boundaries.dialogs import ScriptedFileDialogs +from automation.boundaries.errors import ErrorRecords +from automation.boundaries.spawns import SpawnGuard +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import is_tag_within +from automation.dearpygui.items.regions import WindowReading, read_windows +from automation.dearpygui.items.viewport import read_client_area +from sampletones_application.tags.general import ( + TAG_GLOBAL_DIALOG_ERROR, + TAG_GLOBAL_WINDOW_MAIN, +) +from sampletones_application.utils.gui.modal_queue import ( + ModalQueue, + ModalQueueSnapshot, +) + + +class AfterCheckError(AssertionError): + """Raised when a scenario's application broke a promise every scenario holds it to.""" + + +@dataclass(frozen=True) +class ScreenState: + """What the screen holds as a scenario ends: its windows, the modal line, and the client area.""" + + windows: Tuple[WindowReading, ...] + modal_line: ModalQueueSnapshot + client_area: Rect + + +def read_screen_state() -> ScreenState: + """Reads the screen as a scenario ends. Runs on the render thread.""" + return ScreenState( + windows=read_windows(), + modal_line=ModalQueue.snapshot(), + client_area=read_client_area(), + ) + + +def quiet_findings( + errors: ErrorRecords, + start: int, + stop: Optional[int], +) -> List[str]: + """The unprovoked errors the application logged or let escape a thread, from the ``start``-th to the + ``stop``-th. + """ + return [f"The application reported: {message}" for message in errors.unclaimed(start, stop)] + + +def surviving_thread_findings(threads: Sequence[threading.Thread]) -> List[str]: + """The threads still running once the application has stopped and torn its context down.""" + return [f"The thread '{thread.name}' outlived the application's exit" for thread in threads] + + +def contained_findings( + spawns: SpawnGuard, + dialogs: ScriptedFileDialogs, +) -> List[str]: + """The programs the application tried to start, and the dialogs it opened with no answer queued.""" + return [f"The application tried to start a program: {attempt}" for attempt in spawns.refused] + [ + f"The {request.kind} file dialog '{request.title}' opened with no answer queued" + for request in dialogs.unanswered + ] + + +def screen_findings(state: ScreenState) -> List[str]: + """What the screen still shows as a scenario ends: open conversations, error dialogs, windows off it.""" + findings: List[str] = [] + if state.modal_line.shown is not None or not state.modal_line.is_settled: + findings.append(f"The scenario ended with a modal conversation still open: {state.modal_line}") + + for window in state.windows: + if not window.shown or window.alias == TAG_GLOBAL_WINDOW_MAIN: + continue + + if is_tag_within(window.alias, TAG_GLOBAL_DIALOG_ERROR): + findings.append(f"An error dialog stands on the screen: '{window.label}'") + + if not state.client_area.contains(window.rect): + findings.append(f"The window '{window.alias}' reaches outside the viewport: {window.rect}") + + return findings diff --git a/tests/unit/sampletones_core/calibration/referee/__init__.py b/automation/dearpygui/__init__.py similarity index 100% rename from tests/unit/sampletones_core/calibration/referee/__init__.py rename to automation/dearpygui/__init__.py diff --git a/automation/dearpygui/bridge.py b/automation/dearpygui/bridge.py new file mode 100644 index 000000000..e32c94211 --- /dev/null +++ b/automation/dearpygui/bridge.py @@ -0,0 +1,172 @@ +import math +import threading +import time +from concurrent.futures import Future +from typing import Callable, Final, Protocol, TypeVar + +AnswerT = TypeVar("AnswerT") +NEXT_DRAIN: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +POLL_SECONDS: Final[float] = 0.05 +REFERENCE_FRAME_RATE: Final[int] = 30 + + +class RenderThreadTimeoutError(AssertionError): + """Raised when the render thread leaves a question unanswered while it still runs.""" + + +class RenderThreadStoppedError(AssertionError): + """Raised when the application stops drawing before it answers a scenario's question.""" + + +class ExpectationError(AssertionError): + """Raised when what a scenario expects never holds within its time.""" + + +class RenderThread(Protocol): + """The thread an application's DearPyGui context belongs to, reached from a scenario's own thread. + + The application under test says how work reaches it, which is the one thing this layer asks of + the application. + """ + + def post( + self, + task: Callable[[], None], + *, + frames: int, + ) -> None: + """Runs ``task`` on the render thread once ``frames`` more frames have been drawn.""" + + def is_running(self) -> bool: + """Whether the render thread still draws frames, and so still runs what is posted to it.""" + + def frames_drawn(self) -> int: + """How many frames the render thread has drawn, read from any thread.""" + + +class Deadline: + """The end of a wait, which comes once its seconds have run and the render thread has drawn its frames. + + The frames are those a machine drawing ``REFERENCE_FRAME_RATE`` frames a second draws in the same seconds. A + machine drawing more slowly answers each gesture in more seconds, so its waits stretch with its frames, and a + wait fails on what the application did within them. A render thread that stops ends every wait. + """ + + def __init__( + self, + render_thread: RenderThread, + *, + seconds: float, + ) -> None: + self._render_thread = render_thread + self.seconds = seconds + self.frames = math.ceil(seconds * REFERENCE_FRAME_RATE) + self._end = time.monotonic() + seconds + self._last_frame = render_thread.frames_drawn() + self.frames + + def passed(self) -> bool: + """Whether both the seconds and the frames of the wait have run, or the render thread stopped.""" + if not self._render_thread.is_running(): + return True + + return time.monotonic() >= self._end and self._render_thread.frames_drawn() >= self._last_frame + + +class Bridge: + """The crossing a scenario takes to the render thread, where every reading and gesture happens. + + A question runs between two frames, so what it reads is one consistent frame. Its answer, or the + exception it raised, comes back to the scenario's thread. Waiting is counted in frames drawn, and + an expectation is read once a frame until it holds or its `Deadline` passes. + """ + + def __init__( + self, + render_thread: RenderThread, + *, + answer_timeout: float, + ) -> None: + self._render_thread = render_thread + self._answer_timeout = answer_timeout + + def ask(self, question: Callable[[], AnswerT]) -> AnswerT: + """Runs ``question`` on the render thread and returns what it answered. + + Raises: + RenderThreadTimeoutError: If the render thread leaves the question unanswered. + RenderThreadStoppedError: If the application stops before answering. + """ + answer: Future[AnswerT] = Future() + answered = threading.Event() + + def task() -> None: + try: + answer.set_result(question()) + except BaseException as error: # pylint: disable=broad-exception-caught + answer.set_exception(error) + finally: + answered.set() + + self._render_thread.post(task, frames=NEXT_DRAIN) + self._wait(answered, what="a question") + return answer.result() + + def frames(self, count: int) -> None: + """Waits until ``count`` more frames have been drawn. + + Raises: + RenderThreadTimeoutError: If the frames stop arriving. + RenderThreadStoppedError: If the application stops before they are drawn. + """ + drawn = threading.Event() + self._render_thread.post(drawn.set, frames=count) + self._wait(drawn, what=f"{count} frames") + + def expect( + self, + reading: Callable[[], AnswerT], + holds: Callable[[AnswerT], bool], + *, + description: str, + timeout: float, + ) -> AnswerT: + """Takes ``reading`` once a frame until ``holds`` accepts it, and returns that reading. + + ``reading`` runs on the caller's thread, so it asks the render thread for what it reads and + may combine several answers. + + Raises: + ExpectationError: If no reading holds before the deadline of ``timeout`` seconds passes, naming the + last one. + """ + deadline = self.deadline(timeout) + while True: + value = reading() + if holds(value): + return value + + if deadline.passed(): + raise ExpectationError( + f"Expected {description} within {timeout} s and {deadline.frames} frames; " + f"the last reading was {value!r}" + ) + + self.frames(ONE_FRAME) + + def deadline(self, seconds: float) -> Deadline: + """The deadline of a wait of ``seconds`` that starts now.""" + return Deadline(self._render_thread, seconds=seconds) + + def _wait( + self, + event: threading.Event, + *, + what: str, + ) -> None: + deadline = time.monotonic() + self._answer_timeout + while not event.wait(POLL_SECONDS): + if not self._render_thread.is_running() and not event.is_set(): + raise RenderThreadStoppedError(f"The application stopped before {what} came back") + if time.monotonic() >= deadline: + raise RenderThreadTimeoutError(f"The render thread left {what} unanswered for {self._answer_timeout} s") diff --git a/automation/dearpygui/display.py b/automation/dearpygui/display.py new file mode 100644 index 000000000..d792bf0d8 --- /dev/null +++ b/automation/dearpygui/display.py @@ -0,0 +1,78 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from typing import Final + +from pyvirtualdisplay.display import Display + +KEEP_STATE_BETWEEN_CLIENTS: Final[str] = "-noreset" + + +class DisplayBackend(StrEnum): + """The X server program a display runs on: Xvfb draws into memory, Xephyr into a window on the desktop.""" + + XVFB = "xvfb" + XEPHYR = "xephyr" + + @property + def program(self) -> str: + """The name of the X server program that draws for this backend.""" + return "Xvfb" if self is DisplayBackend.XVFB else "Xephyr" + + +class DisplayServerMissingError(RuntimeError): + """Raised when the X server a run draws on is not installed.""" + + +@dataclass(frozen=True) +class ScreenSize: + """The pixels a display's screen spans.""" + + width: int + height: int + + +class VirtualDisplay: + """An X server of a worker's own, which its scenarios draw on one after another. + + Xvfb draws into memory, which suits a run nobody watches. Xephyr draws into a window on the + desktop, which lets a person follow a scenario as it plays. Either server takes every event a + scenario sends, so the desktop around it stays untouched. + + The server goes on as it stands when its last client leaves. A server that resets then refuses + the connection an application opens a moment after another of its clients closed, and each + reset recompiles the keymap into output nobody reads, which stalls the server once that output + fills its pipe. + """ + + def __init__( + self, + backend: DisplayBackend, + size: ScreenSize, + ) -> None: + self._program = backend.program + self._server = Display( + backend=backend.value, + size=(size.width, size.height), + extra_args=[KEEP_STATE_BETWEEN_CLIENTS], + manage_global_env=False, + ) + + def start(self) -> str: + """Starts the server and returns the name a client connects to it under, such as ``:3``. + + Raises: + DisplayServerMissingError: If the server's program is not installed. + """ + if shutil.which(self._program) is None: + raise DisplayServerMissingError( + f"{self._program} is not installed. Install it from the system's packages, such as " + f"the xvfb package on Debian and Ubuntu, to run the screen scenarios." + ) + + self._server.start() + return self._server.new_display_var + + def stop(self) -> None: + """Stops the server and frees the display name it served.""" + self._server.stop() diff --git a/automation/dearpygui/geometry.py b/automation/dearpygui/geometry.py new file mode 100644 index 000000000..7fa7027f0 --- /dev/null +++ b/automation/dearpygui/geometry.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence + + +@dataclass(frozen=True) +class Point: + """A position on the screen, in whole pixels from its top left corner.""" + + x: int + y: int + + +@dataclass(frozen=True) +class Rect: + """A box on the screen: its top left corner and its size, in pixels.""" + + x: float + y: float + width: float + height: float + + @classmethod + def of( + cls, + corner: Sequence[float], + size: Sequence[float], + ) -> Rect: + """The box standing at ``corner`` with ``size``, as DearPyGui reports both.""" + return cls( + x=corner[0], + y=corner[1], + width=size[0], + height=size[1], + ) + + @property + def center(self) -> Point: + """The pixel a pointer aims at to land on the box.""" + return Point( + x=round(self.x + self.width / 2), + y=round(self.y + self.height / 2), + ) + + def overlap(self, other: Rect) -> Optional[Rect]: + """The part of this box ``other`` covers too, or ``None`` where the two do not meet.""" + left = max(self.x, other.x) + top = max(self.y, other.y) + right = min(self.x + self.width, other.x + other.width) + bottom = min(self.y + self.height, other.y + other.height) + if right <= left or bottom <= top: + return None + + return Rect(x=left, y=top, width=right - left, height=bottom - top) + + def holds(self, point: Point) -> bool: + """Whether ``point`` lies within this box, edges included.""" + return self.x <= point.x <= self.x + self.width and self.y <= point.y <= self.y + self.height + + def contains(self, other: Rect) -> bool: + """Whether ``other`` lies within this box, edges included.""" + return ( + self.x <= other.x + and self.y <= other.y + and other.x + other.width <= self.x + self.width + and other.y + other.height <= self.y + self.height + ) diff --git a/tests/unit/sampletones_core/project/instruments/__init__.py b/automation/dearpygui/gestures/__init__.py similarity index 100% rename from tests/unit/sampletones_core/project/instruments/__init__.py rename to automation/dearpygui/gestures/__init__.py diff --git a/automation/dearpygui/gestures/arrival.py b/automation/dearpygui/gestures/arrival.py new file mode 100644 index 000000000..f5c0dc5f9 --- /dev/null +++ b/automation/dearpygui/gestures/arrival.py @@ -0,0 +1,202 @@ +from contextlib import suppress +from functools import partial +from typing import Callable, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import ( + ONE_FRAME, + Bridge, + RenderThreadStoppedError, +) +from automation.dearpygui.geometry import Point +from automation.dearpygui.gestures.constants import REACH_TIMEOUT_SECONDS +from automation.dearpygui.gestures.errors import GestureLostError +from automation.dearpygui.gestures.witness import InputWitness +from automation.dearpygui.items.reading import read_hovered +from automation.dearpygui.items.types import Item +from automation.dearpygui.items.viewport import read_pointer, read_viewport +from automation.dearpygui.keys import key_name +from automation.dearpygui.reach import UnreachableError, reachable +from automation.dearpygui.xtest import XTestDevice + +ARRIVAL_FRAMES: Final[int] = 10 +IMGUI_DOUBLE_CLICK_SECONDS: Final[float] = 0.30 + + +class Arrival: + """The part of a hand that lets frames pass and confirms that its input reached DearPyGui. + + It owns the bridge, the display's input device, the witness of what Dear ImGui counted and the time of + the last release. It aims at an item once the item stands in reach and holds still, waits out a + double-click window, confirms that the pointer, a held button or a held key reads where it was put, and + confirms a press that came and went from the witness's counts. `Pointer` and `Keyboard` build their + gestures on these steps, and `Scrolling` reaches them through `Pointer`. + """ + + def __init__( + self, + bridge: Bridge, + device: XTestDevice, + ) -> None: + self._bridge = bridge + self._device = device + self._witness = InputWitness() + self._last_release: Optional[float] = None + + def _settle(self, count: int) -> None: + """Lets ``count`` frames pass once a gesture is under way, or none once it closed the application.""" + with suppress(RenderThreadStoppedError): + self._bridge.frames(count) + + def _application_seconds(self) -> Optional[float]: + """The application's clock, or ``None`` once a gesture has closed the application.""" + try: + return float(self._bridge.ask(dpg.get_total_time)) + except RenderThreadStoppedError: + return None + + def _outlast_a_double_click(self) -> None: + """Waits until the last press lies further back than a double-click reaches, in the application's clock. + + A person's next gesture comes later than that, while frames drawn quickly bring it within + reach, where Dear ImGui counts it with the clicks before it. + """ + last = self._last_release + if last is None: + return + + while float(self._bridge.ask(dpg.get_total_time)) - last < IMGUI_DOUBLE_CLICK_SECONDS: + self._bridge.frames(ONE_FRAME) + + def _on_display(self, point: Point) -> Point: + """The display pixel at ``point`` of the viewport.""" + viewport = self._bridge.ask(read_viewport) + return Point(x=round(viewport.x) + point.x, y=round(viewport.y) + point.y) + + def _aim(self, item: Item) -> Point: + """The screen pixel at the middle of ``item``, once a user could press it and it holds still. + + A control takes frames to appear after the gesture that brings it, and a window sized by its + content settles its place over its first frames. The hand waits until the control is in reach + and stands where it stood a frame before, the way a person waits for a dialog to stop moving, + and reports why it stayed out of reach once the wait runs out. + """ + + def read() -> Point: + center = reachable(item).center + viewport = read_viewport() + return Point( + x=round(viewport.x) + center.x, + y=round(viewport.y) + center.y, + ) + + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) + previous: Optional[Point] = None + while True: + try: + point = self._bridge.ask(read) + except UnreachableError: + if deadline.passed(): + raise + point = None + + if point is not None and point == previous: + return point + if deadline.passed(): + raise UnreachableError(f"{item!r} kept moving: it stood at {previous} and then at {point}") + + previous = point + self._bridge.frames(ONE_FRAME) + + def _hovered_within(self, item: Item, frames: int) -> bool: + """Whether ``item`` reports the pointer resting on it within ``frames`` frames, or reports no hover at all. + + An item standing in a tree that rebuilds reports no hover until the rebuild lets it go, which + is why the hover is aimed again until the reach time runs out. + """ + for _ in range(frames): + if self._bridge.ask(lambda: read_hovered(item)) is not False: + return True + + self._bridge.frames(ONE_FRAME) + + return False + + def _confirm_pointer(self, point: Point) -> None: + """Waits a few frames for the application to read the pointer at display pixel ``point``. + + Raises: + GestureLostError: If the application reads the pointer elsewhere throughout. + """ + viewport = self._bridge.ask(read_viewport) + expected = Point(x=point.x - round(viewport.x), y=point.y - round(viewport.y)) + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(read_pointer) == expected: + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"The pointer was moved to {expected} and the application reads it elsewhere") + + def _await_count( + self, + count: Callable[[], int], + target: int, + what: str, + ) -> bool: + """Waits a few frames until the witness's ``count`` reaches ``target``, and says whether the application + still runs. + + A display busy with other work hands input on a few frames late, so the count is read once a frame + for a few frames. A gesture that closes the application ends the wait where the application stopped. + + Raises: + GestureLostError: If the count stays short of ``target`` throughout. + """ + counted = 0 + for _ in range(ARRIVAL_FRAMES): + try: + counted = self._bridge.ask(count) + except RenderThreadStoppedError: + return False + if counted >= target: + return True + + self._settle(ONE_FRAME) + + raise GestureLostError( + f"{what} was pressed on the display, and the application counted {counted} of the {target} " + f"releases the press brings it to" + ) + + def _confirm_keys(self, keys: Sequence[int]) -> None: + for key in keys: + self._confirm(partial(dpg.is_key_down, key), f"The key {key_name(key)}") + + def _confirm( + self, + is_down: Callable[[], bool], + what: str, + ) -> None: + """Checks that the application reads a held button or key as down, unless the gesture closed it. + + A display busy with other work hands the press on a few frames late, so the check reads it + once a frame for a few frames while it stays held. + + Raises: + GestureLostError: If the application reads it as up throughout. + """ + for _ in range(ARRIVAL_FRAMES): + try: + if self._bridge.ask(is_down): + return + except RenderThreadStoppedError: + return + + self._settle(ONE_FRAME) + + raise GestureLostError(f"{what} is held on the display and the application reads it as up") diff --git a/automation/dearpygui/gestures/constants.py b/automation/dearpygui/gestures/constants.py new file mode 100644 index 000000000..c21a8ae79 --- /dev/null +++ b/automation/dearpygui/gestures/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +HOVER_FRAMES: Final[int] = 2 +HOLD_FRAMES: Final[int] = 2 +RELEASE_FRAMES: Final[int] = 1 +SETTLE_FRAMES: Final[int] = 2 +REACH_TIMEOUT_SECONDS: Final[float] = 10.0 diff --git a/automation/dearpygui/gestures/errors.py b/automation/dearpygui/gestures/errors.py new file mode 100644 index 000000000..70225fe24 --- /dev/null +++ b/automation/dearpygui/gestures/errors.py @@ -0,0 +1,8 @@ +class GestureLostError(AssertionError): + """Raised when a gesture's input never reached the control it was aimed at.""" + + +class SlowFramesError(GestureLostError): + """Raised when the display draws its frames too slowly for Dear ImGui to read a gesture the way a person + meant it. + """ diff --git a/automation/dearpygui/gestures/keyboard.py b/automation/dearpygui/gestures/keyboard.py new file mode 100644 index 000000000..cd37241fd --- /dev/null +++ b/automation/dearpygui/gestures/keyboard.py @@ -0,0 +1,63 @@ +from functools import partial +from typing import Sequence + +from automation.dearpygui.bridge import ONE_FRAME +from automation.dearpygui.gestures.arrival import Arrival +from automation.dearpygui.gestures.constants import HOLD_FRAMES, SETTLE_FRAMES +from automation.dearpygui.keys import ( + IMGUI_LEFT_SHIFT, + key_name, + keysym_of, + keysym_of_character, +) + + +class Keyboard(Arrival): + """The keyboard of a hand: key presses with modifiers, and typed text. + + It owns every gesture made with keys and builds each from `Arrival`'s steps. Modifiers go down first and + read as held, and the key itself goes down and comes up in one go, so Dear ImGui reads it down for a + single frame at any frame rate, the one press a person made, and the witness confirms its release. The + full hand also uses it to replace the text of a field. + """ + + def press_key( + self, + key: int, + *, + modifiers: Sequence[int], + ) -> None: + """Presses the Dear ImGui key ``key`` while holding the Dear ImGui keys in ``modifiers``.""" + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + try: + if modifiers: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + + released = partial(self._witness.key_releases, key) + before = self._bridge.ask(released) + self._device.tap_key(keysym_of(key)) + self._await_count(released, before + 1, f"The key {key_name(key)}") + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + + self._settle(SETTLE_FRAMES) + + def type_text(self, text: str) -> None: + """Types ``text`` into whatever holds the keyboard, one character a frame.""" + shift = keysym_of(IMGUI_LEFT_SHIFT) + for character in text: + keysym = keysym_of_character(character) + shifted = self._device.needs_shift(keysym) + if shifted: + self._device.key_down(shift) + try: + self._device.tap_key(keysym) + self._settle(ONE_FRAME) + finally: + if shifted: + self._device.key_up(shift) + + self._settle(SETTLE_FRAMES) diff --git a/automation/dearpygui/gestures/pointer.py b/automation/dearpygui/gestures/pointer.py new file mode 100644 index 000000000..4963f1794 --- /dev/null +++ b/automation/dearpygui/gestures/pointer.py @@ -0,0 +1,301 @@ +from typing import Dict, Final, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import ONE_FRAME +from automation.dearpygui.geometry import Point +from automation.dearpygui.gestures.arrival import ( + ARRIVAL_FRAMES, + IMGUI_DOUBLE_CLICK_SECONDS, + Arrival, +) +from automation.dearpygui.gestures.constants import ( + HOLD_FRAMES, + HOVER_FRAMES, + REACH_TIMEOUT_SECONDS, + RELEASE_FRAMES, + SETTLE_FRAMES, +) +from automation.dearpygui.gestures.errors import ( + GestureLostError, + SlowFramesError, +) +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.types import Item +from automation.dearpygui.keys import keysym_of +from automation.dearpygui.reach import UnreachableError +from automation.dearpygui.xtest import MouseButton + +SINGLE_PRESS: Final[int] = 1 +DOUBLE_PRESS: Final[int] = 2 +TRIPLE_PRESS: Final[int] = 3 +HOVER_ARRIVAL_FRAMES: Final[int] = 5 +DRAG_STEPS: Final[int] = 8 +TITLE_BAR_INSET: Final[int] = 10 + +IMGUI_MOUSE_BUTTONS: Final[Dict[MouseButton, int]] = { + MouseButton.LEFT: dpg.mvMouseButton_Left, + MouseButton.RIGHT: dpg.mvMouseButton_Right, + MouseButton.MIDDLE: dpg.mvMouseButton_Middle, +} + + +class Pointer(Arrival): + """The pointer of a hand: presses, double-clicks, drags and hovering. + + It owns every gesture made with the buttons and builds each from `Arrival`'s steps: aim at the item, + rest over it, press, confirm the press and settle. A click goes down and comes up in one go, and so do + the presses of a double-click, so Dear ImGui reads each press on a frame of its own at any frame rate. + The presses of a double-click then land two frames apart, and Dear ImGui counts them as one double-click + while those frames take less than its double-click time between them. `Scrolling` reuses its hover and + drag to reach scrollbars and the wheel. + """ + + def click(self, item: Item) -> None: + """Presses ``item`` with the left button.""" + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + + def click_holding( + self, + item: Item, + modifiers: Sequence[int], + ) -> None: + """Presses ``item`` with the left button while holding the Dear ImGui keys in ``modifiers``. + + The keys go down a frame before the button and come up once the press has settled, which is + how a person makes a Ctrl-click, so an application reading them while it answers the press + finds them held. + """ + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + try: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + self._press(item, MouseButton.LEFT, SINGLE_PRESS) + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + + self._settle(SETTLE_FRAMES) + + def click_title_bar(self, window: Item) -> None: + """Clicks the title bar of ``window``, which takes the focus from a field in it and leaves the fields as + they are. + + Raises: + UnreachableError: If the window reports no box. + """ + rect = self._bridge.ask(lambda: read_item(window).rect) + if rect is None: + raise UnreachableError(f"{window!r} reports no box to click the title bar of") + + self.click_at(Point(x=rect.center.x, y=round(rect.y) + TITLE_BAR_INSET)) + + def double_click(self, item: Item) -> None: + """Presses ``item`` twice in quick succession with the left button.""" + self._press(item, MouseButton.LEFT, DOUBLE_PRESS) + + def double_click_held( + self, + item: Item, + *, + frames: int, + ) -> None: + """Presses ``item`` twice with the left button, holding the second press down for ``frames`` frames. + + A person lets go of a click some frames after pressing it, and the double-click is answered + as the second press goes down, so whatever the answer brings under the pointer meets a held + button. + """ + self.hover(item) + self._outlast_a_double_click() + imgui_button = IMGUI_MOUSE_BUTTONS[MouseButton.LEFT] + releases, double_clicks = self._bridge.ask(lambda: self._counts(imgui_button)) + self._device.click_button(MouseButton.LEFT, SINGLE_PRESS) + self._device.button_down(MouseButton.LEFT) + try: + self._confirm( + lambda: dpg.is_mouse_button_down(imgui_button), + "The left button", + ) + self._require_double_click(imgui_button, releases, double_clicks) + self._settle(frames) + finally: + self._device.button_up(MouseButton.LEFT) + self._await_count( + lambda: self._witness.button_releases(imgui_button), + releases + DOUBLE_PRESS, + "The left button", + ) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def right_click(self, item: Item) -> None: + """Presses ``item`` with the right button, the gesture that opens a context menu.""" + self._press(item, MouseButton.RIGHT, SINGLE_PRESS) + + def click_at(self, point: Point) -> None: + """Presses the left button at ``point`` of the viewport, for a target DearPyGui reports a position alone for. + + The press confirms it reached the application, while what it landed on is for the scenario + to read from what the press changed. + """ + self._press_at(point, MouseButton.LEFT, SINGLE_PRESS) + + def double_click_at(self, point: Point) -> None: + """Presses the left button twice in quick succession at ``point`` of the viewport.""" + self._press_at(point, MouseButton.LEFT, DOUBLE_PRESS) + + def right_click_at(self, point: Point) -> None: + """Presses the right button at ``point`` of the viewport, for a menu a place offers rather than an item.""" + self._press_at(point, MouseButton.RIGHT, SINGLE_PRESS) + + def triple_click_at(self, point: Point) -> None: + """Presses the left button three times in quick succession at ``point`` of the viewport.""" + self._press_at(point, MouseButton.LEFT, TRIPLE_PRESS) + + def move_to(self, point: Point) -> None: + """Rests the pointer at ``point`` of the viewport.""" + self._device.move(self._on_display(point)) + self._settle(HOVER_FRAMES) + + def drag(self, start: Point, end: Point) -> None: + """Presses the left button at ``start`` of the viewport, carries it to ``end`` over a few frames, and lets go. + + The press is confirmed held as the pointer sets off, so a drag the application never saw + fails where it was lost. + """ + self._outlast_a_double_click() + self._drag_between(self._on_display(start), self._on_display(end)) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def hover(self, item: Item) -> None: + """Rests the pointer over ``item`` until the item reports it. + + Raises: + GestureLostError: If the item never reports the pointer resting on it. + """ + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) + while True: + self._device.move(self._aim(item)) + self._bridge.frames(HOVER_FRAMES) + if self._hovered_within(item, HOVER_ARRIVAL_FRAMES): + return + if deadline.passed(): + raise GestureLostError( + f"The pointer was aimed at {item!r} and the item reports no hover: " + f"something covers it, or the region it scrolls in clips it" + ) + + def _press( + self, + item: Item, + button: MouseButton, + count: int, + ) -> None: + self.hover(item) + self._outlast_a_double_click() + self._press_where_it_stands(button, count) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _press_where_it_stands( + self, + button: MouseButton, + count: int, + ) -> None: + """Presses ``button`` ``count`` times in one go where the pointer stands, and confirms Dear ImGui read + every release, and the double-click when the presses are more than one. + """ + imgui_button = IMGUI_MOUSE_BUTTONS[button] + releases, double_clicks = self._bridge.ask(lambda: self._counts(imgui_button)) + self._device.click_button(button, count) + running = self._await_count( + lambda: self._witness.button_releases(imgui_button), + releases + count, + f"The {button.name.lower()} button", + ) + if running and count > SINGLE_PRESS: + self._require_double_click(imgui_button, releases, double_clicks) + + def _counts(self, imgui_button: int) -> Tuple[int, int]: + """The releases and the double-clicks the witness counted of ``imgui_button`` so far.""" + return ( + self._witness.button_releases(imgui_button), + self._witness.double_clicks(imgui_button), + ) + + def _require_double_click( + self, + imgui_button: int, + releases: int, + double_clicks: int, + ) -> None: + """Checks that Dear ImGui counted a double-click since the witness counted ``double_clicks`` of them. + + The presses came two frames apart, so a missing double-click says the display drew those frames + more slowly than Dear ImGui's double-click time allows. The witness counts a double-click among + the callbacks the frame holds, which can run after the button already reads down, so the count + is read once a frame for a few frames. + + Raises: + SlowFramesError: If Dear ImGui counted the presses as separate clicks. + """ + for _ in range(ARRIVAL_FRAMES): + if self._bridge.ask(lambda: self._witness.double_clicks(imgui_button)) > double_clicks: + return + + self._settle(ONE_FRAME) + + times = self._bridge.ask(lambda: self._witness.button_release_times(imgui_button))[releases:] + took = f"{times[1] - times[0]:.2f} s" if len(times) > 1 else "longer than that" + raise SlowFramesError( + f"Dear ImGui counted no double-click: it counts two presses landing within " + f"{IMGUI_DOUBLE_CLICK_SECONDS} s of each other, and the two frames between these took {took}" + ) + + def _press_at( + self, + point: Point, + button: MouseButton, + count: int, + ) -> None: + self.move_to(point) + self._outlast_a_double_click() + self._press_where_it_stands(button, count) + self._last_release = self._application_seconds() + self._settle(SETTLE_FRAMES) + + def _drag_between(self, start: Point, end: Point) -> None: + """Presses the left button at display pixel ``start``, carries it to ``end`` over a few frames, and lets go. + + Each step waits until the application reads the pointer where the step put it, so every + point the drag passes through reaches the application while the button is down, and the + pointer rests at ``end`` a few frames before the button comes up, as a person stops before + letting go. + """ + self._device.move(start) + self._settle(HOVER_FRAMES) + self._device.button_down(MouseButton.LEFT) + try: + self._settle(HOLD_FRAMES) + self._confirm( + lambda: dpg.is_mouse_button_down(dpg.mvMouseButton_Left), + "The left button", + ) + + for step in range(1, DRAG_STEPS + 1): + point = Point( + x=start.x + round((end.x - start.x) * step / DRAG_STEPS), + y=start.y + round((end.y - start.y) * step / DRAG_STEPS), + ) + self._device.move(point) + self._settle(ONE_FRAME) + self._confirm_pointer(point) + + self._settle(HOLD_FRAMES) + finally: + self._device.button_up(MouseButton.LEFT) + + self._settle(RELEASE_FRAMES) diff --git a/automation/dearpygui/gestures/scrolling.py b/automation/dearpygui/gestures/scrolling.py new file mode 100644 index 000000000..fc57d417f --- /dev/null +++ b/automation/dearpygui/gestures/scrolling.py @@ -0,0 +1,412 @@ +from functools import partial +from typing import Callable, Final, Optional, Sequence, Tuple + +from automation.dearpygui.bridge import ONE_FRAME +from automation.dearpygui.geometry import Point +from automation.dearpygui.gestures.constants import ( + HOLD_FRAMES, + HOVER_FRAMES, + REACH_TIMEOUT_SECONDS, + SETTLE_FRAMES, +) +from automation.dearpygui.gestures.pointer import Pointer +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.regions import ( + enclosing_regions, + read_region_view, + read_scroll, + read_sideways_scroll, + read_visible_box, +) +from automation.dearpygui.items.types import Item +from automation.dearpygui.items.viewport import read_viewport +from automation.dearpygui.keys import IMGUI_LEFT_SHIFT, keysym_of +from automation.dearpygui.reach import UnreachableError +from automation.dearpygui.xtest import MouseButton + +WHEEL_FRAMES: Final[int] = 1 +WHEEL_DOWN: Final[int] = 1 +WHEEL_UP: Final[int] = -1 +SCROLL_NOTCH_LIMIT: Final[int] = 100 +EDGE_INSET: Final[int] = 3 +GRIP_MARGIN_PIXELS: Final[float] = 8.0 +GRIP_DRAGS: Final[int] = 4 +IMGUI_GRAB_MIN_SIZE: Final[float] = 12.0 + +RegionLayout = Tuple[float, float] + + +class Scrolling(Pointer): + """The wheel and the scrollbars of a hand: turning the wheel over a region and bringing an item into view. + + It owns every gesture that moves a region's scroll. It rests the pointer with `Pointer`'s hover, + turns the wheel through the display's input device, and drags a scrollbar grip with `Pointer`'s drag + where the wheel leaves the region alone. + """ + + def wheel( + self, + item: Item, + notches: int, + ) -> None: + """Rests the pointer over ``item`` and turns the wheel ``notches`` notches, down for a positive count.""" + self.hover(item) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def wheel_at( + self, + point: Point, + notches: int, + ) -> None: + """Rests the pointer at viewport point ``point`` and turns the wheel ``notches`` notches, down if positive.""" + self.move_to(point) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def turn_wheel_over( + self, + region: Item, + notches: int, + ) -> None: + """Turns the wheel ``notches`` notches over the right edge of ``region``, down for a positive count. + + The pointer goes where it goes whatever stands open, so the gesture reports nothing; the + scenario reads what the turn moved. + """ + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + self._turn_wheel(notches) + self._settle(SETTLE_FRAMES) + + def scroll_to_end(self, region: Item) -> None: + """Drags the grip of ``region``'s scrollbar to the bottom, as a person reaches the end of a long list. + + A list drawing only the rows in view settles its length as it scrolls, so the grip is dragged + again while the end moves on, and a turn of the wheel takes up the pixels the grip's whole + steps leave over. + + Raises: + UnreachableError: If the region stands short of its end afterwards. + """ + self.scroll_into_view(region) + for _ in range(GRIP_DRAGS): + before = self._bridge.ask(lambda: read_scroll(region)) + if before.position >= before.maximum: + return + + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.maximum + GRIP_MARGIN_PIXELS)) + self._drag_between(start, end) + self._settle(SETTLE_FRAMES) + + short = self._bridge.ask(lambda: read_scroll(region)) + if short.position < short.maximum and short.by_wheel: + self.turn_wheel_over(region, 1) + + after = self._bridge.ask(lambda: read_scroll(region)) + if after.position < after.maximum: + raise UnreachableError(f"The region {region!r} stands short of its end at {after}") + + def scroll_to_top(self, item: Item) -> None: + """Brings ``item`` into view, then turns the wheel over the nearest region it scrolls in a notch at a + time until the item stands at the top of the view, or the region reaches its end, and turns one + notch back where the last notch carried the item past the top. + + Raises: + UnreachableError: If the item scrolls in no region, or the region stops moving. + """ + self._scroll_to_edge(item, _distance_from_top, WHEEL_DOWN) + + def scroll_to_bottom(self, item: Item) -> None: + """Brings ``item`` into view, then turns the wheel over the nearest region it scrolls in a notch at a + time until the item stands at the bottom of the view, or the region reaches its start, and turns one + notch back where the last notch carried the item past the bottom. + + Raises: + UnreachableError: If the item scrolls in no region, or the region stops moving. + """ + self._scroll_to_edge(item, _distance_from_bottom, WHEEL_UP) + + def _scroll_to_edge( + self, + item: Item, + distance_from_edge: Callable[[Item, Item], float], + direction: int, + ) -> None: + self.scroll_into_view(item) + regions = self._bridge.ask(lambda: enclosing_regions(item)) + if not regions: + raise UnreachableError(f"{item!r} scrolls in no region") + + region = regions[0] + for _ in range(SCROLL_NOTCH_LIMIT): + distance = self._bridge.ask(lambda: distance_from_edge(item, region)) + before = self._bridge.ask(lambda: read_scroll(region)) + at_the_end = before.position >= before.maximum if direction > 0 else before.position <= 0 + if distance <= 0 or at_the_end or not before.by_wheel: + self._settle(SETTLE_FRAMES) + return + + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + self._turn_wheel(direction) + self._settle(ONE_FRAME) + after = self._bridge.ask(lambda: read_scroll(region)) + if after == before: + raise UnreachableError(f"{item!r} stays off the edge: the region {region!r} stopped at {before}") + + if self._bridge.ask(lambda: distance_from_edge(item, region)) < 0: + self._turn_wheel(-direction) + self._settle(SETTLE_FRAMES) + return + + raise UnreachableError(f"{item!r} stays off the edge after {SCROLL_NOTCH_LIMIT} turns of the wheel") + + def scroll_into_view(self, item: Item) -> None: + """Turns the wheel over the regions ``item`` scrolls in until the item stands whole in view. + + The nearest region that scrolls is moved first, at its right edge, and the regions around it + after. The wheel moves a region that takes it: the first notch shows how far one notch carries + the region, and each later turn takes as many notches as the distance left needs, the way a + person spins the wheel toward a row. A region the wheel leaves alone is moved by dragging its + scrollbar's grip to where the grip stands for the scroll the item needs, and a few pixels on, + since the grip moves in whole pixels and each of them scrolls further than one. A region that + scrolls sideways as well, such as a deep tree, is then moved sideways with the wheel turned + while Shift is held, which is how Dear ImGui takes a sideways turn. + + Raises: + UnreachableError: If a region stops moving, or the wheel runs out of turns, first. + """ + regions = self._bridge.ask(lambda: enclosing_regions(item)) + self._await_layout(item, regions) + for region in regions: + self._scroll_within(item, region) + self._scroll_sideways_within(item, region) + + self._settle(SETTLE_FRAMES) + + def _await_layout(self, item: Item, regions: Tuple[Item, ...]) -> None: + """Waits until a frame has laid ``item`` out in ``regions`` and the regions have measured what they hold. + + A row built in the frame it is found in has no place yet, and a region learns how far it can + scroll a frame after it draws what it holds, so a reading taken sooner finds nothing to scroll. + The layout has settled once where the item stands in each region's content and how far each + region can scroll read the same two frames running. Both are counted from the content's top, + so a region scrolling on its own, such as one following the playback, settles all the same. + + Raises: + UnreachableError: If the layout keeps changing until the wait runs out. + """ + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) + previous: Optional[Tuple[RegionLayout, ...]] = None + while True: + layout = self._bridge.ask(partial(_layout_in, item, regions)) + if layout is not None and layout == previous: + return + if deadline.passed(): + raise UnreachableError(f"{item!r} found no place in its regions: it stood at {previous}, then {layout}") + + previous = layout + self._bridge.frames(ONE_FRAME) + + def _scroll_within(self, item: Item, region: Item) -> None: + notch = 0.0 + for _ in range(SCROLL_NOTCH_LIMIT): + distance = self._bridge.ask(lambda: _distance_from_view(item, region)) + before = self._bridge.ask(lambda: read_scroll(region)) + if distance == 0 or before.maximum == 0: + return + + if before.by_wheel: + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 + self._turn_wheel(notches if distance > 0 else -notches) + else: + margin = GRIP_MARGIN_PIXELS if distance > 0 else -GRIP_MARGIN_PIXELS + start = self._bridge.ask(partial(_grip_point, region, before.position)) + end = self._bridge.ask(partial(_grip_point, region, before.position + distance + margin)) + self._drag_between(start, end) + notches = 1 + + self._settle(ONE_FRAME) + after = self._bridge.ask(lambda: read_scroll(region)) + if after == before: + raise UnreachableError(f"{item!r} stays out of view: the region {region!r} stopped at {before}") + + notch = abs(after.position - before.position) / notches + + raise UnreachableError(f"{item!r} stays out of view after {SCROLL_NOTCH_LIMIT} turns of the wheel") + + def _scroll_sideways_within(self, item: Item, region: Item) -> None: + notch = 0.0 + for _ in range(SCROLL_NOTCH_LIMIT): + distance = self._bridge.ask(lambda: _sideways_distance_from_view(item, region)) + before = self._bridge.ask(lambda: read_sideways_scroll(region)) + if distance == 0 or before.maximum == 0: + return + if not before.by_wheel: + raise UnreachableError( + f"{item!r} stands beside the view of the region {region!r}, which takes no wheel" + ) + + self._device.move(self._bridge.ask(lambda: _wheel_point(region))) + self._settle(HOVER_FRAMES) + notches = max(1, int(abs(distance) // notch)) if notch > 0 else 1 + self._turn_wheel_holding(notches if distance > 0 else -notches, [IMGUI_LEFT_SHIFT]) + self._settle(ONE_FRAME) + after = self._bridge.ask(lambda: read_sideways_scroll(region)) + if after == before: + raise UnreachableError(f"{item!r} stays beside the view: the region {region!r} stopped at {before}") + + notch = abs(after.position - before.position) / notches + + raise UnreachableError(f"{item!r} stays beside the view after {SCROLL_NOTCH_LIMIT} turns of the wheel") + + def _turn_wheel(self, notches: int) -> None: + button = MouseButton.WHEEL_DOWN if notches > 0 else MouseButton.WHEEL_UP + for _ in range(abs(notches)): + self._device.button_down(button) + self._settle(WHEEL_FRAMES) + self._device.button_up(button) + self._settle(WHEEL_FRAMES) + + def _turn_wheel_holding(self, notches: int, modifiers: Sequence[int]) -> None: + """Turns the wheel while holding the Dear ImGui keys in ``modifiers``, which go down a frame before.""" + for modifier in modifiers: + self._device.key_down(keysym_of(modifier)) + try: + self._settle(HOLD_FRAMES) + self._confirm_keys(modifiers) + self._turn_wheel(notches) + finally: + for modifier in reversed(modifiers): + self._device.key_up(keysym_of(modifier)) + + +def _layout_in(item: Item, regions: Tuple[Item, ...]) -> Optional[Tuple[RegionLayout, ...]]: + """Where ``item`` stands in the content of each region, and how far each region can scroll. + + Reads ``None`` while the item or a region reports no box. Runs on the render thread. + """ + item_box = read_item(item).rect + if item_box is None: + return None + + layout = [] + for region in regions: + region_box = read_region_view(region) + if region_box is None: + return None + + scroll = read_scroll(region) + layout.append((item_box.y - region_box.y + scroll.position, scroll.maximum)) + + return tuple(layout) + + +def _distance_from_view(item: Item, region: Item) -> float: + """How far the region must scroll for ``item`` to stand whole in its view: down for a positive distance. + + Runs on the render thread. + """ + item_box = read_item(item).rect + region_box = read_region_view(region) + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + below = item_box.y + item_box.height - (region_box.y + region_box.height) + if below > 0: + return below + above = item_box.y - region_box.y + if above < 0: + return above + + return 0.0 + + +def _distance_from_top(item: Item, region: Item) -> float: + """How far ``item`` stands below the top of ``region``'s view. Runs on the render thread.""" + item_box = read_item(item).rect + region_box = read_region_view(region) + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + return item_box.y - region_box.y + + +def _distance_from_bottom(item: Item, region: Item) -> float: + """How far ``item`` stands above the bottom of ``region``'s view. Runs on the render thread.""" + item_box = read_item(item).rect + region_box = read_region_view(region) + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + return region_box.y + region_box.height - (item_box.y + item_box.height) + + +def _sideways_distance_from_view(item: Item, region: Item) -> float: + """How far the region must scroll for ``item`` to stand whole in its view: right for a positive distance. + + Runs on the render thread. + """ + item_box = read_item(item).rect + region_box = read_region_view(region) + if item_box is None or region_box is None: + raise UnreachableError(f"{item!r} or the region {region!r} it scrolls in reports no box") + + beyond = item_box.x + item_box.width - (region_box.x + region_box.width) + if beyond > 0: + return beyond + before = item_box.x - region_box.x + if before < 0: + return before + + return 0.0 + + +def _wheel_point(region: Item) -> Point: + """A pixel at the right edge of the part of ``region`` left in view, where its scrollbar stands. + + The middle of a region often lies over a smaller region it holds, which would take the wheel; + the edge belongs to the region itself. Runs on the render thread. + """ + visible = read_visible_box(region) + if visible is None: + raise UnreachableError(f"No part of the region {region!r} stands in view to turn the wheel over") + + viewport = read_viewport() + return Point( + x=round(viewport.x + visible.x + visible.width) - EDGE_INSET, + y=round(viewport.y) + visible.center.y, + ) + + +def _grip_point(region: Item, scroll: float) -> Point: + """The pixel at the middle of ``region``'s scrollbar grip while the region stands scrolled to ``scroll``. + + The grip spans the share of the content the region shows, held at Dear ImGui's smallest grip, + and travels the rest of the track as the region scrolls from its top to its end. The track spans + the region's whole height, so the region stands whole in view for its grip to be dragged. Runs on + the render thread. + """ + box = read_item(region).rect + if box is None: + raise UnreachableError(f"The region {region!r} reports no box to drag its scrollbar in") + + reading = read_scroll(region) + target = min(max(scroll, 0.0), reading.maximum) + grip = min( + max( + box.height * box.height / (box.height + reading.maximum), + IMGUI_GRAB_MIN_SIZE, + ), + box.height, + ) + travel = box.height - grip + viewport = read_viewport() + return Point( + x=round(viewport.x + box.x + box.width) - EDGE_INSET, + y=round(viewport.y + box.y + grip / 2 + travel * target / reading.maximum), + ) diff --git a/automation/dearpygui/gestures/witness.py b/automation/dearpygui/gestures/witness.py new file mode 100644 index 000000000..f7b48f620 --- /dev/null +++ b/automation/dearpygui/gestures/witness.py @@ -0,0 +1,62 @@ +from collections import Counter +from typing import Dict, Final, List, Optional, Tuple, Union + +import dearpygui.dearpygui as dpg + +ANY_KEY: Final[int] = 0 +ANY_BUTTON: Final[int] = -1 + + +class InputWitness: + """What Dear ImGui counted of the display's input: each release of a key or a button, and each double-click. + + It is a global handler registry of its own in the application's DearPyGui context, so its counts grow as + the application's frames read the input, and a count stays once a press has come and gone. A hand reads + them to confirm a press that stood down for a single frame. Every call runs on the render thread, and the + first call puts the registry in place. + """ + + def __init__(self) -> None: + self._registry: Optional[Union[int, str]] = None + self._key_releases: Counter[int] = Counter() + self._button_release_times: Dict[int, List[float]] = {} + self._double_clicks: Counter[int] = Counter() + + def key_releases(self, key: int) -> int: + """How many times Dear ImGui read the Dear ImGui key ``key`` coming up.""" + self._install() + return self._key_releases[key] + + def button_releases(self, button: int) -> int: + """How many times Dear ImGui read the Dear ImGui mouse button ``button`` coming up.""" + self._install() + return len(self._button_release_times.get(button, ())) + + def button_release_times(self, button: int) -> Tuple[float, ...]: + """The application's clock at each release of the Dear ImGui mouse button ``button``, oldest first.""" + self._install() + return tuple(self._button_release_times.get(button, ())) + + def double_clicks(self, button: int) -> int: + """How many double-clicks Dear ImGui counted with the Dear ImGui mouse button ``button``.""" + self._install() + return self._double_clicks[button] + + def _install(self) -> None: + if self._registry is not None and dpg.does_item_exist(self._registry): + return + + with dpg.handler_registry() as registry: + dpg.add_key_release_handler(key=ANY_KEY, callback=self._on_key_released) + dpg.add_mouse_release_handler(button=ANY_BUTTON, callback=self._on_button_released) + dpg.add_mouse_double_click_handler(button=ANY_BUTTON, callback=self._on_double_clicked) + self._registry = registry + + def _on_key_released(self, _sender: Union[int, str], key: int) -> None: + self._key_releases[key] += 1 + + def _on_button_released(self, _sender: Union[int, str], button: int) -> None: + self._button_release_times.setdefault(button, []).append(float(dpg.get_total_time())) + + def _on_double_clicked(self, _sender: Union[int, str], button: int) -> None: + self._double_clicks[button] += 1 diff --git a/automation/dearpygui/hand.py b/automation/dearpygui/hand.py new file mode 100644 index 000000000..181e9958a --- /dev/null +++ b/automation/dearpygui/hand.py @@ -0,0 +1,42 @@ +from automation.dearpygui.gestures.keyboard import Keyboard +from automation.dearpygui.gestures.scrolling import Scrolling +from automation.dearpygui.items.types import Item +from automation.dearpygui.keys import ( + IMGUI_BACKSPACE, + IMGUI_LEFT_CTRL, + IMGUI_LETTER_A, +) + + +class Hand(Scrolling, Keyboard): + """A user's hand on the application: the pointer and the keyboard of the scenario's display. + + A gesture first reads where its control stands, refusing one beyond a user's reach, and then + plays the events a person makes. Every step waits whole frames, since the application reads + input once a frame: the pointer rests over a control before pressing it, and a modifier is held + a frame before the key it modifies goes down. A click and a key press go down and come up in one + go, which Dear ImGui reads over two frames, so a press means what a person meant at any frame + rate: a key stands down too briefly to repeat, and the presses of a double-click land two frames + apart. A gesture that closes the application ends where the application stopped, which is how a + scenario presses the button that leaves it. + + A gesture confirms that it arrived. The control reports the pointer resting on it before a + button goes down, a held button or modifier reads as down while it is held, and Dear ImGui + counts the release of every click and key press and the double-click of a double press. A press + lost on the way fails where it was lost, and a scenario expecting nothing to happen learns that + its gesture was made. + + The pieces are `Arrival` (waiting and confirming), `Pointer` (presses and drags), `Scrolling` + (the wheel and the scrollbars) and `Keyboard` (keys and typed text). + """ + + def replace_text( + self, + item: Item, + text: str, + ) -> None: + """Clicks the field ``item``, selects what it holds, deletes it, and types ``text`` in its place.""" + self.click(item) + self.press_key(IMGUI_LETTER_A, modifiers=[IMGUI_LEFT_CTRL]) + self.press_key(IMGUI_BACKSPACE, modifiers=[]) + self.type_text(text) diff --git a/automation/dearpygui/hosting.py b/automation/dearpygui/hosting.py new file mode 100644 index 000000000..200ea39c6 --- /dev/null +++ b/automation/dearpygui/hosting.py @@ -0,0 +1,89 @@ +import threading +from typing import Callable, Final, List, Protocol + +SCENARIO_THREAD_NAME: Final[str] = "scenario" + + +class ScenarioHangError(AssertionError): + """Raised when a scenario's thread is still running after the application stopped.""" + + +class HostedLoop(Protocol): + """An application's render loop, run on the main thread while a scenario drives it from another. + + The host runs the loop where a windowing system wants its events handled. The scenario finishes + the run from its own thread, and the host closes it once the loop has returned. + """ + + def run(self) -> None: + """Draws frames on this thread until the application stops.""" + + def finish(self, *, failed: bool) -> None: + """Brings the application to a stop once the scenario is done. Runs on the scenario's thread. + + Raises: + AssertionError: Naming what the application showed wrong while it stopped. + """ + + def close(self) -> None: + """Reads what the stopped application left behind. Runs on the main thread after the loop. + + Raises: + AssertionError: Naming what the application left wrong. + """ + + +def host( + scenario: Callable[[], None], + loop: HostedLoop, + *, + join_timeout: float, +) -> None: + """Runs ``scenario`` on a thread of its own while this thread runs ``loop``, then reports how it went. + + The first failure is the one raised, and every later one rides along as a note on it, so a + failing scenario still reports what its application did wrong on the way out. + + Raises: + BaseException: The first failure met: the scenario's own, the finish's or the close's. + ScenarioHangError: If the scenario's thread outlives the loop by more than ``join_timeout``. + """ + failures: List[BaseException] = [] + + def drive() -> None: + try: + scenario() + except BaseException as error: # pylint: disable=broad-exception-caught + failures.append(error) + + try: + loop.finish(failed=bool(failures)) + except BaseException as error: # pylint: disable=broad-exception-caught + failures.append(error) + + thread = threading.Thread(target=drive, name=SCENARIO_THREAD_NAME, daemon=True) + thread.start() + loop.run() + thread.join(join_timeout) + if thread.is_alive(): + failures.append( + ScenarioHangError(f"The scenario was still running {join_timeout} s after the application stopped") + ) + + try: + loop.close() + except AssertionError as error: + failures.append(error) + + _raise_first(failures) + + +def _raise_first(failures: List[BaseException]) -> None: + if not failures: + return + + first, *rest = failures + for later in rest: + first.add_note(f"Also: {type(later).__name__}: {later}") + + raise first diff --git a/automation/dearpygui/isolation.py b/automation/dearpygui/isolation.py new file mode 100644 index 000000000..e23362969 --- /dev/null +++ b/automation/dearpygui/isolation.py @@ -0,0 +1,251 @@ +import json +import signal +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import ( + Dict, + Final, + List, + Literal, + Mapping, + Optional, + Sequence, + Union, +) + +import pytest + +PHASES: Final[Sequence[Literal["setup", "call", "teardown"]]] = ( + "setup", + "call", + "teardown", +) + +CHILD_ARGUMENTS: Final[Sequence[str]] = ( + "-p", + "no:cacheprovider", + "-p", + "no:cov", + "-p", + "no:xdist", + "-q", + "--no-header", +) + +OUTPUT_TAIL_CHARACTERS: Final[int] = 20_000 +OVERRIDE_OPTION: Final[str] = "override_ini" +OVERRIDE_FLAG: Final[str] = "-o" +NODE_SEPARATOR: Final[str] = "::" +XFAIL_MARKER: Final[str] = "xfail" +XFAIL_REASON: Final[str] = "reason" + + +@dataclass(frozen=True) +class ChildRun: + """How a child process ended: its exit status, or ``None`` when it ran out of time, and its output.""" + + returncode: Optional[int] + output: str + + @property + def crashed(self) -> bool: + """Whether the process ended by a signal or ran out of time.""" + return self.returncode is None or self.returncode < 0 + + @property + def killed_by_signal(self) -> bool: + """Whether the process ended by a signal, such as a crash.""" + return self.returncode is not None and self.returncode < 0 + + @property + def ending(self) -> str: + """The way the process ended, in words a failure report can carry.""" + if self.returncode is None: + return "ran out of time and was stopped" + if self.returncode < 0: + return f"was killed by {signal.Signals(-self.returncode).name}" + + return f"exited with status {self.returncode}" + + +class ReportRecorder: + """Writes each report the child's run makes to a file, in the form pytest sends between processes. + + A report is written the moment its phase ends, so a process that dies in a later phase still + leaves the earlier reports behind. + """ + + def __init__( + self, + config: pytest.Config, + path: Path, + ) -> None: + self._config = config + self._path = path + + @pytest.hookimpl + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + """Appends the report of one finished phase to the file as a line of JSON.""" + data = self._config.hook.pytest_report_to_serializable(config=self._config, report=report) + with self._path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(data) + "\n") + + +def run_isolated( + item: pytest.Item, + *, + environment: Mapping[str, str], + working_directory: Path, + report_path: Path, + timeout: float, +) -> List[pytest.TestReport]: + """Runs ``item`` in a fresh interpreter and returns its reports, as if it had run here. + + The child is a pytest run of the item's node id alone, under ``environment`` and started in + ``working_directory``, which is where the application under test finds itself launched. It + writes its reports to ``report_path``. A phase missing from them, because the child crashed or + ran out of time, comes back as a failure carrying the child's output, and so does the last phase + of a child that crashed after reporting all of them. + """ + child = _run_child( + item, + environment=environment, + working_directory=working_directory, + timeout=timeout, + ) + reports = _read_reports(item.config, report_path) + return _completed(item, reports, child) + + +def _run_child( + item: pytest.Item, + *, + environment: Mapping[str, str], + working_directory: Path, + timeout: float, +) -> ChildRun: + command = [ + sys.executable, + "-m", + "pytest", + _located_nodeid(item), + f"--rootdir={item.config.rootpath}", + *CHILD_ARGUMENTS, + *_overrides(item.config), + ] + try: + completed = subprocess.run( + command, + cwd=working_directory, + env=dict(environment), + timeout=timeout, + capture_output=True, + text=True, + check=False, + ) + except subprocess.TimeoutExpired as expired: + return ChildRun( + returncode=None, + output=_text(expired.stdout) + _text(expired.stderr), + ) + + return ChildRun( + returncode=completed.returncode, + output=completed.stdout + completed.stderr, + ) + + +def _overrides(config: pytest.Config) -> List[str]: + """The ``-o`` settings the parent run was started with, which the child collects its item under too.""" + stated: Optional[List[str]] = config.getoption(OVERRIDE_OPTION, default=None) + return [argument for override in stated or [] for argument in (OVERRIDE_FLAG, override)] + + +def _located_nodeid(item: pytest.Item) -> str: + """The item's node id with its file named by an absolute path, which a run started anywhere finds.""" + _, separator, name = item.nodeid.partition(NODE_SEPARATOR) + return f"{item.path}{separator}{name}" + + +def _read_reports(config: pytest.Config, path: Path) -> Dict[str, pytest.TestReport]: + if not path.exists(): + return {} + + reports: Dict[str, pytest.TestReport] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + report = config.hook.pytest_report_from_serializable(config=config, data=json.loads(line)) + reports[report.when] = report + + return reports + + +def _completed( + item: pytest.Item, + reports: Dict[str, pytest.TestReport], + child: ChildRun, +) -> List[pytest.TestReport]: + completed: List[pytest.TestReport] = [] + for phase in PHASES: + report = reports.get(phase) + if report is None: + report = _missing_report(item, phase, child, failed=not completed or completed[-1].passed) + + completed.append(report) + if report.failed and phase == "setup": + break + + if child.crashed and len(reports) == len(PHASES): + completed[-1] = _missing_report(item, PHASES[-1], child, failed=True) + + return completed + + +def _missing_report( + item: pytest.Item, + phase: Literal["setup", "call", "teardown"], + child: ChildRun, + *, + failed: bool, +) -> pytest.TestReport: + """The report of a phase the child never wrote, since the process ended first. + + A scenario marked to fail by a known defect, whose process a signal killed, reports that phase as the expected + failure it names: a crash is a defect the ledger records like any other. + """ + expected = item.get_closest_marker(XFAIL_MARKER) + if failed and child.killed_by_signal and expected is not None: + return pytest.TestReport( + nodeid=item.nodeid, + location=item.location, + keywords={keyword: 1 for keyword in item.keywords}, + outcome="skipped", + longrepr=None, + when=phase, + wasxfail=f"{expected.kwargs.get(XFAIL_REASON, '')} (the process {child.ending})", + ) + + longrepr = None + if failed: + longrepr = ( + f"The scenario's process {child.ending} around its {phase}.\n\n{child.output[-OUTPUT_TAIL_CHARACTERS:]}" + ) + + return pytest.TestReport( + nodeid=item.nodeid, + location=item.location, + keywords={keyword: 1 for keyword in item.keywords}, + outcome="failed" if failed else "passed", + longrepr=longrepr, + when=phase, + ) + + +def _text(output: Optional[Union[bytes, str]]) -> str: + if output is None: + return "" + if isinstance(output, bytes): + return output.decode(errors="replace") + + return output diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/automation/dearpygui/items/__init__.py similarity index 100% rename from tests/unit/sampletones_core/reconstructions/reconstructor/selector/__init__.py rename to automation/dearpygui/items/__init__.py diff --git a/automation/dearpygui/items/colors.py b/automation/dearpygui/items/colors.py new file mode 100644 index 000000000..2079a2a7e --- /dev/null +++ b/automation/dearpygui/items/colors.py @@ -0,0 +1,95 @@ +from typing import Dict, Final, List, Optional, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.items.types import Item + +THEME_COLOR_TYPE: Final[str] = "mvAppItemType::mvThemeColor" +COLOR_ATTRIBUTES: Final[Tuple[str, ...]] = ("color", "fill") +THEME_COLOR_VALUE: Final[str] = "value" +THEME_TARGET: Final[str] = "target" +THEME_CATEGORY: Final[str] = "category" +COLOR_PARTS: Final[int] = 4 +COLOR_DIGITS: Final[int] = 4 +COLOR_STEPS: Final[float] = 255.0 + + +def read_theme(item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, if one is. Runs on the render thread.""" + theme = dpg.get_item_info(item)["theme"] + if theme is None: + return None + + return str(dpg.get_item_alias(theme)) + + +def read_theme_colors(theme: Item) -> Tuple[Tuple[float, ...], ...]: + """The colors every theme color under ``theme`` holds, in the order they were added. Runs on the render thread.""" + colors: List[Tuple[float, ...]] = [] + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + colors.append(tuple(float(part) for part in dpg.get_value(color))) + + return tuple(colors) + + +def read_held_colors() -> Dict[str, Tuple[float, ...]]: + """Every color DearPyGui holds for the interface, keyed by the item and the attribute holding it. Runs on the + render thread. + + A theme color holds its value whether or not anything wearing it is drawn; an item holds its own color and fill + while it stands shown. Every value reads as fractions of the full step, rounded, so a color given in whole steps + reads the same from either side. + """ + held: Dict[str, Tuple[float, ...]] = {} + for item in dpg.get_all_items(): + if dpg.get_item_info(item)["type"] == THEME_COLOR_TYPE: + held[f"{item}:{THEME_COLOR_VALUE}"] = _theme_color(item) + continue + + if not dpg.is_item_shown(item): + continue + + configuration = dpg.get_item_configuration(item) + for attribute in COLOR_ATTRIBUTES: + value = configuration.get(attribute) + if _is_color(value): + held[f"{item}:{attribute}"] = rounded_color(value) + + return held + + +def read_theme_text_color(theme: Item) -> Optional[Tuple[float, ...]]: + """The text color ``theme`` sets, if it sets one. Runs on the render thread.""" + for component in dpg.get_item_children(theme, 1): + for color in dpg.get_item_children(component, 1): + configuration = dpg.get_item_configuration(color) + if ( + configuration.get(THEME_TARGET) == dpg.mvThemeCol_Text + and configuration.get(THEME_CATEGORY) == dpg.mvThemeCat_Core + ): + return _theme_color(color) + + return None + + +def rounded_color(value: Sequence[float]) -> Tuple[float, ...]: + """A color's parts, rounded so a color read from DearPyGui and one stated in whole steps compare equal.""" + return tuple(round(float(part), COLOR_DIGITS) for part in value) + + +def _theme_color(item: Item) -> Tuple[float, ...]: + """The color a theme color item holds, as a fraction of the full step like an item's own color reads. + + DearPyGui answers a theme color in whole steps and an item's color in fractions of one. + """ + return rounded_color(tuple(float(part) / COLOR_STEPS for part in dpg.get_value(item))) + + +def _is_color(value: object) -> bool: + """Whether a configuration value is a color DearPyGui holds, which an unset one, read as negatives, is not.""" + return ( + isinstance(value, (list, tuple)) + and len(value) == COLOR_PARTS + and all(isinstance(part, (int, float)) and part >= 0 for part in value) + ) diff --git a/automation/dearpygui/items/reading.py b/automation/dearpygui/items/reading.py new file mode 100644 index 000000000..9ead88b17 --- /dev/null +++ b/automation/dearpygui/items/reading.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.types import ( + CHILD_WINDOW_TYPE, + WINDOW_TYPE, + Item, +) + +TAG_SEPARATOR: Final[str] = "." + + +@dataclass(frozen=True) +class ItemReading: + """What one item showed in the last frame: whether it stands, where, and whether it answers. + + Attributes: + item: The item read. + exists: Whether the item is in the context at all. + shown: Whether the item and every container around it are shown. + visible: Whether the item was drawn inside its clip region in the last frame. + enabled: Whether the item and every container around it answer a press. + rect: The item's box on the screen, where its kind reports one. + """ + + item: Item + exists: bool + shown: bool + visible: bool + enabled: bool + rect: Optional[Rect] + + +def read_item(item: Item) -> ItemReading: + """Reads ``item`` as the last frame drew it. Runs on the render thread.""" + if not dpg.does_item_exist(item): + return ItemReading( + item=item, + exists=False, + shown=False, + visible=False, + enabled=False, + rect=None, + ) + + state = dpg.get_item_state(item) + configuration = dpg.get_item_configuration(item) + return ItemReading( + item=item, + exists=True, + shown=_shown_with_ancestors(item), + visible=bool(state.get("visible", True)), + enabled=bool(configuration.get("enabled", True)) and _enabled_ancestors(item), + rect=_rect(item, state), + ) + + +def find_item( + container: Item, + matches: Callable[[Item], bool], +) -> Optional[Item]: + """The first item under ``container``, in the order they are drawn, that ``matches`` accepts. + + Runs on the render thread. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if matches(item): + return item + + pending[:0] = [child for children in dpg.get_item_info(item)["children"].values() for child in children] + + return None + + +def read_item_count() -> int: + """How many items the context holds, which grows with what the interface builds. Runs on the render thread.""" + return len(dpg.get_all_items()) + + +def read_hovered(item: Item) -> Optional[bool]: + """Whether the pointer rested on ``item`` in the last frame, or ``None`` for a kind reporting no hover. + + Runs on the render thread. An item covered by a popup, clipped by the region it scrolls in, or + passed over while another item holds the mouse reads as not hovered. + """ + state = dpg.get_item_state(item) + if "hovered" not in state: + return None + + return bool(state["hovered"]) + + +def read_value(item: Item) -> Any: + """The value ``item`` holds, whatever kind the item keeps. Runs on the render thread.""" + return dpg.get_value(item) + + +def read_selected_tab(tab_bar: str) -> str: + """The tag of the tab standing in front of ``tab_bar``. Runs on the render thread.""" + return str(dpg.get_item_alias(dpg.get_value(tab_bar))) + + +def is_tag_within(alias: str, prefix: str) -> bool: + """Whether ``alias`` is ``prefix`` itself or a tag composed under it.""" + return alias == prefix or alias.startswith(prefix + TAG_SEPARATOR) + + +def _shown_with_ancestors(tag: Item) -> bool: + item: Optional[Item] = tag + while item is not None: + if not dpg.is_item_shown(item): + return False + + item = dpg.get_item_parent(item) + + return True + + +def _enabled_ancestors(tag: Item) -> bool: + parent = dpg.get_item_parent(tag) + while parent is not None: + if not dpg.get_item_configuration(parent).get("enabled", True): + return False + + parent = dpg.get_item_parent(parent) + + return True + + +def _rect(item: Item, state: Dict[str, Any]) -> Optional[Rect]: + if "rect_min" in state: + return Rect.of(state["rect_min"], state["rect_size"]) + + item_type = dpg.get_item_info(item)["type"] + if item_type == WINDOW_TYPE: + return _window_rect(item) + if item_type == CHILD_WINDOW_TYPE: + return Rect.of(_child_window_corner(item), state["rect_size"]) + + return None + + +def _child_window_corner(child: Item) -> Tuple[float, float]: + """Where a child window stands on the screen. + + DearPyGui reports a child window's position in the content of the window holding it, which + scrolls, so the corner is that window's own corner moved by the position and back by the scroll. + """ + container = _containing_window(child) + if dpg.get_item_info(container)["type"] == WINDOW_TYPE: + corner = dpg.get_item_pos(container) + else: + corner = _child_window_corner(container) + + position = dpg.get_item_state(child)["pos"] + return ( + corner[0] + position[0] - dpg.get_x_scroll(container), + corner[1] + position[1] - dpg.get_y_scroll(container), + ) + + +def _containing_window(item: Item) -> Item: + parent: Optional[Item] = dpg.get_item_parent(item) + while parent is not None and dpg.get_item_info(parent)["type"] not in ( + WINDOW_TYPE, + CHILD_WINDOW_TYPE, + ): + parent = dpg.get_item_parent(parent) + + if parent is None: + raise LookupError(f"{item!r} stands in no window") + + return parent + + +def _window_rect(window: Item) -> Rect: + return Rect.of( + dpg.get_item_pos(window), + dpg.get_item_state(window)["rect_size"], + ) diff --git a/automation/dearpygui/items/regions.py b/automation/dearpygui/items/regions.py new file mode 100644 index 000000000..59c4d1628 --- /dev/null +++ b/automation/dearpygui/items/regions.py @@ -0,0 +1,217 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import _window_rect, read_item +from automation.dearpygui.items.types import ( + CHILD_WINDOW_TYPE, + TABLE_TYPE, + WINDOW_TYPE, + Item, +) +from automation.dearpygui.items.viewport import read_client_area + + +@dataclass(frozen=True) +class ScrollReading: + """How far a region that scrolls stands from its top, how far it can go, in pixels, and what moves it. + + Attributes: + position: How far the region is scrolled along the axis read. + maximum: How far the region can scroll along it. + by_wheel: Whether the wheel scrolls the region, or its scrollbar alone does. + """ + + position: float + maximum: float + by_wheel: bool + + +@dataclass(frozen=True) +class WindowReading: + """One top-level window as the last frame drew it. + + Attributes: + alias: The window's tag. + label: The title the window carries. + modal: Whether the window holds the screen while it stands. + shown: Whether the window is shown. + rect: The window's box on the screen. + """ + + alias: str + label: str + modal: bool + shown: bool + rect: Rect + + +def read_windows() -> Tuple[WindowReading, ...]: + """Reads every top-level window the context holds. Runs on the render thread.""" + windows = [] + for window in dpg.get_windows(): + if dpg.get_item_info(window)["type"] != WINDOW_TYPE: + continue + + configuration = dpg.get_item_configuration(window) + windows.append( + WindowReading( + alias=str(dpg.get_item_alias(window)), + label=str(configuration.get("label") or ""), + modal=bool(configuration.get("modal")), + shown=bool(dpg.is_item_shown(window)), + rect=_window_rect(window), + ) + ) + + return tuple(windows) + + +def read_scroll(region: Item) -> ScrollReading: + """How far ``region`` is scrolled down. Runs on the render thread.""" + return ScrollReading( + position=dpg.get_y_scroll(region), + maximum=dpg.get_y_scroll_max(region), + by_wheel=_takes_the_wheel(region), + ) + + +def read_sideways_scroll(region: Item) -> ScrollReading: + """How far ``region`` is scrolled to the right. Runs on the render thread.""" + return ScrollReading( + position=dpg.get_x_scroll(region), + maximum=dpg.get_x_scroll_max(region), + by_wheel=_takes_the_wheel(region), + ) + + +def _takes_the_wheel(region: Item) -> bool: + return not dpg.get_item_configuration(region).get("no_scroll_with_mouse", False) + + +def enclosing_regions(item: Item) -> Tuple[Item, ...]: + """The regions around ``item`` that clip what they hold, the nearest first. Runs on the render thread.""" + regions: List[Item] = [] + held = item + parent = dpg.get_item_parent(item) + while parent is not None: + if _clips(parent, held): + regions.append(parent) + + held = parent + parent = dpg.get_item_parent(parent) + + return tuple(regions) + + +def _clips(region: Item, held: Item) -> bool: + """Whether ``region`` clips the child ``held`` and scrolls it: a child window, or a table scrolling that row. + + A table scrolling on its own clips the rows it scrolls, and the rows it freezes stand above + them in view whatever the scroll. It counts as a region where the rows it freezes are rows it + holds, since those rows say where its view begins. One drawing its own header keeps that + height to itself, so the window around it stands as its region. Runs on the render thread. + """ + kind = dpg.get_item_info(region)["type"] + if kind == CHILD_WINDOW_TYPE: + return True + + if kind != TABLE_TYPE: + return False + + configuration = dpg.get_item_configuration(region) + if not configuration.get("scrollY") or configuration.get("header_row"): + return False + + frozen = int(configuration.get("freeze_rows") or 0) + return held not in dpg.get_item_children(region, 1)[:frozen] + + +def read_region_view(region: Item) -> Optional[Rect]: + """The part of ``region``'s box its content shows through, its scrollbars and far padding left out. + + Runs on the render thread. A region reports the room left for its content, which is its box + less its padding on both sides and the scrollbars standing in it, so the view keeps the near + side whole and gives up half of what the region withholds on the far side, which covers its + scrollbar. A table reports no box, so its view is read off the region around it. + """ + if dpg.get_item_info(region)["type"] == TABLE_TYPE: + return _table_view(region) + + box = read_item(region).rect + if box is None: + return None + + room = dpg.get_item_state(region)["content_region_avail"] + return Rect( + x=box.x, + y=box.y, + width=box.width - (box.width - room[0]) / 2, + height=box.height - (box.height - room[1]) / 2, + ) + + +def _table_view(table: Item) -> Optional[Rect]: + """The box a table shows its scrolling rows through: the view around it, from below the rows it freezes. + + Reads ``None`` while the table, the region around it or its frozen rows report no box. Runs on + the render thread. + """ + around = enclosing_regions(table) + view = read_region_view(around[0]) if around else None + if view is None: + return None + + top = _frozen_bottom(table, view.y) + if top is None: + return None + + return Rect(x=view.x, y=top, width=view.width, height=view.y + view.height - top) + + +def _frozen_bottom(table: Item, view_top: float) -> Optional[float]: + """Where the rows a table freezes at its top end, or ``view_top`` while it freezes none. + + Each frozen row's height is that of the tallest item in it, so the bottom is the lowest edge + any item in those rows reaches. Runs on the render thread. + """ + frozen = int(dpg.get_item_configuration(table).get("freeze_rows") or 0) + if frozen == 0: + return view_top + + bottoms = [ + box.y + box.height + for row in dpg.get_item_children(table, 1)[:frozen] + for cell in dpg.get_item_children(row, 1) + for child in dpg.get_item_children(cell, 1) + if (box := read_item(child).rect) is not None + ] + return max(bottoms) if bottoms else None + + +def read_visible_box(item: Item) -> Optional[Rect]: + """The part of ``item``'s box the regions around it and the viewport leave in view. Runs on the render thread.""" + box = _table_view(item) if dpg.get_item_info(item)["type"] == TABLE_TYPE else read_item(item).rect + for region in enclosing_regions(item): + view = read_region_view(region) + if box is None or view is None: + return None + + box = box.overlap(view) + + return box.overlap(read_client_area()) if box is not None else None + + +def read_table(table: Item) -> Tuple[Tuple[Tuple[Item, ...], ...], ...]: + """The items inside each cell of each row of ``table``, row by row. Runs on the render thread. + + A table keeps its columns in one slot of children and its rows in another, and each row holds + one cell per column, whatever item the cell carries. + """ + rows = dpg.get_item_info(table)["children"][1] + return tuple( + tuple(tuple(dpg.get_item_info(cell)["children"][1]) for cell in dpg.get_item_info(row)["children"][1]) + for row in rows + ) diff --git a/automation/dearpygui/items/texts.py b/automation/dearpygui/items/texts.py new file mode 100644 index 000000000..6142d9f6d --- /dev/null +++ b/automation/dearpygui/items/texts.py @@ -0,0 +1,141 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.geometry import Point +from automation.dearpygui.items.types import MENU_ITEM_TYPE, TEXT_TYPE, Item + + +@dataclass(frozen=True) +class EntryReading: + """One entry of a popup menu: its words, where it stands in the viewport, and whether it answers.""" + + label: str + point: Point + enabled: bool + + +def read_popup_entries(popup: Item) -> Tuple[EntryReading, ...]: + """The menu entries a popup window holds, in order. Runs on the render thread. + + DearPyGui reports an entry's position inside the popup alone, so the entry stands where the + popup does, moved by that position. + """ + corner = dpg.get_item_pos(popup) + entries: List[EntryReading] = [] + for child in dpg.get_item_children(popup, 1): + if dpg.get_item_info(child)["type"] != MENU_ITEM_TYPE: + continue + + position = dpg.get_item_state(child)["pos"] + entries.append( + EntryReading( + label=read_label(child), + point=Point( + x=round(corner[0] + position[0]), + y=round(corner[1] + position[1]), + ), + enabled=bool(dpg.get_item_configuration(child).get("enabled", True)), + ) + ) + + return tuple(entries) + + +def read_label(item: Item) -> str: + """The label ``item`` carries. Runs on the render thread.""" + return str(dpg.get_item_label(item) or "") + + +def read_shown_texts(container: Item) -> Tuple[str, ...]: + """The words of every shown text item under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + return _texts(container, shown_only=True) + + +def read_texts(container: Item) -> Tuple[str, ...]: + """The words of every text item under ``container``, shown or waiting to be, such as a tooltip's. + + Runs on the render thread. + """ + return _texts(container, shown_only=False) + + +def read_visible_text(words: str) -> bool: + """Whether a text item reading ``words`` was drawn in the last frame, such as a tooltip standing open. + + Runs on the render thread. + """ + return any( + dpg.get_item_info(item)["type"] == TEXT_TYPE + and dpg.get_value(item) == words + and bool(dpg.is_item_visible(item)) + for item in dpg.get_all_items() + ) + + +def read_shown_labels(container: Item, item_type: str) -> Tuple[str, ...]: + """The labels of every shown item of ``item_type`` under ``container``, in the order they are drawn. + + Runs on the render thread. + """ + labels: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == item_type: + labels.append(read_label(item)) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(labels) + + +def _texts(container: Item, *, shown_only: bool) -> Tuple[str, ...]: + texts: List[str] = [] + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + if shown_only and not dpg.is_item_shown(item): + continue + + info = dpg.get_item_info(item) + if info["type"] == TEXT_TYPE: + texts.append(str(dpg.get_value(item))) + + pending[:0] = [child for children in info["children"].values() for child in children] + + return tuple(texts) + + +def find_labelled( + container: Item, + label: str, + *, + item_type: str, +) -> Item: + """The first item of ``item_type`` under ``container`` whose label reads ``label``. Runs on the render thread. + + A label may carry padding around its words, so the words are compared with the padding stripped. + + Raises: + LookupError: If no such item stands under ``container``. + """ + pending: List[Item] = [container] + while pending: + item = pending.pop(0) + info = dpg.get_item_info(item) + if info["type"] == item_type and read_label(item).strip() == label.strip(): + return item + + for children in info["children"].values(): + pending.extend(children) + + raise LookupError(f"No {item_type} labelled '{label}' stands under {container!r}") diff --git a/automation/dearpygui/items/types.py b/automation/dearpygui/items/types.py new file mode 100644 index 000000000..94efe7ffd --- /dev/null +++ b/automation/dearpygui/items/types.py @@ -0,0 +1,11 @@ +from typing import Final, Union + +Item = Union[int, str] +WINDOW_TYPE: Final[str] = "mvAppItemType::mvWindowAppItem" +CHILD_WINDOW_TYPE: Final[str] = "mvAppItemType::mvChildWindow" +TABLE_TYPE: Final[str] = "mvAppItemType::mvTable" +TREE_NODE_TYPE: Final[str] = "mvAppItemType::mvTreeNode" +MENU_TYPE: Final[str] = "mvAppItemType::mvMenu" +MENU_ITEM_TYPE: Final[str] = "mvAppItemType::mvMenuItem" +TEXT_TYPE: Final[str] = "mvAppItemType::mvText" +BUTTON_TYPE: Final[str] = "mvAppItemType::mvButton" diff --git a/automation/dearpygui/items/viewport.py b/automation/dearpygui/items/viewport.py new file mode 100644 index 000000000..abb6aa3e3 --- /dev/null +++ b/automation/dearpygui/items/viewport.py @@ -0,0 +1,40 @@ +import dearpygui.dearpygui as dpg + +from automation.dearpygui.geometry import Point, Rect + + +def read_viewport() -> Rect: + """The viewport's client area, placed where it stands on the screen. Runs on the render thread.""" + corner = dpg.get_viewport_pos() + return Rect( + x=corner[0], + y=corner[1], + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) + + +def read_viewport_decorated() -> bool: + """Whether the window wears the system's title bar and frame. Runs on the render thread.""" + return bool(dpg.is_viewport_decorated()) + + +def read_viewport_title() -> str: + """The title the viewport's window carries in its title bar. Runs on the render thread.""" + return str(dpg.get_viewport_title()) + + +def read_pointer() -> Point: + """Where the pointer stands in the viewport, as the application last saw it. Runs on the render thread.""" + position = dpg.get_mouse_pos(local=False) + return Point(x=round(position[0]), y=round(position[1])) + + +def read_client_area() -> Rect: + """The viewport's client area in the coordinates its items report. Runs on the render thread.""" + return Rect( + x=0, + y=0, + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + ) diff --git a/automation/dearpygui/keys.py b/automation/dearpygui/keys.py new file mode 100644 index 000000000..b040ce0fc --- /dev/null +++ b/automation/dearpygui/keys.py @@ -0,0 +1,95 @@ +from typing import Dict, Final + +from Xlib import XK + +IMGUI_TAB: Final[int] = 512 +IMGUI_LEFT: Final[int] = 513 +IMGUI_RIGHT: Final[int] = 514 +IMGUI_UP: Final[int] = 515 +IMGUI_DOWN: Final[int] = 516 +IMGUI_PAGE_UP: Final[int] = 517 +IMGUI_PAGE_DOWN: Final[int] = 518 +IMGUI_HOME: Final[int] = 519 +IMGUI_END: Final[int] = 520 +IMGUI_INSERT: Final[int] = 521 +IMGUI_DELETE: Final[int] = 522 +IMGUI_BACKSPACE: Final[int] = 523 +IMGUI_SPACE: Final[int] = 524 +IMGUI_ENTER: Final[int] = 525 +IMGUI_ESCAPE: Final[int] = 526 +IMGUI_LEFT_CTRL: Final[int] = 527 +IMGUI_LEFT_SHIFT: Final[int] = 528 +IMGUI_LEFT_ALT: Final[int] = 529 +IMGUI_DIGIT_ZERO: Final[int] = 536 +IMGUI_LETTER_A: Final[int] = 546 +IMGUI_F1: Final[int] = 572 +DIGIT_COUNT: Final[int] = 10 +LETTER_COUNT: Final[int] = 26 +FUNCTION_KEY_COUNT: Final[int] = 12 + +NAMED_KEYSYMS: Final[Dict[int, str]] = { + IMGUI_TAB: "Tab", + IMGUI_LEFT: "Left", + IMGUI_RIGHT: "Right", + IMGUI_UP: "Up", + IMGUI_DOWN: "Down", + IMGUI_PAGE_UP: "Prior", + IMGUI_PAGE_DOWN: "Next", + IMGUI_HOME: "Home", + IMGUI_END: "End", + IMGUI_INSERT: "Insert", + IMGUI_DELETE: "Delete", + IMGUI_BACKSPACE: "BackSpace", + IMGUI_SPACE: "space", + IMGUI_ENTER: "Return", + IMGUI_ESCAPE: "Escape", + IMGUI_LEFT_CTRL: "Control_L", + IMGUI_LEFT_SHIFT: "Shift_L", + IMGUI_LEFT_ALT: "Alt_L", +} + +DIGIT_KEYSYMS: Final[Dict[int, str]] = {IMGUI_DIGIT_ZERO + offset: str(offset) for offset in range(DIGIT_COUNT)} + +LETTER_KEYSYMS: Final[Dict[int, str]] = { + IMGUI_LETTER_A + offset: chr(ord("a") + offset) for offset in range(LETTER_COUNT) +} + +FUNCTION_KEYSYMS: Final[Dict[int, str]] = {IMGUI_F1 + offset: f"F{offset + 1}" for offset in range(FUNCTION_KEY_COUNT)} + +KEYSYM_NAMES: Final[Dict[int, str]] = { + **NAMED_KEYSYMS, + **DIGIT_KEYSYMS, + **LETTER_KEYSYMS, + **FUNCTION_KEYSYMS, +} + + +class UnmappedKeyError(LookupError): + """Raised when a scenario presses a key the X keyboard table holds no name for.""" + + +def keysym_of(key: int) -> int: + """The X keysym a press of the Dear ImGui key ``key`` arrives as. + + Dear ImGui numbers its keys in an enumeration of its own, and DearPyGui reports a press under + that number. A few of DearPyGui's own ``mvKey_*`` constants carry older values, which is why the + table names the enumeration's numbers. + + Raises: + UnmappedKeyError: If the table holds no name for ``key``. + """ + name = KEYSYM_NAMES.get(key) + if name is None: + raise UnmappedKeyError(f"The X keyboard table holds no name for the Dear ImGui key {key}") + + return XK.string_to_keysym(name) + + +def key_name(key: int) -> str: + """The name the X keyboard table gives the Dear ImGui key ``key``, for a report a person reads.""" + return KEYSYM_NAMES.get(key, str(key)) + + +def keysym_of_character(character: str) -> int: + """The X keysym typing ``character`` sends; a printable ASCII keysym is its own code point.""" + return ord(character) diff --git a/automation/dearpygui/plots.py b/automation/dearpygui/plots.py new file mode 100644 index 000000000..726aa1f92 --- /dev/null +++ b/automation/dearpygui/plots.py @@ -0,0 +1,47 @@ +from functools import partial +from typing import Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import ONE_FRAME, Bridge + +PlotValue = Tuple[float, float] + + +def read_plot_pointer() -> PlotValue: + """The value the plot under the pointer puts there, as its last drawn frame read it.""" + x, y = dpg.get_plot_mouse_pos() + return float(x), float(y) + + +def settled_plot_pointer( + bridge: Bridge, + plot: str, + *, + timeout: float, +) -> PlotValue: + """The value ``plot`` puts under the resting pointer, once the plot holds it for two frames running. + + The plot reads the pointer as it draws, so its value trails the pointer by a frame. + + Raises: + ExpectationError: If the pointer never rests on ``plot`` or its value keeps changing. + """ + bridge.expect( + lambda: bool(dpg.is_item_hovered(plot)), + bool, + description=f"the pointer resting on '{plot}'", + timeout=timeout, + ) + return bridge.expect( + partial(_two_frames_of_pointer, bridge), + lambda readings: readings[0] == readings[1], + description=f"the value under the pointer on '{plot}' holding still", + timeout=timeout, + )[1] + + +def _two_frames_of_pointer(bridge: Bridge) -> Tuple[PlotValue, PlotValue]: + first = bridge.ask(read_plot_pointer) + bridge.frames(ONE_FRAME) + return first, bridge.ask(read_plot_pointer) diff --git a/automation/dearpygui/reach.py b/automation/dearpygui/reach.py new file mode 100644 index 000000000..7fe693401 --- /dev/null +++ b/automation/dearpygui/reach.py @@ -0,0 +1,77 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import ItemReading, read_item +from automation.dearpygui.items.regions import read_visible_box, read_windows +from automation.dearpygui.items.types import Item +from automation.dearpygui.items.viewport import read_client_area + + +class UnreachableError(AssertionError): + """Raised when a scenario reaches for a control a user could not press.""" + + +def reachable(item: Item) -> Rect: + """The box of ``item``, once it stands where a user could press it. Runs on the render thread. + + A control is in reach when it exists, it and every container around it are shown, it was drawn + in the last frame, it answers a press, its box lies inside the viewport, its middle lies in the + part the regions around it leave in view, and no modal window of another tree holds the screen. + + Raises: + UnreachableError: Naming the first condition the control misses. + """ + reading = read_item(item) + refusal = _refusal(reading) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + assert reading.rect is not None + return reading.rect + + +def _refusal(reading: ItemReading) -> Optional[str]: + if not reading.exists: + return "it does not exist" + if not reading.shown: + return "it or a container around it is hidden" + if not reading.visible: + return "it was not drawn in the last frame" + if not reading.enabled: + return "it is disabled" + if reading.rect is None: + return "its kind reports no box to press" + if not read_client_area().contains(reading.rect): + return f"its box {reading.rect} reaches outside the viewport" + + visible = read_visible_box(reading.item) + if visible is None or not visible.holds(reading.rect.center): + return "its middle lies outside the part the regions around it leave in view" + + covering = covering_modal(reading.item) + if covering is not None: + return f"the modal window '{covering}' holds the screen" + + return None + + +def covering_modal(item: Item) -> Optional[str]: + """The modal window of another tree holding the screen over ``item``, if one does.""" + root = _root_window(item) + for window in read_windows(): + if window.shown and window.modal and window.alias != root: + return window.alias + + return None + + +def _root_window(item: Item) -> str: + root: Item = item + parent = dpg.get_item_parent(root) + while parent is not None: + root = parent + parent = dpg.get_item_parent(root) + + return str(dpg.get_item_alias(root)) diff --git a/automation/dearpygui/recording.py b/automation/dearpygui/recording.py new file mode 100644 index 000000000..3886db7d7 --- /dev/null +++ b/automation/dearpygui/recording.py @@ -0,0 +1,66 @@ +import threading +from dataclasses import dataclass +from typing import Callable, Final, Generic, List, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import ONE_FRAME, RenderThread + +ReadingT = TypeVar("ReadingT") +NO_FRAME: Final[int] = 0 + + +@dataclass(frozen=True) +class FrameReading(Generic[ReadingT]): + """What one reading found right after one frame was drawn.""" + + frame: int + value: ReadingT + + +class FrameRecording(Generic[ReadingT]): + """A reading taken right after every frame between a start and a stop, on the render thread. + + An expectation reads once a frame until it holds, and so misses a state lasting a single frame. + A recording keeps every frame's reading, so a scenario asserting that something never happened + between two gestures reads each frame that passed. + """ + + def __init__( + self, + render_thread: RenderThread, + reading: Callable[[], ReadingT], + ) -> None: + self._render_thread = render_thread + self._reading = reading + self._readings: List[FrameReading[ReadingT]] = [] + self._stopped = threading.Event() + self._lock = threading.Lock() + + def start(self) -> None: + """Takes the first reading after the next frame and goes on reading after every frame.""" + self._render_thread.post(self._take, frames=ONE_FRAME) + + def stop(self) -> None: + """Ends the recording; the readings taken so far stay available.""" + self._stopped.set() + + @property + def readings(self) -> Tuple[FrameReading[ReadingT], ...]: + """Every reading taken so far, in frame order, each with the frame it followed.""" + with self._lock: + return tuple(self._readings) + + def values(self) -> List[ReadingT]: + """The value of every reading taken so far, in frame order.""" + return [reading.value for reading in self.readings] + + def _take(self) -> None: + if self._stopped.is_set(): + return + + reading = FrameReading(frame=dpg.get_frame_count(), value=self._reading()) + with self._lock: + self._readings.append(reading) + + self._render_thread.post(self._take, frames=ONE_FRAME) diff --git a/automation/dearpygui/screenshot.py b/automation/dearpygui/screenshot.py new file mode 100644 index 000000000..a97debc18 --- /dev/null +++ b/automation/dearpygui/screenshot.py @@ -0,0 +1,66 @@ +import threading +from collections.abc import Buffer +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge + +WRITE_TIMEOUT_SECONDS: Final[float] = 10.0 + + +def capture(bridge: Bridge, path: Path) -> Path: + """Saves the next frame the application draws as a PNG at ``path``, and returns the path. + + DearPyGui writes the picture while drawing a frame, so the capture waits for the frame to land. + """ + path.parent.mkdir(parents=True, exist_ok=True) + bridge.ask(lambda: dpg.output_frame_buffer(file=str(path))) + bridge.expect( + lambda: bridge.ask(path.exists), + bool, + description=f"the screenshot {path} to be written", + timeout=WRITE_TIMEOUT_SECONDS, + ) + return path + + +@dataclass(frozen=True) +class DrawnFrame: + """One frame as DearPyGui drew it: its size, and its pixels row after row, each red, green, blue and alpha as + 32-bit fractions. + """ + + width: int + height: int + pixels: bytes + + +def drawn_frame(bridge: Bridge) -> DrawnFrame: + """The next frame the application draws. + + DearPyGui hands the frame over while drawing it, so the reading waits for it to land. + """ + landed: List[DrawnFrame] = [] + taken = threading.Event() + + def take(_sender: object, frame: Buffer) -> None: + landed.append( + DrawnFrame( + width=dpg.get_viewport_client_width(), + height=dpg.get_viewport_client_height(), + pixels=memoryview(frame).tobytes(), + ) + ) + taken.set() + + bridge.ask(lambda: dpg.output_frame_buffer(callback=take)) + bridge.expect( + taken.is_set, + bool, + description="the frame to be handed over", + timeout=WRITE_TIMEOUT_SECONDS, + ) + return landed[0] diff --git a/automation/dearpygui/semantic.py b/automation/dearpygui/semantic.py new file mode 100644 index 000000000..afff1a95e --- /dev/null +++ b/automation/dearpygui/semantic.py @@ -0,0 +1,86 @@ +from inspect import Parameter, signature +from typing import Any, Callable, Final, Optional, Sequence + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.items.reading import ItemReading, read_item +from automation.dearpygui.items.types import Item +from automation.dearpygui.reach import UnreachableError, covering_modal + +CallbackRunner = Callable[..., object] +POSITIONAL: Final = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + + +def invoke( + item: Item, + run: CallbackRunner, +) -> None: + """Runs ``item``'s callback as DearPyGui runs it for a press, through ``run``. Runs on the render thread. + + This is the gesture for a control DearPyGui reports a position alone for, such as a menu entry. The + control must exist, stand shown, answer a press and sit outside any other tree's modal window. + The callback receives as many of the sender, the value and the user data as it declares, which + is what DearPyGui hands it. + + Raises: + UnreachableError: Naming what keeps a user from the control. + """ + refusal = _refusal(read_item(item)) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + callback = dpg.get_item_callback(item) + if callback is None: + raise UnreachableError(f"{item!r} answers no press: it carries no callback") + + arguments = (item, dpg.get_value(item), dpg.get_item_user_data(item)) + run(callback, *_declared(callback, arguments)) + + +def choose( + item: Item, + value: Any, + run: CallbackRunner, +) -> None: + """Sets ``item`` to ``value`` and runs its callback as a pick of that option does. Runs on the render thread. + + This is the gesture for a choice DearPyGui draws as one item, such as a radio button's options or a + combo's entries. The choice must stand in reach the way :func:`invoke` asks. + + Raises: + UnreachableError: Naming what keeps a user from the choice. + """ + refusal = _refusal(read_item(item)) + if refusal is not None: + raise UnreachableError(f"{item!r} is out of reach: {refusal}") + + callback = dpg.get_item_callback(item) + if callback is None: + raise UnreachableError(f"{item!r} answers no choice: it carries no callback") + + dpg.set_value(item, value) + arguments = (item, value, dpg.get_item_user_data(item)) + run(callback, *_declared(callback, arguments)) + + +def _refusal(reading: ItemReading) -> Optional[str]: + if not reading.exists: + return "it does not exist" + if not reading.shown: + return "it or a container around it is hidden" + if not reading.enabled: + return "it is disabled" + + covering = covering_modal(reading.item) + if covering is not None: + return f"the modal window '{covering}' holds the screen" + + return None + + +def _declared(callback: Callable[..., object], arguments: Sequence[Any]) -> Sequence[Any]: + parameters = signature(callback).parameters.values() + if any(parameter.kind is Parameter.VAR_POSITIONAL for parameter in parameters): + return arguments + + return arguments[: sum(1 for parameter in parameters if parameter.kind in POSITIONAL)] diff --git a/automation/dearpygui/windows.py b/automation/dearpygui/windows.py new file mode 100644 index 000000000..12b57d8c4 --- /dev/null +++ b/automation/dearpygui/windows.py @@ -0,0 +1,76 @@ +from typing import Final, Iterator + +from Xlib import X +from Xlib.display import Display +from Xlib.protocol.event import ClientMessage +from Xlib.xobject.drawable import Window + +PROCESS_PROPERTY: Final[str] = "_NET_WM_PID" +PROTOCOLS_PROPERTY: Final[str] = "WM_PROTOCOLS" +DELETE_PROTOCOL: Final[str] = "WM_DELETE_WINDOW" +CLIENT_MESSAGE_FORMAT: Final[int] = 32 +UNUSED_FIELD: Final[int] = 0 + + +class MissingWindowError(AssertionError): + """Raised when no window on the display belongs to the process a scenario drives.""" + + +class WindowManager: + """What a window manager does to the application's window on a scenario's display. + + The scenario's X server runs no window manager, so the request a manager sends when a user + presses the close button of a title bar is sent from here, to the window carrying the + application's process id. + """ + + def __init__( + self, + display_name: str, + process_id: int, + ) -> None: + self._display = Display(display_name) + self._process_id = process_id + + def request_close(self) -> None: + """Asks the application's window to close, as the close button of its title bar does. + + Raises: + MissingWindowError: If no window on the display carries the application's process id. + """ + window = self._own_window() + message = ClientMessage( + window=window, + client_type=self._display.intern_atom(PROTOCOLS_PROPERTY), + data=( + CLIENT_MESSAGE_FORMAT, + [ + self._display.intern_atom(DELETE_PROTOCOL), + X.CurrentTime, + UNUSED_FIELD, + UNUSED_FIELD, + UNUSED_FIELD, + ], + ), + ) + window.send_event(message, event_mask=X.NoEventMask) + self._display.sync() + + def close(self) -> None: + """Closes the connection to the display.""" + self._display.close() + + def _own_window(self) -> Window: + process_property = self._display.intern_atom(PROCESS_PROPERTY) + for window in _windows_under(self._display.screen().root): + owner = window.get_full_property(process_property, X.AnyPropertyType) + if owner is not None and list(owner.value) == [self._process_id]: + return window + + raise MissingWindowError(f"No window on the display carries the process id {self._process_id}") + + +def _windows_under(window: Window) -> Iterator[Window]: + for child in window.query_tree().children: + yield child + yield from _windows_under(child) diff --git a/automation/dearpygui/xtest.py b/automation/dearpygui/xtest.py new file mode 100644 index 000000000..5017b1dcf --- /dev/null +++ b/automation/dearpygui/xtest.py @@ -0,0 +1,130 @@ +from enum import IntEnum +from typing import Final + +from Xlib import X +from Xlib.display import Display +from Xlib.ext import xtest + +from automation.dearpygui.geometry import Point + +SHIFTED_COLUMN: Final[int] = 1 +BITS_PER_BYTE: Final[int] = 8 + + +class MouseButton(IntEnum): + """A pointer button as the X server numbers it, the two wheel directions included.""" + + LEFT = 1 + MIDDLE = 2 + RIGHT = 3 + WHEEL_UP = 4 + WHEEL_DOWN = 5 + + @property + def mask(self) -> int: + """The bit the pointer's state carries while this button is down.""" + return X.Button1Mask << (self.value - 1) + + +class UntypableCharacterError(LookupError): + """Raised when the display's keyboard map holds no key that types a character.""" + + +class XTestDevice: + """The pointer and the keyboard of one X display, driven through the XTEST extension. + + Each call sends its events and flushes them, so the server holds them before the next frame polls. + The display belongs to the worker, which keeps every event inside the application under test, and + the worker's scenarios take it in turn. The device therefore starts and ends with every key and + button up, so a scenario that failed halfway through a gesture leaves nothing held for the next. + The display turns key auto-repeat off, so a key held across a slow frame arrives as the one + press a person made. + """ + + def __init__(self, display_name: str) -> None: + self._display = Display(display_name) + self._display.change_keyboard_control(auto_repeat_mode=X.AutoRepeatModeOff) + self.release_everything() + + def move(self, point: Point) -> None: + """Moves the pointer to ``point``, in display pixels.""" + xtest.fake_input(self._display, X.MotionNotify, x=point.x, y=point.y) + self._display.sync() + + def button_down(self, button: MouseButton) -> None: + """Presses ``button`` and holds it down.""" + xtest.fake_input(self._display, X.ButtonPress, int(button)) + self._display.sync() + + def button_up(self, button: MouseButton) -> None: + """Releases ``button``.""" + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def click_button( + self, + button: MouseButton, + presses: int, + ) -> None: + """Presses and releases ``button`` ``presses`` times in one go. + + Dear ImGui reads each press and each release on a frame of its own, so a press stands down for a + single frame and the presses land two frames apart, the closest a display brings them. + """ + for _ in range(presses): + xtest.fake_input(self._display, X.ButtonPress, int(button)) + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def tap_key(self, keysym: int) -> None: + """Presses and releases the key typing ``keysym`` in one go. + + Dear ImGui reads the press on one frame and the release on the next, so the key stands down for a + single frame at any frame rate, too briefly to repeat. + """ + keycode = self._keycode(keysym) + xtest.fake_input(self._display, X.KeyPress, keycode) + xtest.fake_input(self._display, X.KeyRelease, keycode) + self._display.sync() + + def key_down(self, keysym: int) -> None: + """Presses the key typing ``keysym`` and holds it down.""" + xtest.fake_input(self._display, X.KeyPress, self._keycode(keysym)) + self._display.sync() + + def key_up(self, keysym: int) -> None: + """Releases the key typing ``keysym``.""" + xtest.fake_input(self._display, X.KeyRelease, self._keycode(keysym)) + self._display.sync() + + def needs_shift(self, keysym: int) -> bool: + """Whether typing ``keysym`` takes Shift, which is where the keyboard map shifts it.""" + keycode = self._keycode(keysym) + return self._display.keycode_to_keysym(keycode, 0) != keysym and ( + self._display.keycode_to_keysym(keycode, SHIFTED_COLUMN) == keysym + ) + + def release_everything(self) -> None: + """Lets go of every key and button the display holds down, whichever gesture pressed it.""" + for index, byte in enumerate(self._display.query_keymap()): + for bit in range(BITS_PER_BYTE): + if byte & (1 << bit): + xtest.fake_input(self._display, X.KeyRelease, index * BITS_PER_BYTE + bit) + + mask = self._display.screen().root.query_pointer().mask + for button in MouseButton: + if mask & button.mask: + xtest.fake_input(self._display, X.ButtonRelease, int(button)) + self._display.sync() + + def close(self) -> None: + """Lets go of everything still held and closes the connection to the display.""" + self.release_everything() + self._display.close() + + def _keycode(self, keysym: int) -> int: + keycode = self._display.keysym_to_keycode(keysym) + if keycode == 0: + raise UntypableCharacterError(f"The keyboard map holds no key for the keysym {keysym:#x}") + + return int(keycode) diff --git a/automation/environment.py b/automation/environment.py new file mode 100644 index 000000000..6c17c3748 --- /dev/null +++ b/automation/environment.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import re +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Mapping, Optional, Tuple + +from automation.dearpygui.display import DisplayBackend, ScreenSize +from automation.homes import let_go, reopen_folders +from automation.paths import ( + HOME_COPY_NOTE, + HOME_FOLDER, + KEPT_DIRECTORY, + NO_BUS_FILE, + REPORTS_FILE, +) +from sampletones_shared.application import SAMPLETONES_ENV_PREFIX + +DISPLAY_BACKEND_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_DISPLAY" +SCREEN_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_SCREEN" +KEPT_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_KEPT" +HOMES_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_HOMES" +SHARD_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_SHARD" +SHARD_SEPARATOR: Final[str] = "/" +SIZE_SEPARATOR: Final[str] = "x" +ARTIFACTS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_ARTIFACTS" +REPORT_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_REPORT" +STRICT_HISTORY_VARIABLE: Final[str] = f"{SAMPLETONES_ENV_PREFIX}STRICT_HISTORY" +STRICT_HISTORY: Final[str] = "true" +NO_INPUT_METHOD: Final[str] = "@im=none" +DROPPED_PREFIXES: Final[Tuple[str, ...]] = ("PYTEST_", "WAYLAND_") +UNSAFE_CHARACTERS: Final[re.Pattern[str]] = re.compile(r"[^A-Za-z0-9_.-]+") +DEFAULT_SCREEN_SIZE: Final[ScreenSize] = ScreenSize(width=1600, height=1000) +DEFAULT_DISPLAY_BACKEND: Final[DisplayBackend] = DisplayBackend.XVFB + + +class ShardError(ValueError): + """Raised when ``SAMPLETONES_SCREENS_SHARD`` reads other than ``INDEX/COUNT`` with the index from 1 to the count.""" + + +@dataclass(frozen=True) +class Shard: + """One of ``count`` parts of the sorted scenario collection, holding every ``count``-th scenario from its own. + + Attributes: + index: The part's number, from 1 to ``count``. + count: How many parts the collection is spread over. + """ + + index: int + count: int + + @classmethod + def of(cls, stated: str) -> Shard: + """The part ``stated`` names as ``INDEX/COUNT``. + + Raises: + ShardError: If ``stated`` reads otherwise, or the index lies outside 1 to the count. + """ + index_text, separator, count_text = stated.partition(SHARD_SEPARATOR) + if separator != SHARD_SEPARATOR or not index_text.isdecimal() or not count_text.isdecimal(): + raise ShardError(f"{SHARD_VARIABLE} reads {stated!r}; it names a part as INDEX/COUNT, such as 1/3") + + index, count = int(index_text), int(count_text) + if not 1 <= index <= count: + raise ShardError(f"{SHARD_VARIABLE} reads {stated!r}; the index runs from 1 to the count") + + return cls(index=index, count=count) + + def keeps(self, position: int) -> bool: + """Whether the scenario at ``position`` of the sorted collection belongs to this part.""" + return position % self.count == self.index - 1 + + +def shard(environment: Mapping[str, str]) -> Optional[Shard]: + """The part of the scenario collection this run takes, which ``SAMPLETONES_SCREENS_SHARD`` names as + ``INDEX/COUNT``; the whole collection where it is unset. + """ + stated = environment.get(SHARD_VARIABLE) + return Shard.of(stated) if stated is not None else None + + +def display_backend(environment: Mapping[str, str]) -> DisplayBackend: + """The X server a run draws on: Xvfb unless ``SAMPLETONES_SCREENS_DISPLAY`` names another.""" + return DisplayBackend(environment.get(DISPLAY_BACKEND_VARIABLE, DEFAULT_DISPLAY_BACKEND)) + + +def screen_size(environment: Mapping[str, str]) -> ScreenSize: + """The pixels a run's screen spans: the default unless ``SAMPLETONES_SCREENS_SCREEN`` reads ``WIDTHxHEIGHT``.""" + stated = environment.get(SCREEN_VARIABLE) + if stated is None: + return DEFAULT_SCREEN_SIZE + + width, height = stated.split(SIZE_SEPARATOR) + return ScreenSize(width=int(width), height=int(height)) + + +def kept_root(environment: Mapping[str, str]) -> Path: + """The folder a run keeps its reports and failure records under, which ``SAMPLETONES_SCREENS_KEPT`` moves.""" + stated = environment.get(KEPT_VARIABLE) + return Path(stated) if stated is not None else KEPT_DIRECTORY + + +def homes_root(environment: Mapping[str, str]) -> Path: + """The folder the workers make their homes folders in: the system's temporary folder, which + ``SAMPLETONES_SCREENS_HOMES`` moves. + """ + stated = environment.get(HOMES_VARIABLE) + return Path(stated) if stated is not None else Path(tempfile.gettempdir()) + + +@dataclass(frozen=True) +class ScenarioFolders: + """Where one scenario's run keeps its files: the home its application lives in, and what the run keeps. + + The home is scratch, built afresh from the scenario's world in the worker's homes folder, whose path + holds no hidden folder, so the application's browsers reach it from any checkout. What the run keeps lies under + the run's artifacts in the checkout: the reports, a screenshot of a failure, and a copy of the home a + failed scenario left, with a note where that copy lost files. + + Attributes: + root: The scenario's folder under the run's artifacts, kept after the run for a reader. + home: The home directory the application reads and writes as the user's own. + reports: The file the child process writes its pytest reports to. + """ + + root: Path + home: Path + reports: Path + + @classmethod + def of( + cls, + nodeid: str, + homes: Path, + kept: Path, + ) -> ScenarioFolders: + """The folders of the scenario ``nodeid`` names, under a name a file system accepts. + + Args: + nodeid: The scenario's pytest node id. + homes: The folder holding the homes of this worker's scenarios. + kept: The folder the run keeps every scenario's reports and failure records under. + """ + name = UNSAFE_CHARACTERS.sub("_", nodeid) + root = kept / name + return cls( + root=root, + home=homes / name / HOME_FOLDER, + reports=root / REPORTS_FILE, + ) + + def prepare(self) -> None: + """Clears what an earlier run of the scenario left, and lays out an empty home and the artifacts folder.""" + shutil.rmtree(self.root, ignore_errors=True) + shutil.rmtree(self.home, ignore_errors=True) + self.root.mkdir(parents=True) + self.home.mkdir(parents=True) + + def finish(self, *, failed: bool) -> None: + """Lets the scratch home go, keeping a copy among the artifacts where the scenario failed. + + A scenario can leave a folder of its home locked, so every folder opens again first. The copy + keeps what it can read, a note beside the artifacts says what it lost, and the home goes + however the copy ended. + """ + reopen_folders(self.home.parent) + try: + if failed: + self._keep_the_home() + finally: + let_go(self.home.parent) + + def _keep_the_home(self) -> None: + try: + shutil.copytree(self.home, self.root / HOME_FOLDER, symlinks=True) + except OSError as error: + (self.root / HOME_COPY_NOTE).write_text( + f"The copy of the scenario's home is incomplete:\n{error}\n", + encoding="utf-8", + ) + + +def child_environment( + base: Mapping[str, str], + folders: ScenarioFolders, + *, + display: str, +) -> Dict[str, str]: + """The environment a scenario's process starts under: ``base``, pointed at the scenario's own world. + + The home and every XDG directory lie inside the scenario's home, so settings, session state and + the documents folder start empty and stay apart from the user's, and from the homes beside it. + The process makes its temporary files where the machine keeps them, as it does on a user's + machine, which keeps the socket paths it opens there short. The display is the worker's own + server, the session bus address leads nowhere, and the input method is off, so everything a + scenario does stays on its own display and in its own home. + """ + environment = {name: value for name, value in base.items() if not name.startswith(DROPPED_PREFIXES)} + home = folders.home + environment.update( + { + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + "XDG_DOCUMENTS_DIR": str(home / "Documents"), + "DISPLAY": display, + "DBUS_SESSION_BUS_ADDRESS": f"unix:path={folders.root / NO_BUS_FILE}", + "XMODIFIERS": NO_INPUT_METHOD, + STRICT_HISTORY_VARIABLE: STRICT_HISTORY, + ARTIFACTS_VARIABLE: str(folders.root), + REPORT_VARIABLE: str(folders.reports), + } + ) + return environment diff --git a/automation/frames.py b/automation/frames.py new file mode 100644 index 000000000..e11403240 --- /dev/null +++ b/automation/frames.py @@ -0,0 +1,71 @@ +from typing import Final + +import numpy as np + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Rect +from automation.dearpygui.screenshot import drawn_frame + +PIXEL_CHANNELS: Final[int] = 4 +RGB_CHANNELS: Final[int] = 3 +COLOR_TOLERANCE: Final[float] = 1.5 / 255.0 +SETTLE_GAP_FRAMES: Final[int] = 3 +SETTLE_READINGS: Final[int] = 40 + + +class NothingChangedError(AssertionError): + """Raised when two frames compared for what opened between them differ in no pixel.""" + + +class UnsettledScreenError(AssertionError): + """Raised when the screen keeps changing while a reading waits for it to hold still.""" + + +def frame_pixels(bridge: Bridge) -> np.ndarray: + """The next frame the application draws, as rows of pixels of red, green, blue and alpha fractions.""" + frame = drawn_frame(bridge) + return np.frombuffer(frame.pixels, dtype=np.float32).reshape(frame.height, frame.width, PIXEL_CHANNELS) + + +def settled_frame(bridge: Bridge) -> np.ndarray: + """The frame drawn once the screen holds still: two readbacks a few frames apart read the same. + + A card fills and a status line clears a frame or two after the gesture that brought them, and a + frame read in between shows the screen halfway. + + Raises: + UnsettledScreenError: If the readbacks keep differing. + """ + previous = frame_pixels(bridge) + for _ in range(SETTLE_READINGS): + bridge.frames(SETTLE_GAP_FRAMES) + current = frame_pixels(bridge) + if np.array_equal(previous, current): + return current + + previous = current + + raise UnsettledScreenError(f"The screen kept changing over {SETTLE_READINGS} readings") + + +def changed_box(before: np.ndarray, after: np.ndarray) -> Rect: + """The box around every pixel that differs between two frames, which is where a popup opened. + + A popup reports no box of its own, so the frame before it opened and the frame after are + compared, and the difference is the popup with the highlight of whatever opened it. + + Raises: + NothingChangedError: If no pixel differs. + """ + changed = np.any( + np.abs(after[:, :, :RGB_CHANNELS] - before[:, :, :RGB_CHANNELS]) > COLOR_TOLERANCE, + axis=2, + ) + rows = np.nonzero(changed.any(axis=1))[0] + columns = np.nonzero(changed.any(axis=0))[0] + if not rows.size: + raise NothingChangedError("The two frames show the same picture, so nothing opened between them") + + left, right = int(columns.min()), int(columns.max()) + top, bottom = int(rows.min()), int(rows.max()) + return Rect(x=left, y=top, width=right - left + 1, height=bottom - top + 1) diff --git a/tests/unit/sampletones_core/trackers/__init__.py b/automation/holds/__init__.py similarity index 100% rename from tests/unit/sampletones_core/trackers/__init__.py rename to automation/holds/__init__.py diff --git a/automation/holds/base.py b/automation/holds/base.py new file mode 100644 index 000000000..176043c9f --- /dev/null +++ b/automation/holds/base.py @@ -0,0 +1,27 @@ +from typing import List, Protocol + + +class Hold(Protocol): + """Work a scenario keeps under way until it lets go, so a gesture lands while the work runs.""" + + def release(self) -> None: + """Lets the work carry on to its end.""" + + +class Holds: + """Every hold a scenario put on the application's work, let go together before the scenario leaves. + + Leaving waits for work in flight, so the holds are released before the exit is asked for. + """ + + def __init__(self) -> None: + self._holds: List[Hold] = [] + + def add(self, hold: Hold) -> None: + """Puts ``hold`` among the holds released together.""" + self._holds.append(hold) + + def release_all(self) -> None: + """Releases every hold that was added.""" + for hold in self._holds: + hold.release() diff --git a/automation/holds/constants.py b/automation/holds/constants.py new file mode 100644 index 000000000..350e25b0a --- /dev/null +++ b/automation/holds/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +RELEASE_POLL_SECONDS: Final[float] = 0.05 diff --git a/automation/holds/conversion.py b/automation/holds/conversion.py new file mode 100644 index 000000000..b5bf7bbc2 --- /dev/null +++ b/automation/holds/conversion.py @@ -0,0 +1,106 @@ +import time +from functools import partial +from pathlib import Path +from typing import AbstractSet, Final, FrozenSet, Sequence + +import pytest + +import sampletones_core.reconstructions.converter.converter as converter_module +from automation.holds.constants import RELEASE_POLL_SECONDS +from automation.holds.signal import ReleaseSignal +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +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 + +FAKE_FRAMES: Final[int] = 40 +HALFWAY: Final[int] = FAKE_FRAMES // 2 +COUNTED_STAGES: Final[FrozenSet[ReconstructionStage]] = frozenset( + {ReconstructionStage.MATCHING, ReconstructionStage.GATHERING} +) + + +class HeldReconstructor: + """A reconstructor that walks a run's stages, stops halfway through matching until released, and builds + nothing. + + It decides when the run lands, never what it computes: the walk reports the stages a real + reconstruction reports, and a run left with nothing built writes no file. While held it goes on + announcing the halfway mark, which is where a run the reader stops unwinds, and it waits for as + long as the scenario holds it. + + It travels to a worker with the job, the way the real one does, so it is built from a + configuration and the channels the same way, with the release named beside them. + """ + + def __init__( + self, + config: Config, + channels: AbstractSet[ChannelName], + release_path: Path, + ) -> None: + self.config = config + self.channels = channels + self.release_path = release_path + + def reconstruct( + self, + paths: Sequence[Pathlike], + stems_config: StemsConfig, + *, + report: ReconstructionReporter, + ) -> None: + """Walks the stages of a reconstruction, holding halfway through matching, and answers with nothing + built. + + Raises: + OperationCanceled: If the run is withdrawn while it is under way. + """ + del paths, stems_config + for stage in ReconstructionStage: + if stage not in COUNTED_STAGES: + announce(report, stage, STAGE_BEGUN, WHOLE_STAGE) + continue + + for frame in range(FAKE_FRAMES + 1): + announce(report, stage, frame, FAKE_FRAMES) + if stage == ReconstructionStage.MATCHING and frame == HALFWAY: + self._hold(report, stage, frame) + + def _hold( + self, + report: ReconstructionReporter, + stage: ReconstructionStage, + frame: int, + ) -> None: + while not self.release_path.exists(): + announce(report, stage, frame, FAKE_FRAMES) + time.sleep(RELEASE_POLL_SECONDS) + + +class ConversionHold: + """Stands in for the converter's reconstructor with one that holds every run halfway until released.""" + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the converter's reconstructor with one that holds every run halfway through matching.""" + monkeypatch.setattr( + converter_module, + "Reconstructor", + partial(HeldReconstructor, release_path=self._signal.path), + ) + + def release(self) -> None: + """Lets every held run carry on to its end.""" + self._signal.release() diff --git a/automation/holds/export.py b/automation/holds/export.py new file mode 100644 index 000000000..59c88d4b1 --- /dev/null +++ b/automation/holds/export.py @@ -0,0 +1,84 @@ +import itertools +import threading +from typing import Callable, Final + +import pytest + +from automation.holds.constants import RELEASE_POLL_SECONDS +from sampletones_application.services.export.reporter import ( + ExportProgressReporter, +) +from sampletones_application.services.result import ServiceProgress +from sampletones_core.exports.stage import ExportStage + +FIRST_REPORT: Final[int] = 0 + + +class ExportHold: + """Holds every export at one report of its stages, until the scenario releases it. + + A run reports each stage it reaches and then asks whether it is still wanted. The hold answers + that question at the report it stands at, once the export's window has heard the stage, so the + window shows the run under way. While held, the run still hears a cancel and unwinds the way a + cancel unwinds a real run. Released, every run goes on to its end until the scenario holds again. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._held_report = FIRST_REPORT + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Wraps the export reporter so each run asks its question through the hold.""" + build = ExportProgressReporter.__init__ + + def held( + reporter: ExportProgressReporter, + emit: Callable[[ServiceProgress[ExportStage]], None], + withdrawn: Callable[[], bool], + ) -> None: + build(reporter, emit, self._gate(withdrawn)) + + monkeypatch.setattr(ExportProgressReporter, "__init__", held) + + def waiting(self) -> int: + """How many runs stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + """Lets every held run, and every one that starts later, go on to its end.""" + self._released.set() + + def hold_at(self, report: int) -> None: + """Holds every run from now on at its report numbered ``report``, counted from 0. + + The earlier reports pass. + """ + self._held_report = report + self._released.clear() + + def _gate(self, withdrawn: Callable[[], bool]) -> Callable[[], bool]: + """The question one run asks after each report; it waits at the held report until released or + withdrawn. + """ + reports = itertools.count() + + def asked() -> bool: + if next(reports) == self._held_report: + self._wait(withdrawn) + + return withdrawn() + + return asked + + def _wait(self, withdrawn: Callable[[], bool]) -> None: + with self._lock: + self._waiting += 1 + + while not self._released.wait(RELEASE_POLL_SECONDS) and not withdrawn(): + continue + + with self._lock: + self._waiting -= 1 diff --git a/automation/holds/regeneration.py b/automation/holds/regeneration.py new file mode 100644 index 000000000..05697e30e --- /dev/null +++ b/automation/holds/regeneration.py @@ -0,0 +1,76 @@ +import threading +from typing import AbstractSet, Optional + +import pytest + +from sampletones_application.services.regeneration.service import ( + RegenerationService, +) +from sampletones_application.services.result import ServiceError +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +class RegenerationHold: + """Holds every rebuild of an edited channel on its worker before it computes, until the scenario releases + it. + + The rebuild waits where the real one runs, so the application draws and answers while the edit + is on its way, and once released the real rebuild computes the edit's result. The hold lets + every rebuild through from its release on, until the scenario holds again. A scenario standing + for a rebuild that breaks lets the held rebuilds go as that failure instead, which the service + reports the way it reports a rebuild that raised. + """ + + def __init__(self) -> None: + self._released = threading.Event() + self._lock = threading.Lock() + self._waiting = 0 + self._failure: Optional[Exception] = None + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the service's rebuild with one that waits for the release before it computes.""" + rebuild = RegenerationService._run # pylint: disable=protected-access + + def held( + service: RegenerationService, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + self._wait() + if self._failure is not None: + service._emit(ServiceError(exception=self._failure)) # pylint: disable=protected-access + return + + rebuild(service, reconstruction, channel_name, features, heard) + + monkeypatch.setattr(RegenerationService, "_run", held) + + def waiting(self) -> int: + """How many rebuilds stand held.""" + with self._lock: + return self._waiting + + def release(self) -> None: + """Lets every held rebuild, and every one asked for later, carry on.""" + self._released.set() + + def hold_again(self) -> None: + """Holds every rebuild asked for from now on, as the hold did before its release.""" + self._released.clear() + + def fail(self, failure: Exception) -> None: + """Lets every held rebuild, and every one asked for after, go as ``failure``.""" + self._failure = failure + self._released.set() + + def _wait(self) -> None: + with self._lock: + self._waiting += 1 + + self._released.wait() + with self._lock: + self._waiting -= 1 diff --git a/automation/holds/scan.py b/automation/holds/scan.py new file mode 100644 index 000000000..bc38a8222 --- /dev/null +++ b/automation/holds/scan.py @@ -0,0 +1,71 @@ +import time +from pathlib import Path +from typing import Final, Iterator + +import pytest + +import sampletones_application.services.folder_scan.service as scan_module +from automation.holds.constants import RELEASE_POLL_SECONDS +from automation.holds.signal import ReleaseSignal + +HELD_ENTRY: Final[str] = "held-entry.txt" + + +class ScanHold: + """Stands in for the walk a folder scan reads, holding every scan once the tree is read until released. + + The walk meets every entry the folder holds, and then goes on meeting an entry that is no + recording, one every ``interval`` seconds, as a tree of thousands of other files would, until the + scenario lets it end. A reader's Stop is checked between entries, so it is heard while the scan + is held, as soon as the next entry comes. + """ + + def __init__( + self, + signal: ReleaseSignal, + *, + interval: float, + ) -> None: + self._signal = signal + self._interval = interval + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the folder walk of a scan with the held one.""" + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + """Lets the held walk end.""" + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + """Yields every entry below ``root``, then one more entry every ``interval`` seconds until released.""" + yield from root.rglob("*") + while not self._signal.is_released(): + yield root / HELD_ENTRY + time.sleep(self._interval) + + +class WindingDownScanHold: + """Stands in for the walk a folder scan reads, keeping it past its last entry until released. + + The walk meets every entry the folder holds and then waits for the scenario, meeting no further entry. A + reader's Stop is checked between entries, so a stopped read winds down for as long as the scenario holds + it, and a folder asked for meanwhile arrives while it winds down on any machine. + """ + + def __init__(self, signal: ReleaseSignal) -> None: + self._signal = signal + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Replaces the folder walk of a scan with the held one.""" + monkeypatch.setattr(scan_module, "walk_entries", self._walk) + + def release(self) -> None: + """Lets the held walk end.""" + self._signal.release() + + def _walk(self, root: Path) -> Iterator[Path]: + """Yields every entry below ``root``, then waits for the release before the walk ends.""" + yield from root.rglob("*") + while not self._signal.is_released(): + time.sleep(RELEASE_POLL_SECONDS) diff --git a/automation/holds/signal.py b/automation/holds/signal.py new file mode 100644 index 000000000..710e0e1c1 --- /dev/null +++ b/automation/holds/signal.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ReleaseSignal: + """A file whose appearance lets held work carry on, which a worker process sees as well as the scenario.""" + + path: Path + + def release(self) -> None: + """Creates the file, which lets the held work carry on.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.touch() + + def is_released(self) -> bool: + """Whether the file exists, which is whether the work was let go.""" + return self.path.exists() diff --git a/automation/homes.py b/automation/homes.py new file mode 100644 index 000000000..51085100d --- /dev/null +++ b/automation/homes.py @@ -0,0 +1,96 @@ +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Final, Optional + +from automation.paths import HOMES_PREFIX, PROCESSES_DIRECTORY + +HIDDEN_PREFIX: Final[str] = "." +PROCESS_SEPARATOR: Final[str] = "-" + + +class HiddenHomesError(RuntimeError): + """Raised when the folder scenario homes would be made in lies inside a hidden folder.""" + + +def make_worker_homes(parent: Path) -> Path: + """Makes the folder one worker keeps its scenario homes in under ``parent``, named after the worker's process. + + The application's browsers leave hidden folders out, so the homes need a parent outside them. The + folders that crashed workers left, whose process has gone, go first. + + Raises: + HiddenHomesError: If ``parent`` lies inside a hidden folder. + """ + hidden = [part for part in parent.parts if part.startswith(HIDDEN_PREFIX)] + if hidden: + raise HiddenHomesError( + f"Screen scenario homes are made in {parent}, which lies inside the hidden folder '{hidden[0]}', " + "and the application's browsers leave hidden folders out. Point SAMPLETONES_SCREENS_HOMES at a " + "folder outside hidden folders." + ) + + _let_stale_homes_go(parent) + return Path(tempfile.mkdtemp(prefix=f"{HOMES_PREFIX}{os.getpid()}{PROCESS_SEPARATOR}", dir=parent)) + + +def let_go(folder: Path) -> None: + """Removes ``folder``, opening first every folder inside it a scenario left locked.""" + reopen_folders(folder) + shutil.rmtree(folder, ignore_errors=True) + + +def reopen_folders(top: Path) -> None: + """Gives the owner back every folder under ``top``, so its files can be read, copied and removed. + + A scenario that locks a folder opens it again as it ends, and one that crashed or was stopped + leaves it locked. A folder whose mode cannot change stays as it is, and the copy and the removal + after it take what they can. + """ + _open_to_owner(top) + for folder, subfolders, _ in os.walk(top): + for subfolder in subfolders: + _open_to_owner(Path(folder) / subfolder) + + +def _open_to_owner(folder: Path) -> None: + if folder.is_symlink(): + return + + try: + folder.chmod(folder.stat().st_mode | stat.S_IRWXU) + except OSError: + return + + +def _let_stale_homes_go(parent: Path) -> None: + """Removes the homes folders under ``parent`` whose worker process has gone, and keeps every other one. + + A worker removes its own folder as it ends, so a folder left behind belongs to a worker that crashed, + or to a run still going. A folder whose name carries no process is left alone. Whether a process runs + is read from the process folder Linux keeps, as the tier runs on Linux, so a machine without that + folder keeps every homes folder. + """ + if not PROCESSES_DIRECTORY.is_dir(): + return + + for folder in parent.glob(f"{HOMES_PREFIX}*"): + process = _owner_process(folder) + if process is not None and not _is_running(process): + let_go(folder) + + +def _owner_process(folder: Path) -> Optional[int]: + """The process id a homes folder's name carries before its separator, or ``None`` for a name that carries none. + + A temporary folder's random tail holds no separator, so a name made without a process reads as none. + """ + process, separator, _ = folder.name.removeprefix(HOMES_PREFIX).partition(PROCESS_SEPARATOR) + return int(process) if separator and process.isdigit() else None + + +def _is_running(process: int) -> bool: + """Whether the process ``process`` names still runs, read from the process folder Linux keeps.""" + return (PROCESSES_DIRECTORY / str(process)).exists() diff --git a/automation/keyboard.py b/automation/keyboard.py new file mode 100644 index 000000000..0dbd82b11 --- /dev/null +++ b/automation/keyboard.py @@ -0,0 +1,34 @@ +from typing import Final + +from automation.dearpygui.hand import Hand +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, +) +from sampletones_application.utils.gui.keyboard.modifiers import ( + MODIFIER_KEYS, + Modifier, +) +from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut + +LEFT_KEY: Final[int] = 0 +PRIMARY_COMBINATION: Final[int] = 0 +FIRST_ALIAS: Final[int] = 0 + + +def primary_combination(shortcut: Shortcut) -> KeyCombination: + """The combination an action displays, which is the one a person presses.""" + return shortcut.combinations()[PRIMARY_COMBINATION] + + +def first_alias(shortcut: Shortcut) -> KeyCombination: + """The first further combination an action answers to, beside the one it displays.""" + return shortcut.aliases[FIRST_ALIAS] + + +def press_combination(hand: Hand, combination: KeyCombination) -> None: + """Presses ``combination`` on the real keyboard: its modifiers on the left side, then its key. + + Modifiers go down in the order a combination displays them, which is the order a person holds them. + """ + modifiers = [MODIFIER_KEYS[modifier][LEFT_KEY] for modifier in Modifier if modifier in combination.modifiers] + hand.press_key(combination.key, modifiers=modifiers) diff --git a/automation/palettes.py b/automation/palettes.py new file mode 100644 index 000000000..89eb5e974 --- /dev/null +++ b/automation/palettes.py @@ -0,0 +1,35 @@ +from typing import Final, FrozenSet, Sequence, Tuple + +from automation.dearpygui.items.colors import rounded_color +from sampletones_application.paths import PALETTES_DIRECTORY +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.palette import Palette + +Color = Tuple[float, ...] +FULL_STEP: Final[float] = 255.0 + + +def shipped_palettes() -> PaletteCatalog: + """The palettes this build ships, the ones Display settings offers.""" + return PaletteCatalog.load(PALETTES_DIRECTORY) + + +def token_color(palette: Palette, token: str) -> Color: + """The color ``token`` names in ``palette``, as DearPyGui holds it.""" + return in_fractions(palette.colors[token]) + + +def in_fractions(color: Sequence[float]) -> Color: + """A color given in whole steps, as DearPyGui holds it: each part a fraction of the full step.""" + return rounded_color(tuple(part / FULL_STEP for part in color)) + + +def shared_colors(catalog: PaletteCatalog) -> FrozenSet[Color]: + """Every color all the shipped palettes state alike under one token, which no palette swap changes.""" + palettes = tuple(catalog.palettes.values()) + first = palettes[0] + return frozenset( + token_color(first, token) + for token in first.colors + if all(palette.colors.get(token) == first.colors[token] for palette in palettes) + ) diff --git a/automation/paths.py b/automation/paths.py new file mode 100644 index 000000000..17ee9a728 --- /dev/null +++ b/automation/paths.py @@ -0,0 +1,15 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.source import REPOSITORY_ROOT + +AUTOMATION_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "automation" +DEARPYGUI_LAYER_DIRECTORY: Final[Path] = AUTOMATION_DIRECTORY / "dearpygui" +KEPT_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "screens" +HOME_FOLDER: Final[str] = "home" +HOMES_PREFIX: Final[str] = "sampletones-screens-" +HOME_COPY_NOTE: Final[str] = "home-copy-incomplete.txt" +PROCESSES_DIRECTORY: Final[Path] = Path("/proc") +REPORTS_FILE: Final[str] = "reports.jsonl" +NO_BUS_FILE: Final[str] = "no-bus" +FAILURE_SCREENSHOT: Final[str] = "failure.png" diff --git a/tests/unit/sampletones_shared/meta/source/__init__.py b/automation/plugin/__init__.py similarity index 100% rename from tests/unit/sampletones_shared/meta/source/__init__.py rename to automation/plugin/__init__.py diff --git a/automation/plugin/constants.py b/automation/plugin/constants.py new file mode 100644 index 000000000..770c17112 --- /dev/null +++ b/automation/plugin/constants.py @@ -0,0 +1,9 @@ +from pathlib import Path +from typing import Final + +import pytest + +from automation.dearpygui.display import VirtualDisplay + +DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() +HOMES_KEY: Final[pytest.StashKey[Path]] = pytest.StashKey() diff --git a/automation/plugin/fixtures.py b/automation/plugin/fixtures.py new file mode 100644 index 000000000..f7289fe42 --- /dev/null +++ b/automation/plugin/fixtures.py @@ -0,0 +1,179 @@ +import os +from pathlib import Path +from typing import Final, Iterator + +import pytest + +from automation.application.boundaries import Boundaries +from automation.application.running import ScreenApplication +from automation.application.startup import Startup +from automation.boundaries.audio import ( + OutputDevice, + OutputRecord, + provide_output_device, +) +from automation.boundaries.dialogs import ScriptedFileDialogs +from automation.boundaries.errors import ErrorRecords +from automation.boundaries.highlights import TableHighlights +from automation.boundaries.reveals import FileManagerStandIn +from automation.boundaries.spawns import SpawnGuard +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.display import VirtualDisplay +from automation.dearpygui.hand import Hand +from automation.dearpygui.windows import WindowManager +from automation.dearpygui.xtest import XTestDevice +from automation.environment import ( + ARTIFACTS_VARIABLE, + display_backend, + screen_size, +) +from automation.holds.base import Holds +from automation.paths import FAILURE_SCREENSHOT +from automation.plugin.constants import DISPLAY_KEY +from automation.render_thread import QueueRenderThread +from automation.screen import Screen +from automation.worlds.home import World, lived_in_world +from sampletones_application.application import Application +from sampletones_application.config.profile import UserProfile + +ANSWER_TIMEOUT_SECONDS: Final[float] = 30.0 +STAND_IN_PROGRAMS: Final[str] = "programs" +REVEALS_FILE: Final[str] = "reveals.log" +DISPLAY_NAME_KEY: Final[pytest.StashKey[str]] = pytest.StashKey() + + +@pytest.fixture(name="world") +def world_fixture() -> World: + """What the scenario's home holds as the application starts; a scenario overrides it to seed its own.""" + return lived_in_world() + + +@pytest.fixture(name="startup") +def startup_fixture() -> Startup: + """The documents the application opens as it starts; a scenario overrides it to open its own.""" + return Startup( + reconstruction=None, + project=None, + ) + + +@pytest.fixture(name="output_device") +def output_device_fixture() -> OutputDevice: + """The output the scenario's machine offers; a scenario overrides it to start with none.""" + return OutputDevice.SILENT + + +@pytest.fixture(name="screen_render_thread") +def screen_render_thread_fixture() -> QueueRenderThread: + """The queue-backed render thread the scenario's application and bridge share.""" + return QueueRenderThread() + + +@pytest.fixture(name="screen_bridge") +def screen_bridge_fixture(screen_render_thread: QueueRenderThread) -> Bridge: + """The bridge through which a scenario asks the render thread for readings and gestures.""" + return Bridge(screen_render_thread, answer_timeout=ANSWER_TIMEOUT_SECONDS) + + +@pytest.fixture(name="screen_boundaries") +def screen_boundaries_fixture( + output_device: OutputDevice, + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> Boundaries: + """Puts the scenario's stand-ins between the application and the desktop before it starts.""" + dialogs = ScriptedFileDialogs() + dialogs.install(monkeypatch) + errors = ErrorRecords() + errors.install() + artifacts = Path(os.environ[ARTIFACTS_VARIABLE]) + file_manager = FileManagerStandIn(artifacts / STAND_IN_PROGRAMS, artifacts / REVEALS_FILE) + file_manager.install(monkeypatch) + spawns = SpawnGuard() + spawns.install() + spawns.allow(file_manager.folder) + highlights = TableHighlights() + highlights.install(monkeypatch) + output = OutputRecord() + output.install(monkeypatch) + provide_output_device( + output_device, + home=Path.home(), + monkeypatch=monkeypatch, + ) + return Boundaries( + dialogs=dialogs, + errors=errors, + spawns=spawns, + holds=screen_holds, + file_manager=file_manager, + highlights=highlights, + output=output, + ) + + +@pytest.fixture(name="screen_application") +def screen_application_fixture( + world: World, + startup: Startup, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> ScreenApplication: + """SampleToNES as ``sampletones open`` starts it, on the scenario's display and in its home.""" + profile = UserProfile.user() + world.write(profile) + application = Application( + profile=profile, + reconstruction_path=startup.reconstruction, + project_path=startup.project, + ) + return ScreenApplication( + application, + render_thread=screen_render_thread, + bridge=screen_bridge, + boundaries=screen_boundaries, + failure_screenshot=Path(os.environ[ARTIFACTS_VARIABLE]) / FAILURE_SCREENSHOT, + state_path=profile.state, + ) + + +@pytest.fixture(name="screen") +def screen_fixture( + screen_application: ScreenApplication, + screen_render_thread: QueueRenderThread, + screen_bridge: Bridge, + screen_boundaries: Boundaries, +) -> Iterator[Screen]: + """The application the scenario drives, with a user's hand on its display.""" + device = XTestDevice(os.environ["DISPLAY"]) + window_manager = WindowManager(os.environ["DISPLAY"], os.getpid()) + yield Screen( + bridge=screen_bridge, + render_thread=screen_render_thread, + hand=Hand(screen_bridge, device), + window_manager=window_manager, + language=screen_application.language, + shortcuts=screen_application.shortcuts, + dialogs=screen_boundaries.dialogs, + errors=screen_boundaries.errors, + file_manager=screen_boundaries.file_manager, + highlights=screen_boundaries.highlights, + output=screen_boundaries.output, + artifacts=Path(os.environ[ARTIFACTS_VARIABLE]), + ) + window_manager.close() + device.close() + + +def _worker_display(config: pytest.Config) -> str: + """The display this worker's scenarios draw on, started with the first of them.""" + name = config.stash.get(DISPLAY_NAME_KEY, None) + if name is not None: + return name + + display = VirtualDisplay(display_backend(os.environ), screen_size(os.environ)) + name = display.start() + config.stash[DISPLAY_KEY] = display + config.stash[DISPLAY_NAME_KEY] = name + return name diff --git a/automation/plugin/hold_fixtures.py b/automation/plugin/hold_fixtures.py new file mode 100644 index 000000000..a7275561f --- /dev/null +++ b/automation/plugin/hold_fixtures.py @@ -0,0 +1,74 @@ +import os +from pathlib import Path +from typing import Final + +import pytest + +from automation.environment import ARTIFACTS_VARIABLE +from automation.holds.base import Holds +from automation.holds.conversion import ConversionHold +from automation.holds.export import ExportHold +from automation.holds.regeneration import RegenerationHold +from automation.holds.scan import ScanHold +from automation.holds.signal import ReleaseSignal + +CONVERSION_RELEASE_FILE: Final[str] = "release-conversion" +SCAN_RELEASE_FILE: Final[str] = "release-scan" +SCAN_ENTRY_SECONDS: Final[float] = 0.05 + + +@pytest.fixture(name="screen_holds") +def screen_holds_fixture() -> Holds: + """The work the scenario holds under way, let go before it leaves.""" + return Holds() + + +@pytest.fixture(name="conversion_hold") +def conversion_hold_fixture( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ConversionHold: + """Holds every conversion the scenario starts halfway through matching, until the scenario releases it.""" + hold = ConversionHold(ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / CONVERSION_RELEASE_FILE)) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture(name="scan_hold") +def scan_hold_fixture( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ScanHold: + """Holds every folder scan the scenario starts once its tree is read, until the scenario releases it.""" + hold = ScanHold( + ReleaseSignal(Path(os.environ[ARTIFACTS_VARIABLE]) / SCAN_RELEASE_FILE), + interval=SCAN_ENTRY_SECONDS, + ) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture(name="regeneration_hold") +def regeneration_hold_fixture( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> RegenerationHold: + """Holds every rebuild an edit of a channel asks for, until the scenario releases it.""" + hold = RegenerationHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + +@pytest.fixture(name="export_hold") +def export_hold_fixture( + screen_holds: Holds, + monkeypatch: pytest.MonkeyPatch, +) -> ExportHold: + """Holds every export at its first report, until the scenario releases it.""" + hold = ExportHold() + hold.install(monkeypatch) + screen_holds.add(hold) + return hold diff --git a/automation/plugin/hooks.py b/automation/plugin/hooks.py new file mode 100644 index 000000000..d94c7dfbc --- /dev/null +++ b/automation/plugin/hooks.py @@ -0,0 +1,160 @@ +import os +from pathlib import Path +from typing import Final, Generator, List, Optional + +import pytest + +from automation.application.running import ScreenApplication +from automation.dearpygui.hosting import host +from automation.dearpygui.isolation import ReportRecorder, run_isolated +from automation.environment import ( + REPORT_VARIABLE, + ScenarioFolders, + child_environment, + homes_root, + kept_root, + shard, +) +from automation.homes import let_go, make_worker_homes +from automation.plugin.constants import DISPLAY_KEY, HOMES_KEY +from automation.plugin.fixtures import _worker_display + +CHILD_TIMEOUT_SECONDS: Final[float] = 600.0 +JOIN_TIMEOUT_SECONDS: Final[float] = 30.0 +REPORT_RECORDER_NAME: Final[str] = "screens-report-recorder" +SCREEN_APPLICATION_FIXTURE: Final[str] = "screen_application" + + +class ScreenScenarioError(AssertionError): + """Raised when a screen scenario is written in a shape the tier cannot host.""" + + +def is_screen_item(item: pytest.Item) -> bool: + """Whether ``item`` is a screen scenario, which is any function taking the application under test.""" + return isinstance(item, pytest.Function) and SCREEN_APPLICATION_FIXTURE in item.fixturenames + + +def is_child_process() -> bool: + """Whether this pytest run is the fresh process one scenario runs in.""" + return REPORT_VARIABLE in os.environ + + +def pytest_collection_modifyitems( + config: pytest.Config, + items: List[pytest.Item], +) -> None: + """Keeps this run's part of the screen scenarios, where ``SAMPLETONES_SCREENS_SHARD`` names one. + + The scenarios are counted in the order of their node ids, so every worker and every machine of a + run spread over several splits them the same way. Items other than scenarios stay with every part. + """ + if is_child_process(): + return + + part = shard(os.environ) + if part is None: + return + + scenarios = sorted( + (item for item in items if is_screen_item(item)), + key=lambda item: item.nodeid, + ) + left_out = {item.nodeid for position, item in enumerate(scenarios) if not part.keeps(position)} + deselected = [item for item in items if item.nodeid in left_out] + items[:] = [item for item in items if item.nodeid not in left_out] + config.hook.pytest_deselected(items=deselected) + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_protocol( + item: pytest.Item, + nextitem: Optional[pytest.Item], +) -> Optional[bool]: + """Runs a screen scenario in a process of its own and reports what that process reported.""" + del nextitem + if is_child_process() or not is_screen_item(item): + return None + + folders = ScenarioFolders.of(item.nodeid, _worker_homes(item.config), kept_root(os.environ)) + folders.prepare() + environment = child_environment( + os.environ, + folders, + display=_worker_display(item.config), + ) + item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) + failed = False + for report in run_isolated( + item, + environment=environment, + working_directory=folders.home, + report_path=folders.reports, + timeout=CHILD_TIMEOUT_SECONDS, + ): + failed = failed or report.failed + item.ihook.pytest_runtest_logreport(report=report) + + folders.finish(failed=failed) + item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) + return True + + +def _worker_homes(config: pytest.Config) -> Path: + """The folder this worker's scenarios keep their homes in, made with the first of them. + + Each worker makes its own, named after its process, so runs from several checkouts at once keep + their homes apart, and each worker's first scenario lets go of what crashed workers left. + """ + homes = config.stash.get(HOMES_KEY, None) + if homes is not None: + return homes + + homes = make_worker_homes(homes_root(os.environ)) + config.stash[HOMES_KEY] = homes + return homes + + +def pytest_configure(config: pytest.Config) -> None: + """In a scenario's own process, registers the recorder that writes its reports to the report file.""" + if is_child_process(): + recorder = ReportRecorder(config, Path(os.environ[REPORT_VARIABLE])) + config.pluginmanager.register(recorder, REPORT_RECORDER_NAME) + + +def pytest_unconfigure(config: pytest.Config) -> None: + """Stops the virtual display this worker started, and lets its temporary homes go, if it made them.""" + display = config.stash.get(DISPLAY_KEY, None) + if display is not None: + display.stop() + + homes = config.stash.get(HOMES_KEY, None) + if homes is not None: + let_go(homes) + + +@pytest.hookimpl(wrapper=True) +def pytest_pyfunc_call( + pyfuncitem: pytest.Function, +) -> Generator[None, Optional[object], Optional[object]]: + """Runs a screen scenario's body on a thread of its own while the application draws on this one.""" + if not is_child_process() or not is_screen_item(pyfuncitem): + return (yield) + + loop = pyfuncitem.funcargs.get(SCREEN_APPLICATION_FIXTURE) + if not isinstance(loop, ScreenApplication): + raise ScreenScenarioError(f"A screen scenario takes the screen fixture: {pyfuncitem.nodeid}") + + original = pyfuncitem.obj + + def hosted(*arguments: object, **keywords: object) -> None: + host( + lambda: original(*arguments, **keywords), + loop, + join_timeout=JOIN_TIMEOUT_SECONDS, + ) + + pyfuncitem.obj = hosted + try: + return (yield) + finally: + pyfuncitem.obj = original diff --git a/automation/render_thread.py b/automation/render_thread.py new file mode 100644 index 000000000..604e5d418 --- /dev/null +++ b/automation/render_thread.py @@ -0,0 +1,46 @@ +import threading +from typing import Callable + +from sampletones_application.utils.callbacks.queue import CallbackQueue + + +class QueueRenderThread: + """SampleToNES's render thread, reached through the queue its own workers post to. + + The queue drains between frames on the thread drawing them, which is where every DearPyGui call + belongs, and it counts a delay in frames drawn. + """ + + def __init__(self) -> None: + self._running = threading.Event() + self._stopped = threading.Event() + + def post( + self, + task: Callable[[], None], + *, + frames: int, + ) -> None: + """Queues ``task`` to run on the render thread once ``frames`` frames are drawn.""" + CallbackQueue.add(task, delay=frames) + + def is_running(self) -> bool: + """Whether the loop is drawing.""" + return self._running.is_set() + + def frames_drawn(self) -> int: + """How many frames the loop has drawn, counted by the queue it drains.""" + return CallbackQueue.current_frame() + + def start(self) -> None: + """Marks the loop as drawing, which is when posted work starts being answered.""" + self._running.set() + + def stop(self) -> None: + """Marks the loop as stopped, so a question still waiting fails at once.""" + self._running.clear() + self._stopped.set() + + def wait_stopped(self, timeout: float) -> bool: + """Waits up to ``timeout`` seconds for the loop to stop, and says whether it did.""" + return self._stopped.wait(timeout) diff --git a/tests/suite/scenario.py b/automation/scenario.py similarity index 94% rename from tests/suite/scenario.py rename to automation/scenario.py index 29e4375d7..fd99af604 100644 --- a/tests/suite/scenario.py +++ b/automation/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/automation/screen.py b/automation/screen.py new file mode 100644 index 000000000..e5c011723 --- /dev/null +++ b/automation/screen.py @@ -0,0 +1,345 @@ +from contextlib import contextmanager +from pathlib import Path +from typing import ( + Callable, + Dict, + Final, + Iterator, + Optional, + Tuple, + TypeVar, + Union, +) + +import numpy as np + +from automation.boundaries.audio import OutputRecord +from automation.boundaries.dialogs import ( + DialogKind, + DialogRequest, + ScriptedFileDialogs, +) +from automation.boundaries.errors import ErrorRecords +from automation.boundaries.highlights import HighlightPlace, TableHighlights +from automation.boundaries.reveals import FileManagerStandIn +from automation.dearpygui.bridge import Bridge, ExpectationError +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme +from automation.dearpygui.items.reading import read_item_count +from automation.dearpygui.items.regions import WindowReading, read_windows +from automation.dearpygui.items.texts import read_label, read_visible_text +from automation.dearpygui.items.types import Item +from automation.dearpygui.items.viewport import ( + read_pointer, + read_viewport_title, +) +from automation.dearpygui.recording import FrameRecording +from automation.dearpygui.screenshot import capture +from automation.dearpygui.windows import WindowManager +from automation.frames import frame_pixels +from automation.keyboard import ( + first_alias, + press_combination, + primary_combination, +) +from automation.render_thread import QueueRenderThread +from automation.scenario import BaseTestScenario, ScenarioStep +from automation.views.audio_settings import AudioSettings +from automation.views.browsers import FileTree +from automation.views.context_menu import ContextMenu +from automation.views.display_settings import DisplaySettings +from automation.views.exports import Exports +from automation.views.instructions import Instructions +from automation.views.keyboard_settings import KeyboardSettings +from automation.views.main import Main +from automation.views.menus import MenuBar +from automation.views.notices import Notice +from automation.views.project import Project +from automation.views.reconstructions import Reconstructions +from automation.views.sequencer import Sequencer +from automation.views.tabs import Tabs +from sampletones_application.categories.context import ( + channel_label, + generator_label, +) +from sampletones_application.categories.key.text import TextKeyTuple +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_ERROR, + TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, + TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, + TAG_GLOBAL_STATUS_BAR, + TAG_GLOBAL_WINDOW_MAIN, +) +from sampletones_application.tags.main import TAG_MAIN_EXPLORER_TREE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager +from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut +from sampletones_core.constants.enums import ChannelName, GeneratorName + +ReadingT = TypeVar("ReadingT") +EXPECT_TIMEOUT_SECONDS: Final[float] = 10.0 +STEP_SEPARATOR: Final[str] = " → " +SCREENSHOT_SUFFIX: Final[str] = ".png" +STATUS_BAR: Final[str] = compose_tag(TAG_GLOBAL_STATUS_BAR, SUF_BUTTON) + + +class Screen: + """What a scenario holds: SampleToNES on its display, a user's hand on it, and the views it reads. + + A scenario speaks through views, one per tab, card and dialog. A view knows where its controls + stand and reads what they show, so the scenario states gestures and expectations in a user's + terms. A reading taken while the application still moves is waited for with ``expect``, which + reads once a frame until the expectation holds. + """ + + def __init__( + self, + *, + bridge: Bridge, + render_thread: QueueRenderThread, + hand: Hand, + window_manager: WindowManager, + language: LanguageManager, + shortcuts: ShortcutManager, + dialogs: ScriptedFileDialogs, + errors: ErrorRecords, + file_manager: FileManagerStandIn, + highlights: TableHighlights, + output: OutputRecord, + artifacts: Path, + ) -> None: + self.bridge = bridge + self.hand = hand + self._window_manager = window_manager + self._render_thread = render_thread + self._shortcuts = shortcuts + self._dialogs = dialogs + self._errors = errors + self._file_manager = file_manager + self._highlights = highlights + self._output = output + self._artifacts = artifacts + self._language = language + self.tabs = Tabs(bridge, hand) + self.menu = MenuBar(bridge, hand, language) + self.display_settings = DisplaySettings(bridge, hand, self.menu) + self.keyboard_settings = KeyboardSettings(bridge, hand, self.menu) + self.audio_settings = AudioSettings(bridge, hand) + self.project = Project(bridge, hand, self.menu) + self.main = Main(bridge, hand, language) + self.context_menu = ContextMenu(bridge, hand) + self.explorer = FileTree(bridge, hand, TAG_MAIN_EXPLORER_TREE) + self.reconstructions = Reconstructions(bridge, hand, self.menu) + self.sequencer = Sequencer(bridge, hand, self.menu) + self.instructions = Instructions(bridge, hand) + self.exports = Exports(bridge, hand, self.menu) + self.error_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_ERROR) + self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) + self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT) + self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) + + def expect( + self, + reading: Callable[[], ReadingT], + holds: Callable[[ReadingT], bool], + *, + description: str, + ) -> ReadingT: + """Takes ``reading`` once a frame until ``holds`` accepts it, and returns that reading.""" + return self.bridge.expect( + reading, + holds, + description=description, + timeout=EXPECT_TIMEOUT_SECONDS, + ) + + def words(self, key: Union[str, TextKeyTuple]) -> str: + """What the language file says under ``key``, which is what the application shows the user.""" + return self._language[key] + + def shortcut(self, shortcut_id: ShortcutId) -> Shortcut: + """The binding the scheme in place gives ``shortcut_id``: its main key and the further ones.""" + return self._shortcuts.shortcut(shortcut_id) + + def shortcut_words(self, shortcut_id: ShortcutId) -> str: + """How a menu prints the keys of ``shortcut_id`` under the scheme in place.""" + return self._shortcuts.shortcut(shortcut_id).display() + + def frame_pixels(self) -> np.ndarray: + """The next frame drawn, as rows of pixels of red, green, blue and alpha fractions.""" + return frame_pixels(self.bridge) + + def channel_words(self, channel: ChannelName) -> str: + """The name every display gives ``channel``.""" + return channel_label(self._language, channel) + + def generator_words(self, generator: GeneratorName) -> str: + """The words the application names ``generator`` by wherever it offers one.""" + return generator_label(self._language, generator) + + def status(self) -> str: + """What the status bar along the bottom of the window says.""" + return self.bridge.ask(lambda: read_label(STATUS_BAR)) + + def shows_text(self, words: str) -> bool: + """Whether text reading ``words`` was drawn in the last frame, such as a tooltip standing open.""" + return self.bridge.ask(lambda: read_visible_text(words)) + + def expect_item( + self, + reading: Callable[[], Optional[Item]], + *, + description: str, + ) -> Item: + """Takes ``reading`` once a frame until it finds an item, and returns the item. + + Raises: + ExpectationError: If no item is found within the expectation's time. + """ + found = self.expect(reading, lambda value: value is not None, description=description) + if found is None: + raise ExpectationError(f"Expected {description}, and found nothing") + + return found + + def pointer(self) -> Point: + """Where the pointer stands in the viewport, as the application last saw it.""" + return self.bridge.ask(read_pointer) + + def theme_of(self, item: Item) -> Optional[str]: + """The tag of the theme bound to ``item``, which is how a control wears a state such as hover.""" + return self.bridge.ask(lambda: read_theme(item)) + + def item_count(self) -> int: + """How many items the interface holds, which a gesture repeated without end leaves where it stood.""" + return self.bridge.ask(read_item_count) + + @contextmanager + def record(self, reading: Callable[[], ReadingT]) -> Iterator[FrameRecording[ReadingT]]: + """Takes ``reading`` on the render thread after every frame drawn while the block runs. + + ``reading`` runs between frames on the render thread, so it reads DearPyGui directly. + """ + recording = FrameRecording(self._render_thread, reading) + recording.start() + try: + yield recording + finally: + recording.stop() + + def frames(self, count: int) -> None: + """Lets ``count`` frames pass, for a scenario whose promise is itself a number of frames.""" + self.bridge.frames(count) + + def capture(self, name: str) -> Path: + """Keeps a picture of the next frame beside the scenario's other files, as evidence of a look.""" + return capture(self.bridge, (self._artifacts / name).with_suffix(SCREENSHOT_SUFFIX)) + + def title(self) -> str: + """The title on the window's title bar, which names the documents open.""" + return self.bridge.ask(read_viewport_title) + + def is_running(self) -> bool: + """Whether the application still draws its window.""" + return self._render_thread.is_running() + + def close_window(self) -> None: + """Closes the application's window from its title bar, the way a window manager asks it to.""" + self._window_manager.request_close() + + def wait_for_exit(self) -> bool: + """Waits for the application to stop, and says whether it did within the expectation's time.""" + return self._render_thread.wait_stopped(EXPECT_TIMEOUT_SECONDS) + + def sound_heard(self) -> int: + """How many samples carrying sound the application has played so far, which grows while anything sounds. + + The count is taken at the output device, so a sound shorter than the frames between two readings counts + all the same. + """ + return self._output.sounding_samples() + + def dialog_requests(self) -> Tuple[DialogRequest, ...]: + """Every file dialog the application opened so far, in order.""" + return self._dialogs.requests + + def revealed(self) -> Tuple[Path, ...]: + """Every path the application asked the desktop's file manager to show, in order.""" + return self._file_manager.shown() + + def table_highlights(self) -> Dict[HighlightPlace, Tuple[float, ...]]: + """Every highlight standing on a table, with the color the application laid it in.""" + return self._highlights.standing() + + def shown_windows(self) -> Tuple[WindowReading, ...]: + """Every window standing on the screen besides the application's main one.""" + return tuple( + window + for window in self.bridge.ask(read_windows) + if window.shown and window.alias != TAG_GLOBAL_WINDOW_MAIN + ) + + def answer_next_dialog( + self, + kind: DialogKind, + path: Optional[Path], + ) -> None: + """Answers the next file dialog of ``kind`` with ``path``, or dismisses it for ``None``.""" + self._dialogs.answer(kind, path) + + def answer_next_save_as( + self, + path: Path, + type_name: str, + ) -> None: + """Answers the next save dialog with ``path``, the offered file type named ``type_name`` picked in + it. + """ + self._dialogs.answer_save_as(path, type_name) + + def claim_error(self, naming: str) -> None: + """Waits for the error a scenario provoked on purpose, one whose message holds ``naming``. + + A claimed error is the application reporting a failure as it should, so the after-checks leave + it out. An error nobody claims still fails the scenario. + """ + self.expect( + lambda: self._errors.claim(naming), + bool, + description=f"an error naming '{naming}'", + ) + + def press_shortcut(self, shortcut_id: ShortcutId) -> None: + """Presses the keys the scheme in place gives ``shortcut_id`` on the real keyboard.""" + press_combination( + self.hand, + primary_combination(self._shortcuts.shortcut(shortcut_id)), + ) + + def press_shortcut_alias(self, shortcut_id: ShortcutId) -> None: + """Presses the first alias the scheme in place gives ``shortcut_id`` on the real keyboard.""" + press_combination(self.hand, first_alias(self._shortcuts.shortcut(shortcut_id))) + + def scenario(self, *steps: Callable[["Screen"], None]) -> BaseTestScenario["Screen"]: + """The ordered steps of one scenario, each named after its function in a failure it raises.""" + return BaseTestScenario( + label=STEP_SEPARATOR.join(step.__name__ for step in steps), + build=lambda: self, + steps=[ScenarioStep(label=step.__name__, action=_named(step)) for step in steps], + ) + + +def _named(step: Callable[[Screen], None]) -> Callable[[Screen], None]: + def action(screen: Screen) -> None: + try: + step(screen) + except BaseException as error: + error.add_note(f"In the step '{step.__name__}'") + raise + + return action diff --git a/tests/unit/sampletones_shared/meta/source/bindings/__init__.py b/automation/steps/__init__.py similarity index 100% rename from tests/unit/sampletones_shared/meta/source/bindings/__init__.py rename to automation/steps/__init__.py diff --git a/automation/steps/exports.py b/automation/steps/exports.py new file mode 100644 index 000000000..b1ef7e5da --- /dev/null +++ b/automation/steps/exports.py @@ -0,0 +1,9 @@ +from automation.screen import Screen +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project left as it was opened, which goes through at once.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() diff --git a/automation/steps/instructions.py b/automation/steps/instructions.py new file mode 100644 index 000000000..fee7f9cce --- /dev/null +++ b/automation/steps/instructions.py @@ -0,0 +1,47 @@ +import operator +from functools import partial +from pathlib import Path + +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.paths.user import LIBRARY_DIRECTORY + + +def library_path(config: Config) -> Path: + """Where the library built for ``config`` is stored, under the name its settings give it.""" + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +def library_row(screen: Screen, path: Path) -> Item: + """The library card's row of the library stored at ``path``, once it is drawn.""" + library = screen.instructions.library + return screen.expect_item(partial(library.row, path.name), description=f"the row of {path.name}") + + +def open_library_row(screen: Screen, row: Item) -> None: + """Opens a library's row, which is the click that loads it, closing it first where it stands open.""" + tree = screen.instructions.library.tree + if tree.is_open(row): + tree.open_by_click(row) + screen.expect( + partial(tree.is_open, row), + operator.not_, + description="the row closed", + ) + + tree.open_by_click(row) + + +def load_library(screen: Screen, config: Config) -> Item: + """Brings the Instructions tab forward and opens the row of the library built for ``config``, which loads + it. + """ + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + row = library_row(screen, library_path(config)) + open_library_row(screen, row) + return row diff --git a/automation/steps/main.py b/automation/steps/main.py new file mode 100644 index 000000000..6ea25dbc2 --- /dev/null +++ b/automation/steps/main.py @@ -0,0 +1,104 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, Sequence + +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName + +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 + + +def home_path(name: str) -> Path: + """A path in the home the application was started in, which the explorer stands open on.""" + return Path.cwd() / name + + +def explorer_row(screen: Screen, path: Path) -> Item: + """The explorer's row of ``path``, once it is drawn.""" + return screen.expect_item( + lambda: screen.explorer.file_row(path), + description=f"the explorer's row of {path.name}", + ) + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer, waiting for the button to name the larger run. + + A long list draws only the rows in view, so the button counting the run is what shows a + recording joined it. + """ + converter = screen.main.converter + for path in paths: + before = converter.action() + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(converter.action, before.__ne__, description=f"{path.name} gathered") + screen.expect( + lambda: not converter.scan_shown(), + bool, + description="the folder read", + ) + + +def choose_from_the_row_menu(screen: Screen, path: Path, entry: str) -> None: + """Right-clicks the converter's row of ``path`` and chooses ``entry`` from its menu.""" + row = screen.main.converter.list.row(path) + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + screen.expect( + screen.context_menu.is_shown, + bool, + description=f"the menu of {path.name}", + ) + screen.context_menu.choose(entry) + + +def give_each_its_own_channel(screen: Screen, channels: Dict[Path, ChannelName]) -> None: + """Leaves each gathered recording the one channel ``channels`` names for it.""" + listing = screen.main.converter.list + for path, keep in channels.items(): + for channel in ChannelName: + if listing.channel_ticked(path, channel) != (channel == keep): + listing.tick(path, channel) + screen.expect( + partial(listing.channel_ticked, path, channel), + (channel == keep).__eq__, + description=f"{path.name} {channel}", + ) + + +def convert_alone( + screen: Screen, + recording: Path, + *, + channel: ChannelName, + replacing: Sequence[Path], +) -> None: + """Converts ``recording`` alone on ``channel``, and waits for the question the run's end asks. + + The recordings of ``replacing`` the list holds leave it first, and ``channel`` is the one + channel the run may sound. + """ + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + for gathered in replacing: + if converter.list.has_row(gathered): + converter.list.remove(gathered) + screen.expect( + partial(converter.list.has_row, gathered), + operator.not_, + description=f"{gathered.name} gone", + ) + + gather(screen, recording) + give_each_its_own_channel(screen, {recording: channel}) + converter.press_action() + + screen.bridge.expect( + converter.end_prompt.is_shown, + bool, + description="the run's end", + timeout=RUN_TIMEOUT_SECONDS, + ) diff --git a/automation/steps/project.py b/automation/steps/project.py new file mode 100644 index 000000000..1f244c52c --- /dev/null +++ b/automation/steps/project.py @@ -0,0 +1,61 @@ +import operator +from pathlib import Path + +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.project import ProjectContainer + + +def save_new_project(screen: Screen, path: Path) -> None: + """Starts a new project and saves it to ``path`` through File ▸ Save project as.""" + screen.project.create() + save_project_as(screen, path) + + +def save_project_as(screen: Screen, path: Path) -> None: + """Saves the open project to ``path`` through File ▸ Save project as, and dismisses the notice.""" + project = screen.project + screen.answer_next_dialog(DialogKind.SAVE, path) + + project.save_as() + + screen.expect( + project.saved_notice.is_shown, + bool, + description="the project saved notice", + ) + project.saved_notice.confirm() + screen.expect( + project.saved_notice.is_shown, + operator.not_, + description="the notice gone", + ) + + +def retitle_project(screen: Screen, title: str) -> None: + """Types ``title`` into Project properties and confirms it, which leaves the project unsaved.""" + properties = screen.project.properties + properties.open() + screen.expect(properties.is_shown, bool, description="the Project properties dialog") + + properties.retitle(title) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + + +def saved_project_title(path: Path) -> str: + """The title the project file at ``path`` carries.""" + return ProjectContainer.load(path).info.title + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, answering Exit to the question about the project, and waits for the application to stop.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() diff --git a/automation/steps/reconstructions.py b/automation/steps/reconstructions.py new file mode 100644 index 000000000..773429fa4 --- /dev/null +++ b/automation/steps/reconstructions.py @@ -0,0 +1,225 @@ +import operator +from pathlib import Path +from typing import Final, List, Optional + +from automation.dearpygui.items.types import Item +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.constants.symbols import TITLE_SEPARATOR +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY + +APPLICATION_NAME: Final[str] = "global.dialog.title.main_window" +UNTITLED: Final[str] = "global.dialog.label.untitled" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" +REMOVE_RECONSTRUCTION: Final[str] = "reconstructions.browser.label.context_remove_reconstruction" +UNSAVED_MARK: Final[str] = "*" +LOUDEST: Final[int] = 15 +ITEM_SEPARATOR: Final[str] = " " +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" + + +def marked(name: str, *, unsaved: bool) -> str: + """A document's name as the title bar carries it, marked while it holds unsaved changes.""" + return f"{name}{UNSAVED_MARK}" if unsaved else name + + +def titled(screen: Screen, *documents: str) -> str: + """The window title naming ``documents`` after the application, each as the title bar marks it.""" + return TITLE_SEPARATOR.join((screen.words(APPLICATION_NAME), *documents)) + + +def reconstruction_row(screen: Screen, path: Path) -> Item: + """The browser's row of the reconstruction at ``path``, under the heading listing reconstructions by + configuration. + + The heading and every row above the reconstruction's are opened where they stand closed, so the row + is in reach. + """ + browser = screen.reconstructions.browser + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + screen.expect( + lambda: browser.is_open(heading), + bool, + description="the heading open", + ) + + row = screen.expect_item(lambda: browser.file_row(path), description=f"the row of {path.name}") + for above in browser.rows_above(row): + if not browser.is_open(above): + browser.open_by_click(above) + screen.expect( + lambda: browser.is_open(above), + bool, + description=f"{browser.label(above)} open", + ) + + return row + + +def load_from_the_browser(screen: Screen, path: Path) -> None: + """Double-clicks the browser's row of ``path``, which loads it once nothing unsaved stands in the way.""" + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + screen.reconstructions.browser.double_click(reconstruction_row(screen, path)) + + +def expect_open(screen: Screen, path: Path) -> None: + """Waits until the reconstruction stored at ``path`` is the one open.""" + screen.expect( + lambda: screen.reconstructions.shows_open(path), + bool, + description=f"{path.name} open", + ) + + +def edit_envelope( + screen: Screen, + *, + channel: ChannelName, + feature: FeatureKey, + sequence: str, + title: str, +) -> None: + """Types ``sequence`` over a channel's envelope and waits for the edit to land, which marks the title + ``title``. + """ + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + screen.reconstructions.instruments.type_envelope(channel, feature, sequence) + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") + + +def remove_from_the_browser(screen: Screen, path: Path) -> None: + """Removes the reconstruction at ``path`` through its row's menu, answering Remove to the question.""" + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + reconstructions.browser.right_click(reconstruction_row(screen, path)) + screen.expect( + screen.context_menu.is_shown, + bool, + description=f"the menu of {path.name}", + ) + screen.context_menu.choose(screen.words(REMOVE_RECONSTRUCTION)) + screen.expect( + reconstructions.remove_prompt.is_shown, + bool, + description="the question about removing", + ) + + reconstructions.remove_prompt.confirm() + + screen.expect( + reconstructions.remove_prompt.is_shown, + operator.not_, + description="the question gone", + ) + screen.expect(path.exists, operator.not_, description=f"{path.name} gone") + + +def voice_title( + screen: Screen, + project: Optional[str], + ordinal: int, + voice: str, + *, + unsaved: bool, +) -> str: + """The window title while a project's sample stands open: the project, marked, and the sample in brackets. + + The sample is named by its place among the voices, counted from zero in hexadecimal, and its name. + """ + name = marked(project or screen.words(UNTITLED), unsaved=unsaved) + return titled(screen, f"{name} [{ordinal:02X}: {voice}]") + + +def raised(level: int) -> int: + """``level`` one step louder, wrapping past the loudest to silence.""" + return (level + 1) % (LOUDEST + 1) + + +def first_raised(sequence: str) -> str: + """A volume sequence as typed, its first level one step louder.""" + first, *rest = sequence.split(ITEM_SEPARATOR) + return ITEM_SEPARATOR.join((str(raised(int(first))), *rest)) + + +def first_level_raised(levels: List[int]) -> List[int]: + """The volume of each frame, the first frame's one step louder.""" + first, *rest = levels + return [raised(first), *rest] + + +def leading(levels: List[int], frames: List[int]) -> List[int]: + """The first of ``levels``, as many as ``frames`` holds: the frames a document had before an edit.""" + return levels[: len(frames)] + + +def stored_levels(path: Path, channel: ChannelName) -> List[int]: + """The volume each frame of a pulse ``channel`` plays at in the reconstruction stored at ``path``.""" + instructions = Reconstruction.load(path).instructions[channel] + return [instruction.volume for instruction in instructions if isinstance(instruction, PulseInstruction)] + + +def raise_the_first_level( + screen: Screen, + channel: ChannelName, + *, + title: str, +) -> str: + """Raises the first volume of ``channel`` one step and waits for the title to read ``title``. + + Returns: + str: The sequence typed. + """ + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + sequence = first_raised( + screen.expect( + lambda: screen.reconstructions.instruments.envelope(channel, FeatureKey.VOLUME), + bool, + description=f"the volume of {channel} drawn", + ) + ) + edit_envelope( + screen, + channel=channel, + feature=FeatureKey.VOLUME, + sequence=sequence, + title=title, + ) + return sequence + + +def converted(recording: Path) -> Path: + """The one reconstruction a run wrote for ``recording``, wherever below the reconstructions folder it + went. + """ + found = list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{recording.stem}{RECONSTRUCTION_SUFFIX}")) + assert len(found) == 1, found + return found[0] + + +def raise_the_first_level_while_held(screen: Screen, hold: RegenerationHold, channel: ChannelName) -> str: + """Raises the first volume of ``channel`` one step and waits for its rebuild to stand held. + + Returns: + str: The sequence typed. + """ + reconstructions = screen.reconstructions + sequence = first_raised(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME)) + + reconstructions.instruments.type_envelope(channel, FeatureKey.VOLUME, sequence) + + screen.expect(hold.waiting, bool, description="the rebuild held") + return sequence + + +def edited_title(screen: Screen, document: Path) -> str: + """The window title while the open reconstruction ``document`` carries unsaved changes.""" + return titled(screen, marked(document.name, unsaved=True)) diff --git a/automation/steps/sequencer.py b/automation/steps/sequencer.py new file mode 100644 index 000000000..e7693848c --- /dev/null +++ b/automation/steps/sequencer.py @@ -0,0 +1,74 @@ +from functools import partial +from typing import Final, List + +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName + +EDIT_VOICE: Final[str] = "sequencer.voices.label.context_edit" + + +def voice_row(screen: Screen, name: str) -> Item: + """The Voices card's row of the voice ``name``, on the Sequencer tab brought to the front.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices = screen.sequencer.voices + return screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + + +def double_click_voice(screen: Screen, name: str) -> None: + """Double-clicks the voice ``name``, which opens it on the Reconstructions tab.""" + screen.sequencer.voices.edit(voice_row(screen, name)) + + +def open_voice_menu(screen: Screen, name: str) -> None: + """Right-clicks the row of the voice ``name``, which opens its menu.""" + screen.sequencer.voices.right_click(voice_row(screen, name)) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {name}") + + +def open_voice(screen: Screen, name: str) -> None: + """Opens the voice ``name`` on the Reconstructions tab through Edit on its row's menu.""" + open_voice_menu(screen, name) + screen.context_menu.choose(screen.words(EDIT_VOICE)) + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Exits, letting the changed project go at the question about it.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def on_the_sequencer(screen: Screen) -> None: + """Brings the Sequencer tab to the front and waits until the project's voices are listed.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.sequencer.voices.names, bool, description="the project's voices") + + +def checked(screen: Screen, group: MenuElements, label: str) -> bool: + """Whether the entry labeled ``label`` in the menu ``group`` carries its check mark.""" + return next(entry.checked for entry in screen.menu.entries(group) if entry.label == label) + + +def sounding_but(*muted: ChannelName) -> List[bool]: + """What Playback ▸ Channels marks while exactly ``muted`` are silenced, in channel order.""" + return [channel not in muted for channel in ChannelName.items()] + + +def channels_sounding(screen: Screen) -> List[bool]: + """Which channels Playback ▸ Channels marks as sounding, in channel order.""" + return [ + checked( + screen, + MenuElements.GROUP_PLAYBACK_CHANNELS, + screen.channel_words(channel), + ) + for channel in ChannelName.items() + ] diff --git a/tests/unit/sampletones_synthesis/__init__.py b/automation/views/__init__.py similarity index 100% rename from tests/unit/sampletones_synthesis/__init__.py rename to automation/views/__init__.py diff --git a/automation/views/audio_settings.py b/automation/views/audio_settings.py new file mode 100644 index 000000000..637e62763 --- /dev/null +++ b/automation/views/audio_settings.py @@ -0,0 +1,75 @@ +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.reach import UnreachableError +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_BUTTON_APPLY, + TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN, + TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB, + TAG_SETTINGS_AUDIO_WINDOW, +) + +COLOR: Final[str] = "color" +MIDDLE: Final[float] = 0.5 + + +class AudioSettings: + """The Audio settings dialog, as far as its master gain and Apply: the slider, the line reading its level + in decibels, and the button that applies the choices. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_WINDOW)).shown + + def apply(self) -> None: + """Clicks Apply.""" + self._hand.click(TAG_SETTINGS_AUDIO_BUTTON_APPLY) + + def gain(self) -> float: + """The master gain the slider stands at.""" + return float(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN))) + + def decibels(self) -> str: + """What the line under the slider reads.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB))) + + def decibels_color(self) -> Tuple[float, ...]: + """The color the line under the slider is drawn in.""" + return tuple( + float(part) + for part in self._bridge.ask( + lambda: dpg.get_item_configuration(TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB)[COLOR] + ) + ) + + def drag_gain(self, start: float, end: float) -> None: + """Drags the slider from ``start`` to ``end``, each a fraction of its width. + + A fraction beyond 0 or 1 drags past the end of the slider. + + Raises: + UnreachableError: If the slider reports no box. + """ + box = self._bridge.ask(lambda: read_item(TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN).rect) + if box is None: + raise UnreachableError("The master gain slider reports no box") + + middle = round(box.y + box.height * MIDDLE) + self._hand.drag( + Point(x=round(box.x + box.width * start), y=middle), + Point(x=round(box.x + box.width * end), y=middle), + ) diff --git a/automation/views/bar_graph.py b/automation/views/bar_graph.py new file mode 100644 index 000000000..95251d045 --- /dev/null +++ b/automation/views/bar_graph.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.plots import settled_plot_pointer +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_GRAPH_PLOT + +NEAR: Final[float] = 0.25 +FAR: Final[float] = 0.75 +BAR_MIDDLE: Final[float] = 0.5 +HOVER_SECONDS: Final[float] = 10.0 + + +class MissingGraphError(AssertionError): + """Raised when a scenario reaches for a bar graph the screen draws nowhere.""" + + +@dataclass(frozen=True) +class PlotMapping: + """How a plot lays its values across the screen, read off two points the pointer rested on. + + A plot draws its axes in straight lines, so two readings fix where any value lands. + """ + + screen: Tuple[Point, Point] + plot: Tuple[Tuple[float, float], Tuple[float, float]] + + def point(self, x: float, y: float) -> Point: + """The viewport point the plot draws the value ``(x, y)`` at.""" + (near_x, near_y), (far_x, far_y) = self.plot + near, far = self.screen + return Point( + x=round(near.x + (x - near_x) * (far.x - near.x) / (far_x - near_x)), + y=round(near.y + (y - near_y) * (far.y - near.y) / (far_y - near_y)), + ) + + +class BarGraph: + """A graph of one envelope drawn as bars, one per item, which a press or a drag sets.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self.plot = compose_tag(tag, SUF_GRAPH_PLOT) + + def mapping(self) -> PlotMapping: + """Rests the pointer at two points of the plot and reads the values the plot puts under them.""" + rect = self._bridge.ask(lambda: read_item(self.plot).rect) + if rect is None: + raise MissingGraphError(f"The plot '{self.plot}' reports no box") + + points = ( + Point( + x=round(rect.x + rect.width * NEAR), + y=round(rect.y + rect.height * NEAR), + ), + Point( + x=round(rect.x + rect.width * FAR), + y=round(rect.y + rect.height * FAR), + ), + ) + readings: List[Tuple[float, float]] = [] + for point in points: + readings.append(self.value_under(point)) + + return PlotMapping(screen=points, plot=(readings[0], readings[1])) + + def value_under(self, point: Point) -> Tuple[float, float]: + """Rests the pointer at ``point`` and reads the value the plot puts under it.""" + self._hand.move_to(point) + return settled_plot_pointer(self._bridge, self.plot, timeout=HOVER_SECONDS) + + def zoom_in(self, fraction: float, notches: int) -> None: + """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, widening each bar there.""" + rect = self._bridge.ask(lambda: read_item(self.plot).rect) + if rect is None: + raise MissingGraphError(f"The plot '{self.plot}' reports no box") + + self._hand.wheel_at( + Point( + x=round(rect.x + rect.width * fraction), + y=round(rect.y + rect.height * BAR_MIDDLE), + ), + -notches, + ) + + def drag_across( + self, + first: int, + last: int, + *, + value: float, + ) -> None: + """Drags along the height of ``value`` from the bar of item ``first`` to the bar of item ``last``.""" + mapping = self.mapping() + self._hand.drag( + mapping.point(first + BAR_MIDDLE, value), + mapping.point(last + BAR_MIDDLE, value), + ) + + def item_under(self, point: Point) -> Tuple[int, float]: + """The item whose bar stands under ``point``, and the value the plot puts there.""" + x, y = self.value_under(point) + return int(x), y + + def drag_item( + self, + index: int, + *, + start: float, + end: float, + ) -> Point: + """Drags the bar of item ``index`` from the value ``start`` to ``end``, and returns where it let go.""" + mapping = self.mapping() + release = mapping.point(index + BAR_MIDDLE, end) + self._hand.drag(mapping.point(index + BAR_MIDDLE, start), release) + return release diff --git a/automation/views/browsers.py b/automation/views/browsers.py new file mode 100644 index 000000000..198e4d7f8 --- /dev/null +++ b/automation/views/browsers.py @@ -0,0 +1,144 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import find_item, read_item +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.items.types import TREE_NODE_TYPE, Item +from automation.dearpygui.keys import IMGUI_LEFT_CTRL +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.structures.tree.node import ( + FileSystemNode, + GeneratorNode, + LibraryNode, + TreeNode, +) + +NodeTest = Callable[[TreeNode], bool] +WORDS_END_INSET: Final[int] = 4 + + +class FileTree: + """A tree of rows on one of the tabs, each row standing for the node of the tree it was drawn from. + + A row carries its node, so the view finds a row by the file or the library the node names, + and a heading by the words it shows. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tree: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tree = tree + + def file_row(self, path: Path) -> Optional[Item]: + """The first row naming the file or folder at ``path``, if one is drawn.""" + return self._row(lambda node: isinstance(node, FileSystemNode) and node.filepath == path) + + def generator_row(self, generator: GeneratorName) -> Optional[Item]: + """The first row standing for ``generator``, if one is drawn.""" + return self._row(lambda node: isinstance(node, GeneratorNode) and node.generator_name == generator) + + def library_row(self, matches: Callable[[LibraryNode], bool]) -> Optional[Item]: + """The first row standing for a library ``matches`` accepts, if one is drawn.""" + return self._row(lambda node: isinstance(node, LibraryNode) and matches(node)) + + def heading(self, label: str) -> Optional[Item]: + """The first row whose words read ``label``, such as a heading the tree groups its files under.""" + return self._bridge.ask( + lambda: find_item( + self._tree, + lambda item: _is_tree_row(item) and read_label(item).strip() == label.strip(), + ) + ) + + def label(self, row: Item) -> str: + """The words ``row`` reads.""" + return self._bridge.ask(lambda: read_label(row)) + + def rows_above(self, row: Item) -> List[Item]: + """The rows ``row`` stands under, outermost first.""" + + def read() -> List[Item]: + above: List[Item] = [] + parent = dpg.get_item_parent(row) + while parent is not None and dpg.get_item_alias(parent) != self._tree: + if _is_tree_row(parent): + above.append(parent) + parent = dpg.get_item_parent(parent) + + return above[::-1] + + return self._bridge.ask(read) + + def is_open(self, row: Item) -> bool: + """Whether ``row`` stands open onto the rows under it.""" + return bool(self._bridge.ask(lambda: dpg.get_value(row))) + + def open_by_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it, which opens or closes a row of a tree opened by a click.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def double_click(self, row: Item) -> None: + """Brings ``row`` into view and double-clicks it.""" + self._hand.scroll_into_view(row) + self._hand.double_click(row) + + def ctrl_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it while holding Ctrl.""" + self._hand.scroll_into_view(row) + self._hand.click_holding(row, [IMGUI_LEFT_CTRL]) + + def right_click(self, row: Item) -> None: + """Brings ``row`` into view and clicks it with the right button, which opens its menu.""" + self._hand.scroll_into_view(row) + self._hand.right_click(row) + + def right_click_beside(self, row: Item) -> None: + """Brings ``row`` into view and clicks the far end of its words with the right button, which opens + its menu beside them. + + Raises: + LookupError: If the row reports no box. + """ + self._hand.scroll_into_view(row) + rect = self._bridge.ask(lambda: read_item(row).rect) + if rect is None: + raise LookupError(f"The row {row!r} reports no box") + + self._hand.right_click_at(Point(x=round(rect.x + rect.width) - WORDS_END_INSET, y=rect.center.y)) + + def _row(self, matches: NodeTest) -> Optional[Item]: + return self._bridge.ask( + lambda: find_item( + self._tree, + lambda item: _is_tree_row(item) and _node_matches(item, matches), + ) + ) + + +def _is_tree_row(item: Item) -> bool: + return str(dpg.get_item_info(item)["type"]) == TREE_NODE_TYPE + + +def _node_matches(item: Item, matches: NodeTest) -> bool: + user_data = dpg.get_item_user_data(item) + if not isinstance(user_data, tuple) or not user_data: + return False + + node = user_data[0] + return isinstance(node, TreeNode) and bool(matches(node)) diff --git a/automation/views/context_menu.py b/automation/views/context_menu.py new file mode 100644 index 000000000..354b65259 --- /dev/null +++ b/automation/views/context_menu.py @@ -0,0 +1,201 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point, Rect +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.texts import ( + EntryReading, + find_labelled, + read_label, + read_popup_entries, +) +from automation.dearpygui.items.types import MENU_ITEM_TYPE, MENU_TYPE, Item +from automation.dearpygui.semantic import CallbackRunner, invoke +from sampletones_application.tags.general import ( + TAG_GLOBAL_CONTEXT_WINDOW, + TAG_GLOBAL_TAB_INSTRUCTIONS, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue + +ENTRY_INSET: Final[Point] = Point(x=12, y=8) +CLEAR_OF_THE_TABS: Final[int] = 48 +ENABLED: Final[str] = "enabled" +OPENING_FRAMES: Final[int] = 3 + + +class MissingEntryError(AssertionError): + """Raised when a scenario chooses an entry that the open menu lacks.""" + + +class ContextMenu: + """The menu a right-click opens, shared by every list and tree, with its entries in the order shown. + + An entry is chosen by a click where it stands, which closes the menu the way a person's click + does, and the menu is put away by a click beside it, on the bare stretch of the tab bar past the + last tab. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the menu stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).shown + + def box(self) -> Rect: + """Where the menu stands in the viewport.""" + rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_CONTEXT_WINDOW)).rect + if rect is None: + raise MissingEntryError("The context menu stands nowhere") + + return rect + + def entries(self) -> List[EntryReading]: + """The entries of the menu, each with its label and where it stands, in the order shown.""" + return list(self._bridge.ask(lambda: read_popup_entries(TAG_GLOBAL_CONTEXT_WINDOW))) + + def labels(self) -> List[str]: + """The labels of the entries of the menu, in the order shown.""" + return [entry.label for entry in self.entries()] + + def submenus(self) -> List[str]: + """The labels of the submenus the menu offers, in the order shown.""" + + def read() -> List[str]: + return [ + read_label(child) + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1) + if dpg.get_item_info(child)["type"] == MENU_TYPE + ] + + return self._bridge.ask(read) + + def submenu(self, label: str) -> List[Tuple[str, bool]]: + """The entries of the submenu reading ``label``, each with whether it answers. + + Raises: + MissingEntryError: If the menu offers no such submenu. + """ + + def read() -> Optional[List[Tuple[str, bool]]]: + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1): + if dpg.get_item_info(child)["type"] == MENU_TYPE and read_label(child) == label: + return [ + ( + read_label(entry), + bool(dpg.get_item_configuration(entry).get(ENABLED, True)), + ) + for entry in dpg.get_item_children(child, 1) + if dpg.get_item_info(entry)["type"] == MENU_ITEM_TYPE + ] + + return None + + found = self._bridge.ask(read) + if found is None: + raise MissingEntryError(f"The context menu offers no submenu '{label}'") + + return found + + def choose_in( + self, + menu_label: str, + entry_label: str, + ) -> None: + """Opens the submenu reading ``menu_label`` with a click on it, and chooses its entry reading + ``entry_label``. + + DearPyGui reports where a submenu's entries stand inside a window of its own that it names + nowhere, so the entry is chosen by running its callback the way a click does. A click on an + entry closes the menu in the frame it lands in, and the callback runs once that frame is drawn, + so the menu is put away by a click beside it before the callback runs. A dialog the entry + raises then opens on a screen the menu has left, as it does for a person's click. + + Raises: + MissingEntryError: If the menu offers no such submenu. + """ + header = self._submenu_header(menu_label) + self._hand.click_at(Point(x=header.x + ENTRY_INSET.x, y=header.y + ENTRY_INSET.y)) + self._bridge.frames(OPENING_FRAMES) + pressed: List[Tuple[CallbackRunner, Tuple[object, ...]]] = [] + self._bridge.ask( + lambda: invoke( + self._submenu_entry(menu_label, entry_label), + lambda callback, *arguments: pressed.append((callback, arguments)), + ) + ) + self.dismiss() + callback, arguments = pressed[0] + self._bridge.ask(lambda: CallbackQueue.run(callback, *arguments)) + + def choose(self, label: str) -> None: + """Clicks the entry reading ``label``. + + Raises: + MissingEntryError: If the menu offers no such entry. + """ + for entry in self.entries(): + if entry.label == label: + self._hand.click_at( + Point( + x=entry.point.x + ENTRY_INSET.x, + y=entry.point.y + ENTRY_INSET.y, + ) + ) + return + + raise MissingEntryError(f"The context menu offers no '{label}': it offers {self.labels()}") + + def _submenu_header(self, label: str) -> Point: + """Where the submenu reading ``label`` stands in the viewport.""" + + def read() -> Optional[Point]: + corner = dpg.get_item_pos(TAG_GLOBAL_CONTEXT_WINDOW) + for child in dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1): + if dpg.get_item_info(child)["type"] == MENU_TYPE and read_label(child) == label: + position = dpg.get_item_state(child)["pos"] + return Point( + x=round(corner[0] + position[0]), + y=round(corner[1] + position[1]), + ) + + return None + + found = self._bridge.ask(read) + if found is None: + raise MissingEntryError(f"The context menu offers no submenu '{label}'") + + return found + + @staticmethod + def _submenu_entry( + menu_label: str, + entry_label: str, + ) -> Item: + """The entry reading ``entry_label`` of the submenu reading ``menu_label``. + + Runs on the render thread. + """ + menu = find_labelled(TAG_GLOBAL_CONTEXT_WINDOW, menu_label, item_type=MENU_TYPE) + return find_labelled(menu, entry_label, item_type=MENU_ITEM_TYPE) + + def dismiss(self) -> None: + """Clicks the bare stretch of the tab bar past the last tab, which puts the menu away.""" + rect = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS)).rect + if rect is None: + raise MissingEntryError("The last tab stands nowhere to click past it") + + self._hand.click_at( + Point( + x=round(rect.x + rect.width) + CLEAR_OF_THE_TABS, + y=rect.center.y, + ) + ) diff --git a/automation/views/converter.py b/automation/views/converter.py new file mode 100644 index 000000000..c813314af --- /dev/null +++ b/automation/views/converter.py @@ -0,0 +1,319 @@ +from pathlib import Path +from typing import Final, List, Optional + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme +from automation.dearpygui.items.reading import find_item, read_item +from automation.dearpygui.items.texts import read_label, read_texts +from automation.dearpygui.items.types import Item +from automation.views.notices import Notice +from automation.views.prompts import Prompt +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHECKBOX, + SUF_DIALOG_INFO, + SUF_TEXT, + SUF_TOOLTIP, +) +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + PRE_MAIN_CONVERTER_STEMS, + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + 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_GROUP, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_CONVERTER_PROGRESS, + TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_TEXT_STATUS, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_SCAN, + TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, + TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, +) +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_core.constants.enums import ChannelName + +ROW_TEXT: Final[str] = "text" +ROW_GROUP: Final[str] = "group" +ROW_TWISTY: Final[str] = "twisty" +ROW_REMOVE: Final[str] = "button" +ROW_MARK: Final[str] = ".row." +FIRST_LEVEL: Final[int] = 0 +LEVEL_STRIP: Final[str] = "strip" + + +class ConverterList: + """The recordings and folders gathered into the Converter card, one row each. + + A row is named by the path it was gathered from, so a scenario addresses the row of the file + it seeded. A folder's row carries a marker that opens it onto its recordings. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_STEMS) + + def row(self, path: Path) -> str: + """The name of the row gathered from ``path``, which a click picks.""" + return self.tags.row(str(path), ROW_TEXT) + + def has_row(self, path: Path) -> bool: + """Whether a row gathered from ``path`` is drawn.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.row(path)))) + + def label(self, path: Path) -> str: + """The words the row of ``path`` reads.""" + return self._bridge.ask(lambda: read_label(self.row(path))) + + def is_picked(self, path: Path) -> bool: + """Whether the row of ``path`` stands picked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.row(path)))) + + def row_theme(self, path: Path) -> Optional[str]: + """The theme the row of ``path`` wears, or None when it wears none.""" + return self._bridge.ask(lambda: read_theme(self.row(path))) + + def band_theme(self, path: Path) -> Optional[str]: + """The theme the band behind the row of ``path`` wears, or None when it wears none.""" + return self._bridge.ask(lambda: read_theme(self.tags.row(str(path), ROW_GROUP))) + + def rows(self) -> List[str]: + """The names of every row drawn, top to bottom, as the tags they stand under.""" + + def read() -> List[str]: + found: List[str] = [] + find_item(self.tags.well, lambda item: _collect_row(item, found)) + return found + + return self._bridge.ask(read) + + def row_count(self) -> int: + """How many rows are drawn, folders included.""" + return len(self.rows()) + + def channel_box(self, path: Path, channel: ChannelName) -> str: + """The name of the box ticking ``channel`` on the row of ``path``.""" + return self.tags.channel(str(path), channel) + + def channel_ticked(self, path: Path, channel: ChannelName) -> bool: + """Whether ``channel``'s box on the row of ``path`` stands ticked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(path, channel)))) + + def channel_theme(self, path: Path, channel: ChannelName) -> Optional[str]: + """The theme ``channel``'s box on the row of ``path`` wears, or None when it wears none.""" + return self._bridge.ask(lambda: read_theme(self.channel_box(path, channel))) + + def twisty(self, path: Path) -> str: + """The name of the marker that opens the folder gathered from ``path``.""" + return self.tags.row(str(path), ROW_TWISTY) + + def is_open(self, path: Path) -> bool: + """Whether the folder gathered from ``path`` stands open onto its recordings.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.region(str(path))))) + + def remove_button(self, path: Path) -> str: + """The name of the button removing the row of ``path``.""" + return self.tags.row(str(path), ROW_REMOVE) + + def has_levels(self) -> bool: + """Whether the list stands in level bands, which a mix of several recordings draws.""" + return bool(self._bridge.ask(lambda: dpg.does_item_exist(self.tags.level(FIRST_LEVEL, LEVEL_STRIP)))) + + def level_count(self) -> int: + """How many level bands the list stands in, counted by their captions, which a list drawn in one table + has none of. + """ + + def read() -> int: + count = 0 + while dpg.does_item_exist(self.tags.level(count, SUF_TEXT)): + count += 1 + + return count + + return self._bridge.ask(read) + + def hint_shown(self) -> bool: + """Whether the hint asking for recordings stands on the card.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT)).shown + + def list_shown(self) -> bool: + """Whether the list of rows stands on the card.""" + return self._bridge.ask(lambda: read_item(self.tags.well)).shown + + def pick(self, path: Path) -> None: + """Brings the row into view and clicks its name.""" + self._hand.scroll_into_view(self.row(path)) + self._hand.click(self.row(path)) + + def tick(self, path: Path, channel: ChannelName) -> None: + """Brings the row into view and clicks its box for ``channel``.""" + self._hand.scroll_into_view(self.channel_box(path, channel)) + self._hand.click(self.channel_box(path, channel)) + + def toggle_folder(self, path: Path) -> None: + """Clicks the marker beside a folder's name.""" + self._hand.scroll_into_view(self.twisty(path)) + self._hand.click(self.twisty(path)) + + def remove(self, path: Path) -> None: + """Clicks the row's remove button.""" + self._hand.scroll_into_view(self.remove_button(path)) + self._hand.click(self.remove_button(path)) + + +class MixQuestion: + """The question picking which recordings a mix takes, once more are gathered than a mix holds.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) + + def is_shown(self) -> bool: + """Whether the question stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION)).shown + + def box(self, path: Path) -> str: + """The name of the box ticking the recording at ``path``.""" + return self.tags.row(str(path), SUF_CHECKBOX) + + def name(self, path: Path) -> str: + """The name of the line showing the recording at ``path``.""" + return self.tags.row(str(path), ROW_TEXT) + + def is_ticked(self, path: Path) -> bool: + """Whether the box of the recording at ``path`` stands ticked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.box(path)))) + + def is_live(self, path: Path) -> bool: + """Whether the box answers a click, which a box beyond a full pick stops doing.""" + return self._bridge.ask(lambda: read_item(self.box(path))).enabled + + def is_highlighted(self, path: Path) -> bool: + """Whether the line of the recording at ``path`` stands highlighted.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.name(path)))) + + def count_line(self) -> str: + """What the line counting the recordings picked reads.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT))) + + def can_add(self) -> bool: + """Whether Add answers a click.""" + return self._bridge.ask(lambda: read_item(_add_button())).enabled + + def tick(self, path: Path) -> None: + """Brings the box of the recording at ``path`` into view and clicks it.""" + self._hand.scroll_into_view(self.box(path)) + self._hand.click(self.box(path)) + + def add(self) -> None: + """Clicks Add, which takes the recordings ticked.""" + self._hand.click(_add_button()) + + def cancel(self) -> None: + """Clicks Cancel, which closes the question and takes no recording.""" + self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, SUF_BUTTON)) + + +class Converter: + """The Converter card: the list, the button naming the run, and the run's progress and questions. + + A scenario reaches the list, the mix question, the prompts and the notices through the attributes + of the card. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.list = ConverterList(bridge, hand) + self.mix_question = MixQuestion(bridge, hand) + self.cancel_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_CANCEL) + self.end_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_LOAD) + self.overwrite_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET) + self.replace_prompt = Prompt(bridge, hand, TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS) + self.notice = Notice(bridge, hand, compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_DIALOG_INFO)) + self.nothing_below_notice = Notice( + bridge, + hand, + compose_tag(TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, SUF_DIALOG_INFO), + ) + + def action(self) -> str: + """What the button under the list reads: the run it starts, or Cancel while one runs.""" + return self._bridge.ask(lambda: read_label(_action_button())) + + def press_action(self) -> None: + """Brings the button under the list into view and clicks it.""" + self._hand.scroll_into_view(_action_button()) + self._hand.click(_action_button()) + + def run_shown(self) -> bool: + """Whether the card shows a run under way: its status line and its progress.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP)).shown + + def status(self) -> str: + """What the status line of the run reads.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STATUS))) + + def progress(self) -> float: + """How far the run has come, as a fraction.""" + return float(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_PROGRESS))) + + def destination(self) -> str: + """The whole path of the folder the run writes into, as the hover over its shortened path shows it.""" + tooltip = compose_tag(TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, SUF_TOOLTIP) + return " ".join(self._bridge.ask(lambda: read_texts(tooltip))) + + def scan_shown(self) -> bool: + """Whether the window reading a folder stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_WINDOW_SCAN)).shown + + def scan_words(self) -> str: + """What the window reading a folder says it is reading.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER))) + + def stop_scan(self) -> None: + """Clicks Stop on the window reading a folder.""" + self._hand.click(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON)) + + +def _add_button() -> str: + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON) + + +def _action_button() -> str: + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ACTION, SUF_BUTTON) + + +def _collect_row(item: Item, found: List[str]) -> bool: + alias = str(dpg.get_item_alias(item)) + if ROW_MARK in alias and alias.startswith(PRE_MAIN_CONVERTER_STEMS) and alias.endswith(f".{ROW_TEXT}"): + found.append(alias) + + return False diff --git a/automation/views/display_settings.py b/automation/views/display_settings.py new file mode 100644 index 000000000..d95781cac --- /dev/null +++ b/automation/views/display_settings.py @@ -0,0 +1,125 @@ +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.semantic import choose +from automation.views.menus import MenuBar +from automation.views.prompts import Prompt +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, + TAG_SETTINGS_DISPLAY_BUTTON_KEEP, + TAG_SETTINGS_DISPLAY_BUTTON_OK, + TAG_SETTINGS_DISPLAY_BUTTON_REVERT, + TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, + TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE, + TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, + TAG_SETTINGS_DISPLAY_COMBO_PALETTE, + TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, + TAG_SETTINGS_DISPLAY_DIALOG_DISCARD, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue + +ITEMS: Final[str] = "items" + + +class DisplaySettings: + """The Display settings dialog: the window mode, the pacing and the palette, with Cancel and OK.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.discard_prompt = Prompt(bridge, hand, TAG_SETTINGS_DISPLAY_DIALOG_DISCARD) + + def open(self) -> None: + """Opens the dialog from View ▸ Display settings.""" + self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS) + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW)).shown + + def resolution(self) -> str: + """The window size the Resolution list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION))) + + def resolutions(self) -> Tuple[str, ...]: + """The window sizes the Resolution list offers, in order.""" + return tuple( + str(item) + for item in self._bridge.ask(lambda: dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION))[ + ITEMS + ] + ) + + def choose_resolution(self, label: str) -> None: + """Picks the window size reading ``label`` in the Resolution list. + + The window resizes and the countdown starts. + """ + self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, label, CallbackQueue.run)) + + def palette(self) -> str: + """The palette the Palette list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_COMBO_PALETTE))) + + def choose_palette(self, name: str) -> None: + """Picks the palette ``name`` in the Palette list, which repaints the interface at once.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, name, CallbackQueue.run)) + + def vsync(self) -> bool: + """Whether the vertical sync box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC))) + + def toggle_vsync(self) -> None: + """Clicks the vertical sync box, which ticks it or lets it go.""" + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) + + def frame_rate_shown(self) -> bool: + """Whether the Show frame rate box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE))) + + def toggle_frame_rate(self) -> None: + """Clicks the Show frame rate box, which puts the reading on the menu bar or takes it off at once.""" + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE) + + def borderless(self) -> bool: + """Whether the borderless box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS))) + + def toggle_borderless(self) -> None: + """Clicks the borderless box, which changes the window mode and starts the countdown.""" + self._hand.click(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS) + + def countdown_shown(self) -> bool: + """Whether the countdown asking to keep a new window mode stands.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN)).shown + + def keep(self) -> None: + """Presses Keep on the countdown.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_KEEP, SUF_BUTTON)) + + def revert(self) -> None: + """Presses Revert on the countdown.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, SUF_BUTTON)) + + def cancel(self) -> None: + """Presses Cancel, which drops the changes and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, SUF_BUTTON)) + + def confirm(self) -> None: + """Presses OK, which keeps the changes and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_DISPLAY_BUTTON_OK, SUF_BUTTON)) diff --git a/automation/views/exports.py b/automation/views/exports.py new file mode 100644 index 000000000..41a598aee --- /dev/null +++ b/automation/views/exports.py @@ -0,0 +1,224 @@ +from typing import Tuple + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.items.texts import read_shown_texts +from automation.dearpygui.semantic import choose +from automation.views.menus import MenuBar +from automation.views.notices import Notice +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_MODULE_EXPORTED, + TAG_GLOBAL_DIALOG_PATH_MESSAGE, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_EXPORT_BUTTON_CANCEL, + TAG_SETTINGS_EXPORT_GROUP_STAGES, + TAG_SETTINGS_EXPORT_WINDOW, + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_WINDOW, + TAG_SETTINGS_RENDER_BUTTON_CLOSE, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName + + +class MissingControlError(AssertionError): + """Raised when a scenario presses a control the screen draws nowhere.""" + + +class ExportProgress: + """The window an export holds the screen with while it runs: the stages it reached, and Cancel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the window stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_EXPORT_WINDOW)).shown + + def stages(self) -> Tuple[str, ...]: + """The stages the window lists, in the order the run reached them.""" + return self._bridge.ask(lambda: read_shown_texts(TAG_SETTINGS_EXPORT_GROUP_STAGES)) + + def cancel(self) -> None: + """Clicks Cancel, which asks the run to stop.""" + self._hand.click(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON)) + + +class NSFWindow: + """The window setting an NSF program up: its channels, its repeat, its level of compression, + its title and file. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the window stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_WINDOW)).shown + + def offers(self, channel: ChannelName) -> bool: + """Whether ``channel``'s box answers a press, which it does for a channel the source sounds.""" + return self._bridge.ask(lambda: read_item(_channel_box(channel))).enabled + + def is_ticked(self, channel: ChannelName) -> bool: + """Whether ``channel``'s box stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) + + def tick(self, channel: ChannelName) -> None: + """Clicks ``channel``'s box, which ticks it or lets it go.""" + self._hand.click(_channel_box(channel)) + + def press_on(self, channel: ChannelName) -> None: + """Clicks where ``channel``'s box stands, whether or not the box answers.""" + rect = self._bridge.ask(lambda: read_item(_channel_box(channel))).rect + if rect is None: + raise MissingControlError(f"The box of {channel} stands nowhere") + + self._hand.click_at(rect.center) + + def says_a_channel_is_needed(self) -> bool: + """Whether the line asking for at least one channel stands in the window.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)).shown + + def can_export(self) -> bool: + """Whether Export answers a press.""" + return self._bridge.ask(lambda: read_item(_export_button())).enabled + + def press_export(self) -> None: + """Clicks where Export stands, whether or not it answers.""" + rect = self._bridge.ask(lambda: read_item(_export_button())).rect + if rect is None: + raise MissingControlError("Export stands nowhere") + + self._hand.click_at(rect.center) + + def export(self) -> None: + """Clicks Export, which writes the program to the file chosen.""" + self._hand.click(_export_button()) + + def browse(self) -> None: + """Clicks Browse..., which asks for the file the program is written to.""" + self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_BROWSE, SUF_BUTTON)) + + def cancel(self) -> None: + """Clicks Cancel, which closes the window.""" + self._hand.click(compose_tag(TAG_SETTINGS_NSF_BUTTON_CANCEL, SUF_BUTTON)) + + def repeat(self) -> str: + """The way of repeating the Repeat list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_COMBO_REPEAT))) + + def choose_repeat(self, label: str) -> None: + """Picks the way of repeating reading ``label`` in the Repeat list.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_REPEAT, label, CallbackQueue.run)) + + def level(self) -> str: + """The level of compression the Level list names.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_COMBO_SCHEME))) + + def choose_level(self, label: str) -> None: + """Picks the level of compression reading ``label`` in the Level list.""" + self._bridge.ask(lambda: choose(TAG_SETTINGS_NSF_COMBO_SCHEME, label, CallbackQueue.run)) + + def loop_frame(self) -> str: + """What the Frame field reads, in hexadecimal.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME))) + + def type_loop_frame(self, frame: str) -> None: + """Types ``frame`` in hexadecimal into the Frame field, in place of what it held.""" + self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, frame) + + def title(self) -> str: + """What the Title field reads.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_NSF_INPUT_TITLE))) + + def type_title(self, title: str) -> None: + """Types ``title`` into the Title field, in place of what it held.""" + self._hand.replace_text(TAG_SETTINGS_NSF_INPUT_TITLE, title) + + +class RenderWindow: + """The window rendering the song to an audio file: its format, its destination, and Render and Cancel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def is_shown(self) -> bool: + """Whether the window stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_RENDER_WINDOW)).shown + + def cancel(self) -> None: + """Clicks Cancel on a window whose render has yet to start, which closes the window.""" + self._hand.click(compose_tag(TAG_SETTINGS_RENDER_BUTTON_CLOSE, SUF_BUTTON)) + + +class Exports: + """The ways a project, a reconstruction or an instrument leaves the application as a file. + + A project is exported from File ▸ Export, a reconstruction's channels from Reconstruction ▸ Export + instruments. An export runs under its window, and its outcome stands in a notice: the project's names the + format it was written in, and an instrument's names the file it wrote. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + self.progress = ExportProgress(bridge, hand) + self.nsf = NSFWindow(bridge, hand) + self.render = RenderWindow(bridge, hand) + self.project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_MODULE_EXPORTED) + self.file_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_PATH_MESSAGE) + + def export_project(self, item: MenuElements) -> None: + """Chooses File ▸ Export ▸ ``item``.""" + self._menu.choose(MenuElements.GROUP_FILE, item) + + def render_song(self) -> None: + """Chooses File ▸ Render song, which opens the window rendering the song.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_RENDER_SONG) + + def export_reconstruction(self, item: MenuElements) -> None: + """Chooses Reconstruction ▸ Export instruments ▸ ``item``.""" + self._menu.choose(MenuElements.GROUP_RECONSTRUCTION, item) + + +def _channel_box(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) + + +def _export_button() -> str: + return compose_tag(TAG_SETTINGS_NSF_BUTTON_EXPORT, SUF_BUTTON) diff --git a/automation/views/history.py b/automation/views/history.py new file mode 100644 index 000000000..5fcbcff81 --- /dev/null +++ b/automation/views/history.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.types import TEXT_TYPE, Item +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_HISTORY_BUTTON_REDO, + TAG_SEQUENCER_HISTORY_BUTTON_UNDO, + TAG_SEQUENCER_HISTORY_WINDOW_LIST, +) + +TABLE_TYPE: Final[str] = "mvAppItemType::mvTable" +ENTRY_GROUP: Final[int] = 1 +COLOR_KEY: Final[str] = "color" + + +@dataclass(frozen=True) +class HistorySegment: + """One piece of a history line: its words and the color they are drawn in.""" + + words: str + color: Tuple[float, ...] + + +@dataclass(frozen=True) +class HistoryLine: + """One entry of the History card as it reads: its pieces, and whether it is the state in force.""" + + segments: Tuple[HistorySegment, ...] + current: bool + + @property + def words(self) -> str: + """The words of every piece of the line, joined by spaces.""" + return " ".join(segment.words for segment in self.segments) + + +class History: + """The History card of the Sequencer: one line per entry, the newest on top, with Undo and Redo below.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def lines(self) -> Tuple[HistoryLine, ...]: + """The lines the card draws, newest first.""" + return self._bridge.ask(read_history) + + def current(self) -> HistoryLine: + """The line of the entry the project stands at.""" + return next(line for line in self.lines() if line.current) + + def jump_to(self, line: int) -> None: + """Clicks the ``line``-th line from the top, which puts the project at the entry it names.""" + selectable = self._bridge.ask(lambda: read_line_selectables()[line]) + self._hand.scroll_into_view(selectable) + self._hand.click(selectable) + + def undo(self) -> None: + """Clicks Undo below the list, which steps the project back one entry.""" + self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_UNDO, SUF_BUTTON)) + + def redo(self) -> None: + """Clicks Redo below the list, which steps the project forward one entry.""" + self._hand.click(compose_tag(TAG_SEQUENCER_HISTORY_BUTTON_REDO, SUF_BUTTON)) + + +def read_history() -> Tuple[HistoryLine, ...]: + """The lines the History card draws, newest first. Runs on the render thread.""" + tables = [ + child + for child in dpg.get_item_children(TAG_SEQUENCER_HISTORY_WINDOW_LIST, 1) + if dpg.get_item_info(child)["type"] == TABLE_TYPE + ] + if not tables: + return () + + lines: List[HistoryLine] = [] + for row in dpg.get_item_children(tables[0], 1): + selectable, group = dpg.get_item_children(row, 1)[: ENTRY_GROUP + 1] + lines.append( + HistoryLine( + segments=_segments(group), + current=bool(dpg.get_value(selectable)), + ) + ) + + return tuple(lines) + + +def read_line_selectables() -> Tuple[Item, ...]: + """The clickable row of each line the History card draws, newest first. Runs on the render thread.""" + tables = [ + child + for child in dpg.get_item_children(TAG_SEQUENCER_HISTORY_WINDOW_LIST, 1) + if dpg.get_item_info(child)["type"] == TABLE_TYPE + ] + if not tables: + return () + + return tuple(dpg.get_item_children(row, 1)[0] for row in dpg.get_item_children(tables[0], 1)) + + +def _segments(group: Item) -> Tuple[HistorySegment, ...]: + """The pieces of one history line, each with its words and color, in the order drawn.""" + return tuple( + HistorySegment( + words=str(dpg.get_value(text)), + color=tuple(float(part) for part in dpg.get_item_configuration(text)[COLOR_KEY]), + ) + for text in dpg.get_item_children(group, 1) + if dpg.get_item_info(text)["type"] == TEXT_TYPE + ) diff --git a/automation/views/instructions.py b/automation/views/instructions.py new file mode 100644 index 000000000..db833b15a --- /dev/null +++ b/automation/views/instructions.py @@ -0,0 +1,102 @@ +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_value +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.items.types import Item +from automation.views.browsers import FileTree +from automation.views.notices import Notice +from automation.views.prompts import Prompt +from automation.views.waveform import Waveform +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.graphs import SUF_GRAPH_Y_AXIS +from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM, + TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, + TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION, + TAG_INSTRUCTIONS_LIBRARY_PANEL, + TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS, + TAG_INSTRUCTIONS_LIBRARY_TREE, +) + +GENERATE_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, SUF_BUTTON) +CANCEL_GENERATION_BUTTON = compose_tag(TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, SUF_BUTTON) +SPECTRUM_BANDS = compose_tag(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_SPECTRUM, SUF_GRAPH_Y_AXIS) +BAR_SERIES_TYPE: Final[str] = "mvAppItemType::mvBarSeries" + + +class Library: + """The Instructions tab's library card: the libraries its folder holds, and the line saying where they stand.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tree = FileTree(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_TREE) + self.rebuild_prompt = Prompt(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION) + self.regenerate_prompt = Prompt( + bridge, + hand, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION, + ) + self.notice = Notice(bridge, hand, TAG_INSTRUCTIONS_LIBRARY_PANEL) + + def row(self, filename: str) -> Optional[Item]: + """The row standing for the library stored under ``filename``, if one is drawn.""" + return self.tree.library_row(lambda node: node.library_key.filename == filename) + + def generate_label(self) -> str: + """What the button generating a library reads: Generate, or Regenerate where the library stands.""" + return self._bridge.ask(lambda: read_label(GENERATE_BUTTON)) + + def generate(self) -> None: + """Clicks the button generating a library, scrolling it into view first.""" + self._hand.scroll_into_view(GENERATE_BUTTON) + self._hand.click(GENERATE_BUTTON) + + def cancel_generation(self) -> None: + """Clicks the button that cancels a generation under way.""" + self._hand.click(CANCEL_GENERATION_BUTTON) + + def status(self) -> str: + """What the line under the tree says about the library in hand.""" + return str(self._bridge.ask(lambda: read_value(TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS))) + + +class Instructions: + """The Instructions tab: the library card, and the waveform and spectrum of the instruction picked in it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self.library = Library(bridge, hand) + self.waveform = Waveform( + bridge, + hand, + TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM, + ) + + def spectrum_bands(self) -> int: + """How many bands the spectrum draws.""" + + def read() -> int: + return sum( + 1 + for child in dpg.get_item_children(SPECTRUM_BANDS, 1) + if dpg.get_item_info(child)["type"] == BAR_SERIES_TYPE + ) + + return self._bridge.ask(read) diff --git a/automation/views/instruments.py b/automation/views/instruments.py new file mode 100644 index 000000000..c0cf84926 --- /dev/null +++ b/automation/views/instruments.py @@ -0,0 +1,189 @@ +from typing import Final, Optional, Tuple + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme +from automation.dearpygui.items.reading import ( + read_item, + read_selected_tab, + read_value, +) +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.keys import IMGUI_ENTER +from automation.dearpygui.semantic import choose +from automation.views.bar_graph import BarGraph +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_GROUP, + SUF_TABLE, + SUF_TEXT, +) +from sampletones_application.tags.graphs import SUF_GRAPH, SUF_GRAPH_RAW_DATA +from sampletones_application.tags.reconstructions import ( + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, + TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, + TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName, FeatureKey + +AUDITION_GROUP: Final[str] = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, SUF_GROUP) + + +class Instruments: + """The instruments card: a tab per channel, each with a graph and a field per envelope it governs.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def bring_forward(self, channel: ChannelName) -> None: + """Clicks the tab of ``channel``.""" + self._hand.click(tab(channel)) + + def front(self) -> str: + """The tag of the tab standing in front.""" + return self._bridge.ask(lambda: read_selected_tab(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR)) + + def tab_label(self, channel: ChannelName) -> str: + """What ``channel``'s tab reads, which names the open instrument on the tab it is edited under.""" + return self._bridge.ask(lambda: read_label(tab(channel))) + + def tab_shown(self, channel: ChannelName) -> bool: + """Whether ``channel``'s tab stands on the screen.""" + return self._bridge.ask(lambda: read_item(tab(channel))).shown + + def tab_theme(self, channel: ChannelName) -> Optional[str]: + """The theme ``channel``'s tab wears, which mutes the tab of a channel that plays nothing.""" + return self._bridge.ask(lambda: read_theme(tab(channel))) + + def size(self, channel: ChannelName) -> str: + """What ``channel``'s tab reads as the size of the instrument it exports.""" + return str( + self._bridge.ask( + lambda: read_value( + compose_tag( + tab(channel), + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + ) + ) + ) + ) + + def rows(self, channel: ChannelName) -> Tuple[FeatureKey, ...]: + """The envelopes ``channel``'s tab draws a row for, top to bottom.""" + + def read() -> Tuple[FeatureKey, ...]: + return tuple(feature for feature in FeatureKey if read_item(_row(channel, feature)).shown) + + return self._bridge.ask(read) + + def field(self, channel: ChannelName, feature: FeatureKey) -> str: + """The name of the field holding the sequence of ``channel``'s ``feature`` envelope.""" + return compose_tag( + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, + channel, + feature, + SUF_GRAPH_RAW_DATA, + SUF_TEXT, + ) + + def envelope(self, channel: ChannelName, feature: FeatureKey) -> str: + """The sequence the field under ``channel``'s ``feature`` graph holds, as the reader would edit it.""" + return str(self._bridge.ask(lambda: read_value(self.field(channel, feature)))) + + def field_theme(self, channel: ChannelName, feature: FeatureKey) -> Optional[str]: + """The theme the field wears, which marks a sequence refused or one an export shortens.""" + return self._bridge.ask(lambda: read_theme(self.field(channel, feature))) + + def hover_field(self, channel: ChannelName, feature: FeatureKey) -> None: + """Rests the pointer on ``channel``'s ``feature`` field, which puts what the field takes on the + status bar. + """ + field = self.field(channel, feature) + self._hand.scroll_into_view(field) + rect = self._bridge.ask(lambda: read_item(field).rect) + if rect is None: + raise LookupError(f"The field '{field}' stands nowhere") + + self._hand.move_to(rect.center) + + def type_envelope(self, channel: ChannelName, feature: FeatureKey, sequence: str) -> None: + """Brings ``channel``'s tab forward, types ``sequence`` over its ``feature`` field and presses + Enter. + """ + self.bring_forward(channel) + field = self.field(channel, feature) + self._hand.scroll_into_view(field) + self._hand.replace_text(field, sequence) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + def graph(self, channel: ChannelName, feature: FeatureKey) -> BarGraph: + """The graph drawn for ``channel``'s ``feature`` envelope.""" + return BarGraph( + self._bridge, + self._hand, + compose_tag( + TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, + channel, + feature, + SUF_GRAPH, + ), + ) + + def offers_pitch_stepper(self, channel: ChannelName) -> bool: + """Whether ``channel``'s tab offers the stepper setting the pitch its arpeggio is read against.""" + window = compose_tag(tab(channel), SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW) + return self._bridge.ask(lambda: read_item(compose_tag(window, SUF_TABLE))).shown + + def offers_audition(self) -> bool: + """Whether the Audition switch stands, which it does while an instrument is open.""" + return self._bridge.ask(lambda: read_item(AUDITION_GROUP)).shown + + def audition(self) -> str: + """The generator the Audition switch names.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION))) + + def choose_audition(self, label: str) -> None: + """Picks the generator reading ``label`` in the Audition list.""" + self._bridge.ask( + lambda: choose( + TAG_RECONSTRUCTIONS_INSTRUMENTS_RADIO_AUDITION, + label, + CallbackQueue.run, + ) + ) + + def can_export(self, channel: ChannelName) -> bool: + """Whether ``channel``'s Export instrument button answers.""" + return self._bridge.ask(lambda: read_item(export_button(channel))).enabled + + def export(self, channel: ChannelName) -> None: + """Clicks ``channel``'s Export instrument button.""" + self._hand.scroll_into_view(export_button(channel)) + self._hand.click(export_button(channel)) + + +def tab(channel: ChannelName) -> str: + """The name of the tab of ``channel``.""" + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel) + + +def export_button(channel: ChannelName) -> str: + """The name of the Export instrument button of ``channel``.""" + return compose_tag( + TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, + tab(channel), + SUF_BUTTON, + ) + + +def _row(channel: ChannelName, feature: FeatureKey) -> str: + return compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, channel, feature, SUF_GROUP) diff --git a/automation/views/keyboard_settings.py b/automation/views/keyboard_settings.py new file mode 100644 index 000000000..fc98c68d6 --- /dev/null +++ b/automation/views/keyboard_settings.py @@ -0,0 +1,88 @@ +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.keys import IMGUI_ENTER +from automation.views.menus import MenuBar +from automation.views.prompts import Prompt +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + PRE_SETTINGS_KEYBINDINGS_ROW, + SUF_SETTINGS_KEYBINDINGS_ACTION, + SUF_SETTINGS_KEYBINDINGS_SHORTCUT, + TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, + TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, + TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, + TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, + TAG_SETTINGS_KEYBINDINGS_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + + +class KeyboardSettings: + """The Keyboard settings dialog: one row per action with the keys it answers to, and Cancel and OK.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.reassign_prompt = Prompt(bridge, hand, TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN) + + def open(self) -> None: + """Opens the dialog from View ▸ Keyboard settings.""" + self._menu.choose(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS) + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_KEYBINDINGS_WINDOW)).shown + + def listen_for(self, shortcut_id: ShortcutId) -> None: + """Clicks the keys of ``shortcut_id``'s row, which listens for the next keys pressed.""" + cell = _shortcut_cell(shortcut_id) + self._hand.scroll_into_view(cell) + self._hand.click(cell) + + def write_keys(self, shortcut_id: ShortcutId, text: str) -> None: + """Picks the row of ``shortcut_id`` by its name, writes ``text`` over the keys in the entry box + and presses Enter, which gives the action exactly the keys written. + """ + action = _action_cell(shortcut_id) + self._hand.scroll_into_view(action) + self._hand.click(action) + self._hand.replace_text(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, text) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + def keys_of(self, shortcut_id: ShortcutId) -> str: + """What the row of ``shortcut_id`` shows as its keys.""" + return self._bridge.ask(lambda: read_label(_shortcut_cell(shortcut_id))) + + def cancel(self) -> None: + """Presses Cancel, which drops the keys edited and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, SUF_BUTTON)) + + def confirm(self) -> None: + """Presses OK, which puts the keys edited in force.""" + self._hand.click(compose_tag(TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, SUF_BUTTON)) + + +def _shortcut_cell(shortcut_id: ShortcutId) -> str: + return compose_tag( + PRE_SETTINGS_KEYBINDINGS_ROW, + shortcut_id.value, + SUF_SETTINGS_KEYBINDINGS_SHORTCUT, + ) + + +def _action_cell(shortcut_id: ShortcutId) -> str: + return compose_tag( + PRE_SETTINGS_KEYBINDINGS_ROW, + shortcut_id.value, + SUF_SETTINGS_KEYBINDINGS_ACTION, + ) diff --git a/automation/views/main.py b/automation/views/main.py new file mode 100644 index 000000000..49ebe0c4f --- /dev/null +++ b/automation/views/main.py @@ -0,0 +1,148 @@ +from typing import Optional + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.items.texts import read_texts +from automation.dearpygui.semantic import choose +from automation.views.converter import Converter +from automation.views.source import SourceSettings +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_COLLAPSE_BODY, + SUF_COLLAPSE_RAIL, + SUF_COLLAPSE_STRIP, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_STEP_BUTTON_LIT, +) +from sampletones_application.tags.main import ( + PRE_MAIN_SOURCE_STEP, + TAG_MAIN_ADVANCED_BUTTON_SELECT_LIBRARY_DIRECTORY, + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PATH_LIBRARY_DIRECTORY_DISPLAY, + TAG_MAIN_CONVERTER_RADIO_MODE, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.main.source import CHANNEL_CAP_STEPS + +OUTPUT_LABEL_KEYS = { + OutputKind.PER_RECORDING: "main.converter.label.mode_each", + OutputKind.MIXED: "main.converter.label.mode_mixed", +} + + +class Card: + """A card of the interface that folds into its header bar at a click on the bar.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tag = tag + + def is_shown(self) -> bool: + """Whether the card stands on the screen.""" + return self._bridge.ask(lambda: read_item(self.tag)).shown + + def is_collapsed(self) -> bool: + """Whether the card stands folded into its bar, its body put away.""" + return not self._bridge.ask(lambda: read_item(compose_tag(self.tag, SUF_COLLAPSE_BODY))).shown + + def toggle(self) -> None: + """Clicks the card's header bar, which folds an open card and opens a folded one.""" + strip = compose_tag(self.tag, SUF_COLLAPSE_STRIP) + self._hand.scroll_into_view(strip) + self._hand.click(strip) + + def folds_sideways(self) -> bool: + """Whether the card folds into a rail at its side, and not into its bar.""" + return self._bridge.ask(lambda: read_item(self.rail)).exists + + @property + def rail(self) -> str: + """The rail a sideways card folds into, which a click unfolds.""" + return compose_tag(self.tag, SUF_COLLAPSE_RAIL) + + @property + def strip(self) -> str: + """The header bar a click folds the card on.""" + return compose_tag(self.tag, SUF_COLLAPSE_STRIP) + + def unfold_from_the_rail(self) -> None: + """Clicks the rail a sideways card stands folded into, which unfolds it.""" + self._hand.click(self.rail) + + +class Main: + """The Main tab: its cards, the converter's settings and the folders the advanced settings name.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + language: LanguageManager, + ) -> None: + self._bridge = bridge + self._hand = hand + self._language = language + self.advanced = Card(bridge, hand, TAG_MAIN_ADVANCED_PANEL) + self.converter = Converter(bridge, hand) + self.source = SourceSettings(bridge, hand) + + def card(self, tag: str) -> Card: + """A view of the card standing under ``tag``.""" + return Card(self._bridge, self._hand, tag) + + def output(self) -> OutputKind: + """What the converter's output switch stands at.""" + label = self._bridge.ask(lambda: read_value(TAG_MAIN_CONVERTER_RADIO_MODE)) + for output, key in OUTPUT_LABEL_KEYS.items(): + if self._language[key] == label: + return output + + raise LookupError(f"The output switch reads '{label}', which names no output") + + def choose_output(self, output: OutputKind) -> None: + """Picks ``output`` on the converter's output switch.""" + label = self._language[OUTPUT_LABEL_KEYS[output]] + self._bridge.ask(lambda: choose(TAG_MAIN_CONVERTER_RADIO_MODE, label, CallbackQueue.run)) + + def choose_channels_at_once(self, count: int) -> None: + """Clicks the step naming ``count`` channels at once.""" + self._hand.scroll_into_view(_step_tag(count)) + self._hand.click(_step_tag(count)) + + def lit_channels_at_once(self) -> Optional[int]: + """The step of Channels at once that stands lit, if one does.""" + + def read() -> Optional[int]: + for step in CHANNEL_CAP_STEPS: + if read_theme(_step_tag(step)) == TAG_GLOBAL_THEME_STEP_BUTTON_LIT: + return step + + return None + + return self._bridge.ask(read) + + def choose_library_directory(self) -> None: + """Clicks the button that asks for the library folder.""" + button = compose_tag(TAG_MAIN_ADVANCED_BUTTON_SELECT_LIBRARY_DIRECTORY, SUF_BUTTON) + self._hand.scroll_into_view(button) + self._hand.click(button) + + def library_directory(self) -> str: + """The whole path of the library folder, as the hover over its shortened path shows it.""" + tooltip = compose_tag(TAG_MAIN_ADVANCED_PATH_LIBRARY_DIRECTORY_DISPLAY, SUF_TOOLTIP) + return " ".join(self._bridge.ask(lambda: read_texts(tooltip))) + + +def _step_tag(step: int) -> str: + return compose_tag(PRE_MAIN_SOURCE_STEP, str(step), SUF_BUTTON) diff --git a/automation/views/menus.py b/automation/views/menus.py new file mode 100644 index 000000000..c03c91f8e --- /dev/null +++ b/automation/views/menus.py @@ -0,0 +1,232 @@ +from dataclasses import dataclass +from typing import Final, List + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.items.texts import find_labelled, read_label +from automation.dearpygui.items.types import MENU_ITEM_TYPE, MENU_TYPE, Item +from automation.dearpygui.items.viewport import read_viewport +from automation.dearpygui.semantic import invoke +from automation.frames import changed_box, frame_pixels, settled_frame +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.general import ( + TAG_GLOBAL_TAB_INSTRUCTIONS, + TAG_GLOBAL_TEXT_MENU_FPS, + TAG_GLOBAL_WINDOW_MAIN, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue + +HEADER_INSET: Final[Point] = Point(x=12, y=8) +ENTRY_INSET: Final[Point] = Point(x=12, y=8) +HOVER_SECONDS: Final[float] = 5.0 +ENABLED: Final[str] = "enabled" +SHORTCUT: Final[str] = "shortcut" +GROUP_TYPE: Final[str] = "mvAppItemType::mvGroup" +FAR_END_INSET: Final[int] = 30 + + +@dataclass(frozen=True) +class MenuEntry: + """One entry of a menu on the bar: its words, whether it answers, the keys printed beside it, and its check mark.""" + + label: str + enabled: bool + keys: str + checked: bool + + +class MenuBar: + """The menu bar along the top of the window, read by the words its menus show. + + A menu and its entry are found by their labels, which the language file gives, so a scenario + names an entry the way a user reads it. DearPyGui reports a menu entry's position alone, so + choosing one runs its callback the way a click on it does. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + language: LanguageManager, + ) -> None: + self._bridge = bridge + self._hand = hand + self._language = language + + def choose( + self, + group: MenuElements, + item: MenuElements, + ) -> None: + """Chooses the entry ``item`` of the menu ``group``.""" + self._bridge.ask(lambda: invoke(self._entry(group, item), CallbackQueue.run)) + + def choose_tagged(self, tag: str) -> None: + """Chooses the entry carrying ``tag``, which reaches an entry whose words another entry shares.""" + self._bridge.ask(lambda: invoke(tag, CallbackQueue.run)) + + def is_tagged_enabled(self, tag: str) -> bool: + """Whether the entry carrying ``tag`` answers a press.""" + return self._bridge.ask(lambda: read_item(tag).enabled) + + def frame_rate_reading_shown(self) -> bool: + """Whether the reading of the frame rate stands at the far end of the bar.""" + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_TEXT_MENU_FPS)).shown + + def open(self, group: MenuElements) -> None: + """Clicks the header of the menu ``group``, which opens its popup.""" + corner = self.header(group) + self._hand.click_at(Point(x=corner.x + HEADER_INSET.x, y=corner.y + HEADER_INSET.y)) + + def header(self, group: MenuElements) -> Point: + """Where the header of the menu ``group`` stands on the bar, its top left corner.""" + corner = self._bridge.ask(lambda: dpg.get_item_state(self._menu(group))["pos"]) + return Point(x=round(corner[0]), y=round(corner[1])) + + def open_submenu( + self, + group: MenuElements, + submenu: MenuElements, + ) -> None: + """Opens the menu ``group`` and rests the pointer on its entry ``submenu``, which unfolds the submenu. + + DearPyGui reports where an entry stands inside its popup alone, and the popup nowhere, so the + popup is found by what it painted over the frame before it opened: its left edge is the painted + box's, and its top stands its own height above the box's bottom. Both frames are read once the + screen holds still, and the first frame DearPyGui hands over in a process is bare, so one reading + is let go first. + """ + frame_pixels(self._bridge) + before = settled_frame(self._bridge) + self.open(group) + self._bridge.expect( + lambda: self.is_open(group), + bool, + description=f"the {self.label(group)} menu open", + timeout=HOVER_SECONDS, + ) + painted = changed_box(before, settled_frame(self._bridge)) + height = self._bridge.ask(lambda: float(dpg.get_item_state(self._menu(group))["rect_size"][1])) + offset = self._bridge.ask(lambda: dpg.get_item_state(self._submenu(group, submenu))["pos"]) + self._hand.move_to( + Point( + x=round(painted.x + offset[0] + ENTRY_INSET.x), + y=round(painted.y + painted.height - height + offset[1] + ENTRY_INSET.y), + ) + ) + self._bridge.expect( + lambda: self.submenu_unfolded(group, submenu), + bool, + description=f"{self.label(submenu)} unfolded", + timeout=HOVER_SECONDS, + ) + + def is_open(self, group: MenuElements) -> bool: + """Whether the menu ``group`` stands open, which gives its popup a size.""" + return bool(self._bridge.ask(lambda: dpg.get_item_state(self._menu(group))["rect_size"][1] > 0)) + + def submenu_unfolded( + self, + group: MenuElements, + submenu: MenuElements, + ) -> bool: + """Whether the submenu ``submenu`` of the menu ``group`` stands unfolded, which gives its popup a size.""" + return bool(self._bridge.ask(lambda: dpg.get_item_state(self._submenu(group, submenu))["rect_size"][1] > 0)) + + def close(self) -> None: + """Clicks the bare far end of the tab bar, clear of every menu's popup, which puts an open menu away.""" + last_tab = self._bridge.ask(lambda: read_item(TAG_GLOBAL_TAB_INSTRUCTIONS).rect) + viewport = self._bridge.ask(read_viewport) + if last_tab is None: + raise LookupError("The last tab reports no box to click beside") + + self._hand.click_at(Point(x=round(viewport.width) - FAR_END_INSET, y=last_tab.center.y)) + + def entries(self, group: MenuElements) -> List[MenuEntry]: + """The entries of the menu ``group``, in order: their words, whether they answer, and the keys printed.""" + + def read() -> List[MenuEntry]: + return [ + MenuEntry( + label=read_label(entry), + enabled=bool(dpg.get_item_configuration(entry).get(ENABLED, True)), + keys=str(dpg.get_item_configuration(entry).get(SHORTCUT, "")), + checked=bool(read_value(entry)), + ) + for entry in _menu_items(self._menu(group)) + ] + + return self._bridge.ask(read) + + def is_enabled( + self, + group: MenuElements, + item: MenuElements, + ) -> bool: + """Whether the entry ``item`` of the menu ``group`` answers a press.""" + return self._bridge.ask(lambda: read_item(self._entry(group, item)).enabled) + + def is_checked( + self, + group: MenuElements, + item: MenuElements, + ) -> bool: + """Whether the entry ``item`` of the menu ``group`` carries its check mark.""" + return bool(self._bridge.ask(lambda: read_value(self._entry(group, item)))) + + def _menu(self, group: MenuElements) -> Item: + """The menu ``group`` on the bar. Runs on the render thread.""" + return find_labelled(TAG_GLOBAL_WINDOW_MAIN, self.label(group), item_type=MENU_TYPE) + + def _submenu( + self, + group: MenuElements, + submenu: MenuElements, + ) -> Item: + """The entry of the menu ``group`` that unfolds the submenu ``submenu``. Runs on the render thread.""" + return find_labelled(self._menu(group), self.label(submenu), item_type=MENU_TYPE) + + def _entry( + self, + group: MenuElements, + item: MenuElements, + ) -> Item: + """The entry ``item`` of the menu ``group``. Runs on the render thread.""" + menu = find_labelled( + TAG_GLOBAL_WINDOW_MAIN, + self.label(group), + item_type=MENU_TYPE, + ) + return find_labelled( + menu, + self.label(item), + item_type=MENU_ITEM_TYPE, + ) + + def label(self, element: MenuElements) -> str: + """The words the menu bar shows for ``element``.""" + return self._language[ + Page.GLOBAL, + Panel.MENU, + TextType.LABEL, + element, + ] + + +def _menu_items(menu: Item) -> List[Item]: + """The entries under ``menu`` in the order drawn, those of its groups among them. Runs on the render thread.""" + found: List[Item] = [] + for child in dpg.get_item_children(menu, 1): + kind = dpg.get_item_info(child)["type"] + if kind == MENU_ITEM_TYPE: + found.append(child) + elif kind == GROUP_TYPE: + found.extend(_menu_items(child)) + + return found diff --git a/automation/views/notices.py b/automation/views/notices.py new file mode 100644 index 000000000..1360f5fcd --- /dev/null +++ b/automation/views/notices.py @@ -0,0 +1,35 @@ +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.views.prompts import Prompt + + +class Notice: + """A dialog telling the reader something, such as a failure, which the reader acknowledges with OK. + + Each kind of notice composes its windows under a tag of its own, which is the one the view is + given. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self.prompt = Prompt(bridge, hand, tag) + + def is_shown(self) -> bool: + """Whether a notice of this kind stands on the screen.""" + return self.prompt.is_shown() + + def words(self) -> str: + """Everything the notice says, its lines joined by spaces. + + Raises: + MissingPromptError: If no notice of this kind stands on the screen. + """ + return self.prompt.words() + + def dismiss(self) -> None: + """Presses OK.""" + self.prompt.confirm() diff --git a/automation/views/project.py b/automation/views/project.py new file mode 100644 index 000000000..fd7c03e6a --- /dev/null +++ b/automation/views/project.py @@ -0,0 +1,96 @@ +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.views.menus import MenuBar +from automation.views.prompts import Prompt +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_DIALOG_PROJECT_OPEN, + TAG_GLOBAL_DIALOG_PROJECT_SAVED, + TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, +) +from sampletones_application.tags.settings import ( + TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL, + TAG_SETTINGS_PROPERTIES_BUTTON_OK, + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_TITLE, + TAG_SETTINGS_PROPERTIES_WINDOW, +) + + +class ProjectProperties: + """The Project properties dialog: the title, the author and the highlights a project carries.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + + def open(self) -> None: + """Opens the dialog from File ▸ Project properties.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES) + + def is_shown(self) -> bool: + """Whether the dialog stands on the screen.""" + return self._bridge.ask(lambda: read_item(TAG_SETTINGS_PROPERTIES_WINDOW)).shown + + def title(self) -> str: + """The title the title field holds.""" + return str(self._bridge.ask(lambda: read_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE))) + + def retitle(self, title: str) -> None: + """Types ``title`` over the title the field holds.""" + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, title) + + def retype_highlights(self, first: int, second: int) -> None: + """Types ``first`` and ``second`` over the fields of the rows the tracker tints.""" + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, str(first)) + self._hand.replace_text(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, str(second)) + + def confirm(self) -> None: + """Presses OK, which keeps the typed values and closes the dialog.""" + self._hand.click(compose_tag(TAG_SETTINGS_PROPERTIES_BUTTON_OK, SUF_BUTTON)) + + def cancel(self) -> None: + """Presses Cancel, which closes the dialog and leaves the project as it was.""" + self._hand.click(compose_tag(TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL, SUF_BUTTON)) + + +class Project: + """The project as the File menu reaches it, with the questions the application asks about it.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._menu = menu + self.properties = ProjectProperties(bridge, hand, menu) + self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED) + self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_SAVED) + self.replace_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_PROJECT_OPEN) + + def open(self) -> None: + """Chooses File ▸ Open project, which asks for a file.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT) + + def create(self) -> None: + """Starts a new project from File ▸ New project.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT) + + def close(self) -> None: + """Closes the project from File ▸ Close project.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_CLOSE_PROJECT) + + def save_as(self) -> None: + """Saves the project from File ▸ Save project as, which asks where.""" + self._menu.choose(MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS) diff --git a/automation/views/prompts.py b/automation/views/prompts.py new file mode 100644 index 000000000..b3d2d28eb --- /dev/null +++ b/automation/views/prompts.py @@ -0,0 +1,92 @@ +from typing import List, Optional, Tuple + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import is_tag_within +from automation.dearpygui.items.regions import WindowReading, read_windows +from automation.dearpygui.items.texts import read_shown_labels, read_shown_texts +from automation.dearpygui.items.types import BUTTON_TYPE +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, +) + + +class MissingPromptError(AssertionError): + """Raised when a scenario answers a prompt that stands nowhere on the screen.""" + + +class Prompt: + """A question the application asks in a dialog of its own, found by the tag its kind composes under. + + Each prompt the application opens carries a fresh suffix on its kind's tag, so the prompt is the + shown window standing under that tag, and its buttons are composed from the window it stands in. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tag = tag + + def window(self) -> Optional[WindowReading]: + """The prompt's window while one is shown, and ``None`` otherwise.""" + shown = self.shown_windows() + return shown[0] if shown else None + + def shown_windows(self) -> List[WindowReading]: + """Every window of the prompt's kind standing on the screen, which is one for a question asked once.""" + return [ + window + for window in self._bridge.ask(read_windows) + if window.shown and is_tag_within(window.alias, self._tag) + ] + + def is_shown(self) -> bool: + """Whether a prompt of this kind stands on the screen.""" + return self.window() is not None + + def title(self) -> str: + """The title the prompt's window carries.""" + return self._standing().label + + def words(self) -> str: + """Everything the prompt says, its lines joined by spaces.""" + window = self._standing() + texts: Tuple[str, ...] = self._bridge.ask(lambda: read_shown_texts(window.alias)) + return " ".join(texts) + + def answers(self) -> Tuple[str, ...]: + """The buttons the prompt offers, read left to right.""" + window = self._standing() + return self._bridge.ask(lambda: read_shown_labels(window.alias, BUTTON_TYPE)) + + def confirm(self) -> None: + """Presses the button that says yes: Discard, Load, Exit and the like.""" + self._press(SUF_BUTTON_OK) + + def save(self) -> None: + """Presses Save on a prompt that offers to save first.""" + self._press(SUF_BUTTON_SAVE) + + def cancel(self) -> None: + """Presses the button that takes the question back.""" + self._press(SUF_BUTTON_CANCEL) + + def _press(self, suffix: str) -> None: + self._hand.click(compose_tag(self._standing().alias, suffix, SUF_BUTTON)) + + def _standing(self) -> WindowReading: + """The window of the prompt on the screen; raises ``MissingPromptError`` when none stands.""" + window = self.window() + if window is None: + raise MissingPromptError(f"No prompt under '{self._tag}' stands on the screen") + + return window diff --git a/automation/views/reconstructions.py b/automation/views/reconstructions.py new file mode 100644 index 000000000..9dd7f362a --- /dev/null +++ b/automation/views/reconstructions.py @@ -0,0 +1,166 @@ +from pathlib import Path + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.items.texts import read_texts +from automation.dearpygui.semantic import choose +from automation.views.browsers import FileTree +from automation.views.instruments import Instruments +from automation.views.menus import MenuBar +from automation.views.prompts import Prompt +from automation.views.stems import StemsCard +from automation.views.waveform import Waveform +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_TOOLTIP, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, +) +from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_CHANNEL, + TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION, + TAG_RECONSTRUCTIONS_BROWSER_TREE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_core.constants.enums import ChannelName + +OPEN_FILE_TOOLTIP = compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, SUF_TOOLTIP) + + +class Reconstructions: + """The Reconstructions tab: the browser of saved reconstructions, and the one open beside it. + + Every question about unsaved changes to the open reconstruction stands under one kind of + prompt, whichever gesture asked it, so ``unsaved_prompt`` reads any of them, and its title and + words tell which. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._hand = hand + self._menu = menu + self.browser = FileTree(bridge, hand, TAG_RECONSTRUCTIONS_BROWSER_TREE) + self.unsaved_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION) + self.replaced_prompt = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED) + self.remove_prompt = Prompt( + bridge, + hand, + TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_RECONSTRUCTION_CONFIRMATION, + ) + self.saved_notice = Prompt(bridge, hand, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED) + self.waveform = Waveform( + bridge, + hand, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM, + ) + self.instruments = Instruments(bridge, hand) + self.stems = StemsCard(bridge, hand) + + def open_from_menu(self) -> None: + """Chooses Reconstruction ▸ Open, which asks for a file.""" + self._menu.choose( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_OPEN, + ) + + def close_from_menu(self) -> None: + """Chooses Reconstruction ▸ Close.""" + self._menu.choose( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_CLOSE, + ) + + def save_from_menu(self) -> None: + """Chooses Reconstruction ▸ Save.""" + self._menu.choose( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_SAVE, + ) + + def can_close(self) -> bool: + """Whether Reconstruction ▸ Close answers.""" + return self._menu.is_enabled( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_CLOSE, + ) + + def can_save(self) -> bool: + """Whether Reconstruction ▸ Save answers.""" + return self._menu.is_enabled( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_SAVE, + ) + + def open_file(self) -> str: + """The whole path of the open reconstruction's file, as the hover over its shortened path shows it. + + While nothing is open, or the reconstruction has no file yet, the hover shows the status of the line. + """ + return " ".join(self._bridge.ask(lambda: read_texts(OPEN_FILE_TOOLTIP))) + + def shows_open(self, path: Path) -> bool: + """Whether the reconstruction open is the one stored at ``path``.""" + return self.open_file() == str(path.absolute()) + + def file_line(self) -> str: + """What the Reconstruction file line of Source settings shows: a shortened path, or a status.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE))) + + def can_retune(self) -> bool: + """Whether the NES frequency field takes a new rate.""" + return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)).enabled + + def retune_lock_explained(self) -> bool: + """Whether hovering the NES frequency field shows why the rate is locked.""" + return self._bridge.ask( + lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED) + ).shown + + def retune_lock_words(self) -> str: + """What hovering the locked NES frequency field says.""" + return " ".join( + self._bridge.ask(lambda: read_texts(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)) + ) + + def audio_source(self) -> str: + """The source the switch above the waveform names: the reconstruction, or the original audio.""" + return str(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE))) + + def can_choose_audio_source(self) -> bool: + """Whether the switch above the waveform answers a press.""" + return self._bridge.ask(lambda: read_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE)).enabled + + def choose_audio_source(self, label: str) -> None: + """Picks the source the switch names ``label``.""" + self._bridge.ask( + lambda: choose( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + label, + CallbackQueue.run, + ) + ) + + def channel_ticked(self, channel: ChannelName) -> bool: + """Whether the box of ``channel`` above the waveform stands ticked.""" + return bool(self._bridge.ask(lambda: read_value(_channel_box(channel)))) + + def tick_channel(self, channel: ChannelName) -> None: + """Clicks the box of ``channel`` above the waveform, which flips its tick.""" + self._hand.click(_channel_box(channel)) + + +def _channel_box(channel: ChannelName) -> str: + return compose_tag(PRE_RECONSTRUCTION_CHANNEL, channel.value) diff --git a/automation/views/sequencer.py b/automation/views/sequencer.py new file mode 100644 index 000000000..8161526e8 --- /dev/null +++ b/automation/views/sequencer.py @@ -0,0 +1,302 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.regions import read_region_view, read_table +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.items.types import Item +from automation.dearpygui.keys import IMGUI_ENTER +from automation.views.browsers import FileTree +from automation.views.history import History +from automation.views.main import Card +from automation.views.menus import MenuBar +from automation.views.notices import Notice +from automation.views.prompts import Prompt +from automation.views.tracker import Tracker +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import ( + TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED, + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, + TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY, + TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP, +) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY, + TAG_SEQUENCER_BROWSER_TREE, + TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY, + TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, + TAG_SEQUENCER_MODULE_INPUT_ROWS, + TAG_SEQUENCER_MODULE_INPUT_SPEED, + TAG_SEQUENCER_MODULE_INPUT_TEMPO, + TAG_SEQUENCER_ORDER_TABLE, + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_DIALOG_REMOVE, + TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TABLE, + TAG_SEQUENCER_VOICES_WINDOW, +) +from sampletones_core.constants.enums import ChannelName + +NAME_COLUMN: Final[int] = 2 +KIND_COLUMN: Final[int] = 0 +COLOR: Final[str] = "color" +MASTER_ORDER_ROW: Final[int] = 0 +FIRST_CHANNEL_ORDER_ROW: Final[int] = 2 +ORDER_LABEL_CELLS: Final[int] = 1 +CELL_CONTENT: Final[int] = 0 +CLEAR_OF_THE_EDGE: Final[int] = 12 + + +class Voices: + """The Voices card of the Sequencer: one row per voice of the project, its number and its name.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.remove_prompt = Prompt(bridge, hand, TAG_SEQUENCER_VOICES_DIALOG_REMOVE) + self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) + self.imported_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_INSTRUMENT_IMPORTED) + self.card = Card(bridge, hand, TAG_SEQUENCER_VOICES_PANEL) + + def kind_color(self, name: str) -> Tuple[float, ...]: + """The color of the mark naming the kind of the voice ``name``.""" + + def read() -> Tuple[float, ...]: + row = next( + row + for row in read_table(TAG_SEQUENCER_VOICES_TABLE) + if read_label(row[NAME_COLUMN][CELL_CONTENT]) == name + ) + return tuple(float(part) for part in dpg.get_item_configuration(row[KIND_COLUMN][CELL_CONTENT])[COLOR]) + + return self._bridge.ask(read) + + def pick(self, row: Item) -> None: + """Clicks ``row``, which picks its voice.""" + self._hand.scroll_into_view(row) + self._hand.click(row) + + def is_picked(self, position: int) -> bool: + """Whether the row at ``position`` stands picked.""" + return bool(self._bridge.ask(lambda: dpg.is_table_row_highlighted(TAG_SEQUENCER_VOICES_TABLE, position))) + + def new_instrument(self) -> None: + """Clicks New instrument above the list.""" + self._hand.scroll_into_view(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) + self._hand.click(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) + + def new_instrument_answers(self) -> bool: + """Whether New instrument above the list answers a press, which it does while a project is open.""" + return self._bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT)).enabled + + def hover_new_instrument(self) -> None: + """Rests the pointer on New instrument above the list, whether it answers or stands greyed out. + + Raises: + LookupError: If the button reports no box. + """ + box = self._bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT)).rect + if box is None: + raise LookupError("New instrument stands nowhere") + + self._hand.move_to(box.center) + + def names(self) -> List[str]: + """The names the rows show, top to bottom.""" + + def read() -> List[str]: + return [read_label(name) for name in _names()] + + return self._bridge.ask(read) + + def row(self, name: str) -> Optional[Item]: + """The name cell of the first row reading ``name``, if one is drawn.""" + return self._bridge.ask(lambda: next((item for item in _names() if read_label(item) == name), None)) + + def edit(self, row: Item) -> None: + """Double-clicks ``row``, which opens its voice on the Reconstructions tab.""" + self._hand.scroll_into_view(row) + self._hand.double_click(row) + + def right_click(self, row: Item) -> None: + """Clicks ``row`` with the right button, which opens its menu.""" + self._hand.scroll_into_view(row) + self._hand.right_click(row) + + def click_below_the_rows(self) -> None: + """Clicks the empty foot of the list, which clears the mark on a voice.""" + view = self._bridge.ask(lambda: read_region_view(TAG_SEQUENCER_VOICES_WINDOW)) + if view is None: + raise LookupError("The list of voices stands nowhere") + + self._hand.click_at( + Point( + x=view.center.x, + y=round(view.y + view.height) - CLEAR_OF_THE_EDGE, + ) + ) + + def right_click_below_the_rows(self) -> None: + """Clicks the empty foot of the list with the right button, which opens the list's own menu.""" + view = self._bridge.ask(lambda: read_region_view(TAG_SEQUENCER_VOICES_WINDOW)) + if view is None: + raise LookupError("The list of voices stands nowhere") + + self._hand.right_click_at( + Point( + x=view.center.x, + y=round(view.y + view.height) - CLEAR_OF_THE_EDGE, + ) + ) + + +class Playback: + """Song playback as the Playback menu offers it: Play, which reads Pause while a song plays, and Stop.""" + + def __init__( + self, + bridge: Bridge, + menu: MenuBar, + ) -> None: + self._bridge = bridge + self._menu = menu + + def play(self) -> None: + """Chooses Playback ▸ Play.""" + self._menu.choose(MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY) + + def stop(self) -> None: + """Chooses Playback ▸ Stop.""" + self._menu.choose(MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_STOP) + + def play_entry(self) -> str: + """What the first entry of the Playback menu reads: Play while stopped, Pause while a song plays.""" + return self._bridge.ask(lambda: read_label(TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) + + def can_stop(self) -> bool: + """Whether Playback ▸ Stop answers, which it does while something plays.""" + return self._bridge.ask(lambda: read_item(TAG_GLOBAL_MENU_ITEM_PLAYBACK_STOP)).enabled + + +class OrderTable: + """The order table: a row per channel under the Master row, a column per frame of the song.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def label(self, channel: Optional[ChannelName], position: int) -> str: + """What the entry of ``channel`` at frame ``position`` reads, the Master row's for ``None``.""" + return self._bridge.ask(lambda: read_label(_order_entry(channel, position))) + + def positions(self) -> int: + """How many frames the song orders.""" + return self._bridge.ask( + lambda: len(read_table(TAG_SEQUENCER_ORDER_TABLE)[MASTER_ORDER_ROW]) - ORDER_LABEL_CELLS + ) + + def click(self, channel: Optional[ChannelName], position: int) -> None: + """Clicks the entry of ``channel`` at frame ``position``, scrolling it into view first.""" + entry = self._bridge.ask(lambda: _order_entry(channel, position)) + self._hand.scroll_into_view(entry) + self._hand.click(entry) + + +class ModuleOptions: + """The module options card: the song's NES frequency, rows, tempo and speed.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.retune_prompt = Prompt(bridge, hand, TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY) + + def nes_frequency(self) -> int: + """The NES frequency the field holds.""" + return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY))) + + def retype_nes_frequency(self, frequency: int) -> None: + """Types ``frequency`` over the NES frequency field and presses Enter, which commits it.""" + self._retype(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, frequency) + + def tempo(self) -> int: + """The tempo the field holds.""" + return self._read(TAG_SEQUENCER_MODULE_INPUT_TEMPO) + + def retype_tempo(self, tempo: int) -> None: + """Types ``tempo`` over the tempo field and presses Enter, which commits it.""" + self._retype(TAG_SEQUENCER_MODULE_INPUT_TEMPO, tempo) + + def speed(self) -> int: + """The speed the field holds.""" + return self._read(TAG_SEQUENCER_MODULE_INPUT_SPEED) + + def retype_speed(self, speed: int) -> None: + """Types ``speed`` over the speed field and presses Enter, which commits it.""" + self._retype(TAG_SEQUENCER_MODULE_INPUT_SPEED, speed) + + def rows(self) -> int: + """The rows per pattern the field holds.""" + return self._read(TAG_SEQUENCER_MODULE_INPUT_ROWS) + + def retype_rows(self, rows: int) -> None: + """Types ``rows`` over the rows per pattern field and presses Enter, which commits it.""" + self._retype(TAG_SEQUENCER_MODULE_INPUT_ROWS, rows) + + def _read(self, field: str) -> int: + return int(self._bridge.ask(lambda: dpg.get_value(field))) + + def _retype(self, field: str, value: int) -> None: + self._hand.scroll_into_view(field) + self._hand.replace_text(field, str(value)) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + + +class Sequencer: + """The Sequencer tab: the tracker, the voices, the history, and song playback. + + ``frequency_prompt`` is the question a reconstruction made at a rate other than the project's asks + before it joins. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + menu: MenuBar, + ) -> None: + self.voices = Voices(bridge, hand) + self.playback = Playback(bridge, menu) + self.tracker = Tracker(bridge, hand) + self.history = History(bridge, hand) + self.browser = FileTree(bridge, hand, TAG_SEQUENCER_BROWSER_TREE) + self.frequency_prompt = Prompt(bridge, hand, TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY) + self.order = OrderTable(bridge, hand) + self.module = ModuleOptions(bridge, hand) + + +def _names() -> List[Item]: + """The name cell of every voice row, top to bottom. Runs on the render thread.""" + return [row[NAME_COLUMN][CELL_CONTENT] for row in read_table(TAG_SEQUENCER_VOICES_TABLE) if len(row) > NAME_COLUMN] + + +def _order_entry(channel: Optional[ChannelName], position: int) -> Item: + """The entry of ``channel`` at frame ``position`` in the order table. Runs on the render thread.""" + row = MASTER_ORDER_ROW if channel is None else FIRST_CHANNEL_ORDER_ROW + ChannelName.items().index(channel) + entry: Item = read_table(TAG_SEQUENCER_ORDER_TABLE)[row][ORDER_LABEL_CELLS + position][CELL_CONTENT] + return entry diff --git a/automation/views/source.py b/automation/views/source.py new file mode 100644 index 000000000..a06912341 --- /dev/null +++ b/automation/views/source.py @@ -0,0 +1,47 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_CHANNELS, SUF_CHECKBOX +from sampletones_application.tags.main import ( + PRE_MAIN_SOURCE_CHANNEL, + TAG_MAIN_SOURCE_TEXT_SUBJECT, +) +from sampletones_core.constants.enums import ChannelName + + +class SourceSettings: + """The Source settings card: what the picked row converts with, channel by channel.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def subject(self) -> str: + """What the card names as the row it speaks for.""" + return str(self._bridge.ask(lambda: dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT))) + + def channel_box(self, channel: ChannelName) -> str: + """The tag of the checkbox that lets the row convert with ``channel``.""" + return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel, SUF_CHANNELS, SUF_CHECKBOX) + + def channel_ticked(self, channel: ChannelName) -> bool: + """Whether the checkbox of ``channel`` stands ticked.""" + return bool(self._bridge.ask(lambda: dpg.get_value(self.channel_box(channel)))) + + def channel_theme(self, channel: ChannelName) -> Optional[str]: + """The theme the checkbox of ``channel`` wears, or ``None`` while it wears none.""" + return self._bridge.ask(lambda: read_theme(self.channel_box(channel))) + + def tick(self, channel: ChannelName) -> None: + """Clicks the checkbox of ``channel``, scrolling it into view first.""" + self._hand.scroll_into_view(self.channel_box(channel)) + self._hand.click(self.channel_box(channel)) diff --git a/automation/views/stems.py b/automation/views/stems.py new file mode 100644 index 000000000..954f28048 --- /dev/null +++ b/automation/views/stems.py @@ -0,0 +1,106 @@ +from typing import Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item, read_value +from automation.dearpygui.items.regions import enclosing_regions +from automation.dearpygui.items.texts import read_label +from automation.views.prompts import Prompt +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHECKBOX, + SUF_GROUP, + SUF_SWATCH, + SUF_TEXT, +) +from sampletones_application.tags.reconstructions import ( + PRE_RECONSTRUCTION_STEMS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, +) +from sampletones_application.ui.elements.stems.tags import StemsTags + + +class StemsCard: + """The Stems card of the Reconstructions tab: a row per recording of the open reconstruction. + + A row is known by the key the card gives its recording, which is the recording's number in + the reconstruction. The card is the last of its column, so a gesture on a row first scrolls the + column to its end, as a person scrolls down to it. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + self.tags = StemsTags(prefix=PRE_RECONSTRUCTION_STEMS) + self.remove_prompt = Prompt( + bridge, + hand, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_REMOVE_STEM_CONFIRMATION, + ) + + def levels_collapsed(self) -> bool: + """Whether the Collapse levels box stands ticked, which folds the level headings away.""" + return bool(self._bridge.ask(lambda: read_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS))) + + def toggle_levels(self) -> None: + """Clicks the Collapse levels box, which folds the level headings away or brings them back.""" + self._hand.scroll_into_view(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS) + self._hand.click(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS) + + def has_row(self, key: str) -> bool: + """Whether a row for the recording ``key`` stands on the screen.""" + reading = self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_GROUP))) + return reading.exists and reading.shown + + def name(self, key: str) -> str: + """The name the row of ``key`` shows.""" + return self._bridge.ask(lambda: read_label(self.tags.row(key, SUF_TEXT))) + + def is_highlighted(self, key: str) -> bool: + """Whether the row's name stands highlighted, as a picked row does.""" + return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_TEXT)))) + + def swatch(self, key: str) -> Tuple[float, ...]: + """The color the row's swatch is filled with.""" + return tuple( + float(part) + for part in self._bridge.ask(lambda: dpg.get_item_configuration(self.tags.row(key, SUF_SWATCH))["fill"]) + ) + + def can_remove(self, key: str) -> bool: + """Whether the row's remove button answers.""" + return self._bridge.ask(lambda: read_item(self.tags.row(key, SUF_BUTTON))).enabled + + def remove(self, key: str) -> None: + """Clicks the row's remove button, which asks first.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_BUTTON))) + + def click(self, key: str) -> None: + """Clicks the name of the row of ``key``, which picks it.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_TEXT))) + + def reveal(self, key: str) -> None: + """Double-clicks the row's name, which shows its recording in the file manager.""" + self._hand.double_click(self._in_view(self.tags.row(key, SUF_TEXT))) + + def hears(self, key: str) -> bool: + """Whether the row's box stands ticked, which is whether its recording is heard.""" + return bool(self._bridge.ask(lambda: read_value(self.tags.row(key, SUF_CHECKBOX)))) + + def tick(self, key: str) -> None: + """Clicks the box of the row of ``key``, which flips whether its recording is heard.""" + self._hand.click(self._in_view(self.tags.row(key, SUF_CHECKBOX))) + + def _in_view(self, item: str) -> str: + """Scrolls the column the card stands in to its end and ``item`` into view, and returns ``item``.""" + column = self._bridge.ask(lambda: enclosing_regions(self.tags.well))[-1] + self._hand.scroll_to_end(column) + self._hand.scroll_into_view(item) + return item diff --git a/automation/views/tabs.py b/automation/views/tabs.py new file mode 100644 index 000000000..2191a680c --- /dev/null +++ b/automation/views/tabs.py @@ -0,0 +1,42 @@ +from typing import Dict, Final + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_selected_tab +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.general import ( + TAG_GLOBAL_TAB_INSTRUCTIONS, + TAG_GLOBAL_TAB_MAIN, + TAG_GLOBAL_TAB_RECONSTRUCTION, + TAG_GLOBAL_TAB_SEQUENCER, + TAG_GLOBAL_TABS, +) + +TAB_TAGS: Final[Dict[Tab, str]] = { + Tab.MAIN: TAG_GLOBAL_TAB_MAIN, + Tab.RECONSTRUCTIONS: TAG_GLOBAL_TAB_RECONSTRUCTION, + Tab.SEQUENCER: TAG_GLOBAL_TAB_SEQUENCER, + Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS, +} + +TABS_BY_TAG: Final[Dict[str, Tab]] = {tag: tab for tab, tag in TAB_TAGS.items()} + + +class Tabs: + """The tab bar across the window: which tab stands in front, and the headers that bring one forward.""" + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def bring_to_front(self, tab: Tab) -> None: + """Clicks the header of ``tab``.""" + self._hand.click(TAB_TAGS[tab]) + + def front(self) -> Tab: + """The tab standing in front.""" + return TABS_BY_TAG[self._bridge.ask(lambda: read_selected_tab(TAG_GLOBAL_TABS))] diff --git a/automation/views/tracker.py b/automation/views/tracker.py new file mode 100644 index 000000000..9c7ca9cc0 --- /dev/null +++ b/automation/views/tracker.py @@ -0,0 +1,332 @@ +from typing import Dict, Final, FrozenSet, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import Bridge +from automation.dearpygui.geometry import Point +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme_text_color +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.regions import read_region_view +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.items.types import Item +from automation.dearpygui.keys import IMGUI_LEFT_SHIFT +from automation.dearpygui.reach import UnreachableError +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_TRACKER_INPUT_OCTAVE, + TAG_SEQUENCER_TRACKER_TABLE, +) +from sampletones_application.ui.elements.table.caret import CaretOverlay +from sampletones_application.ui.panels.sequencer.columns import ( + tracker_table_column, +) +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName + +HEADER_ROW: Final[int] = 0 +CELL_GROUP: Final[int] = 0 +NUMBER_CELL: Final[int] = 1 +NUMBER_LABEL: Final[int] = 0 +HALF: Final[float] = 0.5 + + +def frame_table_row(row: int) -> int: + """Where a pattern row of the shown frame stands among the table's rows. Runs on the render thread. + + The table holds the header and the rows of the song either side of the frame as well, so a + frame row is found by the pattern row it carries rather than by its place. + + Raises: + UnreachableError: If the table holds no such row. + """ + for index, table_row in enumerate(dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)): + if dpg.get_item_user_data(table_row) == row: + return index + + raise UnreachableError(f"The tracker holds no row {row}") + + +def tracker_cell(row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Item: + """The selectable drawing one subcolumn of one channel's cell on a pattern row. Runs on the render thread. + + The Sample column stands for ``channel`` ``None``. + """ + return _slot(frame_table_row(row), channel, subcolumn) + + +def _table_rows() -> Tuple[Item, ...]: + """Every row the tracker's table holds, the header first. Runs on the render thread.""" + return tuple(dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)) + + +def _frame_rows() -> Dict[int, int]: + """Where each pattern row of the shown frame stands among the table's rows, by its index. Runs on the render + thread. + """ + rows: Dict[int, int] = {} + for index, table_row in enumerate(_table_rows()): + row = dpg.get_item_user_data(table_row) + if isinstance(row, int): + rows[row] = index + + return rows + + +def _number(table_index: int) -> Item: + """The row-number label of the table's ``table_index``-th row. Runs on the render thread.""" + cell = dpg.get_item_children(_table_rows()[table_index], 1)[NUMBER_CELL] + label: Item = dpg.get_item_children(cell, 1)[NUMBER_LABEL] + return label + + +def _slot(table_index: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Item: + """The selectable drawing one slot of the table's ``table_index``-th row. Runs on the render thread.""" + table_row = _table_rows()[table_index] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + group = dpg.get_item_children(cell, 1)[CELL_GROUP] + slot: Item = dpg.get_item_children(group, 1)[SUBCOLUMNS.index(subcolumn)] + return slot + + +def tracker_cell_theme(row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + """The theme the cell's text is drawn with, which cells of one kind and state share. Runs on the render thread.""" + theme = dpg.get_item_info(tracker_cell(row, channel, subcolumn))["theme"] + return int(theme) if theme is not None else None + + +def _header_label(channel: Optional[ChannelName]) -> Item: + """The name heading ``channel``'s column, the Sample column's for ``None``. Runs on the render thread.""" + table_row = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)[HEADER_ROW] + cell = dpg.get_item_children(table_row, 1)[tracker_table_column(channel)] + label: Item = dpg.get_item_children(cell, 1)[CELL_GROUP] + return label + + +class Tracker: + """The tracker grid: a row per pattern row, the Sample column and a column per channel, three slots to a cell. + + A cell's slots are the voice, the transpose and the volume, and a gesture names a slot by its + row, its channel, or ``None`` for the Sample column, and its subcolumn. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + ) -> None: + self._bridge = bridge + self._hand = hand + + def header(self, channel: Optional[ChannelName]) -> str: + """What the header of ``channel``'s column reads, the Sample column's for ``None``.""" + return self._bridge.ask(lambda: read_label(_header_label(channel))) + + def click_header(self, channel: ChannelName) -> None: + """Clicks the name heading ``channel``'s column, which mutes the channel or lets it sound again.""" + label = self._bridge.ask(lambda: _header_label(channel)) + self._hand.click(label) + + def text_color(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[Tuple[float, ...]]: + """The color one slot's text is drawn in, as the theme the slot wears sets it.""" + + def read() -> Optional[Tuple[float, ...]]: + theme = tracker_cell_theme(row, channel, subcolumn) + return None if theme is None else read_theme_text_color(theme) + + return self._bridge.ask(read) + + def label(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> str: + """What one slot reads.""" + return self._bridge.ask(lambda: read_label(tracker_cell(row, channel, subcolumn))) + + def labels(self, row: int, channel: Optional[ChannelName]) -> Tuple[str, ...]: + """What the three slots of one cell read, voice first.""" + return tuple(self.label(row, channel, subcolumn) for subcolumn in SUBCOLUMNS) + + def theme(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> Optional[int]: + """The theme one slot's text wears, or ``None`` while it wears none.""" + return self._bridge.ask(lambda: tracker_cell_theme(row, channel, subcolumn)) + + def click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot, which puts the caret there.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click(item) + + def hover(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Rests the pointer on one slot, which the status bar then describes.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.hover(item) + + def leave(self) -> None: + """Rests the pointer on the octave field above the grid, away from every slot.""" + self._hand.hover(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE) + + def shift_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot holding Shift, which stretches the block from the caret to it.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click_holding(item, [IMGUI_LEFT_SHIFT]) + + def right_click(self, row: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot with the right button, which opens its menu.""" + item = self._bridge.ask(lambda: tracker_cell(row, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.right_click(item) + + def has_caret(self, row: int, channel: Optional[ChannelName]) -> bool: + """Whether the caret stands in ``channel``'s cell on ``row``, as the box drawn over the grid shows it.""" + return self._bridge.ask(_caret_cell) == (row, channel) + + def caret_row(self, channel: Optional[ChannelName]) -> Optional[int]: + """The pattern row the caret stands on in ``channel``'s column, if it stands in that column.""" + cell = self._bridge.ask(_caret_cell) + if cell is None or cell[1] != channel: + return None + + return cell[0] + + def is_row_tinted(self, row: int) -> bool: + """Whether the row carries a background, as a beat, a bar, the caret's row or the playing row does.""" + return bool( + self._bridge.ask(lambda: dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, frame_table_row(row))) + ) + + def centered_row(self) -> Optional[int]: + """The pattern row whose middle stands on the band's middle, within half a row, if one does. + + The band is the part of the grid the header leaves in view, and only a row drawn in the last + frame is read. + """ + + def read() -> Optional[int]: + band = read_region_view(TAG_SEQUENCER_TRACKER_TABLE) + if band is None: + return None + + middle = band.y + band.height * HALF + for row, table_index in _frame_rows().items(): + number = read_item(_number(table_index)) + if number.rect is None or not number.visible: + continue + + if abs(number.rect.y + number.rect.height * HALF - middle) <= number.rect.height * HALF: + return row + + return None + + return self._bridge.ask(read) + + def rows_above_the_frame(self) -> int: + """How many rows stand between the header and the frame's first row.""" + return self._bridge.ask(lambda: frame_table_row(0) - (HEADER_ROW + 1)) + + def numbers_above(self, count: int) -> Tuple[str, ...]: + """What the row numbers of the ``count`` rows nearest above the frame read, top to bottom.""" + + def read() -> Tuple[str, ...]: + first = frame_table_row(0) + return tuple(read_label(_number(index)) for index in range(first - count, first)) + + return self._bridge.ask(read) + + def numbers_below(self, count: int) -> Tuple[str, ...]: + """What the row numbers of the ``count`` rows nearest below the frame read, top to bottom.""" + + def read() -> Tuple[str, ...]: + last = max(_frame_rows().values()) + return tuple(read_label(_number(index)) for index in range(last + 1, last + 1 + count)) + + return self._bridge.ask(read) + + def number_color(self, row: int) -> Optional[Tuple[float, ...]]: + """The color the row number of a pattern row of the frame is drawn in.""" + return self._bridge.ask(lambda: _theme_color(_number(frame_table_row(row)))) + + def number_color_above(self, offset: int) -> Optional[Tuple[float, ...]]: + """The color the row number standing ``offset`` rows above the frame's first row is drawn in.""" + return self._bridge.ask(lambda: _theme_color(_number(frame_table_row(0) - offset))) + + def click_above(self, offset: int, channel: Optional[ChannelName], subcolumn: SubColumn) -> None: + """Clicks one slot of the row standing ``offset`` rows above the frame's first row.""" + item = self._bridge.ask(lambda: _slot(frame_table_row(0) - offset, channel, subcolumn)) + self._hand.scroll_into_view(item) + self._hand.click(item) + + def tinted_rows_in_view(self) -> FrozenSet[int]: + """The pattern rows drawn in the last frame that carry a background, by their index.""" + + def read() -> FrozenSet[int]: + return frozenset( + row + for row, table_index in _frame_rows().items() + if read_item(_number(table_index)).visible + and dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, table_index) + ) + + return self._bridge.ask(read) + + def octave(self) -> int: + """The octave the octave field holds.""" + return int(self._bridge.ask(lambda: dpg.get_value(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE))) + + def raise_octave(self) -> None: + """Clicks the plus at the right end of the octave field, a square as tall as the field.""" + box = self._bridge.ask(lambda: read_item(TAG_SEQUENCER_TRACKER_INPUT_OCTAVE).rect) + if box is None: + raise UnreachableError("The octave field reports no box") + + self._hand.click_at( + Point( + x=round(box.x + box.width - box.height * HALF), + y=round(box.y + box.height * HALF), + ) + ) + + +def _theme_color(item: Item) -> Optional[Tuple[float, ...]]: + """The text color the theme bound to ``item`` sets. Runs on the render thread.""" + theme = dpg.get_item_info(item)["theme"] + return read_theme_text_color(theme) if theme is not None else None + + +def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]: + """The pattern row and the column the caret's box stands in, while it shows one. Runs on the render thread. + + The caret is a box drawn over the grid, and a table reports a highlighted column for every cell + in it, so the box is what says where the caret stands. A cell spans its three slots. + """ + middle = _caret_middle() + if middle is None: + return None + + x, y = middle + for row, table_index in _frame_rows().items(): + number = read_item(_number(table_index)) + if number.rect is None or not number.visible or not number.rect.y <= y < number.rect.y + number.rect.height: + continue + + for channel in CHANNEL_AXIS: + first = read_item(_slot(table_index, channel, SUBCOLUMNS[0])).rect + last = read_item(_slot(table_index, channel, SUBCOLUMNS[-1])).rect + if first is not None and last is not None and first.x <= x < last.x + last.width: + return row, channel + + return None + + +def _caret_middle() -> Optional[Tuple[float, float]]: + """The middle of the box the caret overlay draws, while it shows one. Runs on the render thread.""" + rectangle = CaretOverlay._rectangle # pylint: disable=protected-access + if rectangle is None or not dpg.does_item_exist(rectangle): + return None + + configuration = dpg.get_item_configuration(rectangle) + if not configuration.get("show"): + return None + + corner, far = configuration["pmin"], configuration["pmax"] + return (corner[0] + far[0]) * HALF, (corner[1] + far[1]) * HALF diff --git a/automation/views/waveform.py b/automation/views/waveform.py new file mode 100644 index 000000000..03da75731 --- /dev/null +++ b/automation/views/waveform.py @@ -0,0 +1,143 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.bridge import ONE_FRAME, Bridge +from automation.dearpygui.geometry import Point, Rect +from automation.dearpygui.hand import Hand +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.plots import settled_plot_pointer +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import ( + SUF_GRAPH_PLOT, + SUF_GRAPH_X_AXIS, + SUF_GRAPH_Y_AXIS, + SUF_WAVEFORM_OVERLAY, + SUF_WAVEFORM_POSITION_INDICATOR, +) + +LINE_SERIES_TYPE: Final[str] = "mvAppItemType::mvLineSeries" +FIRST_POINT: Final[int] = 0 +Y_VALUES: Final[int] = 1 +MIDDLE: Final[float] = 0.5 +SETTLING_SECONDS: Final[float] = 10.0 + + +class MissingPlotError(AssertionError): + """Raised when a scenario reaches for a waveform the screen draws nowhere.""" + + +class Waveform: + """A waveform graph: where it stands, what it draws, and the cursor marking the sample that plays. + + Positions along the graph are read in samples, which is what the graph's x axis counts. A + gesture names a point by the fraction of the plot's width it lies at, halfway down the plot. + """ + + def __init__( + self, + bridge: Bridge, + hand: Hand, + tag: str, + ) -> None: + self._bridge = bridge + self._hand = hand + self._tag = tag + self.plot = compose_tag(tag, SUF_GRAPH_PLOT) + self.x_axis = compose_tag(tag, SUF_GRAPH_X_AXIS) + self.y_axis = compose_tag(tag, SUF_GRAPH_Y_AXIS) + self.cursor_line = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) + self.overlay = compose_tag(tag, SUF_WAVEFORM_OVERLAY) + + def point(self, fraction: float) -> Point: + """The viewport point ``fraction`` of the way across the plot, halfway down it.""" + box = self._box() + return Point( + x=round(box.x + box.width * fraction), + y=round(box.y + box.height * MIDDLE), + ) + + def sample_under(self, fraction: float) -> float: + """Rests the pointer ``fraction`` of the way across the plot and reads the sample the plot puts under it. + + Raises: + ExpectationError: If the plot never reports the pointer resting on it. + """ + self._hand.move_to(self.point(fraction)) + sample, _ = settled_plot_pointer(self._bridge, self.plot, timeout=SETTLING_SECONDS) + return sample + + def cursor(self) -> Optional[float]: + """The sample the cursor marks, or ``None`` while no cursor stands on the plot.""" + return self._bridge.ask(lambda: read_cursor(self.cursor_line)) + + def limits(self) -> Tuple[float, float]: + """The first and the last sample the plot shows.""" + low, high = self._bridge.ask(lambda: dpg.get_axis_limits(self.x_axis)) + return float(low), float(high) + + def series(self) -> Tuple[str, ...]: + """The tags of the lines the plot draws, in the order it draws them, the last on top.""" + + def read() -> Tuple[str, ...]: + return tuple( + str(dpg.get_item_alias(child)) + for child in dpg.get_item_children(self.y_axis, 1) + if dpg.get_item_info(child)["type"] == LINE_SERIES_TYPE + ) + + return self._bridge.ask(read) + + def series_tag(self, layer: str) -> str: + """The tag of the line the plot draws for ``layer``.""" + return compose_tag(self.y_axis, layer) + + def drawn(self, layer: str) -> List[float]: + """The heights the plot draws ``layer`` at, one per point along it.""" + return [float(value) for value in self._bridge.ask(lambda: dpg.get_value(self.series_tag(layer)))[Y_VALUES]] + + def zoom_in(self, fraction: float, notches: int) -> None: + """Turns the wheel up ``notches`` notches ``fraction`` of the way across the plot, narrowing what it shows.""" + self._hand.wheel_at(self.point(fraction), -notches) + + def click(self, fraction: float) -> None: + """Clicks the plot ``fraction`` of the way across it.""" + self._hand.click_at(self.point(fraction)) + + def double_click(self, fraction: float) -> None: + """Double-clicks the plot ``fraction`` of the way across it.""" + self._hand.double_click_at(self.point(fraction)) + + def triple_click(self, fraction: float) -> None: + """Triple-clicks the plot ``fraction`` of the way across it.""" + self._hand.triple_click_at(self.point(fraction)) + + def drag(self, start: float, end: float) -> None: + """Drags across the plot from ``start`` to ``end``, each a fraction of its width.""" + self._hand.drag(self.point(start), self.point(end)) + + def _box(self) -> Rect: + """The plot's box once it stands where it stood a frame before, as a layout settling after a load leaves it.""" + settled = self._bridge.expect( + self._two_frames_of_box, + lambda boxes: boxes[0] is not None and boxes[0] == boxes[1], + description=f"the plot '{self.plot}' standing still", + timeout=SETTLING_SECONDS, + )[0] + if settled is None: + raise MissingPlotError(f"The plot '{self.plot}' reports no box") + + return settled + + def _two_frames_of_box(self) -> Tuple[Optional[Rect], Optional[Rect]]: + first = self._bridge.ask(lambda: read_item(self.plot).rect) + self._bridge.frames(ONE_FRAME) + return first, self._bridge.ask(lambda: read_item(self.plot).rect) + + +def read_cursor(cursor_line: str) -> Optional[float]: + """The sample ``cursor_line`` marks in this frame, or ``None`` while it stands hidden. Runs on the render thread.""" + if not read_item(cursor_line).shown: + return None + + return float(dpg.get_value(cursor_line)[FIRST_POINT][FIRST_POINT]) diff --git a/tests/unit/sampletones_synthesis/envelopes/__init__.py b/automation/vocabulary/__init__.py similarity index 100% rename from tests/unit/sampletones_synthesis/envelopes/__init__.py rename to automation/vocabulary/__init__.py diff --git a/automation/vocabulary/converter.py b/automation/vocabulary/converter.py new file mode 100644 index 000000000..7bf3ff04e --- /dev/null +++ b/automation/vocabulary/converter.py @@ -0,0 +1,9 @@ +from typing import Final + +FOLDER_ROW: Final[str] = "global.stems.template.folder_row" +CONVERT_ONE: Final[str] = "main.converter.template.convert_recording" +CONVERT_SEVERAL: Final[str] = "main.converter.template.convert_recordings" +CONVERT_NOTHING: Final[str] = "main.converter.label.convert_button" +CANCEL_RUN: Final[str] = "main.converter.label.cancel_button" +REMOVE_RECORDING: Final[str] = "main.converter.label.context_remove_stem" +SCAN_PROGRESS: Final[str] = "main.converter.template.scan_progress" diff --git a/automation/vocabulary/dialogs.py b/automation/vocabulary/dialogs.py new file mode 100644 index 000000000..8de61c7a1 --- /dev/null +++ b/automation/vocabulary/dialogs.py @@ -0,0 +1,15 @@ +from typing import Final + +SAVE: Final[str] = "global.dialog.label.save" +DISCARD: Final[str] = "global.dialog.label.discard" +CANCEL: Final[str] = "global.dialog.label.cancel" +CLOSE: Final[str] = "global.dialog.label.close" +LOAD_TITLE: Final[str] = "global.dialog.title.load_unsaved_reconstruction" +LOAD_MESSAGE: Final[str] = "global.dialog.message.load_unsaved_reconstruction" +CLOSE_TITLE: Final[str] = "global.dialog.title.close_unsaved_reconstruction" +CLOSE_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_reconstruction" +EDIT_TITLE: Final[str] = "global.dialog.title.edit_voice_unsaved_reconstruction" +EDIT_MESSAGE: Final[str] = "global.dialog.message.edit_voice_unsaved_reconstruction" +EXIT_RECONSTRUCTION_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_reconstruction" +EXIT_PROJECT_MESSAGE: Final[str] = "global.dialog.message.exit_unsaved_project" +CLOSE_PROJECT_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_project" diff --git a/automation/vocabulary/exports.py b/automation/vocabulary/exports.py new file mode 100644 index 000000000..5f5858c25 --- /dev/null +++ b/automation/vocabulary/exports.py @@ -0,0 +1,5 @@ +from typing import Final + +EXPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.context_export_instrument" +NSF_TYPE: Final[str] = "global.dialog.filter.nsf" +NSF_EXPORTED: Final[str] = "global.dialog.message.nsf_project_exported_successfully" diff --git a/automation/vocabulary/instruments.py b/automation/vocabulary/instruments.py new file mode 100644 index 000000000..e6e1c52db --- /dev/null +++ b/automation/vocabulary/instruments.py @@ -0,0 +1,3 @@ +from typing import Final + +TOO_LONG: Final[str] = "reconstructions.instruments.message.status_sequence_too_long" diff --git a/automation/vocabulary/libraries.py b/automation/vocabulary/libraries.py new file mode 100644 index 000000000..371b877ff --- /dev/null +++ b/automation/vocabulary/libraries.py @@ -0,0 +1,3 @@ +from typing import Final + +LIBRARY_LOADED: Final[str] = "instructions.library.template.library_loaded_template" diff --git a/automation/vocabulary/playback.py b/automation/vocabulary/playback.py new file mode 100644 index 000000000..7361bedc0 --- /dev/null +++ b/automation/vocabulary/playback.py @@ -0,0 +1,5 @@ +from typing import Final + +PAUSE: Final[str] = "global.menu.label.item_playback_pause" +PLAY: Final[str] = "global.menu.label.item_playback_play" +RESUME: Final[str] = "global.menu.label.item_playback_resume" diff --git a/automation/vocabulary/settings.py b/automation/vocabulary/settings.py new file mode 100644 index 000000000..2594f5111 --- /dev/null +++ b/automation/vocabulary/settings.py @@ -0,0 +1,3 @@ +from typing import Final + +LISTENING: Final[str] = "settings.keybindings.message.capturing" diff --git a/tests/unit/sampletones_synthesis/filters/__init__.py b/automation/worlds/__init__.py similarity index 100% rename from tests/unit/sampletones_synthesis/filters/__init__.py rename to automation/worlds/__init__.py diff --git a/automation/worlds/home.py b/automation/worlds/home.py new file mode 100644 index 000000000..946102e12 --- /dev/null +++ b/automation/worlds/home.py @@ -0,0 +1,97 @@ +import os +from dataclasses import dataclass +from typing import Optional, Protocol, Tuple + +from automation.environment import screen_size +from sampletones_application.config.managers.application import ( + ApplicationConfigManager, +) +from sampletones_application.config.managers.state import ( + ApplicationStateManager, +) +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ( + ApplicationConfig, +) +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH + + +class HomeFile(Protocol): + """Something a scenario's home holds before the application starts: a recording, a document, a library.""" + + def write(self) -> None: + """Writes it under the home of the scenario's process.""" + + +@dataclass(frozen=True) +class World: + """What a scenario's home holds before the application draws its first frame. + + Each file is written by the same model and the same manager the application reads it with, so + a home seeded here is a home a previous run could have left. + + Attributes: + state: The session the application restores, or ``None`` for a home no run has left one in. + application_config: The application's settings, or ``None`` for a home holding none. + config: The reconstruction settings in the documents folder, or ``None`` for a home holding none. + files: Everything else the home holds. + """ + + state: Optional[ApplicationState] + application_config: Optional[ApplicationConfig] + config: Optional[Config] + files: Tuple[HomeFile, ...] + + def write(self, profile: UserProfile) -> None: + """Lays the world out in the home ``profile`` reads its settings from.""" + if self.state is not None: + state_manager = ApplicationStateManager(profile.state) + state_manager.state = self.state + state_manager.save() + + if self.application_config is not None: + config_manager = ApplicationConfigManager(profile.config) + config_manager.config = self.application_config + config_manager.save() + + if self.config is not None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + self.config.save(CONFIG_PATH) + + for file in self.files: + file.write() + + +def lived_in_world() -> World: + """The home of a user who ran the application once: a session left behind, and nothing else.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=(), + ) + + +def screen_filling_state() -> ApplicationState: + """A session whose window spans the scenario's screen, which the application fits within its margins.""" + size = screen_size(os.environ) + return ApplicationState( + viewport=ViewportState( + width=size.width, + height=size.height, + x=0, + y=0, + ) + ) + + +def one_worker_config() -> Config: + """Reconstruction settings running one job at a time, which keeps a scenario's run on a worker of its + own. + """ + config = Config() + general = config.general.model_copy(update={"max_workers": 1}) + return config.model_copy(update={"general": general}) diff --git a/automation/written.py b/automation/written.py new file mode 100644 index 000000000..81e07f7a8 --- /dev/null +++ b/automation/written.py @@ -0,0 +1,31 @@ +from sampletones_application.config.managers.application import ( + ApplicationConfigManager, +) +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.state import ( + ApplicationStateManager, +) +from sampletones_application.config.profile import UserProfile +from sampletones_application.config.session.application.config import ( + ApplicationConfig, +) +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH + + +def written_state() -> ApplicationState: + """The session the application left in its home, read by the manager that restores it at the next start.""" + return ApplicationStateManager(UserProfile.user().state).state + + +def written_application_config() -> ApplicationConfig: + """The application settings left in the home, read by the manager the next start reads them with.""" + return ApplicationConfigManager(UserProfile.user().config).config + + +def written_config() -> Config: + """The reconstruction settings left in the documents folder, read by the manager the next start reads + them with. + """ + return ConfigManager(CONFIG_PATH).config 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..4af5a007e 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,38 +1,22 @@ # Python API -This page is for using _SampleToNES_ as a library from your own Python code. Everything you need is re-exported from the top-level `sampletones` package — its facade — so `from sampletones import ...` is the whole public surface. Consult this page when you want to render instructions, generate a library, or run and export a reconstruction outside the application. +This page is for using _SampleToNES_ as a library in your own Python code. Use it to render instructions, generate a library, or run and export a reconstruction outside the application. -A few lower-level helpers used in the examples (`write_wave`, `generate_library`) are not part of the facade; they are imported from `sampletones_core` with their full path, as shown. +Names come from two packages: -## Public surface +- The names in the table below come from `sampletones`: `from sampletones import ...`. +- The examples also use a few helpers from `sampletones_core` that are not in the table: `write_wave`, `rendered_mix`, `ensure_library`, `DEFAULT_CHANNELS`, and the FamiTracker instrument writers. Import them with the full path each example shows. -```python -from sampletones import ( - Config, - Window, - InstructionLibrary, - Reconstruction, - Reconstructor, - GeneratorName, - Generator, - PulseGenerator, - TriangleGenerator, - NoiseGenerator, - Instruction, - PulseInstruction, - TriangleInstruction, - NoiseInstruction, -) -``` +## Public surface | Name | Purpose | | --- | --- | | `Config` | generation configuration; build it with `Config.load(path)` or `Config.default()` | | `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` | +| `Reconstructor` | runs a reconstruction: `Reconstructor(config, channels)("sample.wav")` | +| `Reconstruction` | the result of a reconstruction — its 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 | @@ -48,24 +32,18 @@ The package version is available as `sampletones.__version__`. from sampletones import Config, PulseGenerator, PulseInstruction from sampletones_core.audio.io import write_wave -# Load configuration config = Config.load("config.json") -# Prepare generator and instruction generator = PulseGenerator(config) instruction = PulseInstruction(on=True, pitch=55, volume=7, duty_cycle=2) - -# Generate waveform audio = generator(instruction) -# Save audio file -sample_rate = config.sample_rate -write_wave("pulse.wav", sample_rate, audio) +write_wave("pulse.wav", config.sample_rate, audio) ``` The output is a single `G2` square wave one frame long. -Each generator keeps an oscillator phase and clock. By default a call renders a standalone waveform and leaves that state where it was; pass `save=True` to advance it into the next call, so a sequence of instructions renders as one continuous signal: +Each generator keeps an oscillator phase and clock. A call renders a standalone waveform and leaves that state where it was. Pass `save=True` to advance the state into the next call, so a sequence of instructions renders as one continuous signal: ```python audio = generator(instruction, save=True) # advances the generator state @@ -73,17 +51,17 @@ audio = generator(instruction, save=True) # advances the generator state ### Generate an instruction library -A reconstruction searches an [instruction library](../formats/instruction-libraries.md) built for its configuration, so the library must exist first. Generate it once for a given config: +A reconstruction searches an [instruction library](../formats/instruction-libraries.md) built for its configuration by this version of _SampleToNES_, so prepare the library first: ```python from sampletones import Config -from sampletones_core.scripts.library import generate_library +from sampletones_core.headless.library import ensure_library config = Config.load("config.json") -generate_library(config) # renders every instruction and writes the .ins library +ensure_library(config) # builds the .ins library when it is missing or another version built it ``` -The same step is reached from the application's _Instructions_ tab, or on the command line with `sampletones --generate --config config.json`. +The application's _Instructions_ tab and `sampletones library --config config.json` do the same. ### Reconstruct a sample @@ -92,25 +70,22 @@ With a library in place for the configuration: ```python from sampletones import Config, Reconstructor from sampletones_core.audio.io import write_wave +from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.reconstructions.reconstruction.renders import rendered_mix -# Load configuration config = Config.load("config.json") -# Prepare the reconstructor -reconstructor = Reconstructor(config) +# The channels the run may use +reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS)) -# Reconstruct an audio file and save the reconstruction reconstruction = reconstructor("sample.wav") reconstruction.save("reconstruction.stn") - -# Save the reconstruction waveform -sample_rate = config.sample_rate -write_wave("reconstruction.wav", sample_rate, reconstruction.approximation) +write_wave("reconstruction.wav", config.sample_rate, rendered_mix(reconstruction)) ``` ### 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 per-channel instructions and config: ```python from sampletones import Reconstruction @@ -120,15 +95,24 @@ 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 [envelopes](../formats/famitracker.md#b-the-2a03-instrument) of each channel. `build_instrument` turns one channel's envelopes into a FamiTracker instrument, and `write_fti` saves it as an `.fti` file. `repitched` says whether a module's transpose rows reach the instrument, which a standalone file never has: ```python from sampletones import Reconstruction +from sampletones_core.formats.famitracker.builder import build_instrument +from sampletones_core.formats.famitracker.instrument import write_fti +from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX reconstruction = Reconstruction.load("reconstruction.stn") -for name, features in reconstruction.export().items(): - features.save(f"{name}.fti", instrument_name=str(name)) +for channel, features in reconstruction.export().items(): + instrument = build_instrument( + STANDALONE_INSTRUMENT_INDEX, + channel.value, + features, + repitched=False + ) + write_fti(f"{channel.value}.fti", instrument) ``` -This writes one `.fti` per channel. A complete FamiTracker `.ftm` module is assembled from a project in the application, not from a single reconstruction — see [FamiTracker formats](../formats/famitracker.md). +This writes one `.fti` per channel, named after the channel. A `.ftm` module comes from a project in the application. See [FamiTracker formats](../formats/famitracker.md). diff --git a/docs/concepts/calibration.md b/docs/concepts/calibration.md deleted file mode 100644 index a74453795..000000000 --- a/docs/concepts/calibration.md +++ /dev/null @@ -1,66 +0,0 @@ -# Calibration - -The [reconstruction criterion](reconstruction.md) is full of tunable choices — the -spectrum method, gamma, the perceptual exponent on the loudness curve, the -spectral/temporal blend, the Viterbi transition weights — and their best values are -an empirical question. **Calibration** answers it with a repeatable experiment: -reconstruct a fixed probe corpus under several candidate configurations and let -independent judges score the results, so configuration decisions rest on measured -quality and targeted listening. The harness lives in `sampletones_core.calibration` -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] -``` - -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 + -triangle + noise), so every run reconstructs with an explicitly chosen channel -set and results stay comparable across machines. - -It has three moving parts: - -- **A synthetic corpus** (`calibration.corpus`) of short, deterministic probe - signals in six categories: steady tones across the pitch range, pulse timbres - of several duty cycles, white and dark noise, tone-plus-noise mixes, - percussive transients (snare, kick, pluck) and a crescendo probing the dynamic - range. Each probe is a `sampletones_synthesis` voice — oscillators, envelopes - and filters composed from one shared, exactly-rendered configuration - vocabulary — built from the probe families in - `sampletones_config/calibration/corpus.yaml`. Each category isolates one kind of decision the criterion must get - right — pitch, timbre, noise balance, attack sharpness, level tracking — and - the fixed seed makes every run bit-identical, so scores are comparable across - runs and code changes. -- **Variants** (`calibration.runner`): the requested knob values are swept as a - cartesian product over a base configuration, each combination becoming a - labeled, complete configuration. Missing instruction libraries are generated - on the fly. Every corpus item is reconstructed under every variant, and the - approximation is compared against the preprocessed original on the common - scale set by the working level (see [Reconstruction](reconstruction.md)). -- **Referees** (`calibration.referee`): a referee is a full-reference audio - distance — zero for identical signals, growing with audible difference. - Referees judge in their own representation, independent of the criterion under - test; an outside yardstick keeps the comparison honest when the criterion - itself is what changes between variants. The built-in referee measures - log-spectral distance over ERB-spaced bands at three STFT resolutions - simultaneously, covering the time–frequency trade-off that the criterion - resolves with a single frame length. Band energies are floored at a fixed - audibility range below the reference's loudest band, so the score reflects - audible content and holds steady under a common gain. Its tuning is a - `RefereeConfig` loaded from `sampletones_config/calibration/referee.yaml`. - When the [zimtohrli](https://github.com/google/zimtohrli) binary is installed - it joins automatically as a second, psychoacoustic referee. - -Each run writes a timestamped directory containing the corpus WAVs, `report.csv` -(one row per variant × item × referee) and `report.md` — a per-referee pivot -with one row per variant and one column per corpus category, plus the overall -mean. Lower scores mean closer reconstructions, and the numbers rank variants -relative to one another within a run. The built-in referee measures per-band -energy agreement, which makes it most reliable for timbre, noise-balance and -level questions; pitch accuracy is best arbitrated by the external referee or by -ear. The intended workflow is therefore: sweep, read the pivot, shortlist the -contenders, and listen to those. diff --git a/docs/concepts/compression.md b/docs/concepts/compression.md new file mode 100644 index 000000000..a4714ab3c --- /dev/null +++ b/docs/concepts/compression.md @@ -0,0 +1,281 @@ +# Song compression + +This document explains the ideas behind fitting a whole song into the space an NES program has for it. +Read it before changing the encoder's scheme, or when you need to know what a layer earns. The layout +the encoder writes is in [NSF export](../formats/nsf.md). The package that implements it, with the driver that reads it back, is described 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. Every +byte the song takes is a byte of cartridge space. Compression is part of the format. + +The scheme works in layers, and each layer can be switched off on its own so that what it saves can be +measured ([section 6](#6-what-it-achieves)). 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](../glossary.md#channel) has a full set of register values. Written out plainly, that is 11 +bytes a tick: three each for the two pulse channels and the triangle, two for the noise. + +At 60 ticks a second, 11 bytes a tick fills the space behind the driver in **48 seconds**. A song of three +minutes needs 118800 bytes, and the console has about 32000. So either songs stay under a minute, or the +stream is stored in a form the driver can unpack as it plays. + +The content in those bytes is far smaller than the bytes themselves. What a tick says is a volume, a duty +cycle, a pitch and a noise period, roughly five bytes' worth even before anything repeats. And a great +deal repeats. A channel resting through a passage writes the same three bytes over and over. A song built +by playing the same drum sample at many rows writes that sample's envelopes once per row. + +## 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 hides the repetition: three unrelated series +interleaved turn over at every tick even when each of them is nearly constant. + +The first thing the encoder does is separate them. Each register becomes a **plane**: one byte per tick, +the whole song long. 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. A plane standing at the value it is seeded to throughout is +left out of the block altogether, such as a bend a channel never makes or the control byte of a channel +that never sounds. + +This one change is most of the win. Split into planes and coded, the three-minute arrangement of +[section 6](#6-what-it-achieves) falls from 11 bytes a tick to 3.5. + +### 2.2 Pitches instead of dividers + +A tone channel names a pitch by the [divider](../glossary.md#divider) the hardware counts down from. The +divider 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 two other things. The **pitch index** is how far the +frame's note sits above the lowest pitch the table covers. The **bend** is the divider steps the tick +stands away from that note's own divider. The song block carries a table the driver resolves the index +through, and the driver adds the bend to the result. A pulse channel is therefore three planes. The +triangle is two: it sounds at one level, so its value plane carries the pitch and names silence with an +index no pitch uses. A bend is counted from the note, so it keeps the same bytes wherever a row transposes +the note to. Only a bend past the signed byte is counted from the pitch lying nearest the divider. + +The bend plane has a value only where a note bends. The value plane's top bit flags each bent note from +its first bent tick to its last. The bend plane holds those ticks' steps alone, so it runs on a clock of +its own. A note played straight costs it nothing, and a channel that never bends leaves it out of the +block. A loop re-enters it at the value its flags have reached, which is a boundary like any other. + +The saving of the planes comes from the triangle, whose value plane names silence, so its control byte +leaves the block. What the index earns is that **a pitch index can be +transposed and a divider cannot.** The same figure played at five pitches is five unrelated byte +sequences in divider space. In index space it is one sequence and five offsets, and its bend is the same +bytes throughout. That turns a repeated sample into a single dictionary entry +([section 5](#5-the-dictionary)). + +### 2.3 A value and the ticks it lasts + +A register rarely reads every bit of the byte written to it. A pulse control byte holds a duty cycle and a +volume around two bits the hardware wants set. A noise control byte holds a volume under the same two. A +noise period byte holds a mode bit above three the register reads nothing from. Those spare bits carry no +sound, and a long reconstruction spends most of its planes repeating one value. + +So a plane states the value and the ticks it lasts in the same byte: the value in the bits the register +reads, the count in the bits it ignores. That byte is a **symbol**, and a run costs one byte however long +it lasts, up to the count the plane's own spare bits reach. The driver masks a symbol to the register byte +and ors in the bits the hardware fixes, and counting one repeat off is a subtraction. + +A plane whose register reads all eight bits keeps a symbol a tick, so both kinds read the same way and the +block states no division of its own. **Every count in the token language counts symbols**, and a symbol +covers the ticks its own count states. + +## 3. The token language + +Each plane is written as a sequence of **tokens**, and the driver reads them forward as the song plays. A +token says one of three things, and the opcode byte's top two bits say which: + +- **Hold** keeps the value the plane reached, for up to `MAX_HOLD_TICKS` symbols. It takes one byte. +- **Literal** takes the next few bytes, one symbol each, up to `MAX_LITERAL_BYTES` of them. It takes one + byte plus the values. +- **Phrase** plays entry *p* of the dictionary, for up to `MAX_PHRASE_TICKS` symbols, optionally with + every value shifted. It takes one byte where it plays the count the phrase states, and a count byte more + where it states its own. A full id byte and a shift add a byte each. + +Literals alone can write any plane, so the codec always has an answer. Holds and phrases make that answer +short. + +Three 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 *symbols* it covers, and +that may run past the phrase's last value. Past the end, the plane holds that value onward. A note does +the same when its envelope has finished and the note is still sounding. One dictionary entry therefore +serves the same figure however long it is held. A count shorter than the body cuts the note off, as 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 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. + +**A phrase may state its own count.** A figure played at one length throughout states that length once, in +the phrase's own entry, and every token playing it there names the phrase alone. The bit that says so +comes out of the opcode's id field, so a phrase opcode names 31 ids outright where a hold counts to 64. +The phrases a song leans on hardest take those ids, and the rest name themselves in a further byte. + +## 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. + +The encoder therefore searches for the cheapest reading. The plane becomes a graph. Each symbol is a node. +Each token that could start there is an edge to the symbol 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.** The weights are +bytes, so the search optimizes the quantity that has to fit in the program area. + +### 4.1 The edges + +From each symbol, the encoder offers: + +- a **hold**, where the value repeats the one before it. It runs as far as the repeated run does, up to + `MAX_HOLD_TICKS` symbols. +- a **literal**, reaching this symbol from the cheapest start within the last `MAX_LITERAL_BYTES` symbols. +- a **phrase**, one edge per dictionary entry the plane plays from here. It covers as many symbols as the + plane agrees with the entry, plus however long the entry's last value carries. + +A literal can start at any of the last `MAX_LITERAL_BYTES` symbols, and checking every start would make +the parse quadratic. A literal costs its opcode plus its bytes, whatever its length, so a start that a +later start beats stays beaten. The encoder keeps the live starts in order with the best at the front, and +it prices all of a plane's literals in one pass. + +### 4.2 Why the search beats taking the longest match + +The obvious alternative takes the longest phrase that matches at each symbol, otherwise a hold, otherwise +a literal. It goes wrong constantly. Taking a 40-symbol phrase for two bytes looks better than taking a +30-symbol one, until stopping at 30 would have let the next 60 symbols be a single hold. Costs also +depend on the dictionary: the same phrase is two bytes with a cheap id and four with an escaped id and a +shift. The search weighs those against each other, and a rule of thumb cannot. + +### 4.3 Where a song comes round + +A song that repeats re-enters its streams partway through and not at the beginning. 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 symbol, and a token, on every plane, and that token has to +state its values outright instead of leaning 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 cannot start there, because a hold 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. Each phrase is stored once in the song block and named by tokens wherever it occurs. + +A phrase's position in the table is its id, and the ids are not equally priced. The ids below the escape +value fit inside the opcode byte, and the rest need a byte of their own. The order of the table is +therefore part of the encoding, and the phrases a song relies on most 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 and +need no discovery**. Each sample slice offers the two planes it writes, at the pitch and level it was +reconstructed at. Every row playing that sample becomes a token naming those entries with the shift the +row asks for. No search is involved. Most of a project's compression comes from here: on the arrangement +of [section 6](#6-what-it-achieves), the instruments alone take it from 2.980 bytes a tick to 1.717, and +transposition to 1.447. + +A project can offer more phrases than the table holds, up to `MAX_PHRASE_IDS`. When it does, each phrase +is weighed by what it would spare the song, and the ones that pay most keep their place. The export says +so in the log, instead of dropping whichever sample happened to be listed last. + +### 5.2 The search fills the rest + +The instruments cover the notes and leave 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 `MIN_CANDIDATE_LENGTH` to `MAX_CANDIDATE_LENGTH` symbols that appears in them. It groups the +runs by **shape**: the step from each value to the next. A figure played at five pitches then 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** and not against raw length keeps the search honest. A run of 40 +identical values looks large by length and is worth nothing, because a hold already covers it for one +byte. Only spans the parse is paying for can pay a candidate back. + +The best few candidates of each round are then confirmed the expensive way. The whole song is parsed again +with each one added, and the round keeps whichever shrank the total. The estimate ranks, and 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. 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 phrases 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. The table is therefore rebuilt with the busiest phrases first and the song is parsed again. The +process repeats until it settles, for at most `SETTLING_ROUNDS` rounds. + +## 6. What it achieves + +Measured by `uv run sampletones codec report` over its three-minute arrangement of 10800 ticks, counting +the dictionary, the streams and the pitch table. Each row builds on the one above it: + +| what is stored | bytes per tick | ratio | ticks that fit | +|---|---|---|---| +| a record per tick per channel | 11.000 | 1.00 | 2907 | +| a plane per register, in holds and literals | 3.517 | 3.13 | 9092 | +| planes with a pitch index and a bend, repeats packed | 2.980 | 3.69 | 10731 | +| phrases from the instruments | 1.717 | 6.41 | 18750 | +| phrases played transposed | 1.447 | 7.60 | 22326 | +| phrases from the search as well | **0.874** | **12.59** | **37660** | + +The arrangement bends every note its first pulse channel plays, and that channel's bend plane carries +every one of them. A song played straight leaves its bend planes out of the block. The full scheme stores **37660 ticks, 10.5 +minutes at 60 Hz**, in the space where a record per tick reaches 48 seconds. Encoding happens once, where +the file is written. + +The format's constants are settled from a corpus of songs. The duty cycle stays in the control byte +because volume and duty turn over together, so a plane of its own pays two opcodes for what one covers +and gives up the repeat count its register's spare bits carry. The pitch index earns its place through +the transposition it makes possible, the fourth row of the table against the fifth. + +An export chooses how far down these layers it goes. Its **Level** names the layers read in order: + +- *None* spells every plane out as literals. +- *Held sounds* adds holds. +- *Samples* adds the phrases the project's samples seed, played transposed. +- *Full search* is the whole codec. + +A lighter level finishes sooner and takes more room. Every level plays the same song. + +## 7. Limitations + +- **The search reads a dense plane only so far.** A reconstruction whose planes turn over at nearly every + tick offers enormous numbers of candidates. A round gathers a fixed number of them, shared among the + planes by what each has to offer. A plane that spells out little is read whole. A dense one is read as + far as its share reaches, and the figures beyond that point go unsearched. It shows at lengths where the + song already exceeds the program area, so it costs a diagnosis and not a song. A dense export of two + minutes is stored materially larger than the same song at one. +- **The dictionary holds at most `MAX_PHRASE_IDS` phrases.** A project with enough samples fills it from + its instruments alone. The search then has no room left to work in, and further samples compete for + slots on measured value. +- **A phrase holds at most `MAX_PHRASE_LENGTH` values and a token covers at most `MAX_PHRASE_TICKS` + symbols.** 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. + Nothing exploits the correlation between a channel's own control and value planes. + +## Appendix — where the exact shape is written + +This page explains the scheme. The bytes themselves, with the opcodes, the operand each carries and the +bounds they impose, are in [NSF export](../formats/nsf.md). diff --git a/docs/concepts/instruction-library.md b/docs/concepts/instruction-library.md index 65aba3baa..fca6c8da6 100644 --- a/docs/concepts/instruction-library.md +++ b/docs/concepts/instruction-library.md @@ -1,66 +1,55 @@ # Instruction library -An instruction library is the catalogue 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 -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. +This page defines what an instruction library is and what keys it. Read it before changing how a library +is generated or how its key is built. [Reconstruction algorithms](reconstruction.md) describes the search +over the catalog. [Instruction libraries](../formats/instruction-libraries.md) documents the file. -## Why the library is precomputed +An instruction library is the catalog of NES sounds _SampleToNES_ searches when it reconstructs audio. It +has every instruction a channel can play: each combination of pitch, volume, timbre and on/off state. Each +entry has the waveform its instruction produces and a description of its frequency content. +Reconstruction searches this catalog. For each slice of the input, the engine looks for the entries whose +combined sound is closest to that slice. -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 -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. +## Why the library is precomputed -The library is also a first-class artifact in its own right: you can generate, -browse and audition one from the _Instructions_ tab without reconstructing -anything, which is a good way to hear what a given channel and configuration can -actually produce. +The number of distinct instructions is large but fixed, and the same candidates are compared against every +frame of every sample. The library renders each candidate's waveform and analyzes its spectrum once, up +front. That turns the per-frame work into a lookup instead of a re-synthesis. A library is built once for +a given configuration and reused across every reconstruction that shares it. ## Phase independence -Two recordings of the same note can look completely different sample-by-sample -depending on where in its cycle each one starts — their phase. To keep matching -about *what a sound is* rather than *when it happened to begin*, each candidate's -stored spectrum is computed as an average over many phase offsets. The result is -essentially phase-independent, so a candidate matches a frame on the shape of its -spectrum, not on an accident of alignment. +Two recordings of the same note can look completely different sample by sample, depending on where in its +cycle each one starts. That position is the phase. Each candidate's stored spectrum is an average over +many phase offsets, so a candidate matches a frame on the shape of its spectrum and not on where its cycle +begins. [The candidate catalog](reconstruction.md#31-the-candidate-catalog-library) describes how the +average is made. ## What a library is keyed by -A library depends on the configuration values that change the rendered waveforms -or the way their spectra are measured: +A library depends on the configuration values that change the rendered waveforms or the way their spectra +are measured: -* the **sample rate** and **NES frequency**, which together set the length of a - frame and so the length of each rendered waveform; -* the **spectrum method** (`fft`, `logfft` or `cqt`) and **transformation gamma**, - which set how each waveform's frequency content is measured and weighted. +* the **sample rate** and **NES frequency**, which together set the length of a frame and so the length + of each rendered waveform; +* the **spectrum method** (`fft`, `logfft` or `cqt`) and **transformation gamma**, which set how each + waveform's frequency content is measured and weighted. -These values form the library's key. Changing any of them describes a different -sound space, so it selects a different library — and generates a fresh one if -none exists yet. What the spectrum method and gamma actually do is covered in -[Reconstruction algorithms](reconstruction.md) (§3.2–3.3), because the target -audio is measured the same way; the library and the target always share one -representation so their spectra are directly comparable. +These values form the library's key. Changing any of them describes a different sound space, so it +selects a different library and generates a fresh one if none exists. The target audio is measured the +same way, so the library and the target always share one representation and their spectra are directly +comparable. [Spectrum methods](reconstruction.md#32-spectrum-methods-fft-log-fft-and-cqt) explains what the +spectrum method and gamma do. -## Generating and exploring +## Versions and rebuilding -Generate a library from the _Instructions_ tab, or on the command line with -`sampletones --generate`. Generation renders every instruction and stores its -waveform and spectrum; a configuration that has no library yet is also built -automatically the first time a reconstruction needs it. Regenerating a library -that already exists replaces it. +A library belongs to the version of _SampleToNES_ that built it. A library built by another version is +rebuilt in its place the first time a reconstruction needs it. A configuration with no library is built +the same way. Regenerating a library that already exists replaces it. +[Data compatibility](../development/release/compatibility.md) describes the version rule. -The _Instructions_ tab lists the instructions in a library and shows the selected -one's waveform and spectrum alongside a player, so a library doubles as a way to -explore the raw material a reconstruction is assembled from. +Building a library by hand is covered in the guide, under +[building a library yourself](../guide/converting.md#building-a-library-yourself). -On disk a library is a single `.ins` file whose name encodes its configuration -key; see [Instruction libraries](../formats/instruction-libraries.md) for the -file format. +On disk a library is a single `.ins` file whose name encodes its configuration key. See +[Instruction libraries](../formats/instruction-libraries.md) for the file format. diff --git a/docs/concepts/project.md b/docs/concepts/project.md index 6f9ddaa75..45c6ad87f 100644 --- a/docs/concepts/project.md +++ b/docs/concepts/project.md @@ -1,34 +1,33 @@ # 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 -[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. +This page says what a project is and what it holds. Read it before changing what a saved project +contains. [Projects](../formats/projects.md) documents the file. + +A project is a whole composition in _SampleToNES_: a song written for the four NES channels, with the +voices it is built from. A [reconstruction](reconstruction.md) is a single converted sound. A project has +many of them, the instruments you made beside them, and the arrangement that plays them all. An +entire piece lives in one file. ## What a project brings together -- the **samples** — the reconstructions you have imported, each a playable - instrument in the song; -- 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 - at, and the title, author, and comment that describe it. +- The [**voices**](../glossary.md#voice): the reconstructions you have imported and the instruments you + made. A [row](../glossary.md#row) can play any of them. +- The **song**: the [patterns](../glossary.md#pattern) written for each channel and the order they play + in. +- The **settings and info**: the tempo, speed and NES frequency the song plays at, and the title, author + and comment that describe it. -You create and edit all of this on the [Sequencer](../guide/sequencer.md) tab, and -export the finished piece as a FamiTracker [module](../formats/famitracker.md). +The [sequencer guide](../guide/sequencer.md) covers writing a project and exporting it. ## 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 -[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. +A project stores the reconstructions it uses inside its own file. It also stores each instrument there. +Moving or sharing the file therefore moves the whole composition: the arrangement and every sound it +needs. The stored reconstructions are +[detached](../formats/reconstructions.md#detached-reconstructions) from their source-audio paths, because +those paths mean nothing on another machine. The project opens the same wherever it goes. ## On disk -A project is saved as a `.stp` file — a small document describing the song and its -settings, alongside the embedded reconstructions. -[Projects](../formats/projects.md) documents that structure. +A project is saved as a `.stp` file: a document describing the song and its settings, with the embedded +reconstructions beside it. [Projects](../formats/projects.md) documents that structure. diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 05ebc6027..863e4e858 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -1,95 +1,93 @@ # Reconstruction algorithms -This document explains how _SampleToNES_ turns an arbitrary audio sample into a -*reconstruction* — a sequence of NES instructions that, when played back on the -console's sound hardware, approximates the original. It is written to be readable -without prior knowledge of the codebase, while pointing at the packages that -implement each part. +This document explains how _SampleToNES_ turns an audio sample into a *reconstruction*: a sequence of NES +instructions that approximates the original when it plays on the console's sound hardware. Read it to see +how a frame's sound is described, scored and chosen, and where each setting acts. -The tunable choices described here are set empirically; the experiment that -picks them is described in [Calibration](calibration.md). +The tunable choices described here are set empirically. [Calibration](../tools/calibration.md) describes +the experiment that sets them. ## 1. The problem -The NES sound chip (Ricoh 2A03 APU) can only produce a few simple, fixed -waveforms across four usable channels: - -- two **pulse** (square) channels — each with 4 duty cycles and 15 volume levels; -- one **triangle** channel — fixed shape and amplitude, pitch only; -- one **noise** channel — a pseudo-random LFSR generator with 16 periods, 15 - volume levels and a short/long mode. - -A program steers these channels by issuing *instructions* a few dozen times per -second (for example, *pulse 1: note A-4, volume 12, 50 % duty*). Approximating an -arbitrary sound this way produces a **reconstruction**: one instruction stream per -channel whose mixed, rendered output resembles the input as closely as the -hardware permits. By default a reconstruction uses one pulse channel, the triangle -and the noise; the second pulse can be enabled in the configuration. The channel -models live in `sampletones_core.generators` and the instruction value types in -`sampletones_core.instructions`. - -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 -waveforms, the combination of instructions whose mixed output best matches that +The NES sound chip ([Ricoh 2A03 APU](../glossary.md#2a03-apu)) produces a few simple, fixed waveforms +across four usable channels: + +- two [**pulse**](../glossary.md#pulse-square) (square) channels, each with 4 + [duty cycles](../glossary.md#duty-cycle) and 15 volume levels; +- one [**triangle**](../glossary.md#triangle) channel of fixed shape and amplitude, with pitch only; +- one [**noise**](../glossary.md#noise) channel: a pseudo-random [LFSR](../glossary.md#lfsr) generator + with 16 periods, 15 volume levels and a short/long mode. + +A program steers these channels by issuing *instructions* at the +[NES frequency](../glossary.md#nes-frequency), for example *pulse 1: note A-4, volume 12, 50 % duty*. +Approximating an arbitrary sound this way produces a **reconstruction**: one instruction stream per +channel whose mixed, rendered output resembles the input as closely as the hardware permits. By default a +reconstruction uses one pulse channel, the triangle and the noise. The second pulse can be switched on +when a recording's channels are chosen. + +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 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 **criterion** that scores how well a candidate matches the target + ([section 4](#4-scoring-a-candidate-the-criterion)), and +- a **selection strategy** that searches the catalog efficiently ([section 5](#5-choosing-instructions)). -Everything is compared in a perceptually-weighted **frequency** representation -rather than raw samples, because two sounds that are perceptually identical can -have very different waveforms depending on phase. +Everything is compared in a perceptually weighted **frequency** representation instead of raw samples, +because two sounds that are perceptually identical can have very different waveforms depending on phase. ## 2. The pipeline -`Reconstructor` (`sampletones_core.reconstructions.reconstructor`) carries the -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). -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 - (`sampletones_core.fft.fragment`); from here on each channel holds one - 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, - keeping each oscillator continuous across frames. -7. **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 -and playback. +A reconstruction runs through a fixed sequence of stages: + +1. **Load** the audio: mix to mono, resample, and optionally clean it up (normalize, quantize). Several + sources load together, so one scale drawn from the peak of their sum keeps them at the balance they + were captured in. The reconstruction records that scale. +2. **Set a working level.** Scale the whole signal so its typical frame plays at the level one channel + renders at full volume, which keeps quiet passages matchable + ([section 3.4](#34-the-working-level-coefficient)). +3. **Fragment** the signal into short, fixed-length frames. From here on each channel has one instruction + per frame. +4. **Describe each frame** by a spectral feature that captures its frequency content + ([section 3](#3-representing-a-frame)). +5. **Assign** every frame's channels to the sources. Each channel also gets the candidates it may sound + there, and each source is judged against its own audio by the criterion + ([section 5](#5-choosing-instructions) and [section 4](#4-scoring-a-candidate-the-criterion)). +6. **Decode** each channel's stream, reading its candidates across the whole recording + ([section 5](#5-choosing-instructions)). +7. **Refine** each chosen note onto the [divider](../glossary.md#divider) the recording's own fundamental + stands at ([section 6](#6-refining-the-pitch)). +8. **Render** the chosen instructions back into audio through the generators, keeping each oscillator + continuous across frames. +9. **Reassemble** the channels into the final approximation, and save it with the instruction streams as + a reconstruction. + +Stages 3–7 are where the algorithms described below live. The rest is preparation and playback. ## 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 -the corresponding spectral feature. Because NES waveforms are periodic, a -candidate's feature is computed as an average over many phase offsets, which makes -it essentially phase-independent — matching then compares spectral *shape* rather -than an accident of alignment. The library is keyed by the parameters that affect -it (sample rate, frame size, spectrum method, gamma, …) so a configuration change -produces a fresh library. See [Instruction library](instruction-library.md) for -the library as an artifact — how it is generated, explored and keyed. +Before any reconstruction, the program precomputes a **library**. For every possible instruction it +renders the waveform that instruction produces and stores the corresponding spectral feature. NES +waveforms are periodic, so a candidate's feature is computed from its power spectrum averaged over many +phase offsets. That makes it essentially phase-independent, and matching compares spectral *shape* and +not an accident of alignment. The library is keyed by the parameters that affect it (sample rate, frame +size, spectrum method, gamma, …), so a configuration change produces a fresh library. See +[Instruction library](instruction-library.md) for how it is generated and keyed. ### 3.2 Spectrum methods: FFT, log-FFT and CQT -The feature of a frame is a frequency *histogram* (`sampletones_core.structures`), -produced by one of three methods (`sampletones_core.fft.spectrum`, -configurable via `library.spectrum_method`): +The feature of a frame is a frequency *histogram*, produced by one of three methods +(`library.spectrum_method`). The figures below are for 44.1 kHz audio at a 60 Hz change +rate: | method | frequency axis | resolution | time support | |----------|---------------------------|-----------------------------------------|---------------------------| | `fft` | linear | uniform, `Δf ≈ sample_rate / N ≈ 27 Hz` | one short window (~37 ms) | | `logfft` | logarithmic, floored at `Δf` | the FFT's `Δf`, on a musical axis | one short window (~37 ms) | -| `cqt` | logarithmic (constant-Q) | constant *relative* (fine low end) | long for low notes (~300 ms) | +| `cqt` | logarithmic (constant-Q) | constant *relative* (fine low end) | long for low notes (~600 ms) | They sit at different points of the **time–frequency trade-off** (the Gabor limit: sharper frequency resolution requires a longer time window, and vice versa): @@ -106,12 +104,18 @@ sharper frequency resolution requires a longer time window, and vice versa): sharply in time. - **CQT** (constant-Q transform) places bins geometrically and gives every musical interval the same number of bins, so it resolves low pitches finely. - It is the default. + It is the default. Its lowest bin sits at the lowest note the chip sounds: the + triangle's, an octave below the pulse's, since the triangle steps through its wave + at half the pulse's rate. A bass line on the triangle is therefore read from its + fundamental, which is where the pitch of a bent note is read from too. + Bins above the pulse's lowest note are identical at either floor. The lower floor adds the + triangle's lowest octave, which a triangle bass needs, at the cost of conversion time and a larger + library. The price is time support: its low-frequency basis functions are long (hundreds of 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. + computes the CQT **once over the whole signal** with a hop of one frame, 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 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 @@ -122,7 +126,7 @@ by its energy gain). ### 3.3 The gamma transform Whatever the method, the raw power spectrum is mapped into a "feature space" by a -Yeo-Johnson-family transform (`sampletones_core.fft.transformer`) controlled by a +Yeo-Johnson-family transform controlled by a `transformation_gamma` in `[0, 100]`: - `gamma = 0` → identity: the feature is the power spectrum (the default); @@ -131,141 +135,248 @@ Yeo-Johnson-family transform (`sampletones_core.fft.transformer`) controlled by Intermediate values interpolate smoothly. Higher gamma compresses the dynamic range, emphasizing quiet spectral detail relative to loud peaks. The transform is applied identically to target and candidate features, so it re-weights the comparison rather -than changing what is represented. +than changing what is represented. Arithmetic on features — a library candidate's +average over phases, the mix of a frame's picks — is carried out on the power spectra +they describe and transformed afterward, so its result is the feature of that power +at every gamma. ### 3.4 The working level (coefficient) -A single **coefficient** scales the input before matching so that its typical -loudness lands in the amplitude range the NES channels span. It anchors to a -*robust* level — a high percentile of the per-frame peak amplitudes over the audible -frames (`active_frame_level` in `sampletones_core.audio`) — rather than to the single -loudest sample. Anchoring to the peak would let one transient (a kick, a click) -push the rest of the signal below the quietest note the hardware can play, leaving -most frames un-matchable; anchoring to a robust level keeps the bulk of the signal -within reach while a lone transient simply saturates to the loudest available note. +A single **coefficient** scales the input before matching, so its typical frame plays at the level one +channel renders at full volume. The typical frame is a *robust* level: a high percentile of the per-frame +RMS levels over the audible frames. A lone transient such as a kick or a click therefore saturates to the +loudest available note, while the bulk of the signal stays within reach of the quietest one. RMS measures +how much sound a frame carries. A channel's volume renders that quantity, whatever the waveform's crest. + +That level is brought to the full-scale RMS level of the quietest tone channel the setup covers. This is +the triangle, whose RMS level is its peak over √3. A pulse, which swings between two levels, takes over +when no triangle is covered, and the noise channel does for a noise-only setup. A steady tone then lands at +what a single tone channel renders whole, so one channel can answer it, and louder frames call on more +channels. ## 4. Scoring a candidate: the criterion -`Criterion` (`sampletones_core.reconstructions.criterion`) scores a candidate -against the target frame as a weighted sum of a spectral and a temporal term: +The **criterion** scores a candidate against the target frame as a weighted sum of a spectral and a +temporal term: ``` -cost = α · spectral + β · temporal (default α = 0.8, β = 0.2) +cost = α · spectral + β · temporal ``` -- **spectral** compares the two frequency features with a perceptually-weighted - distance, normalized by the target's own energy so the score is about *shape*. The - per-bin distance is configurable — squared error, absolute error, or (the default) - a **β-divergence** (a Kullback–Leibler-style measure that, for partials above the - spectral floor, penalizes leaving target energy uncovered more strongly than adding - energy beyond it). Bins are weighted by their span in auditory critical bands (the - ERB scale) times the K-weighting loudness curve (ITU-R BS.1770), so each bin counts - in proportion to the hearing resolution and loudness contribution it represents. -- **temporal** is the RMS difference between the target *waveform* and the candidate - rendered at its best phase against the target, normalized by the target frame's own - level. Evaluating it at the aligned phase makes it measure waveform *shape* — a - property the magnitude spectrum discards — while keeping the spectral/temporal - blend stable across frame loudness. - -A lower cost is a better match. The criterion evaluates many candidates at once and, -on machines with a GPU, runs on the array backend in `sampletones_shared`. +α and β are the `spectral_loss_weight` and `temporal_loss_weight` settings. + +- **spectral** compares the two frequency features with a perceptually weighted distance. The distance is + normalized by the target's own energy, so the score is about *shape*. The per-bin distance is + configurable: squared error, absolute error, or a **β-divergence**, which is the default. A + β-divergence is a Kullback–Leibler-style measure that penalizes leaving target energy uncovered more + strongly than adding energy beyond it. + + Both sides are measured above a floor set under the frame's loudest bin by the configured dynamic range + (`generation.metric.dynamic_range_decibels`). An addition therefore costs what it adds wherever it stays + audible beside what the frame sounds, such as quiet noise under a loud tone. A frame of noise costs what + a channel leaves out of it. A frame quieter than `generation.metric.silence_floor` is measured from that + level, which keeps a silent frame's score finite. + + Bins are weighted by their span in auditory critical bands (the ERB scale) times the K-weighting + loudness curve (ITU-R BS.1770). Each bin then counts in proportion to the hearing resolution and + loudness contribution it represents. +- **temporal** measures the target *waveform* against what the frame's channels are expected to render. It + is normalized by the target frame's own level, so the spectral/temporal blend holds across frame + loudness. A candidate whose frames repeat one waveform shape (a note, or noise whose register cycle fits + inside a frame) renders its waveform at its best phase against what the other channels leave of the + target. That makes the term measure waveform *shape*, a property the magnitude spectrum discards. A + candidate whose frames show different stretches of a pseudo-random sequence renders its mean level with + a spread about it. For waveforms expected to sum to `E` with a per-sample variance `V`, + `E mean((t − x)²) = mean((t − E)²) + V`, whose root normalizes as above. + +A lower cost is a better match. The criterion scores many candidates at once, and runs on the graphics +card where the machine has one. ## 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. Each is answered separately. -Both score candidates 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. +- **Ownership**: which channels a source holds in this frame. The assignment + ([section 5.1](#51-assigning-channels)) answers it. +- **The stream**: what a channel plays across the frames it holds. A decoder answers it, and the + `generation.decoder.selector` setting chooses the decoder. -### 5.1 Greedy (per-frame) +Both work from the same candidate scoring, the same criterion and the same library. -The greedy selector treats every frame independently: +A **source** is one recording in the conversion, a [stem](../glossary.md#stem). A conversion from a single +file has one source. A [pick](../glossary.md#pick), a [mix](../glossary.md#mix) and a [column](../glossary.md#column) are +defined in the glossary. -``` -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` -``` +The assignment leaves every channel in play a column per frame. The decoder reads those columns into one +candidate per frame. Each decoder says how wide a column it reads, and the assignment builds columns to +exactly that width. + +A candidate is scored by the frame's cost with it sounding beside the picks its source already holds in +that frame. The channel's silence is always among the candidates. The picks add up to a mix: their +phase-averaged power spectra add, and so do the waveforms they are expected to render and their variances. + +Scoring runs in two stages. First, every candidate of one channel's kind is added to the mix and ranked by +the phase-independent spectral term. Then the best `top_k`, together with the kind's silence, are +re-scored with the full criterion. + +A candidate whose frames repeat one shape adds its waveform. With `find_best_phase` on, the waveform is +aligned to what the mix leaves of the target. Otherwise the candidate's library sample is added from its +start. Either phase stands in for the one the generator reaches when the frame is rendered. A candidate +whose frames show different stretches of a sequence renders whatever stretch its channel has reached. It +therefore adds its mean level and its variance, whatever `find_best_phase` says. + +The scored candidates form the channel's column, best first, with silence ahead of an equal cost. `top_k` +sets how many of them a wide decoder reads. + +### 5.1 Assigning channels + +A frame is assigned one pick at a time, for as long as a source may still take a channel and a pick lowers +a frame's cost. Each round takes the (source, channel) pair whose best candidate lowers its source's cost +the most, weighted by the energy of the source's frame, and adds that candidate to the source's mix. + +When the picks end, every channel still free goes to the first sounding source that may hold it, headed by +silence. Then every held channel is scored once more with its source's other channels sounding. That last +pass lets a channel taken early fall silent where the later channels cover its sound, or sound where they +leave room. + +A frame one channel renders whole therefore sounds one channel. Once the triangle covers a sine, adding a +pulse or the noise raises the cost, and those channels hold their silence. + +A channel no pick took keeps its column, headed by its silence, so the decoder may still sound it where +the frames around ask for it. It counts against its source's count, so no decoded frame sounds more +channels than that count. + +A channel no source may hold is [**resting**](../glossary.md#resting). It plays its channel's null +instruction for that frame, which keeps every channel's stream in step with the frames it describes. + +A classic single-file conversion is one source covering every enabled channel. The one mix answers the +frame itself, and every channel in each frame the source sounds in is held, sounding or silent. Several +sources, a precedence hierarchy, a [drive](../glossary.md#drive) per source channel and a per-source +count of channels sounding at once are the general case, described in [Stems reconstruction](stems.md). +There each mix answers one source's own sound, so the channel a source wins carries that source's +material. + +### 5.2 Greedy decoding -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. +The greedy decoder plays each frame's best candidate, reading one candidate per column. Each frame is +decided by its own cost alone, which is fast and simple. The instruction streams follow each frame's match +wherever it leads. That is audible as jitter even where every individual frame is well matched. -### 5.2 Viterbi (continuity-aware) +### 5.3 Viterbi 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 -finds, per channel, the lowest-cost **path** through that lattice, where the path -cost combines: +The Viterbi decoder weighs a frame's candidates against the frames around them. It reads `top_k` +candidates per column, with the channel's silence among them, and forms a lattice of states over time. Per +channel, it finds the lowest-cost **path** through the lattice. 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. - -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 - -Once instructions are chosen, each one is rendered back through its generator -(`sampletones_core.generators`), which carries oscillator phase across frames so -there are no clicks at frame boundaries; an "off" instruction yields silence for that -channel and frame. The per-channel renderings are concatenated and summed into the -final approximation, and the `Reconstruction` keeps both the audio and the -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 - -- **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 - whose *useful* content spans a wider range than that (a long crescendo, a very - quiet passage under a loud one) cannot be fully captured: content far below the - working level falls under the quietest playable note and is rendered as silence. -- **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. - -## Appendix — key parameters and where things live - -Default configuration (44.1 kHz, 60 Hz change rate, channels pulse 1 + triangle + -noise): - -| parameter | default | notes | -|--------------------------|---------|----------------------------------------------------| -| frame length | 735 | `sample_rate / nes_frequency`, ~17 ms | -| spectrum method | `cqt` | `fft` / `logfft` / `cqt` | -| `transformation_gamma` | 0 | 0 = power spectrum, 100 = log | -| spectral / temporal weight | 0.8 / 0.2 | criterion blend | -| spectral distance | β-divergence | also `squared`, `absolute` | -| selector | Viterbi | `greedy` / `viterbi` | -| normalize / quantize | on / off | input preprocessing | - -Package map: - -| concern | package | -|---------------------------------|------------------------------------------------------| -| NES channel models | `sampletones_core.generators` | -| instruction value types | `sampletones_core.instructions` | -| windowing, spectra, features | `sampletones_core.fft` | -| candidate catalogue | `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` | -| criterion calibration | `sampletones_core.calibration` | -| analytic waveform synthesis | `sampletones_synthesis` | +- 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. + +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. Coming back on costs what any other on/off change costs. A frame the decoder +settles on a silent instruction is released to the resting stem, so the resting stem id and the silence +name the same frames. A channel decoded silent throughout is [standing by](../glossary.md#standing-by). + +## 6. Refining the pitch + +The catalog is built on the equal-tempered grid, so the matching places a frame no closer than the nearest +semitone. The hardware is finer: a note reaches a channel as an 11-bit [divider](../glossary.md#divider). +One step of that divider spans **0.85 cents at A-0, 4 cents at C-3 and 16 cents at C-5**. It reaches 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. Material that was never in A=440 equal temperament, which is most +recordings of most instruments, sits somewhere inside it. + +The **refinement** uses that room. It runs after the decoder has settled which note each frame plays and +before the frames are rendered. It works where the run asks: a stem entry names the channels it carries +toward its own recording, so one recording's bass line can land on its exact tuning while another's lead +keeps the grid. + +### 6.1 Reading rather than searching + +The refinement reads the pitch out of the recording. Searching for it fails on two counts. Against a +target whose timbre the candidate cannot match, the criterion answers a detune with a ripple of local +minima, so the lowest-cost divider lands far from the truth. And scoring an extra candidate per frame +costs the rendering and feature extraction the library exists to avoid. + +The reading comes from the transform instead, from the **phase** the constant-Q transform already computes +and the spectrum discards. A partial standing between two bin centers still advances its phase at its own +rate. Comparing that advance across two columns against the rate the bin itself turns at gives the +partial's frequency far more finely than the bins are spaced. The reading takes the first few harmonics of +the note the decoder chose. It weights each by the energy behind it and settles each against the +fundamental the harmonics below it agreed on. A harmonic counts where its partial stands within half a +semitone of where that fundamental puts it, the room a note owns, so a partial another voice sounds a bin +away stays out of the reading. The advance across two columns repeats every `sample_rate / hop` hertz +(60 Hz at the defaults), which from about 1 kHz up is narrower than a note's room. There a second reading, +of the advance over a sixteenth of a hop, names the repeat the first harmonic stands on, and the advance +across two columns keeps its precision inside it. This places the note far more finely than a divider step across the whole range. + +The reading also says how much of the frame stands behind it: the share of the column's energy its +harmonics hold, with every bin measured on the scale the features use. On that scale a bass takes the +share its level gives it in every register, so a melody over a low bass keeps its reading. One +threshold (`generation.refinement.confidence`) therefore 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. The generator answers +with the divider steps that land the note nearest the frequency read, bounded to **half the gap to each +neighboring note**. That bound 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. The per-frame proposals are therefore settled by a change-penalized walk, the same shape the +Viterbi decoder uses to settle a note contour. The cost of a bend is how far it is 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. That keeps the walk to a handful of states even where a note owns tens of +dividers. A bend counts divider steps from its own note, so each note's frames are settled on their own: a +frame that reads nothing keeps a bend its own note read, and a new note starts from its own reading. + +A frame makes no proposal where it rests, where the stem holding it leaves that channel out, 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 instrument an unrefined run writes. + +Each stem entry says which recordings are carried, and on which channels, in `bends`: a subset of the +channels it occupies, and of the three that load a divider. A channel a stem leaves out keeps the note the +matching chose. A stem carrying nothing at all is never read, so the transform is spent only where a bend +comes of it. + +The settings under `generation.refinement` shape a bend once it is asked for: `confidence`, +`change_weight` and `window`. They hold for a whole run. [The configuration file](../formats/configuration.md) +lists them. + +## 7. Rendering and reassembly + +A reconstruction is its instruction streams. Each stream is rendered back through the channel that plays +it, as it stands. The channel carries the oscillator's phase across frames, so frame boundaries make no +clicks, and resets it on a new note where the settings say so. An "off" instruction gives silence for that +channel and frame. The per-channel renderings are summed into the final approximation. + +The audio is rendered on demand, so what a reconstruction shows is what an export plays. A `.stn` +therefore holds the instructions, the per-frame ownership and the setup they were chosen under, which is +far smaller than the audio would be. The working level from +[section 3.4](#34-the-working-level-coefficient) is stored too, so the reconstruction and the original can +be shown and played on a common scale. + +## 8. Limitations + +- **Dynamic range.** A single NES tonal channel spans a limited range from its quietest to its loudest note, + and the coefficient is one global scalar. Material whose *useful* content spans a wider range than that + cannot be fully captured. A long crescendo and a very quiet passage under a loud one are examples. + Content far below the working level falls under the quietest playable note and is rendered as silence. +- **The triangle's fixed level.** The triangle plays at one volume. A bass quieter than that level is left + out, and a bass near it is played. +- **CQT time resolution.** Constant-Q analysis needs long windows at low frequencies, so low-pitched + transients are smeared in time under `cqt`. `fft` and `logfft` localize time better at the cost of + low-frequency resolution. +- **Per-channel independence in Viterbi.** Channels are decoded independently once the assignment has + settled their columns. That 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, has 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. diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md new file mode 100644 index 000000000..1166debaf --- /dev/null +++ b/docs/concepts/stems.md @@ -0,0 +1,173 @@ +# Stems reconstruction + +This document explains how the four channels are shared out when a reconstruction is built from several +stems. Read it before changing how stems share channels. +[Reconstruction](reconstruction.md) describes the single-sample pipeline it builds on. +[Reconstructions](../formats/reconstructions.md) documents the stored record. +[Stems in the application](../development/application/stems.md) covers what the application does with a +stems reconstruction: the Stems card, an edit and a removal. + +A stems reconstruction converts several audio [stems](../glossary.md#stem) at once. Each stem is matched +against the [instruction library](../glossary.md#instruction-library) on its own. Within each +[frame](../glossary.md#frame), the channels are handed to the stems one [pick](../glossary.md#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. + +The terms [level](../glossary.md#level-stems), [drive](../glossary.md#drive), +[mix](../glossary.md#mix), [column](../glossary.md#column) and [resting](../glossary.md#resting) are +defined in the glossary. + +## 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 has 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. A batch is many +single-source jobs and is not a mode of its own. + +One pipeline lets a per-source channel set, the drive each channel is pushed at, a count of channels one +source may sound at once and a hierarchy reach every conversion alike. It also keeps the classic run from +becoming 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. A stem's picks are +therefore scored against the sound that stem contributes, and the channels it wins carry that recording. +Ownership and content say the same thing: a stem heard on its own plays what was recorded on it. + +The whole set of recordings is scaled by one factor drawn from the peak of its sum, which holds the stems +at the balance they were captured in. The document records that +[recording scale](../glossary.md#recording-scale), so a recording keeps the level it held in the +conversion once another leaves the document, across a save and a reload. The working-level coefficient is +measured on that sum, exactly as for a single file. The reconstruction the run assembles is the sum of the stems' approximations. It +approximates the summed recordings because each part approximates its part. + +### 3. A drive reaches for a louder instruction + +A stem's settings name a drive per channel it holds. `1.00` is the level the library is calibrated to. The +drive belongs to the conversion alone. Every candidate is read at unit drive, so the instruction winning a +frame is the one that sounds it at the drive. A channel pushed harder is answered by louder instructions, +up to the loudest that channel holds. Past that the channel saturates, which is the effect a drive is used +for. + +A drive applies to one channel of one stem. Raising it lifts that part of the mix, and the other stems and +the stem's other channels stay where they are. Afterwards a frame sounds the instruction it carries, so +nothing reads the drive once the conversion is done. The instruction is the record of the level the run +reached. + +### 4. A stem sounds where its recording sounds + +A stem takes a channel in the frames where 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. That keeps a recording that is quiet through a passage from sounding the passage on the channels it +holds elsewhere. + +### 5. One pick at a time, while a pick helps + +A pick scores each eligible stem's candidates by the cost of that stem's own frame with the candidate +sounding beside the stem's earlier picks. It uses the same two-stage criterion as the single-sample +pipeline. The best candidate across the active level wins, is added to that stem's mix, and takes the +channel. + +Picks continue while a candidate lowers a frame's cost, a stem's count leaves it room, and free channels +remain. A channel no pick took goes, silent, to the first sounding stem in hierarchy order that may still +hold it. Then every held channel is scored once more with its stem's other channels sounding. + +Each stem has a mix of its own. That keeps a stem's later picks from re-approximating what its earlier +picks already cover, and it leaves what the other stems sound out of the stem's mix. + +### 6. A frame is answered whole + +Every channel the setup covers leaves a frame either held by a stem or **resting**. A resting channel +holds its channel's null instruction over a silent frame and records the resting stem id. Instruction +streams, rendered approximations and the per-frame stem record therefore all run parallel to the frames +they describe: frame *i* of a channel is frame *i* of the recording. A frame the decoder settles on a +silent instruction records the resting stem id too, so the id and the silence name the same frames. A +channel that rests through every frame is [standing by](../glossary.md#standing-by), whatever left it +silent, and has no stream at all. A recording the assignment leaves holding no frame on any channel takes +no part in the result either: it leaves the record with its source, unless no recording holds a frame. + +This is what makes a channel count and a hierarchy usable. Without it, a frame a count left unclaimed +would shorten that channel's streams and carry its later frames early. What the channel plays would drift +out of step with the recording it was matched against. + +### 7. Ownership and decoding compose + +The assignment answers *which stem owns which channel this frame*. The decoder answers *what that channel +plays across frames*. Each held channel leaves the frame with a column of candidates, its silence among +them, as wide as the configured decoder reads. 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 count left free sits in the path as the off state it is. +[Choosing instructions](reconstruction.md#5-choosing-instructions) describes the decoders. + +### 8. Levels pick in order, and the mode sets how + +The hierarchy groups stem ids into levels that pick in the listed order. In `strict` mode a level uses up +its stems' channel counts before the next level picks. In `round_robin` mode the levels take turns, +granting every level's stems one channel per round, and a run lasts as many rounds as the largest count +among the stems sounding. Both modes hold every stem to the count its own settings name. A stem holding +fewer channels than that count runs out of channels first. + +### 9. 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. +Comparing them alone would hand a channel to whichever recording is easiest to approximate. Within a +level, a candidate is therefore ranked by how far it lowers its stem's frame cost, weighted by the energy +behind it. The channel reaches the stem whose sound it covers most. Precedence between levels stays the +hierarchy's, because that is what a reader arranges the levels to say. + +### 10. Ties resolve deterministically + +Equal candidates go to the stem earlier in level order. Channels of one kind at one drive are scored as +one column and resolve to the lowest free channel of that group. Successive picks over one kind therefore +land on the lowest free channel, and a rerun assigns the same way every time. Two channels of a kind that +a stem drives differently are scored separately, because each renders the recording at its own level. + +### 11. The single-sample case stays exact + +One stem covering every enabled channel at unit drive, sounding as many channels at once as it has, is the +single-sample reconstruction. Property tests hold the assignment to an independent restatement of the +frame objective that scores every candidate alone. The one pipeline therefore serves the single-sample +case exactly. + +### 12. The working level follows the covered channels + +The summed recordings are scaled so their typical frame plays at the full-scale RMS level of the quietest +tone channel the setup covers, or of the quietest covered channel when the setup covers no tone channel +(see [the working level](reconstruction.md#34-the-working-level-coefficient)). One channel renders that +level whole, so a run sounding fewer channels targets a level its channels reach. Every setup measures +against the channel a single recording would be answered by. + +## The setup and the record + +The **setup** is the entries and the precedence hierarchy with its mode. An entry is an id and the +settings its recording is converted with: + +- the channels it may occupy, +- which of those it carries toward the divider it really sounds, +- the drive on each channel it holds, +- how many of those channels it may sound at once. + +Every per-recording choice is a field on those settings. The list a reader sets a run up in, the entry the +run records and a later reader of that record therefore all say the same thing. The settings check +themselves: a drive for exactly the channels held, each within its bounds, and a count of at least one. +The setup keeps the ids unique and the hierarchy naming every entry exactly once, so an inconsistent setup +can be neither built nor stored. + +The setup is built per conversion from the sources and the reader's choices, and it travels with the job. +It is part of the request and not of the standard configuration. A source the reader left holding no +channel takes no part: it reaches neither the recordings nor the entries, and the target stays what the +covered channels can render. + +A stem's frame has to reach a floor before it can take a channel: the quietest note any channel renders, +measured against the working level. Below that, the frame has nothing audible to contribute. + +The record stored in a reconstruction has the setup the assignment was made under, less the recordings +that ended up holding no frame. It also has one source per entry, naming the recording and the file it was +read from, and, per channel, the stem that holds each frame, parallel to the instruction streams. Every +reconstruction has one. Together with the instruction streams it is the whole of what a `.stn` says. See +[Reconstructions](../formats/reconstructions.md). + +The assignment is greedy per frame, so which stem owns a channel can change from one frame to the next. diff --git a/docs/concepts/timing.md b/docs/concepts/timing.md new file mode 100644 index 000000000..e9dce73ea --- /dev/null +++ b/docs/concepts/timing.md @@ -0,0 +1,134 @@ +# Song timing + +This document explains how a song's tempo becomes the whole number of ticks each row lasts. Read it before +changing the groove, or when you need to know why the rows of a song last unequal ticks. The code lives in +`sampletones_core/timing/`. + +## 1. The problem + +A tracker plays a song row by row, and every row lasts a whole number of [ticks](../glossary.md#tick). The +project's settings ask for an exact row length: + +``` +ticks_per_row = speed × nes_frequency × 150 / (tempo × 60) +``` + +At tempo 150 and 60 Hz this is the speed itself. At most other tempi it falls between two whole numbers. +At 60 Hz, speed 6 and tempo 125, a row should last 7.2 ticks. A row can last 7 ticks or 8, so the song +mixes the two, and the mix decides how the rhythm feels. The ticks each row lasts are the song's +[groove](../glossary.md#groove). + +The [meter](../glossary.md#metric-highlight) groups the rows. The first highlight is the beat and the +second is the bar. The defaults, 4 and 16, give bars of four beats of four rows. The tempo counts beats. + +## 2. What a good groove does + +Four properties describe a good groove. + +1. **It keeps time.** Every bar starts on the tick nearest the moment the tempo puts it at. The rounding + never adds up, so the song holds its tempo however long it plays. +2. **It keeps proportion.** Every row lasts the whole number just below or just above its exact length: + 7 or 8 ticks for 7.2. +3. **It keeps accents.** The longer rows go to the strongest positions. In a bar, the first half of the + beats comes first, then the first of each half, and so on down. In a beat, the first row comes first, + then the middle row, then the quarters. At 7.2 ticks a row, a bar of four beats plays: + + ``` + 8 7 7 7 | 8 7 7 7 | 8 7 7 7 | 7 7 7 7 115 ticks, against an exact 115.2 + ``` + +4. **It needs no look-ahead.** A player knows how long a row lasts when it reaches it, without reading the + rows after it. + +**The four can conflict.** Suppose the speed could change in the middle of a beat. A player without +look-ahead would then have to decide the length of a beat's first row before knowing whether the beat +earns a surplus tick, which property 3 wants on that row. No procedure keeps all four in that case, while +any three of them can be kept together. A _SampleToNES_ song has one speed throughout, so its groove keeps +all four. + +**Time is kept at the bar line.** Starting every beat on its nearest tick, too, would put the surplus +wherever the running count happens to land, which breaks property 3. So the bar lines stay exact, and a +beat inside a bar leans toward its strong positions. In common time a beat starts within about a tick of +its exact moment. A bar holding many beats is halved more times, so its beats can stray further: up to +about three ticks in a bar of 256 one-row beats, the deepest the settings allow. + +## 3. How the ticks are placed + +**The rate is held within what the engine plays.** A row lasts at least one tick. A tempo above +`2.5 × speed × nes_frequency` asks for shorter rows, so every row lasts one tick and the song plays slower +than its tempo states. FamiTracker and Bitphase play such a tempo the same way. + +**Each bar takes the ticks between its own start and the next bar's start.** Each start is rounded to its +nearest tick, counted from the song's first row. This is what keeps time: no bar line is more than half +a tick away from its exact moment, however far into the song it falls. + +**Two frames of one pattern can differ by a tick.** At 60 Hz, speed 6 and tempo 210, a 16-row pattern +lasts 68 4/7 ticks. The first frame plays 69 and the second 68, as the bar lines fall: + +``` +5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks +5 4 4 4 5 4 4 4 5 4 4 4 5 4 4 4 68 ticks +``` + +Once the exact lengths of a run of patterns add up to whole ticks, the grooves start over: here every +seven patterns, which last 480 ticks. A pass through the whole song lasts the whole number of ticks +nearest its exact length, and a song that repeats replays that pass. + +**A bar's ticks are halved down to its rows.** The bar's beats are cut in two, the larger part first, so +three beats part as two and one. Each part takes the share of the ticks nearest its exact length, held to +what its rows can carry, so every row keeps proportion. Each part is halved again, and a single beat is +cut between its rows the same way. The surplus therefore settles on the strongest positions. Three rows +sharing 23 ticks, for example, play `8 7 8`. + +**The meter restarts at every pattern,** as a tracker's highlights do. A pattern that isn't a whole number +of bars ends on a shorter bar. A beat of 3 rows in a bar of 7, over a 16-row pattern, gives bars of 7, 7 +and 2 rows, with beats of 3, 3 and 1 in the first two. + +**Every player reads the same timing.** In-app playback, rendering, the NSF export, the Bitphase export +and the tracker playback check all ask how long a row lasts by the row's place in the song. Each answer +follows from that place alone, so a player starting from any row reads its length directly. + +## 4. Ticks and the clocks they play on + +Rows are planned in engine ticks, at the song's NES frequency. An instrument's envelope moves once a tick, +and a note starts on a tick, so the tick is the unit a row's length can be counted in. Each way of +playing the song then turns ticks into its own time once: + +- **In-app playback and rendering** turn each tick into audio samples. A tick rarely lasts a whole number + of samples, so the samples are spread the way the ticks are spread over rows: the fraction carries from + tick to tick. +- **The NSF** runs on the console's own call, about 60.1 times a second on NTSC. Its driver adds the song's + rate, measured in calls, to a counter on every call and plays the ticks that fall out. A 30 Hz song + moves on every other call, a 41 Hz song holds about one call in three, and a 300 Hz song moves up to + five ticks a call. Any rate the settings allow therefore plays at its own speed. + +Each clock carries its own remainder, so neither adds up over a song. A bar line the console plays lands +within half a tick of its exact moment plus one call. Planning the rows in ticks also keeps the NSF on the +rhythm the app plays, and its stream holds only engine ticks, so a phrase repeats wherever it is played. + +## 5. The exports + +**The NSF plays the app's ticks.** Its stream holds the ticks the song walk plays, and its driver re-clocks +them onto the console's call (section 4). The stream stores how long each row's phrase lasts beside the +phrase, so a frame whose groove differs from the one before costs a few more bytes than a frame that +repeats it. + +**Bitphase plays the app's rows.** Its song has a speed and a tempo. At tempo 0 the speed alone sets each +row, so the export writes tempo 0 and a speed effect on every row that lasts differently from the row +before it. [Tempo as a +groove](../formats/bitphase.md#d-tempo-as-a-groove) has the details. + +**FamiTracker places the longer rows by its own count.** The export writes the project's speed and tempo +as they stand, and FamiTracker carries the rounding from row to row as it plays. Its song keeps the tempo +over time, and the longer rows fall wherever its running count lands. A module's rows can therefore last +a tick more or less than the app's, while every bar stays close to where the tempo puts it. A NES +frequency other than 50 or 60 Hz reaches FamiTracker as its engine speed. + +## 6. How it is checked + +The test suite holds the groove of every combination of tempo, speed, NES frequency and meter it sweeps +to the properties above, across many frames of a song. It measures how far each bar line lands from its +exact moment, checks that every row lasts the floor or the ceiling of its exact length, and checks which +rows of a beat and which beats of a bar carry the surplus. It also follows the bar lines through the +console's call at several rates, and holds what a player spends asking for a row's length to a small +share of what walking the song costs. diff --git a/docs/development/application/browser.md b/docs/development/application/browser.md new file mode 100644 index 000000000..0f0a2f37b --- /dev/null +++ b/docs/development/application/browser.md @@ -0,0 +1,89 @@ +# The Reconstruction Browser + +This document governs the tree of reconstructions the **Reconstruction** and **Sequencer** tabs share: how a reconstructions directory becomes rows, what a row represents, and what it answers. Consult it when changing what the browser lists, how a row reads, or what a click on one does. It complements [`architecture.md`](../architecture.md) (layering and ownership) and [`guidelines.md`](../guidelines.md) (coding rules). + +Four terms recur. A **row** is one line of the tree. A **heading** is a row the browser writes itself, such as a frequency pair or a source folder, as opposed to a row for a path on the disk. A **branch** is one of the two top-level views of the same reconstructions: the configuration branch lists them as the disk holds them, and the sample branch groups them by the audio they were made from. A **mode** is a state a browser can be in, such as showing favorites only. + +--- + +## Principles + +1. **One reading of the disk feeds every view.** A refresh walks the reconstructions directory once into a `ReconstructionScan`, and every branch is built from that record. The views therefore agree about what exists by construction, and a folder name is parsed into its configuration fields once per refresh. +2. **The model carries the shape, and the panel carries the widgets.** Which rows exist, what they are called, which of them fold together and in what order they sit are decided on the tree. Both tabs render one model, so they show one shape, and each rule can be exercised without a window. +3. **A row's identity is its path, and its name is a label.** Favorites, the context menus, copy-path, playback and opening a reconstruction all key on `filepath`. That frees a name to be 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 implies.** A frequency pair, a transformation, a source folder and one source audio each become a row that has no path of its own. What such a row offers follows from the subtree beneath it. +5. **One thing may stand in several places.** A reconstruction is listed by the configuration that produced it and again by the audio it was made from. An action on the thing, and not on the row, asks for every row representing it (`Tree.find_nodes`, `BrowserManager.nodes_at`) and hands them to both tabs. +6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the background worker (tag, label, font, theme, handler, open state), and the main thread creates the widgets from those specs, spread across frames. +7. **What a browser narrows to is its own.** Both tabs render one model, so the panel showing a browser decides which rows it shows. A search typed in one tab leaves the other reading as it was, and each browser opens in the mode a session left it in. +8. **The reader's shape is theirs to keep.** Which rows are open is what the reader made of the tree, so a browser records it and brings it back. A refresh, a change of filter and a repaint leave the tree standing as it was, and so does the next run of the application. What a filter unfolds on top of that shape is remembered as the filter's own. It is held for as long as the filter is and handed back when the filter goes. The one exception is a row the reader's own opening has come to stand on, which stays open so the view they built stays on the screen. + +--- + +## The two branches + +A refresh walks the directory once, and both branches are built from that one scan. The configuration branch lays the scanned folders out as they sit, giving each configuration directory a friendly name. The sample branch regroups every top-level configuration directory's reconstructions by the audio they mirror, and gathers each audio's variants under one sample row, each labeled by its configuration. A heading is extended and not repeated: one of that name is looked up among the siblings of its own kind and class, so a folder and an audio sharing a name stay two rows. + +The two branches share a policy. A configuration directory at the top level of the reconstructions directory is the one lifted under groups and transposed into the sample view. A configuration directory nested inside a plain folder keeps its friendly name where it sits. A reconstruction outside every configuration directory appears in the configuration branch, since that branch follows the disk. + +Which row carries the configuration follows the branch. In the configuration branch it is the directory that names it. In the sample branch it is the variant leaf, since there the configuration is what distinguishes one row from the next. + +## The shaping rules + +* **Prune.** A heading the browser wrote that gathers nothing leaves, deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk. +* **Collapse.** A heading above a single row folds into that row, which takes the joined name and rises into its place. 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, 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 second configuration arrives. A heading naming something somebody chose — an audio file, a source folder — hands the row a name that is no longer the configuration's own text, and the row records that it gathered a plain name. A reader of configuration text meets it as the plain name it has become, which is why such a row reads in the default font while a chain of configuration headings stays in the fixed-width one. +* **Order.** Containers come ahead of leaves, then rows follow a natural sort over the label, so a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every label is final. The branches directly under the container root keep the order the builder gives them. +* **Unique sibling labels.** Where siblings would read alike, every member of that label takes its short configuration hash. One rule serves the channel directories under a transformation group, the nested configuration directories, and the variants under a sample. + +## Rebuilds and rows + +**One rebuild is in flight at a time.** A browser is asked to rebuild from either tab and from several places in the application. A rebuild starts on the tree worker, which takes the tree lock, brings the model up to date, collects the rows into specs and hands them to the emitter. The emitter clears the old rows and stages the new ones in budget-sized batches, so interactive callbacks run between slices. A whole-tree rebuild asked for while the lock is held, by another rebuild, a library load or a library generation, is kept for the release. The release asks for it again once the tree stands free, and the latest request is the one kept. + +**A row's tag is composed and not stored.** The tag joins the names above the row, which reads the row back to whoever inspects the widget tree, and it appends a digest over the exact path of `(node_type, name)` pairs. Rows the names alone spell alike, such as a folder and the audio beside it or two labels that differ only in spacing or case, therefore keep tags of their own. Since a tag is composed, any holder of a node can address its row. That is how expanding a subtree, repainting a star and applying a filter reach the widgets. + +**What a row answers follows its kind.** A reconstruction plays on a click, opens on a double click, and offers its path items, the tab's own actions and the favorite mark. A directory offers its path items and the favorite mark. A group or a sample represents no path, so its menu reads the subtree: how many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows it by, and, on a sample, the audio its reconstructions were made from, answered through any one of them. + +**A row naming a reconstruction says what made it and what it is made of.** The configuration comes from a directory name. A row carries it where the configuration is what distinguishes that row, and reads it from the directory above otherwise, so both branches say the same thing about one file. The recordings come from the document, which is the only place that knows them. A row asks for them as the pointer reaches it, and the answer is kept against the moment the file was last written. One reading answers for both tabs. A pointer crossing many rows leaves the reading of the row it comes to rest on, and an answer landing after the details were built rebuilds them where they stand. Each recording is listed under the color the id it was converted as gives it, the color [the stems card](stems.md) paints it in. A document naming one recording says what the row already says, so the list is left to the documents holding several. + +**Favorites are paths.** A row is a favorite when its path is in the session's set, and it reads as part of a favorite folder when any of its parents is. A reconstruction therefore reads as part of a favorite folder wherever a view puts it, including the sample branch, whose headings carry no path. One path reaches the panel as several rows, so `application.py` resolves the toggled path into every row representing it and hands them to both tabs. Each row repaints with the ancestry its own path carries. A starred path the disk no longer holds stays starred, so a file on a drive unplugged for one run carries its star once the drive is back. + +## Filtering + +`TreeFilter` holds what a browser is currently asked to show, and the panel showing it owns the filter. It is given whole and replaced whole (`with_query`, `with_favorites_only`), so one place resolves what the browser shows. `NO_FILTER` is the filter a browser showing its whole tree holds. + +The two criteria answer different questions, so each lands in a different place: + +| Criterion | What it decides | Where it lands | What a change costs | +|---|---|---|---| +| `favorites_only` | which rows the browser **draws** | the rows the mode shows are the rows collected into specs, so `TreeEmitter` creates widgets for those alone | a redraw: the rows are collected again from the model in hand, on the tree worker | +| `query` | which of the drawn rows are **shown** | `show` is flipped over the rows already on screen, once the typing settles | a resolution of the query, debounced | + +One rule serves both. `TreeVisibility` takes the rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row one holds. It keeps the named rows and the rows above them, so what a pass holds in memory follows the size of what was found, and a row beneath a match is answered from its own path upward. + +**What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion names are its **anchors**. For a search, the anchors are the rows whose label matched. For the favorites mode, an anchor is a row a star sits on, or, where no row represents the starred path, the shallowest rows that path reaches. In the sample branch the headings carry no path, so the variants are the rows a starred folder arrives at. + +**A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a match opens along with the rows above it. The favorites mode points the reader at a star, so what opens is the rows above it, while the star's own row stands where the reader left it. A starred folder is therefore revealed. A starred reconstruction inside a starred folder anchors on its own, and that opens the folder above it. + +**Which stars are followed is the reader's.** The mode decides what is drawn. Whether it also unfolds is a preference given per kind of favorite, held in the application's browser configuration. A starred reconstruction reads the preference for reconstructions. A starred folder, and everything it brings in where no row represents it, reads the preference for directories. By default both are off, so turning the mode on narrows the tree and leaves every row standing as it was. The panel reads the pair through `TreeLogicProtocol`, once per resolution. + +**An opening belongs to the pass that asked for it.** Switching the mode on is the reader asking to be shown their favorites, so the pass that switch starts is the one that follows a star, and the rows it opens are noted in the mode's own memory. Later passes read that memory. A refresh, a query or a star gained meanwhile therefore leaves the reader looking at their favorites, while the stars followed stay the ones the switch asked about. The pass that reads the mode off lets the memory go, and those rows fold back. A change of preference asks for nothing. It is answered the next time the reader asks for the mode, which keeps a menu click from moving the tree the reader is working in. + +**A row the favorites mode holds back has nothing it would show.** A row the mode shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it. Declining a row therefore declines its subtree, and one decision covers it while the traversal walks on. + +**Two memories, each holding what one hand opened.** `RowExpansionMemory` (`ui/elements/tree/expansion.py`) owns both and the rules that join them. It holds each row by the tag it is addressed under, so a later pass creates the row open again. + +- The reader's rows hold what the reader did: a click, the expansion items, the collapse control. A session writes them down. +- The mode's rows hold the way down it opened, and they go when the mode does. A narrowed browser therefore hands the tree back the way the reader had it. + +Where the reader's own rows have come to stand on a row of the mode's, the memory takes that way down over as the reader's, so the view they built stays on the screen (principle 8). Folding a row is the reader's word on it whichever hand opened it, so the row stays folded. A pass writes from the tree worker while a click writes from the main thread, so one lock covers every answer the memory gives. Both sets are held to the rows the model states, read afresh on every pass, so a row a moved reconstructions directory left behind leaves them with it. Each browser is configured to record a shape or not, and a browser that records none leaves the memory empty. + +A search unfolds by the same rule from the other end: its matches and the rows above them open for as long as the query stands, resolved afresh on each pass, and clearing the query folds them back. + +**The shape outlives the run.** A browser is handed the mode and the rows it opens with as it is built, reports a change of mode where it happens, and is asked for its shape once, at exit. Each tab's rows go to `ApplicationState.expanded_rows` under the panel's tag, and its mode to `ApplicationState.favorites_filters` under the same tag. A browser therefore opens in the mode it was left in, with the rows it was left with, and a collapsed card is remembered the same way. + +**The Main tab's explorer remembers folders.** Its rows are the folders on disk, read a level at a time as the reader opens one. `ExplorerManager` therefore holds two facts about a folder: whether its children have been read, and whether its row is open. They part company, since a folder read and then folded away is loaded and closed. The open one is the shape a session writes to `ApplicationState.expanded_directories`. A refresh reads down to each remembered folder, reading every folder it needs once. A remembered folder the disk no longer holds is read down to as far as it still stands and stays remembered, so a drive unplugged for one run opens where it was left once it is back. + +**What the mode costs.** Resolving the mode walks the model once per rebuild, on the tree worker, testing each row's path and its parents against the session's set. The anchors the preference follows are read out of that one answer. The resolution materializes the starred rows and the rows above them, and what reaches DearPyGui is the drawn rows alone. A favorites-only browser therefore creates widgets for the starred reconstructions and their headings and not for the whole tree, even over a very large directory. A keystroke resolves only the query, because the mode decides the drawn rows. A favorite toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring one takes it out along with what it held. + +A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back empty, so the filter's answer reads where the rows would be. + +**The controls each card carries.** A checkbox under the search box switches the favorites mode. Only the reconstruction browsers hold it, because their rows represent the paths a session stars, and it follows the tree's lock, since a rebuild is what it asks for. Folding the whole tree away is the other control. It reaches the rows through the model and not the widget tree, so one pass covers a branch however deep it runs, and it records what it set, which leaves the memory empty. The explorer folds first and drops the folders it had read afterward, so opening one lists it as it stands on disk. diff --git a/docs/development/application/config-organization.md b/docs/development/application/config-organization.md new file mode 100644 index 000000000..57b37ac45 --- /dev/null +++ b/docs/development/application/config-organization.md @@ -0,0 +1,81 @@ +# Configuration Organization + +_SampleToNES_ ships its configuration as a YAML data package, `sampletones_config`. This document sets out the principles that decide where a configuration value belongs and how it is read. Use it as the reference when adding or moving a value. It sits alongside [`architecture.md`](../architecture.md) (application layering and ownership) and [`guidelines.md`](../guidelines.md) (coding rules). + +The word "configuration" names three different things in this codebase. This document governs the first: + +- **Shipped configuration**: the `sampletones_config` YAML package with layout, theme, palettes, keybindings, language, behavior, deployment and the import boundaries. *(This document.)* +- **Runtime user preferences**: mutable state persisted to the user profile (`sampletones_application/config`, for example `PlaybackConfig`, `ShortcutsConfig`, `ApplicationState`), governed by that package. +- **Project generation settings**: JSON stored beside a project (`sampletones_core/configs`, `config.json`), documented in [the configuration file format](../../formats/configuration.md). + +--- + +## Principles + +### 1. Data and meaning are separate + +`sampletones_config` carries values, and the schema that interprets them lives in the package that reads them. The dependency runs one way. A consumer imports the data package only to resolve its directory (`CONFIG_DIRECTORY`), and the package itself is pure YAML with an empty `__init__.py`. Each schema lives with its reader: + +- `sampletones_application` owns the layout, theme, palette, keybinding, language, behavior and deployment schemas. +- `sampletones_tools` owns the import-boundary schemas. +- `sampletones_shared` owns the loader primitives (`load_yaml_model`, `load_yaml_model_dir`). + +The data therefore carries the values and the consumer carries the meaning, and the two evolve on their own terms. + +Data a package reads ships with that package, beside the schema that reads it, and `package_directory` places it. For example, the defaults every reconstruction starts from sit in `sampletones_core/configs/generation.yaml`. `sampletones_config` holds what the application reads and the import boundaries. The boundaries state the repository's package layers for every tree, and [package layers](../packages.md) refers to them. + +### 2. The top level is organized by domain + +`sampletones_config` has one top-level directory per schema family and its loader. 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 color field in `layout/` and a color entry in `theme/` both name a palette token, and the palette turns that name into a value. A directory has 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. + +Keybindings are a domain of the same shape. A scheme is a named set that a preference selects by name, so the directory has one file per scheme, named after the scheme it declares, and every scheme answers the same action set. An entry names one `ShortcutId`, and each scheme must have a combination for it. The directory carries the combinations, which are a reader's to choose. The actions and the categories are code ([`keyboard.md`](keyboard.md)). + +### 3. The config tree mirrors the code + +The layout config is shaped like the code that reads it. Its directory tree matches the `LayoutConfig` model tree, which mirrors the application's feature-area taxonomy across `ui/panels/`, `logic/` and `view_model/`. A value's place in the config therefore predicts its place in the code. Three conventions keep the mirror true: + +- **A feature area is a directory of fragments.** `load_yaml_model_dir` loads each area. Every `.yaml` supplies the model's ``, and an optional `root.yaml` carries the loose scalars that have no section file. Two cross-cutting resources, `fonts.yaml` and `glyphs.yaml`, are single self-contained files at the `layout/` root. +- **File stem = field = model.** `choice.yaml` fills field `choice`, validated by `ChoiceLayout` in `choice.py`. The three names match within a domain, so one name traces a value from YAML through field to schema. A stem is unique within its domain. The same name may recur across domains as a related but distinct resource: `layout/general/plus_minus_buttons.yaml` sizes a widget, and `theme/plus_minus_buttons.yaml` styles it. +- **Tabs sit where their coordinators sit.** The notebook tabs live under `layout/tabs/`, matching `coordinators/tabs/` and aggregated as `LayoutConfig.tabs`, so a tab's configuration is found where its code is. Cross-tab and shared areas stay at the `layout/` root as their own feature areas: `general/`, the plot-element family `graphs/`, the transport toolbar `player/`, and the dialogs `project_properties/` and `settings/`. + +### 4. Every value has one home + +A value lives in exactly one place, owned by the concept it describes. A tab's own geometry, width and height together, lives in that tab's directory. The shared outer column skeleton (`side`, `center_weight`) lives in `general/`, since it belongs to every tab equally. The values that drive responsive resizing (`baseline_viewport_width`, `baseline_viewport_height` and the graph-stack cap) live together in `general/responsive.yaml`, because they are the input to one scheme. Ownership decides the home: whoever owns the concept holds the value, and it appears once. + +### 5. Storage shape and consumer shape differ + +Principles 1–4 shape the config for **where a value is authored**. A consumer often needs a different shape. The composition root (`Application.__init__`, where the validated `LayoutConfig` already exists) reconciles the two and hands each consumer a view built for it: + +- A **feature consumer**, a panel or UI element, receives its model whole: `GUIConverterPanel(layout: ConverterLayout)`. The model carries all of that panel's own geometry, so a new field reaches the panel through the model it already holds. +- A **generic primitive**, such as the responsive math, the column builders or raw `dpg.configure_item`, receives the plain integers it computes with. It blends a config value with a live runtime measurement (for example `dpg.get_viewport_client_width()`) and works below the level of any layout model. +- A **tab coordinator** receives a per-tab view: a frozen-dataclass DTO in the `parameters/` package that gathers exactly what one tab needs. A small factory produces any narrowed slice a consumer needs (`TreeColors.create`, `PitchStepperStyle.from_general`). + +The type shows which side of the boundary a value is on. A frozen Pydantic model with `extra="forbid"` is a YAML fragment, and a `@dataclass(frozen=True)` is a view derived in code. The composition root is the one place that knows both shapes, so each deep path from storage to consumer is written once, in one factory. A coordinator depends only on the narrowed view handed to it, and the knowledge of where each value sits in the tree stays in the factory. + +--- + +## Domains + +Each domain is one top-level directory with one schema owner and one load path. `sampletones_config/README.md` lists the directories and the schema owning each, beside the data itself. + +The palettes load first, and the source holding the active one is injected as validation **context**. Any color field in layout or theme therefore 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 shipped default for a name the build does not carry, so a preference outlives the build that wrote it. `ShortcutCatalog` answers the same way for a keybinding scheme. [`keyboard.md`](keyboard.md) describes how a scheme is validated, layered under a reader's own rebindings and chosen per platform. + +Layout and theme schemas are `frozen=True, extra="forbid"`, and loading is eager at the composition root, wrapped as `SystemError`, so a mismatch between YAML and schema surfaces loudly at startup. + +Behavior loads as its own domain, with its own directory, schema owner and load call. It attaches to the layout result as `LayoutConfig.behavior`, so every consumer reaches it as `layout.behavior.*` through one access path. + +Boundaries is the domain a developer tool reads, not the application. It declares the layer graphs the packages divide into, the imports each part of the application stays clear of, the spellings a tree keeps out, and the standard-library rule the bootstrap scripts hold to. A declaration draws on the named prefix groups in `general.yaml`, so a set that several rules reach for is written once, and a name that reaches no group is refused as the domain is read. [Package layers](../packages.md) says what the graphs mean. + +--- + +## Loading + +Three load mechanisms serve the three grouping schemes: + +- **Field aggregation** (layout, and every domain that mirrors the code). The config is built field by field, as principle 3 describes, and validated strictly with `extra="forbid"`. +- **Tag-graph discovery** (theme). Theme is grouped by the widget family it styles, for a person navigating it. Each spec's `tag` and `extends` carry the load meaning: every file under `theme/` is read, validated, resolved against its parent and registered by tag. Every theme extends the base unless it names another parent. +- **Name-keyed discovery** (palettes, keybindings). Every file in the directory is read and indexed by the name it declares, with each file's stem held against that name, so one name traces a palette or a scheme from a stored preference to the file on disk. + +Deployment and the boundaries each load through a `.load()` classmethod of their own, 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/application/dialogs.md b/docs/development/application/dialogs.md new file mode 100644 index 000000000..8a3cb5a74 --- /dev/null +++ b/docs/development/application/dialogs.md @@ -0,0 +1,127 @@ +# Dialogs + +A dialog sets its size once, and where it opens follows from that size. Consult this when a dialog opens at +the wrong size or in the wrong place, when a prompt raised from an answer never shows, and when adding one. +`GUIWindow` is the single place a dialog's window is opened, so what this document says holds for every +dialog the application raises. + +## What a dialog sets + +`DialogGeometry` (`layout/primitives.py`) carries a dialog's whole geometry: + +- **`width`** is always set and is held both ways: it is the least the window may take and the most. A + stretched item (a field, a combo, a button at `width=-1`) measures itself against the region the window + offers. A window free to widen to its content, with content sized from the window, hands each other a + little more every frame until the screen stops them. Holding the width at what the dialog sets leaves the + two agreeing from the first frame. It also gives every dialog the same reading width whatever it holds. +- **`height`** is the size the dialog opens at. A dialog holding more than that grows to hold it, so a + reader is always shown the whole of what the dialog says. A dialog that learns its length as it opens, + such as a prompt whose text wraps or a form that unfolds a group once a run begins, leaves the height out + and takes the height its content asks for. + +Both must be above zero, so a position can always be computed from a dialog's size. + +## Where a dialog opens + +Every dialog opens centered on the viewport's client area, which is the space a position is measured in. + +A dialog with a height is placed before it is ever drawn. Both numbers are in hand, so `GUIWindow.show` sets +the position between building the tree and the first frame that carries it. DearPyGui leaves an unplaced +modal where the pointer last was, and a position set through the API takes precedence over that. A placed +dialog therefore never appears at the pointer. + +A dialog whose height its content settles has nothing to place from until a frame has measured it, so the correction centers it. It is drawn once where the modal opened, once centered against the height it had reached by then, and it stands where it belongs from the third frame on. The correction re-reads the drawn size each frame and centers the window against it, so a dialog stays centered all the way to the size it settles at, and it ends once two readings agree. The correction ends on purpose: a dialog has no `no_move`, so a reader can drag it, and a pass that kept measuring would drag it back. The error dialog, whose **Show traceback** unfolds a text box, grows downward from where it stands. + +A dialog is never placed above or left of the viewport, so a dialog taller than the viewport keeps its title bar reachable. + +## One modal at a time + +DearPyGui shows one modal at a time. A modal built while another stands opens hidden, where nobody can +reach it, and its title-bar close runs as though the reader had dismissed it. A modal built in the frame +another one left in meets the same fate, since that frame still draws the one that left. + +The screen therefore belongs to one conversation at a time. A conversation is a dialog, the modals it +hands the screen to while it steps aside, and the ones its answers raise. A modal asked for from anywhere +else, such as the report of a job that finished, waits in line. It opens once the conversation holding the +screen has ended, a frame after its last window left, and the line opens in the order it was asked. A dialog +asked for again while it waits takes one place, at the back of the line, with the newer request. One hidden +while it waits leaves the line. + +**A question reads what it asks about once the screen is free for it.** A guard with something to ask +takes a turn in the line, and reads its state again when the line reaches that turn. The conversation that +held the screen has settled what it changes by then. An exit asked for while Close project asks therefore +finds the project Discard closed, and asks nothing about it. A chain of several guards, such as the exit's, +waits for the screen as a whole while any of them has something to ask. Every guard then reads its state on +the free screen, so a project that Project properties left unsaved is asked about, though it was clean when +the window was closed. A request with nothing to ask goes on at once, whatever holds the screen, so closing +the window over a dialog leaves at once when nothing is unsaved. A turn that opens no question lets the line +go on to the next window in the same frame. + +`ModalQueue` (`utils/gui/modal_queue.py`) keeps the line. `GUIWindow.show` is the only way a window enters +it. A guard takes its turn through the `when_free` of the `DialogsRenderer` it was given, which `asking` +(`utils/callbacks/gates.py`) waits on. A caller raises a dialog whenever it has one to raise and never waits +a frame of its own for the screen. A window that reports work under way and leaves the rest of the interface +live beside it is no modal, so it opens at once. + +## How a dialog answers + +A dialog that closes on its answer leaves the screen first, and the answer runs a frame later as a hand-off +of its conversation. Whatever the answer raises, such as a question of its own or an error, opens ahead of +the line. Leaving also releases the dialog's keyboard claim, so a prompt the answer raises holds the +keyboard alone. `GUIWindow._leave_then` is that step. An answer that fails with an error leaves the hand-offs +after it, and the line, to go on a frame later. + +What the answer needs, such as a ticked box or the fields of a form, is read before the dialog leaves. Only +the first answer runs: a second click reaches a dialog that has already gone. + +The save prompt's Save runs the save once the prompt has gone, and the save reports a `SaveOutcome`. A +document written to disk goes on to what the prompt was guarding. A save the reader called off, such as a +file dialog closed without a name, brings the prompt back with the same question. A save that failed has +shown its error, and that error stands alone on screen. A save the prompt asked for shows no message of its +own when it lands, since the reader asked to go on and what the prompt guards opens next. A document with +no file to write to asks for one, the way Save As does. + +A dialog that comes back once the modal it raised is answered steps aside. `yield_to` takes it off screen +and keeps its tree, and `resume` brings it back, a frame each way. The dialog keeps the screen while it +stands aside, so nothing waiting in line opens between it and the prompt it raised. + +## A gesture asks once + +A gesture that asks before it replaces a document, closes one or leaves the application holds one +conversation at a time. The conversation is the chain of questions the gesture passes, and every way out of +a question reaches whoever asked it. An answer that goes on lets the request through. Cancel, Escape, the +title bar's close and a save that failed turn it away. So does an error raised anywhere along the chain: +before a question, as the question reaches the screen, or after its answer. A request therefore always ends +in one of the two. + +While a conversation stands, the same gesture asked for again asks nothing, so two closes before the first is +answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the +edits of the open reconstruction and the turn in the line too. A gesture repeated while an edit is on its way +asks once the edit lands, and one repeated while another conversation holds the screen asks once it ends. +`SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, and the composition root wraps every such +gesture in one, whichever door it is asked for through. + +**The latest request wins.** A gesture whose question is the same whatever it carries goes on with the +request made last. Starting or opening a document, editing a voice, closing and leaving ask this way. A +reader who double-clicks one file and then another before the question shows opens the second. The menu's +Open, asked for after a browser's file, shows the file dialog. Cancel drops every request the conversation +gathered. `LatestRequestFlight` holds such a conversation. + +**A question built from the request keeps its request.** Loading what a run wrote asks whether the open +document is backed by that very file, so the answer holds for that file alone. A press made while the question +stands is absorbed, and the answer loads the file the question named. `FirstRequestFlight` holds such a +conversation. + +## Where it is written + +`GUIWindow.dialog_window` (`ui/elements/window.py`) is the only place a dialog's `dpg.window` is opened. +Every dialog is therefore modal, resists resizing and collapse, and offers the title bar's close button +exactly where it answers for closing. `GUIDialogWindow` adds the keyboard ring over it. The windows under +`utils/gui/dialogs/windows/` are the shapes the application raises without writing a window of its own: a +confirmation, a save prompt, an error report and a notice. A dialog that belongs to one tab lives under +`ui/panels/dialogs/`. + +[render-thread.md](render-thread.md) says why the centering correction counts frames and does not wait on one. + +Who *raises* a dialog is a different question, and [architecture.md](../architecture.md) answers it: dialog +presentation belongs to coordinators. diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md new file mode 100644 index 000000000..ffbacbb4d --- /dev/null +++ b/docs/development/application/keyboard.md @@ -0,0 +1,112 @@ +# The Keyboard and the Actions It Reaches + +This document describes how a key press reaches behavior in `sampletones_application`, and how an **action** is declared and shown. An action is the one name a press, a menu item and a context item all reach one behavior by. The document governs `utils/gui/keyboard/`, `utils/gui/shortcuts/`, the schemes under `sampletones_config/keybindings/`, and the menu surfaces that print an action. Consult it when giving a panel keys of its own, adding a shortcut, or putting an action on a menu. + +The design truths it realizes are principles 12 and 14 of [`architecture.md`](../architecture.md): one dispatcher owns the keyboard, and an action is declared once. This document describes the mechanism behind both. + +--- + +## The dispatcher + +DearPyGui delivers a press to every registered key handler with the same global reach. No handler can stop another handler, or ImGui itself, from also seeing it. Priority and consume semantics therefore exist only where the application builds them. + +A single `KeyRouter` (`utils/gui/keyboard/`) owns the one `add_key_press_handler` for the whole application. It snapshots the modifier state once into a frozen `KeyEvent` and offers that event to registered **scopes** from the highest priority to the lowest. The first active scope whose handler returns `True` claims the press and ends the walk. This software walk is the only consume mechanism the framework leaves available. + +Each keyboard consumer registers one scope through `register(handle, *, priority, active)`. `active()` reports whether the scope wants keys at this moment, and `handle(event) -> bool` acts on the press and reports whether it claimed it. + +### Priorities + +The application orders its scopes by priority, from highest to lowest: + +| Priority | Scope | Active when | Behavior | +|----------|-------|-------------|-----------| +| `MODAL` | 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` | a sub-panel the keys are meant for | its tab is in front, its card is open, and the sub-panel holds what the keys act on: a cursor, a row picked out, or an open audition | handles the keys its own category names and yields every combination it does not own, so a higher-reaching shortcut still wins | +| `SHORTCUT` | application shortcuts (`ShortcutManager`) | always | fires the matching shortcut while no field is being edited, or whenever the shortcut is `field_transparent` | + +The router offers a panel the key ahead of the shortcut scope. A panel therefore returns `False` on any combination it does not own. A panel that reads keys as entry, such as notes or hex digits, takes **plain presses** alone: a key pressed on its own or with Shift, the way a character is typed. Ctrl, Alt and Super make a press a command, and a command goes on to the shortcuts. The grid therefore lets `Ctrl+D` through, and an open instrument's note keys let `Ctrl+Z` through. That lets field-transparent shortcuts, such as the tab switch, reach the shortcut scope while a grid cursor is set. + +### A panel scope answers on its own tab, from an open card + +A cursor, a picked row and an open audition all outlive a move to another tab and a collapsed card. A panel is therefore given a predicate that reports whether its tab is the one in front. It reads the predicate, and its card's collapse, at the moment of the press, as it reads focus. The composition root resolves the tab, and the scope composes the answer into its `active`. That keeps the fact in one place and leaves the router's contract as it stands: the scope decides whether it wants the key. + +A shortcut in the `SHORTCUT` scope that reaches a panel's selection asks the panel the same question. The channel keys reach the converter's picked row only while its list would take a key itself. + +### Focus is pulled, not pushed + +Whether a text or value field keeps a plain key for itself is one router query, `is_field_focused`. It reads the focused item from DearPyGui at the moment of the press and counts it while that item is actively being edited. Every input is covered by construction, and the router alone holds the rule. + +The query resolves the focused item to the field behind it. A `dpg.group` reports the state of the widget inside it, and DearPyGui names the outermost such group as the focused item. The instruments panel's sequence input, laid out beside its copy button inside a card body group, reaches the keyboard as that group. An active group therefore answers with the field being edited below it. The query follows the one branch that reports focus, so a panel-spanning group costs a key press only the path down to its field. + +**Focus is claimed per key.** A focused input keeps the keys it genuinely consumes and yields the rest. A text or number field consumes `Space` and `Shift+Space`, because space is a character it types, and `Escape`, which cancels the field. Those keys serve the field while it holds focus. + +A command, a combination held with Ctrl or Super, fires from anywhere, which is why playing from the shown frame works while typing. A text or number field keeps only the commands that edit what it holds: select all, copy, cut, paste, undo and redo, spelled with Ctrl and with Super. Alt is a typing key as well as a modifier: AltGr and Option type characters through it, and Windows reports AltGr as Ctrl+Alt. A text field therefore keeps an Alt combination on a key that types a character, while one on a function, caret or editing key fires, so `Alt+F4` reaches its action from a field. A number field types no such character, so every Alt combination fires from it. An action meant to fire while the reader types takes Ctrl or Super. Playing from the cursor row belongs to the grid: the sequencer grid claims it while the grid itself holds the keyboard. + +**Interactive widgets release the keyboard.** A selectable cell or a transport button hands focus back after its click, so the next playback key reaches the router. `Space` and `Escape` therefore stay live in the moment after any click. + +### The modal stack + +The router holds a stack of modal handlers. `push_modal` and `pop_modal` bracket a dialog's lifetime, and the built-in `MODAL` scope routes each press to the top of the stack. `MODAL` outranks the panel and shortcut scopes, so every scope beneath it reads the keyboard as though the application had no dialogs at all. + +A release names its handler and removes that handler's latest claim, wherever it stands. A dialog can close while a prompt it raised still stands, and the prompt keeps the keyboard. + +--- + +## The vocabulary + +One key table (`utils/gui/keyboard/keys.py`) reads a key both ways: the name a file writes and the code a press carries. One combination type, `KeyCombination`, parses that spelling, displays it, and answers whether a press matches it. + +**The combination is data and the category is code.** Which keys reach an action is the reader's to choose, and which scope answers them follows from where the action is handled. A scheme is validated as it loads: every `ShortcutId` is answered, every key name resolves, and one combination reaches one action within a category. A collision is a `SystemError` at startup, beside the layout and palette failures. + +--- + +## Schemes + +### A preference layers over the shipped scheme + +`ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it. `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping. An override lists every key of its action, main key first and joined by commas, and a key spells the comma as `Comma`. A stored single key is therefore an action with one key. The overrides are read together, so entries that move keys between their actions stand in whatever order they were written. An override is reported and left out whole when it names an action this build does not have, a key the table does not have, or a combination another action of its category keeps. A stale entry therefore costs itself, and its action keeps the scheme's keys. + +A change reaches the running application through `ShortcutSource.on_bindings_changed`, the keyboard's analog of the palette switch ([`palette.md`](palette.md)). The dispatcher re-reads the keys, and the menus re-print their accelerators. Each registration names the action it fires, so a rebind has little to catch up. + +### 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: every key each now answers, main key first, and none where it was left unbound. Only those actions reach the preference, and every other key follows the scheme beneath. + +An edit changes the keys the reader names, and the action keeps the rest. A pressed key becomes the action's main key, the one the menus print, and the keys it had follow it. A list written in the entry box becomes exactly the action's keys, each named once, and a comma written where a key goes is the comma key. Clear unbinds every key. An action left on the keys the scheme ships holds no edit, so a change that leaves the keys as they were gives the reader nothing to store or discard. + +An assignment displaces only what it takes. Giving an action keys its category already answers takes those keys from their holders in the same step, so every scheme a draft produces is valid. A holder keeps its other keys, the next one becoming its main key. The dialog names every holder in one question and asks before that step is taken. Cancel on that question brings the reader back to where they gave the keys: a written list to the entry box, and a pressed key to its row, listening for the next press. A row reads as listening exactly while it takes the keys. The dialog edits the draft, and a commit 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. `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) says which scheme each platform ships. A profile makes that choice once, at creation, and the stored name selects from then on. The modifier table reads every spelling on every platform, and `Modifier.SUPER` displays as the name the machine is labeled with. A scheme written for one keyboard therefore loads, validates and reads on another, and the completeness validation holds every shipped scheme to the same action set. + +--- + +## Actions + +Declaring an action takes four links, and the `shortcut-actions` check holds every one of them (see [`architecture.md`](../architecture.md#enforcement)): + +| Link | Where | What it says | +|------|-------|----------------| +| 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 give their call differently, and the check knows both. + +A **family** is an action that a whole enum parameterizes, such as an export item per format or an item per channel. It is a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. `FAMILY_SHORTCUT_IDS` names the mappings that are families, so what excuses an action from giving a call of its own is written down. `SHORTCUT_IDS_BY_NAME` answers with every action and stays outside that list. + +A **panel-scope** action gives no call at all, because its key scope 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 + +`ShortcutManager.add_menu_item(shortcut_id, ...)` is how a menu names an action. 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 that call is then the one that switches the state it shows. + +### A set of actions several menus show is declared by whoever owns them + +The owner writes one builder, such as `GUISequencerVoicesPanel.add_action_items` for a voice or a grid's edit surface for a cell. Each menu that shows the set decides where to print it: the panel's own row menu, the menu bar's **Edit** group through `EditSurfaceProtocol` and `EditRouter`, or the **Voice** group through the panel. Adding an action to the builder reaches every menu, and the dividers around it belong to the menu and not to the set. + +### A menu whose contents follow a selection is filled 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. `MenuSection` (`ui/elements/menu_section.py`) is the mechanism. A marker leads the menu, and the framework reports it drawn once a frame while the menu is open. A gap in those reports marks a fresh opening and refills the section. The `MenuSection` docstring says why the marker leads. diff --git a/docs/development/application/palette.md b/docs/development/application/palette.md new file mode 100644 index 000000000..144e1fcd7 --- /dev/null +++ b/docs/development/application/palette.md @@ -0,0 +1,39 @@ +# Colors and Palettes + +This document describes how a color is written, composed, and handed to DearPyGui, and what a +palette change costs. It governs `utils/palette/` and `utils/gui/palette/`. Consult it when adding a +color the interface draws with, or a shade the interface derives from one. + +The design truth it realizes is principle 13 of [`architecture.md`](../architecture.md): a color is a +token, resolved where it is drawn. This document holds the mechanism. + +--- + +## A color is a token + +Every annotation names `BaseColor` (`utils/palette/colors/`): a dataclass field, a signature, a dictionary +key. `WrittenColor` appears only on the Pydantic field that validates a YAML entry, the one place a written +token is read out of the configuration. The `rgba` read happens where the value is handed to a widget, and +a consumer keeps the token, so whoever holds a color follows a palette swap. + +## A shade is composed by naming its form + +`utils/palette/colors/` has one module per form. `base.py` declares the abstract `rgba`, and each form is a +peer module beside it (`literal`, `named`, `faded`, `grayscale`, `blended`, `layered`) that answers with a +`BaseColor` of its own, for example `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. + +## A palette change is one switch + +DearPyGui copies a color when it receives it. `PaletteBindings` (`utils/gui/palette/`) therefore records +each `(item, argument)` a palette color reached, and `dpg_set_palette_color` and +`dpg_add_palette_theme_color` are how a color gets there. + +`PaletteSource.activate` then fires the composition root's listener. The listener re-applies the bindings, +refreshes the viewport clear color, and repaints the sequencer for the row and cell highlights DearPyGui +keeps as table state. + +The `palette-colors` hook reports each of these: an attribute assigned a resolved `rgba`, a theme color +filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` +(see [`architecture.md`](../architecture.md#enforcement)). diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md new file mode 100644 index 000000000..dee2b7839 --- /dev/null +++ b/docs/development/application/playback.md @@ -0,0 +1,164 @@ +# Playback and Transport + +This document governs sound across the application: what may be heard, who decides, and what each transport command means. Consult it when adding audio a user can start, a surface that starts it, or a control over what is heard. The contracts here bind every tab and every player. It complements [`architecture.md`](../architecture.md), [`keyboard.md`](keyboard.md) (which owns the keyboard-routing layer) and [`guidelines.md`](../guidelines.md). + +Two terms recur. A **source** is what plays one kind of audio: a reconstruction, an instruction or the sequencer song. The **transport** is the shared play, pause and stop control over them. + +--- + +## Principles + +1. **The output is one scarce resource, arbitrated by intent.** There is a single device, a single stream and a single thing sounding. Audio a user asked a tab to play outranks audio a click auditioned. Every request says which kind it is, and the arbiter decides what is heard, not the caller's eagerness. +2. **Ownership is the state.** Which source owns the live stream is the one fact that says what is playing. Commands, labels and indicators all derive what they do and show from it, so they agree by construction. +3. **A command addresses one target, resolved from where the user is.** Context decides which source a verb means, and one resolution serves every verb. The same key therefore means the same thing on a given screen every time. +4. **Surfaces describe the target, and the transport decides.** The menu, the toolbar and the keyboard reach identical verbs and report identical state, so a new surface adds another way in to the same behavior. +5. **Listening choices stay out of the document.** What the user chooses to hear is session state, and what the project holds is the whole song. Saving, export, rendering and history read the document, so each of them works on the full song whatever the user is listening to. A render reads the document as it stood when it was asked for: every channel sounding, at unity gain, played through once. +6. **Live state is pulled while sound is produced.** A player reads the settings that shape its sound as it renders, so a change is heard as the render-ahead buffer drains. A listening control therefore takes effect inside the sound already playing. +7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from the project's tempo and meter together with the row's place in the song. It is a function of position: the same row lasts the same time however playback reached it, and a module exported from the song can state the same figures. The integer tick counts the [groove](../../glossary.md#groove) places *are* the tempo, so a render realizes them exactly at every rate it offers. + +## Two kinds of sound + +**Preview** is 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 preview sounds the frames it renders.** An instrument sounds the envelopes it has, and a sample previews the frames its reconstruction recorded. Both sound exactly what they carry, because a recording's drive matters only while the conversion runs. + +**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. An audition and the voice list's preview have no row behind them, so each has a span of its own. A volume dimension that ends at silence releases the voice, and the sound stops there. A voice that circles from a loop point never reaches a last item, so it sounds for the ticks `AUDITION_TICKS` offers it. One count serves both spans, 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, as it does for a reconstruction's playback. A preview follows its own sound alone: `play` reports whether the request took the output, and the audition starts following only when it did. A request 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. + +A report comes from the thread writing the audio, and the mark is a widget, so every report crosses to the render thread before it moves anything (architecture principle 6). A source's player reads where its own playback stands when the report arrives. A seek made in the meantime therefore stands: the mark shows the device as it is, and a report that set out before the seek draws the seek. + +**Intentional playback** is the audio a tab is built around: a reconstruction's audio, an instruction's audio or the sequencer song. The source that started it owns it, and it is resumable, seekable and stoppable. At most one intentional source is engaged at any moment. + +Priority ranks the two kinds and settles every contest for the device. Starting intentional playback preempts a sounding preview, and a preview requested while intentional playback holds the device is declined. + +**A sound starts only on a device in force.** Every request, of either kind, asks the device manager for the output before a thread or a stream opens, so a machine offering no output device refuses the request at once with `NoOutputDeviceError`. One presenter, `PlaybackFailurePresenter` (`coordinators/playback/failures.py`), tells the reader what stopped any sound: a missing output reads as a plain notice, and every other failure as an error. A failure on the thread playing the sound, such as a stream the device refused or a write it failed, reads the same whichever source played it, and is told once. + +## Engagement + +A source is **engaged** while it owns the device output, whether it is sounding or held paused. A source therefore reports itself engaged only while *its own* audio is the one on the device. While a preview sounds, ownership rests outside every source and each of them reports itself idle. + +Engagement is the ownership test of principle 2 in practice, and it is the single fact the transport and the toolbar consult. + +## The target + +The transport acts on one **target**, resolved in two steps. The active tab's own source is the target when that tab has one to play: a loaded reconstruction, a loaded instruction or the song of an open project. Otherwise the target is whatever source is engaged. The Main tab plays only previews, so its target is always the source engaged elsewhere, if any. The transport thus controls the tab you are on when it has something to play, and otherwise controls what is already sounding. + +Starting a tab's idle source takes the device over, from a preview or from a source engaged elsewhere. Stop silences background audio and leaves the device idle. Play passes over a sounding preview whenever the active tab has a source of its own, so a preview answers to Stop alone. + +## The verbs + +The transport's verbs are reached identically from the Playback menu, the toolbar and the keyboard: + +| Command | Behavior | +|---------|-----------| +| 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. | +| Play from start | Starts the active tab's source from the beginning. | +| Play from this frame | Sequencer: plays the song from the first row of the frame the tracker shows. | +| Play from here | Sequencer panels: plays the song from the cursor's row. | +| Stop | Silences everything, the engaged source and any preview, from any tab. | + +Each verb is an action, so the combination it answers to is the shipped scheme's (`sampletones_config/keybindings/`) and every surface prints what the scheme says. + +**A click on a waveform puts the playhead at a sample.** The Reconstructions and Instructions waveforms draw the audio their tab's own source plays, so a click reaches that source at the sample under the pointer. An engaged source moves there and goes on sounding or stays paused, and an idle one starts sounding there. The seek and the ownership it depends on are read under one lock, so a source another has taken the output from starts sounding and does not move a playback it lost. A waveform that draws a voice's own audio draws nothing its player sounds, so it takes no click and its hint names none. + +The left button carries three gestures: a click seeks, a drag pans the view, and a double-click fits it to the audio. `PlotClickGesture` (`ui/elements/graphs/gesture.py`) settles which one a press was, so the playhead and the view each answer only the gesture meant for them. + +The wheel carries the view too. Scrolling zooms x, and Shift with the wheel zooms y. Alt with the wheel pans x, and Alt and Shift pan y, by `pan_factor` of the span in view per notch, stopping at the bounds the graph constrains its axes to. `PlotWheelPan` (`ui/elements/graphs/pan.py`) makes those moves. `GUIGraph` locks both axes on every hover frame Alt is held, so the plot's own zoom leaves the wheel to the pan, and a lane linked to the waveform holds the same lock. The spectrum, whose view is fixed to its band, does not register the pan. + +Because the target prefers the active tab's own source, Play/Pause acts on what the user is looking at whenever that screen can play something. On a screen that plays nothing of its own, it reaches the source already sounding. Those screens are the Main tab, an empty Reconstruction or Instructions tab, and the Sequencer before a project is open. So it resumes a paused reconstruction from the Main tab, and starts the song on the Sequencer with a project open. + +## What the surfaces show + +The toolbar's transport strip and the Playback menu describe the target. The Play/Pause/Resume label and the paused indicator report what the toggle will do. Stop is available while any sound is on the device, engaged or previewed. The display therefore carries across tabs that play nothing of their own, showing the source sounding elsewhere, and shows the local source on tabs that have one. A verb tied to one screen, such as playing from the shown frame, is offered on that screen with its document open. + +**A source reports its own transitions.** Each source tells the surfaces when it starts, pauses, resumes, stops or ends, whichever gesture moved it: the transport, a context menu, a key a grid owns, or the end of the sound. The surfaces therefore stay in step with a source no caller remembered to mention. A preview belongs to no source, so the surfaces read the device for it. + +The sequencer view reports the playhead too, at the reach the **follow mode** chooses: the sounding row, the frame that holds it, or the view the user placed. The mode is one setting with two derived answers: whether the tracker shows the frame being played, and whether it keeps the sounding row at the center of its band. Those two are its whole contract, and every surface that follows the playhead reads them. The song player holds the mode and emits it with every position, so the menu's check and the grid's scrolling settle in one step when the mode changes mid-playback. + +A mark belongs to what it names. The playhead's position is a frame and a row within it. The order grid marks the frame under every mode. The row's mark stands wherever the tracker draws the sounding row: on the frame's own rows while the frame it shows is the frame that sounds, and on a row of the song standing beside that frame while one of those sounds. The mark travels with the frame across a structural order edit. Every mode paints on this rule, and the mode governs where the view sits. + +**The tracker shows the song around the frame.** Its band keeps the row it follows at its center: the sounding row while Follow rows plays, and otherwise the cursor's row as a key carries it. Above and below the frame stand as many of the song's rows as half the band holds, each read as its own frame reads it and drawn dimmed and read-only, and blank where the song ends. Every row of the frame therefore has the room to reach the center, and one calculation places the cursor and the playhead alike. The band measures its height as the window resizes, and a new reach builds the grid again. + +## Keyboard delivery + +Playback keys arrive through the application's single key handler (architecture principle 12). [`keyboard.md`](keyboard.md) describes how a focused field claims some of them and yields the rest. + +## Silencing channels + +The sequencer's tracker channels can be silenced for listening. One mute set holds the silenced channels for the open document, and everything else derives from it: the **active-channel mask** the song mixes through, and solo. Soloing silences the other channels and remembers the mix it replaced, so soloing the same channel again returns to that mix. Deriving both from one set keeps the gestures consistent, because any way of reaching "silence the rest" leaves the same state as any other. + +Every surface shows that one set and switches it. A channel's name is the switch in both the tracker and the order table: click to silence, modified click to solo, the master name for the whole mix. Both tables hand the gesture and its right-click menu to one object, so they offer the same wording and the same behavior, and both take their shades from one pair of colors. 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 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 saving, module export and any rendered output write the full song. The history stack holds project state alone, so undo, redo and history jumps carry the mute set across untouched. The mute set belongs to the listening session, so opening, creating or closing a document starts a fresh one with every channel audible. + +## What the channel holds + +A sample has a value for every dimension of every frame, and its reconstruction names the dimensions the channel governs. The instrument writes the rest itself. Each channel carries a value per dimension (volume, arpeggio, bend, timbre), and an instrument that leaves one empty sounds it at the value the channel holds. A FamiTracker instrument follows the same rule with a sequence left out. + +Every note starts those values where a song starts them: full volume, no arpeggio offset, no bend, the first timbre (duty 0 on a pulse channel, the long mode on noise). A dimension the instrument leaves empty therefore sounds at that start for the whole note, whatever the note before it wrote. An empty volume plays at the row's level. FamiTracker and Bitphase start a note the same way, so an exported song plays in the tracker as it does here. + +Within a note, every frame the instrument writes hands its value to the channel. A silent frame sets its level alone and leaves pitch and timbre where the note last put them. + +The row's level scales the instrument's. A channel sounds their product over the full level. A pulse channel rounds it to the nearest step. The noise channel rounds it down, and sounds the quietest level wherever that comes out silent while both levels sound. FamiTracker and Bitphase set the noise level by that rule, so an exported song's noise plays there at the level it plays here. On the pulse channels the two trackers part: Bitphase rounds to the nearest step as the app does, and FamiTracker rounds down as it does on noise. + +A pass through the song begins on the same values, so starting the song and looping back to its first row sound the same. Seeking within a running song keeps the values, since the sounding note has reached them. + +## What a row's pitch means + +A pitch cell holds what the reader typed: a note the channel sounds, or a step from the voice's own reference, on either kind of voice. The row keeps that face, and the walk resolves it when the row is played: a note sounds where it says, and a step moves the voice from where it rests. A note therefore keeps its pitch when the voice under it changes, and a step keeps its interval, which is what each face is for. + +A voice placed with a pitch starts there. A sample placed without one plays as it was recorded. An instrument has no pitch of its own to sound, so one placed without a pitch restarts its envelopes on the pitch the channel is sounding, whichever voice sounded it; that is how a row retriggers a note already standing. On a silent channel it starts nothing. A channel is silent from the song's start, after a note-off, and while its voice has no frames on it; a voice whose frames have run out is still the channel's note, since no row has ended it. A pitch without a note-on moves the note already sounding and leaves a silent channel as it is. + +The exports write every note at the step the walk gives it, so an instrument-only row reaches a tracker as the note it sounds here, and one that sounds nothing reaches it as an empty cell. The walk is the song's own, driven row by row through the same rule, so the two cannot drift apart. + +## Rendering the song to a file + +A render writes the whole song to an audio file through the synthesizer that plays it. `RowSynthesizer` serves both: the player drives it to feed the device, and the render drives it to feed a file writer. The synthesis is therefore written once, and the file and the playback agree on what the song sounds like by construction. + +Two things differ between them, and whoever asks for the audio sets each. The **document** is read through an interface. The synthesizer reads its project through `ProjectSource`, which the live controller satisfies for playback and a frozen `ProjectSnapshot` satisfies for a render. The player therefore follows every edit as the buffer drains (principle 6), while a render describes one state of the document however the project moves on. The **rate** is the consumer's: the device for playback, the chosen output format for a render. The synthesizer rebuilds its generators and its tick clock when either changes, so a file is written at the rate its engine ran at. + +A rate is asked for once there is audio to take it, which is the first row the synthesizer renders. A device has been chosen by the time playback starts, and a format by the time a render does. A session on a machine with no output device opens on that rule, and everything that writes and does not sound works on it: editing, exporting a module and rendering to a file. + +The session keeps the device the user last applied in Audio settings. A remembered device unplugged for one run is therefore looked for again in the next, the way a remembered folder is. + +The song's exact length follows from the timing model before a sample is rendered. The order's length in rows gives the ticks, and the tick clock gives the samples those ticks span. That figure is what the progress bar counts against and what a finished file measures. + +Rendering is an exclusive operation (architecture principle 10). It occupies the application from the moment its dialog opens until that dialog closes. It joins the same busy authority as conversion and library generation, so each of the three holds the others off and every surface offering one reads a single answer. + +The write takes one pass, or two where the user asks for a normalized peak. The first pass spills raw samples and discovers the peak, and 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, and holds a single unit across both. A cancel is honored between rows and between encoded blocks. A render that is stopped or fails clears the destination and the spill, so a path in a result always names a finished file. + +## Teardown + +The device is torn down once every source holding a stream has released it. A source that streams to the device writes from a thread of its own, so only that source can bring the writing to a stop and hand the stream back. The hand-back is what leaves the backend safe to terminate. + +`PlaybackRouter.shutdown()` is the entry point the application calls as it quits. It reaches every registered source and not only the engaged one, so a source holding a stream is wound down whatever the transport reports at that moment. + +The device holds a release per stream it handed out and invokes it whenever it needs the output free: as the backend is torn down, and on a device change, where the release stops the song so the new device opens cleanly. A stream that outlives its release leaves the running backend in place. The manager reports the failure and keeps the instance, since the source still writes to memory that terminating would reclaim. + +The manager's own playback is held to the same rule. A stop waits a while for its worker and then lets it go, since a device can be slow to open a stream. The manager keeps a worker it let go of, and a teardown waits for it, so the backend is never terminated under an open in progress. A worker that outlives that wait leaves the backend running, as a stream that outlives its release does. + +## Who governs what + +| Concern | Owner | +|---------|-------| +| The device, its stream, and arbitration between requests | `AudioDeviceManager` (`sampletones_core/audio/`) | +| The ranking that settles a contest for the device | `PlaybackPriority` (`logic/shared/`) | +| The verbs, target resolution, and the registry of sources | `coordinators/playback/` | +| A source's engagement reporting | the transport's player protocol, implemented per source | +| What the reader is told when a sound fails | `PlaybackFailurePresenter` (`coordinators/playback/failures.py`) | +| The boundary that hands a transport command's failure to the presenter | `GuardedPlayer` (`coordinators/playback/guard.py`) | +| The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) | +| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | +| The values a note starts from and a channel holds between frames | `ChannelPerformance` (`sampletones_core/performance/state.py`) | +| What a row's voice and pitch start, and what they move | `apply_row` and `note_step` (`sampletones_core/performance/rows.py`) | +| The face a pitch cell holds and the step it asks for | `Note`, `Step` and `step_of` (`sampletones_core/project/patterns/pitch.py`) | +| How a row's level and transpose reach what a channel sounds | `apply_modifiers` (`sampletones_core/performance/modifiers.py`) | +| How long a row lasts, and how many samples its ticks span | `SongTiming` and `TickClock` (`sampletones_core/timing/`) | +| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) | + +The sequencer song is an ordinary intentional source alongside the reconstruction and instruction players. It implements the same protocol and is arbitrated by the same rules. diff --git a/docs/development/application/reconstruction-edits.md b/docs/development/application/reconstruction-edits.md new file mode 100644 index 000000000..f6ed122ef --- /dev/null +++ b/docs/development/application/reconstruction-edits.md @@ -0,0 +1,75 @@ +# Editing the Open Reconstruction + +This document describes how the Reconstructions tab changes the document it has open. It governs +`logic/reconstruction/rewrites/` and the parts of `ReconstructionCoordinator` that put a document away or +wait on its edits. Consult it when adding a gesture that edits the open document, one that reads or puts +away the whole document, or one that replaces the document from outside the tab. + +A rebuild of an edited channel runs on a worker thread, so the reader can make the next change before the +last one has landed. Every rule below keeps the document as though each change had landed before the next +one was made. + +--- + +## One step at a time, in the reader's order + +The open document changes as a line of steps: a channel the reader moved, a recording taken out, a new NES +frequency, and a gesture that reads or puts away the whole document. The line takes one step at a time, in +the order the reader asked for them, and each step is built from the document the step before it left. Two +quick edits therefore both land, whichever dimensions or channels they touch. + +## A step carries what the reader moved + +A channel change holds the dimensions the reader moved alone. At its turn it is written over the +envelopes the document holds then, and it reaches the recordings the reader hears then. The rest of the +channel is read afresh, so what a step before it rebuilt stands. A set of envelopes read from an older +document names every frame of the channel, a frame a removal has since released included, and writing it +back would make that frame the reader's own ([Stems in the application](stems.md)). + +Changes of one channel waiting next to each other merge into one, so a drag collapses into the place it +ended. A change joins a change of its own channel waiting at the end of the line. The running rebuild and +every other step keep their places, so each position the reader asked for is rebuilt or overtaken by a later +one. + +## A result lands on the document it was computed from + +A rebuild remembers the document it started from, and its result lands only while that document is open. A +change from outside the tab that replaces the document, such as a load, opening another voice, a close, an +undo reaching the open sample or a replaced sample, puts away the edits meant for the document it replaces. +The result of a rebuild still running for it is dropped when it arrives. A closed or replaced document +therefore keeps what it held, and the history stays as it was. + +## The panel draws what the document will hold + +The instruments panel reads the document's envelopes through the changes still on their way, so the bars, +the plots and the byte figures answer for the document once the line empties. A change the line lets go of, +such as a failed rebuild, redraws the panel from the document as it stands. + +## A step that no longer applies is skipped + +A step meets the document the steps before it left. A removal of a recording an earlier step already took +out, or of the last recording standing, is skipped. So is a rate the document already runs at. + +## A gesture on the whole document waits for the edits before it + +Undo, redo and a history jump, saving, loading, opening a voice, closing, exporting, adding the document to +the sequencer, the project's own save, new, open, close, export and render, a new project rate, removing, +replacing or duplicating a voice, taking an instrument from it or exporting one, and the exit all read or +put away a whole document. Each waits for the edits made before it, so an undo right after a drag undoes the +drag and a save writes it. With nothing on its way, the gesture runs at once. + +A channel change drawn while a removal or such a gesture waits is refused, since it was drawn on a view the +waiting step is about to change. The panel is redrawn once the line empties, which takes the refused change +off the screen. + +## The project rate reaches the open sample as a step + +A new project rate retunes the samples in the background, one batch for all of them. The rate waits for the +edits before it, and the sample open on the tab then takes it as a step of its own line, so the sample +joins the rate change's history entry with every other sample. A batch result for a sample edited since the +batch started is retuned again from what the sample now holds. + +## The waveform fades while the line is busy + +The waveform is faded from the moment a step waits or runs until the last one lands. A reader sees the +document is being rewritten for the whole span, however many steps it takes. diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md new file mode 100644 index 000000000..c38ae2d30 --- /dev/null +++ b/docs/development/application/render-thread.md @@ -0,0 +1,95 @@ +# The Render Thread and What Crosses to It + +This document describes how work reaches DearPyGui from somewhere other than the thread that owns +its context, and what each crossing costs. It governs `utils/gui/render_thread.py`, +`utils/gui/callbacks.py`, `utils/gui/frame.py`, and the queue in `utils/callbacks/`. Consult it when +a worker thread has something to show, when a gesture rebuilds widgets, when work needs a frame to +have been drawn first, or when work should run after a delay. + +The design truth it realizes is principle 6 of [`architecture.md`](../architecture.md): DearPyGui's +context belongs to the render thread. This document holds the mechanism. + +--- + +## A background result crosses through `CallbackQueue` + +Services run long work on background threads and post each result to `CallbackQueue` with a priority. The +main-thread render loop **drains** the queue: each frame it runs the due results within a per-frame time +budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across frames while rendering +continues. Every background result reaches UI state this way. Applying one to UI state directly from the +worker thread is forbidden. + +A logic object hearing a worker's report, such as a library generation or the audio device's position, +posts its own handler to the queue the same way. The logic layer reaches the queue, and `utils/gui` +belongs to the visual layers. + +A failure crosses the same way. `UnhandledFailures` posts the report of a failure nothing recovered from to +the queue, so the error dialog is built on the render thread whichever thread failed (see the Error Handling +Policy in [`architecture.md`](../architecture.md#entry-points--the-last-resort)). + +## Work arriving from a worker crosses through `on_render_thread` + +A thread of our own, such as a subtree being rebuilt, reaches the interface while the render thread is +walking the very items it would create and drop. An item freed there is freed with no Python thread state, +which crashes the process. + +`on_render_thread` (`utils/gui/render_thread.py`) is the crossing. Work already on the render thread runs +where it stands, and work arriving from any other thread joins the queue. A worker that reads a value or +sets one on a standing widget still goes through it, since the hazard is the thread and not the gesture. + +A run claims the drawing thread when its loop starts. Where no run has claimed the thread, as while the +interface is being built, the work runs in place. + +The thread that drew keeps the context after the loop stops, until the context is destroyed, since the +teardown runs there. A worker's work that arrives after the loop has stopped joins the queue like any +other, and nothing drains the queue once the loop has stopped, so that work never runs. The context being +taken down therefore stays with the thread taking it down. A worker reading something long, such as a folder, also listens for +the shutdown and gives up at its next step, so the teardown finds it ended. + +## A widget's own gesture is held for the frame + +DearPyGui answers a gesture on a thread of its own, so a callback that rebuilds widgets there runs while +the render loop walks the very items it drops. `hold_callbacks` (`utils/gui/callbacks.py`) turns on manual +callback management when the context is created, and `run_held_callbacks` runs what DearPyGui gathered at +the top of each frame's drain. A gesture therefore reaches the interface from the thread that drew it, and +`on_render_thread` is a direct call inside a callback, because the callback already runs there. + +What a gesture costs is paid between frames. A callback heavy enough to be felt should spread its work +across frames itself. + +**A held gesture reads the items still standing.** The callback runs after the frame that gathered it, and +a rebuild in between can take away the row a hover, a click or a drop landed on. A callback reads what that +item carries through `dpg_get_item_user_data` (`utils/gui/dpg.py`), which answers nothing for an item that +is gone, so a gesture on a row the list has dropped says and does nothing. + +## A gesture that waits keeps the frames going + +A callback on the render thread holds the frames up for as long as it runs, and a native dialog runs for as +long as the reader takes to answer it. `answered_while_drawing` (`utils/gui/render_thread.py`) puts that +waiting on a thread of its own and draws frames until it reports back. `utils/file_dialogs/api.py` opens +native dialogs this way. The gestures those frames gather wait for the drain that follows, so the interface +stays painted while it is inert. + +## Work that needs a drawn frame names the frame it waits for + +Reading a laid-out size, or letting a configuration take effect, needs a frame to have been drawn with it. +The drain runs between frames and not inside one. `FrameCallbackManager.set_frame_callback` +(`utils/gui/frame.py`) names the frame the work is picked up on, and a callback uses it to wait for one. + +Work that needs a particular item drawn waits on that item instead. An item visible handler reports each +frame DearPyGui draws the item in and no frame it is hidden in, such as a collapsed card or a tab in the +back. Standing the handler on and off makes it the clock of work that follows the item on screen. The stems +list settles its windowed region this way. + +The drain makes the wait a scheduled one. The render thread inside a drain is between frames, so the next +frame is the drain's own to reach. `dpg.split_frame` there waits for the very frame the wait itself +prevents, and the application stops for good. Naming a frame count with `set_frame_callback` asks for the +same wait and lets the loop keep running. + +## Delayed work goes through the queue + +To change the interface after a delay, post the change with `call_after` (`utils/callbacks/delay.py`). The +change waits in the queue until the delay has passed, and then runs on the render thread. + +Work still waiting in the queue at shutdown is discarded, so delayed work never reaches the interface after +the context has closed. A separate timer thread could make such an access and crash. diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md new file mode 100644 index 000000000..f0e330ceb --- /dev/null +++ b/docs/development/application/screens.md @@ -0,0 +1,356 @@ +# Screen Scenarios + +This document governs the screen tier: tests that run the whole application, draw its frames on a display, +and press its controls the way a user does. It covers `tests/screens/`, where the scenarios live, +`tests/suite/screens/`, which holds the material they seed their homes with, and `automation/`, the unit that +drives the application for them. Consult it before writing a scenario, and when a change to the interface +needs proving that no lower tier can give. + +--- + +## Principles + +### 1. A behavior is proven at the lowest tier that sees it + +Most behavior is proven below this tier. Logic is tested without DearPyGui, a widget on a real context +with simulated frames, and the whole application headless with its display calls stood in for. None of +them draws a frame. A screen scenario covers what only a drawn frame shows: where a control stands and +whether it fits, what a frame-lag contract decides, which scope a real key press reaches, hover, drag and +double-click, the exit and its teardown, and what a restart restores. A rule a screen depends on gets its +own case below, and the scenario keeps only what the screen adds. + +### 2. A scenario drives the application a user runs + +A scenario starts the application as `sampletones open` does and runs its own render loop unchanged. The +pointer and the keyboard are real input sent to the display, so a press travels the path a user's press +travels. A scenario reads what the screen shows and what the application wrote to disk. It reaches the +application through what the application makes public: tags, language keys, shortcuts, services and the +modal queue's snapshot. A seam a scenario needs and cannot find is added to the application as a public +one, which keeps the scenario independent of how the code is arranged inside. + +### 3. Each scenario runs in a world of its own + +Every scenario runs in a fresh process started in a home directory of its own, so settings, session state +and the documents folder hold only what the scenario put there. The process ends with the scenario, so the +exit and its teardown are part of what the scenario proves, and a crash on the way out fails it. A worker +draws on an X server of its own, so its scenarios take only its own input. + +The `world` fixture says what the home holds before the first frame: the session a previous run left, the +settings, and the recordings, documents and libraries the scenario works on. Each file is written by the +model and the manager the application reads it with, so a seeded home is one a real run could have left. A +scenario class overrides `world` to seed its own, and a folder of scenarios working on the same documents +overrides it once in its `conftest.py`. The [compatibility corpus](../../../tests/data/compatibility/README.md) +supplies the documents a release wrote, whole or damaged: restated at a version no step reaches, cut +short, or replaced by bytes of another kind. + +### 4. A scenario is written to break the application + +A scenario that only confirms the path its author had in mind proves little. Each rule a scenario proves +gets a positive example and a negative or boundary one, with real values, and each area is attacked along +the [questions](#questions-a-gui-change-asks) below. + +- **A negative ends with its witness.** After the gesture that should change nothing, the same path is used + once more where it should act, and then the whole state is asserted: the count is one lower and the row + that left is the one named. A late effect of the negative then shows in the count. Waiting a fixed + window instead would only restate the application's own delays. +- **A gesture confirms that it arrived** (see [Gestures](#gestures)), so a negative never passes because + its input was lost. +- **A frame-lag promise is read on every frame** between the gestures, since a reading once a frame until + it holds misses a flash that lasts one frame. +- **Where several states meet several doors, one suite walks the table**: every document state against + every door that closes it, every kind of broken file against every door that opens one. +- **An expected value comes from the rule**: a language key with its arguments, a palette token, or a hand + computation written in the test. A value the code under test answered proves nothing about that code. +- **A scenario that could pass vacuously is shown failing** on a fault planted for the purpose: a negative, + an attack, or a check that reads state the gesture might never reach. + +A break of something a document or a docstring states is recorded as a known bug, below, and its fix is +separate work. A break of behavior nothing states is a question for the maintainer. + +### 5. A scenario waits for what it expects + +Waiting is for a reading to hold. An expectation reads once a frame until it holds or its time runs out, +and a gesture waits for its control to be reachable and to stand still. A fixed sleep hides a race, and a +retry hides a bug: a scenario that passes only sometimes is a bug. A count of frames appears in a scenario +only where the count is the contract itself. + +A wait's time runs in seconds and in frames: the frames a machine drawing 30 a second draws in those +seconds. The wait ends once both have run, so a machine drawing slowly gives the application as many frames +as a fast one before an expectation fails. A witness that lasts a moment is read where it stays: a sound +counts once it reaches the output device, and a stopped read that must still be winding down is held there +until the scenario lets it go. + +### 6. Every scenario is held to the same promises + +Every scenario is held to the same after-checks. A failing gesture is reported and the interface keeps +running: the failure is logged, and the error dialog shows it unless a report already stands. The checks +therefore read the log as well as the screen. + +- **Quiet.** The application logged no error and no thread let an exception escape. A scenario that + provokes a failure claims the error it provokes, and an error nobody claims still fails it. +- **Contained.** The application started no program, and every file dialog it opened had an answer + waiting. +- **Settled.** No modal conversation is left open, and every window lies inside the viewport. +- **Leaving cleanly.** The application leaves through the Exit shortcut without asking anything, stops, + and writes its session state. Its background work winds down within the exit's deadline, and no thread + of its own outlives the exit. + +A known bug is a case marked `xfail(strict=True)` that names its [ledger](../bugs-and-todos.md) entry. The +mark makes the run fail once the bug is fixed, until the mark goes. A known crash is marked the same way: +the parent reports a marked scenario whose process a signal killed as the failure it expects, naming the +signal. + +### 7. The driver is a unit of its own, and its DearPyGui layer knows nothing of SampleToNES + +`automation/` operates the running application from outside, for whoever needs it driven: the scenarios +here, and the pictures the guide is illustrated with ([assets](../assets.md)). It stands beside the +source tree as a checkout unit ([tooling](../tooling.md)): it imports the program and nothing from +`tests/`, and the import-boundary check holds it to that. The scenarios' material stays in the test tree: the seeds and +the worlds built from the archived corpus, the mini library and the history projects are what the +scenarios prove with, so `tests/suite/screens/` keeps them and the plugin asks for a world by its fixture. + +Inside the unit, `automation/dearpygui/` drives any DearPyGui application: the process per scenario, the +display, the input, the readings and the waiting. The SampleToNES layer around it holds what belongs to +this application: the views, the stand-ins at its boundaries and the after-checks. A case in +`tests/integration/tooling/` holds the direction. The DearPyGui layer grows only when a scenario here needs +something, and it moves to a project of its own the day a second application wants it. + +## How a scenario runs + +The parent pytest run starts each scenario as a child pytest run of that one test, in a fresh interpreter. +The child writes its reports as it goes, and the parent reports them as its own, so selecting, rerunning and +strict expected failures behave as for any test. A child that crashes or runs out of time is reported as a +failure carrying its output, unless a crash is the known bug its scenario is marked with. + +Inside the child, the application is built on the main thread, where its render loop runs. The scenario +runs on a thread of its own. Every reading and every gesture crosses to the render thread through the +queue the application's own workers post to, between two frames ([the render thread](render-thread.md)). + +## Gestures + +A pointer gesture aims at the middle of a control once the control is in reach: it exists, it and every +container around it are shown, it was drawn in the last frame, it and every container around it answer a +press, its box lies inside the viewport, its middle lies in the part the regions around it leave in view, and +no modal window of another tree holds the screen. The control must also stand where it stood a frame before, +since a dialog sized by its content settles its place over its first frames ([dialogs](dialogs.md)). + +A control in a region that scrolls is brought into view the way a person brings it: the wheel turns over a +region that takes the wheel, and the grip of a region's scrollbar is dragged where the region ignores the +wheel. The hand measures how far to scroll once a frame has laid the control out: a row found in the frame +that built it has no place yet, and a region learns how far it scrolls a frame after it draws what it +holds. A table that scrolls its own rows, such as the tracker's, is a region for those rows: its view +begins below the rows it freezes, and those stay in view whatever the scroll. + +Keys are pressed on the real keyboard, with modifiers held a frame before the key. A scenario names an +action by its `ShortcutId`, and the keys come from the scheme in place. The display repeats no held key. + +Dear ImGui judges a press by time: two presses count as a double-click when they land within 0.30 s, and a +key held past 0.275 s repeats. A click and a key press therefore go down and come up in one go, and Dear +ImGui reads the press on one frame and the release on the next at any frame rate. A key stands down for a +single frame, too briefly to repeat, and the presses of a double-click land two frames apart. Where two +frames take longer than the double-click time, the hand raises `SlowFramesError` and says how far apart the +presses landed. + +A person lets go of a click some frames after pressing it, and DearPyGui answers a double-click as its +second press goes down. A scenario holding that press for as long as a person does sees what the answer +brings under a button still down. + +A gesture confirms that it arrived. Before a button goes down, the control must report the pointer resting +on it: DearPyGui calls a control visible while the region it scrolls in clips it, and a press there lands +on something else. While a button or a modifier is held, the application must read it as down within a +few frames, since a busy display hands input on late. A click or a key press that came and went is +confirmed by a witness, a handler registry of the hand's own that counts every release and every +double-click Dear ImGui reads. Each step of a drag waits until the application reads the pointer where the +step put it, and the pointer rests at the end before the button comes up, as a person stops before letting +go. A press lost on the way fails where it was lost. + +A worker's scenarios take its display in turn, and a key or a button left down would meet the next +scenario's first gesture. A gesture that fails lets go of what it holds, and each scenario starts and ends +with every key and button of the display up. + +A plot reads the pointer as it draws, so the value it puts under the pointer trails the pointer by a +frame. A scenario that aims by plot values reads one once the plot has kept it for two frames in a row. + +DearPyGui reports a position alone for a menu entry, so choosing one runs its callback the way a click does, +through the queue's own error reporting. + +## Views + +A scenario speaks through views, one per tab, card and dialog, under `automation/views/`. A view +knows its controls by their tags and its words by their language keys. A tag that moves changes one view, +and a scenario reads as the gestures a user makes. + +What only the drawn frame shows is read from it: `screen.frame_pixels()` hands over the next frame, which is +how a menu popup's width is measured. + +## The boundaries + +- **File dialogs.** A native dialog stops the queue while it stands, so a scenario queues its answer before + the gesture that opens the dialog, and the stand-in answers at once. An answer names a folder that stands + on the disk, as a native dialog's does, and a save may pick one of the file types the dialog offers by + its name. +- **Audio.** The default output device plays into silence in real time, so playback runs and nothing is + heard. It is the one device the application finds on every machine: the scenario's ALSA reads a + configuration of its own, which keeps the machine's sound cards and sound server outside the run, and a + clock returns each write and each stop when a device would. JACK looks for a server no one runs and + starts none, so a JACK server on the machine stays outside the run too. A scenario can start on a + machine offering no device at all, or on one whose device refuses every stream, with the same + configuration in place. +- **Programs.** An audit hook refuses every program the application tries to start: a file manager, a + browser or a dialog tool would open on the desktop around the run. A shared library lookup passes, and + so do the stand-ins below. +- **The file manager.** Stand-in programs lead the search path: `xdg-mime` names a file manager, and that + file manager and `xdg-open` note each path they are asked to show. The application's own reveal code + runs, and `screen.revealed()` reads what it showed. +- **The window manager.** The scenario's X server runs none, so `screen.close_window()` sends the request + a title bar's close button sends. +- **Table highlights.** DearPyGui holds a table's row, cell and column highlights as table state and + reports whether one stands, so the calls laying and lifting them are wrapped from the first frame on, and + `screen.table_highlights()` reads each highlight standing with its color. + +## Holding work in flight + +A gesture made while work runs is only proven if the work is provably still running when the gesture +lands. A hold keeps it there: a stand-in decides **when** the work lands and never **what** it computes. +A held conversion walks the stages a real one reports and stops halfway through matching, and a held +folder scan meets the folder's entries and then one entry after another that is no recording. Each goes +on reporting while it is held, so Stop and Cancel are heard where the real work hears them. A held rebuild +of an edited channel waits on its worker before it computes, so the edit is drawn while it is on its way, +and the real rebuild runs once it is released. A scenario standing for a rebuild that breaks lets the held +rebuilds go as that failure, which reaches the application the way a rebuild that raised does. A held +export stops at one report of its stages, after its window has heard the stage, and goes on answering a +cancel there; a scenario can move the report it stops at, to land a cancel between two files. A hold lasts +until the scenario releases it, and every hold is released before the scenario leaves, because leaving +waits for the work in flight. A scenario asks for a hold through its fixture: `conversion_hold`, +`scan_hold`, `regeneration_hold` or `export_hold`. + +## What survives a restart + +What a restart keeps is proven in two halves, each a scenario on a fresh home. In the first, the application +leaves, and the scenario reads the files it wrote through their models. In the second, the world seeds the +same value through the same model, and the scenario reads it on the screen. A writer and a reader that +disagree fail one of the halves. + +## Questions a GUI change asks + +Before a change to the interface gets its scenarios, ask which of these it meets, and provoke it in a +scenario: + +- **Render thread.** What does it touch in DearPyGui away from the render thread? +- **Teardown.** What happens when the window closes while it runs? +- **Modal over modal.** What if its dialog opens while another stands, waits or answers? +- **Frame lag.** Which geometry does it read in the frame that changes it? +- **In flight.** What if the user acts while its work is still running? +- **Unsaved work.** What does every path that replaces or closes a document ask? +- **Twice.** What does the same gesture do twice, and a double click against a single one? +- **Held press.** What does a press still down when the screen changes do to what comes under the pointer? +- **Key scope.** Which scope claims its keys: a field, a modal, a panel, a collapsed card or a rebound key? +- **Palette.** Does a live palette swap repaint it? +- **Restart.** What of it survives a restart? +- **Scale.** Does it still answer with a large folder or a long list in it? +- **Failure.** What does the user read when a file is missing, old or truncated, or a write fails? + +The list holds questions, never history. A fixed bug becomes a scenario, a step, a stand-in or an +after-check, and the list keeps only the general question that leads to it. A question joins only when no +question here would have led to the test that caught a bug, and near-duplicates merge, so the list stays +short enough to read before every change. + +A bug fix's scenario is shown failing on the code before the fix, and the pull request says so. + +## How the scenarios are laid out + +`tests/screens/` has a package per area of the application (`application`, `exports`, `history`, +`instructions`, `interface`, `main`, `prompts`, `reconstructions`, `sequencer`), and an area has a package +per subject. +A subject package holds one file per responsibility: the classes in a file prove one family of rules of +that subject, and a file that grows past about 300 lines or starts answering a second question is split +by subject. A file name says its responsibility, such as `test_note_keys.py`. + +What several files of a subject share sits in modules of the package, written once two files need it: + +| Module | Holds | +|---|---| +| `constants.py` | Scalar `Final` constants and language keys | +| `cases.py` | Case dataclasses, followed by the tables built from them | +| `steps.py` | Steps, readings and the builders of a world | +| `conftest.py` | Fixtures every file of the package uses, such as `world` and `startup` | + +What several areas share lives in `automation/vocabulary/`, one module per area: the keys of +the dialogs and their messages, of the converter and of playback. The names of the seeded recordings +stand with the seeds, in `tests/suite/screens/seeds/`. +A value that one file alone uses stays in that file. Constants stand directly under the imports in every +module, so a reader meets the values before the code that uses them; the tables of `cases.py` follow the +types they are made of. A case in `tests/integration/tooling/` holds that order. Scenario files import from +`constants.py`, `cases.py`, `steps.py` and the vocabulary, and never from one another. + +The driver under `automation/` follows the same rules: a package per concern (`application`, `boundaries`, +`holds`, `plugin`, `views`, `steps`, `worlds` for the world type and the empty world, and the DearPyGui layer +with its `gestures` and `items`), and a module per responsibility inside it. So does the scenarios' material +under `tests/suite/screens/`, with `seeds` and the `worlds` built from them. + +### Docstrings + +A docstring tells a reader what is tested, how the scenario goes and what is expected, in a few plain +sentences. + +- A test class opens with the rule it proves, told from the user's side. A second sentence gives the + logic where the method names leave it open: the setup, the gestures and the witness a negative ends with. +- A test method gets one sentence on what is expected where its name does not already say. +- A fixture says what the home holds or what opens at start. +- A step or a reading says what it does and what it leaves behind. +- A case type says what one row means, and each of its fields what it holds. +- The steps defined inside a test carry their gesture as their name and need no docstring. + +## Running and watching + +`make screens` runs every scenario across a few workers, each on an Xvfb of its own. Each worker draws +on the processor, so CI runs two of them, which leaves each application more of a small runner's processors +for its frames. `make system-deps` +installs Xvfb with the other development packages on Linux, and a missing server is named when the run +starts. The scenarios run on Linux, and [the ledger](../bugs-and-todos.md) holds the other platforms. + +To follow a scenario as it plays, draw on Xephyr, which opens its screen as a window on the desktop: + +``` +SAMPLETONES_SCREENS_DISPLAY=xephyr uv run python -m pytest tests/screens --no-cov -k display_settings +``` + +A scenario's home is scratch: it is built from the scenario's world in a homes folder made for each +worker under the system's temporary folder, whose path holds no hidden folder, so the application's +browsers reach it from a worktree under `.worktrees/` too, and it goes once the scenario ends. A homes +folder inside a hidden folder is refused, and `SAMPLETONES_SCREENS_HOMES` points the run at another +parent. The application's process keeps the machine's own temporary folder, as it does on a user's +machine, since the socket files a conversion opens there need a short path. The worker's folder is +named after its process, and each worker's first scenario removes the folders whose process has gone, +such as a crashed worker's, and leaves another live run's alone. A folder a scenario left locked opens +again before its home is copied and removed. + +What a run keeps lies under `build/screens/`, in a folder named after the test: the reports, and when the +scenario failed, a screenshot of the last frame and a copy of the home it left. A copy that lost files +leaves a note beside it. `screen.capture` keeps a picture as evidence of a look, for a pull request rather +than an assertion. `SAMPLETONES_SCREENS_KEPT` moves that folder, and `SAMPLETONES_SCREENS_SCREEN`, read as +`WIDTHxHEIGHT`, sizes the virtual screen, so a run made for another purpose keeps apart from the scenarios'. +`SAMPLETONES_SCREENS_SHARD`, read as `INDEX/COUNT`, keeps every `COUNT`-th scenario of the collection sorted +by node id from the `INDEX`-th, so CI spreads the scenarios over several machines, each drawing two +applications at a time, and the scenarios run beside the rest of the suite instead of after it. + +## Who governs what + +| Concern | Owner | +|---|---| +| A scenario per process, its reports | `automation/dearpygui/isolation.py`, `plugin/hooks.py` | +| The fixtures a scenario asks for | `automation/plugin/fixtures.py`, `plugin/hold_fixtures.py` | +| The render thread crossing and waiting | `automation/dearpygui/bridge.py` | +| What a user can reach | `automation/dearpygui/reach.py`, `semantic.py` | +| The gestures | `automation/dearpygui/hand.py` and `gestures/`: `arrival.py` (waiting and confirming), `witness.py` (what Dear ImGui counted), `pointer.py`, `scrolling.py`, `keyboard.py` | +| What a reading says of an item | `automation/dearpygui/items/`: `reading.py`, `regions.py`, `texts.py`, `colors.py`, `viewport.py` | +| The display a worker draws on | `automation/dearpygui/display.py` | +| The application under test and how it ends | `automation/application/` | +| The stand-ins at the boundaries | `automation/boundaries/` | +| Work held in flight | `automation/holds/` | +| The after-checks | `automation/checks.py` | +| What a scenario holds and reads | `automation/screen.py`, `views/`, `steps/` | +| The world type and the empty world | `automation/worlds/home.py` | +| What a scenario's home holds | `tests/suite/screens/worlds/`, and the files it seeds in `seeds/` | +| The words several areas share | `automation/vocabulary/` | +| The window manager's requests | `automation/dearpygui/windows.py` | diff --git a/docs/development/application/sequencer-blocks.md b/docs/development/application/sequencer-blocks.md new file mode 100644 index 000000000..4530fb5ca --- /dev/null +++ b/docs/development/application/sequencer-blocks.md @@ -0,0 +1,143 @@ +# Sequencer blocks + +A **block** is a rectangle of one sequencer grid, lifted out of the song so it can be written back somewhere else. Copy, cut, paste and delete are the four gestures over it, and both grids, the tracker's pattern rows and the order's frames, carry the same set. Each grid's first column is an **aggregate** that summarizes the channel columns beside it: the tracker's **Voice** column and the order's **Master** row. + +This document describes the rules those gestures follow, how a block leaves the app as text, how a selection is drawn, and how a grid's actions reach the menus and the keyboard that fire them. Consult it when changing copy, cut, paste, delete, shifting or selecting in either grid. [Architecture](../architecture.md) describes the layering they sit in, and the [coding guidelines](../guidelines.md) hold the conventions the code follows. + +## Three vocabularies, kept apart + +A gesture crosses three representations, and each has one owner: + +| Term | Where it lives | What it names | +|------|----------------|---------------| +| **Cursor** | `ui/panels/sequencer/input/` | Where the reader is typing, plus the anchor a selection was started from | +| **Region** / **Cell** | `view_model/sequencer/region.py` | The rectangle a gesture acts on, and the single cell a paste is anchored at, in grid coordinates with inclusive bounds | +| **Block** | `logic/sequencer/tracker/`, `logic/sequencer/order/` | The values themselves, keyed by offsets from the cell they were read at | + +A region names *where*, and a block carries *what*. A block holds offsets and not coordinates, which lets it land anywhere it is anchored. + +Two axes underpin both grids: + +- **`CHANNEL_AXIS`** (`constants/sequencer.py`) lists the aggregate first and then the four channels. Index 0 is the aggregate column and 1 to 4 are the channels. Both grids lay out along it, so a row index means the same thing in either. +- **`TrackerSlot`** (`view_model/sequencer/slot.py`) pairs a column with a subcolumn and can be read as a single flat index. Navigation and selection walk the flat index, and an edit addresses the pair. + +## A cell reaches a block in one of three states + +The block's map alone carries the state, so every consumer reads it the same way: + +| State | In the map | Written as | +|-------|-----------|------------| +| A value | Key present, holding it | That value | +| Empty | Key present, holding `None` | Emptiness: the target is cleared | +| Mixed | Key absent | Nothing: the target keeps what it had | + +Mixed is what an aggregate cell reads when the channels beneath it disagree, the same `?` the grid displays. An aggregate cell that answers for no channel reads as empty, the way the grid draws it. The tracker's pitch and volume do so on a row where no sample is playing. Display and clipboard route through one rule, `Agreement` (`sampletones_shared/utils/agreement.py`), so a block says about a cell exactly what the table it was read from shows there. + +Absence also settles the order's growth (below): a column a block says nothing about reaches nothing. + +## Kind alignment is arithmetic + +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: 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 carries the rule. The anchor decides *where* a block lands, and the block decides *which kind* goes where. + +## A paste is a run of the single-cell edits + +The writers resolve every cell to the single-cell edit the grid already has. No writer restates the aggregate column's fan-out, so a pasted cell means exactly what the same value typed by hand means. Each write is therefore explainable, and the aggregate's rules have one home. + +The order the writes are taken in has two consequences: + +- Within a position, the aggregate row is written before the channels beneath it, so a 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 **Voice** column clears the channels of that row. + +## The order grows to what a paste reaches + +A block pasted past the last frame appends frames. The rule is stated in terms of writes and not of the block's shape: the order grows to the last position a write actually lands at. A `?`-only overrun column appends nothing, and one holding an empty cell appends the frame it silences. Rows clipped at **Noise** take their columns' growth with them. + +Growth runs before the first write, so one history entry covers the appended frames and the values in them, and a single undo takes both back. Delete keeps the order's length: emptied trailing frames stay as silent ones. + +## A shift reads the columns behind a region + +Transpose and volume move whole cells, while a region names its edges as subcolumns. A shift therefore reads the columns a region covers (`TrackerRegion.columns`) and reaches each of their channels once, at every row the region spans. Two consequences follow: + +- A nudge raised with the cursor on a volume subcolumn still moves that cell's transpose. +- A region covering the sample column together with a channel beneath it moves that channel a single step, since the sample column represents the channels a value typed in it writes to. + +Each cell reaches the grid through the single-cell adjustment that already governs it, as a pasted cell does. A shift therefore lands exactly the writes the same nudge repeated by hand would make, the transpose and volume ranges included. + +## A block states itself as text + +A copy also writes the block to the desktop's clipboard, as the lines the grid prints. A tracker block: + +``` +SampleToNES/1 tracker rows=2 slots=3..5 +00 +05 3 +.. -02 . +``` + +and an order block: + +``` +SampleToNES/1 order rows=1 positions=0..1 +00 03 +``` + +The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and strings alone. The desktop's clipboard is reached through `TextClipboard` (`utils/gui/clipboard/protocol.py`), one more piece of external behavior behind a protocol ([Architecture](../architecture.md), principle 11). The sequencer coordinator wires the two. + +**A field prints what the grid prints in its cell**, which carries the three states across. A value reads as its value, an empty cell as the dots beneath it, and a mixed one as the marks filling its field. The marks fill the whole width, so every line measures the same and a block pasted into a message still reads as a grid. Reading takes any run of them. + +**The header is a declaration the body is held to.** It names the grid, the count of rows, and the span of slots or positions the block stands on. A body whose lines or fields disagree with it is not a 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 voice by list position**, the figure the grid prints. A block carried to another project therefore plays whichever voice stands at that position there. A position the project's list falls short of reads as mixed, as the writer already treats a voice it has nothing to place. + +**A pitch field prints the face its cell holds**: a note by its name, a step as a signed decimal offset. A name ending in the noise channel's suffix reads as a period, and any other name as a note the tonal channels play. + +A field the form has no reading for makes the whole text a refusal, so a parse answers with a block or with nothing. Names and digits are read in either case, and steps and volumes are held to the ranges a row accepts, so text typed by hand lands the values the grid would. + +### Which block a paste writes + +A copy writes both clipboards, and a paste asks the desktop for its text first. That text stands while it parses as a block for *that* grid. Any other text leaves the grid's own block in hand. A block copied in a second instance therefore pastes here, and a copy taken in this one survives whatever else the desktop picks up afterward. `can_paste_block` asks the same question through a `ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one string compare. + +The program that owns the desktop's clipboard answers a read in its own time. A paste therefore writes its block only after the answer arrives. A menu shows Paste based on the last answer it received, and updates the item when the new answer arrives. + +## A grid declares its actions once + +Where they are shown is decided by whoever asks for them. Each grid builds its whole action set from one **target**: the cell a gesture is aimed at, paired with the region that gesture acts on. Three surfaces resolve that target their own way. The keyboard and the menu bar's **Edit** menu both aim at the cursor's cell and anchor a paste there. A context menu aims at the cell it was raised on and anchors a paste at that cell. + +The region behind a target is `region_at` on the shared input state: the selection when the cell falls inside it (`Region.covers`), and the cell alone otherwise. Copying one cell therefore needs no selection made first, and a menu raised inside a selection acts on the whole of it. + +One builder means an action added to a grid appears on every surface. The accelerator **Edit** prints is the one that grid answers to, since a binding is declared once and every reader of it reads that entry ([Architecture](../architecture.md), principle 12). + +`EditRouter` (`coordinators/edit/`) is the menu-side counterpart of the `KeyRouter` the keyboard runs through. Each surface says whether it owns the editing gestures at this moment, with the same predicate its key scope answers with, so the menu offers what the next press would reach. The router asks the surface that does to build its items into the menu the bar has opened. The router holds no state and resolves the surface on each call, so the menu shows the actions of whoever holds the cursor at the moment it is opened. When no grid answers, the bar shows the clipboard commands grayed out, so a reader working from the menus learns they exist. + +**`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share a combination inside a shortcut category, so this branch is the route. It also matches tracker convention. + +## One gesture, one history entry + +Cut, delete and paste each record exactly one entry, whichever surface fired them, and none of them coalesces. A block gesture is already a whole gesture, and folding two consecutive pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction, since it mutates nothing. + +A shift coalesces, because a nudge is a step of one gesture and not a whole one. The block it covers is its coalescing target, so a streak over one selection leaves a single step to undo. A shift after the cursor moves or the selection is reached out starts the next entry. Transpose and volume count separately, each carrying its own action. + +## A shape selects to the grid's own edges + +`Ctrl+A` and its neighbors select a whole shape at once. The input state gives each shape as a run of bounds along one axis: slots in the tracker, rows in the order. A single builder takes that run, 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. A grid that lays out nothing keeps the selection it had. + +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 items name too, so a key and an item reach the same rectangle. In the tracker a shape ends at the frame's last row, so standing one brings the row the cursor landed on to the band's center, as a `Shift+End` reach does. + +## Dragging a range out + +Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what stands painted and the drag gesture that draws it. The grid says which of its cells the selection covers, in its own coordinates. The repaint that follows reaches the cells whose membership changed and marks each through the selectable's own selected state, which the 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 reports no hover for the cells a held pointer passes over. A drag carried past an edge reads as the edge, so it selects up to it. + +The tracker's row lookup is arithmetic. It measures from the first row's top edge and divides by the row height, which holds while the rows are evenly pitched. That is what the pattern table's zero vertical cell padding and item spacing are for: a vertical padding there would drift the lookup further down the grid. The order's position lookup is arithmetic in the same way and takes its pitch from the first two columns. Its channel lookup walks the rows, because the master row stands apart from the channels beneath it. + +### A drag past the edge carries the view + +A pointer held past the cells on screen travels the grid under it, so a selection reaches further than the viewport holds. `grid/scroll/` holds the travel. A grid says which axis it scrolls along and where its cells stand along it, and one class reads the two each frame. The tracker travels vertically and the order horizontally, from that same class. + +These rules make the travel feel like one gesture: + +- **The pointer report drives it.** A held pointer keeps reporting wherever it is carried to, including past the window, so the travel runs off the same report the drag itself reads. +- **The frame's own duration paces it**, so the same stretch of grid passes under the pointer however fast the frames arrive. The pace depends on how far past the edge the pointer stands. It rises from a floor to a ceiling over a few cells' overshoot: a nudge creeps, and a reach covers the grid. +- **Each step is added to the offset last issued.** A table reports the scroll it was drawn with and not the one just set, so a travel that read it back would re-issue an offset it has already reached. The travel rests as soon as the pointer stands within the band again, at the press that opens the next gesture, and on a rebuild. The travel that follows sets out from the offset the grid is drawn with. diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md new file mode 100644 index 000000000..f5ea864d1 --- /dev/null +++ b/docs/development/application/stems.md @@ -0,0 +1,93 @@ +# Stems in the application + +This document covers what the application does with a reconstruction built from several stems: how it loads and names the recorded stems, what the Stems card offers, and what an edit or a removal does to the per-frame record. Consult it when changing an edit path, the record, or what the card offers. + +The assignment that writes the record is [Stems reconstruction](../../concepts/stems.md). [Architecture](../architecture.md) describes the layers this code sits in. + +Five terms recur. The **record** is the per-frame account of which recording plays each frame of each channel. A frame's **owner** is the recording the record names for it. A frame **rests** when nobody holds it, and the record then names the resting stem id. An **authored** frame is one the reader wrote by hand, and the record names the authored stem id. A **level** is a rank in the hierarchy the conversion used. + +## The recorded stems + +A stems reconstruction records one source per entry, naming its recording and the file it was read from. The application reads the locations through `Reconstruction.audio_filepath`, which `StemsData.paths` answers: a tuple holding one path per recording, and empty once the reconstruction is detached from its origin, as a project sample is. The names stay whatever happens to the locations, so a detached document still says which recordings it was built from. A document whose own file is removed in the Browser keeps the locations of its recordings. + +Opening the document loads the recorded stems through `load_recordings`, at the scale the conversion recorded for the whole set it read. Each stem therefore carries the level it held in the conversion's mix, a stem heard on its own sounds at that level, and it keeps that level once another recording leaves the document. A document written before the scale was recorded holds one recording, which reads at its own peak, as its conversion read it. The mix of them is the original audio the source toggle and the waveform offer, computed fresh on every load. A recorded stem that is 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. + +A document on disk is named after its file. The converter names that file from every recording it read, through the naming rules in `sampletones_core.reconstructions.naming`, so the name stands when a recording later leaves the document. A document held in memory, such as a project sample, carries the name it was given. + +The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a double-click. The Audio source panel keeps the reconstruction's own file, the choice between the two waveforms and the engine rate, which a document living on disk retimes as a step of its edits. A project sample leaves the rate to the project, and a document with no file keeps it until it is saved to one. The logic names which of the two locks the rate, and the panel's hint says that reason. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. + +## The stems card + +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 is 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 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, and unticking silences them. + +### Principles of the card + +1. **Selection filters what plays, shows what it filters, and scopes what is edited.** A ticked set projects the document and does not mutate it. The waveform shows the ticked frames alone. The reconstruction toggle plays them mixed, and original playback plays the recordings heard anywhere, mixed. The instruments panel draws the envelopes of that same part beside the figures measuring it, and both WAV export and instrument export write what stands on screen. 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. The same set says which frames an instrument edit writes (see [Editing a stems reconstruction](#editing-a-stems-reconstruction)). + + Two rules shape the reading. **A filtered reading shows the frames it leaves out as silence in place**, so the envelopes, the waveform and the record line up column for column. **It ends at the last frame the reader hears**, so a channel whose sound the reader's choice took away reads as standing by: its plot is empty, its figures are at nothing and its instrument is written nowhere. A rest answers to no recording, so every reader hears it. A channel resting through every frame stands by in the document itself, whatever silenced it, so the figures beside a plot name the bytes the export writes. An edit that silences a channel is measured standing by as it arrives, and the panel draws that channel empty once its regeneration lands. Together these rules make what a reader sees, hears, edits and exports one and the same part of the document. +2. **A box appears where the choice reaches something.** A stem draws a box on a channel exactly where the record gives it a frame there, so every box the card offers changes what is heard. A recording holding no frame anywhere leaves the document, so every row offers a box somewhere. A document where no recording holds a frame keeps them all, and each of their rows reads as holding none. The frames a reader wrote by hand answer to no recording, so they gather in a row of their own that reads and behaves like any other. +3. **Every stem starts heard everywhere it holds frames.** A freshly opened stems reconstruction ticks every box, so the waveform and the original are the unfiltered document. +4. **The global channel choice takes precedence.** A channel switched off for the whole reconstruction mutes its column and leaves 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, and the stems choice filters 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](playback.md)). Saving the reconstruction records the assignment and not the selection. The banding works the same way: collapsing the levels changes how the card draws and never what it describes. +7. **Removing a recording edits the document.** Where a box steers listening, the remove button rewrites what is described. The change is asked about first and recorded in the project history, and [Editing a stems reconstruction](#editing-a-stems-reconstruction) says what it releases. A reconstruction holds at least one recording, so the last row standing keeps its button disabled. +8. **The ribbon shows what the record holds.** Under the waveform, a lane per channel in play divides into the stretches one recording holds throughout, each painted in that recording's color. A recording takes its color from the id it was converted as, which it keeps for as long as it stays on the record. One recording therefore reads alike wherever it is drawn — on this card, under the waveform, beneath an instrument's bars, and in the list [the browser](browser.md) shows on hovering a reconstruction — and a recording leaving the document leaves the others' colors as they were. A lane appears for every channel in play, one unbroken stretch where a single owner holds it throughout, so a channel's lane answers whether it sounds and by whom independently of every other channel's. A stretch reads solid where the reader hears the recording holding it, faded where the reader left it out, and in the surface's own ground where the frames rest. It names its owner whether or not it is listened to. The instruments panel paints the same stretches in a band beneath each dimension's bars, where a dimension answering to a single owner has nothing to tell apart and draws no band. +9. **The menu makes the same choices as the boxes.** A right-click on a row offers Mute or Unmute, Solo or Unsolo, and the file items every listing offers. Mute and Unmute set the recording's choice across every channel it offers. Solo hears one recording on every channel it offers and silences the others, and it remembers the choice it replaced, so Unsolo returns to it whichever recordings were soloed in between. A choice made by hand, or a regenerated document, ends that memory, and Unsolo then hears every recording whole. The frames a reader wrote have no file, so their row offers no file items. Both choices stay session state, like the boxes. + +### What the code guarantees + +A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and the original mix covers the recordings of the stems heard on any channel. It is silence while no stem is heard, and there is none while no recording loaded. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. + +Removal runs through `without_stem`, which returns a fresh reconstruction holding what the rules in [Editing a stems reconstruction](#editing-a-stems-reconstruction) leave. A confirmed removal is a step of the open document's edits, taken after the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)), and it lands as a `ReconstructionEdit`, the payload both a regenerated instrument and a removed recording travel as. One path therefore rebinds the open document and records the edit against the project history. They differ in what the instruments panel does. A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel keeps drawing them. A removal redraws every channel from the document it leaves. + +## Editing a stems reconstruction + +A conversion answers, per channel and frame, which recording plays there. Everything a reader does to the document afterward stands on that answer: the instruments panel rewrites what a channel plays, the stems card chooses what is heard, and the remove button takes a recording out. This section describes the account of ownership all three keep, and the rules each gesture follows. + +### Principles of editing + +1. **A frame has exactly one owner.** Every frame of a channel in play is held by one recording, by the reader's own hand, or by nobody. The hardware reads one instruction per channel per frame, so the channel itself allows only this, and it makes the per-frame record a partition of the channel's frames. The resting stem id names the frames nobody holds, and the authored stem id names the frames the reader wrote. +2. **Rest and silence name the same frames.** A frame rests exactly when its instruction is silent, which the conversion establishes and every later gesture keeps. A reader looking at a silent frame and a reader looking at the record therefore learn the same thing, and the rules below follow from it. +3. **An edit rewrites what a frame plays and leaves who plays it.** Ownership answers a question an edit does not ask, so a frame carries its owner through any change to its instruction. A frame takes an owner by coming into play and releases it by falling silent. A reader can therefore shape a recording's part while the document keeps its account of where that part came from. +4. **A reconstruction records what is played and reads what is heard.** The document holds the instruction each channel plays per frame, the recording behind each of those frames, the setup they were chosen under and the working level. Its sound is read from those through the generators, as the instructions stand. That one answer serves the waveform, playback, an export and the mixed approximation alike. A drive settles which instruction a conversion records and is read no further. Every gesture below is therefore complete once it has settled the frames. +5. **What is heard is what is edited.** The channels a recording is ticked on are one choice serving two readings: the frames the waveform draws, and the frames an edit writes. A recording switched off on a channel reads there and stays as it stands, so a reader reaches one recording's part at a time through the card already in front of them. +6. **The setup is the conversion's, and a recording leaving is what rewrites it.** The entries, the levels, the channels each recording may occupy, its bends, its drives and its count record how the document was made. A gesture leaves them as they stand for every recording still holding a frame. A recording leaves the setup when a removal takes it out, or when an edit leaves it holding no frame, and it takes its source and its place in the hierarchy with it. +7. **Detaching drops where a recording lives and keeps who played what.** A recording's name and the frames it holds belong to the document, and its location belongs to this machine. The record keeps both, so detaching lets each location go and keeps every name. A reconstruction embedded in a project therefore keeps a working stems card. + +### What the record holds + +The per-frame record answers for every channel in play, and these hold after every gesture: + +- A channel in play sounds in some frame, and has one owner per frame of its stream, each naming a recorded entry, the resting stem id or the authored stem id. +- A channel resting through every frame stands by, whatever silenced it, and has no stream and no record at all. +- A frame rests exactly where its instruction is silent. +- A frame a recording holds lies on a channel that recording's settings occupy, and an authored frame answers to no settings. +- Every entry holds a frame somewhere, unless no entry does. A recording left holding no frame leaves the record, while a record where none holds a frame keeps them all, since nothing sounds that could tell them apart. + +### What an edit carries + +An edit hands one channel a fresh set of envelopes, which become that channel's stream. Each frame's owner follows from the frame it was and the frame it becomes: + +| the frame | becomes | +|---|---| +| sounding before and after | its owner, unchanged | +| sounding, edited silent | resting | +| resting, edited into play | authored | +| resting, edited silent | resting | +| written past the end of the stream | authored where it sounds, resting where it stays silent | +| dropped from the end of the stream, within the scope | gone, together with its ownership | +| dropped from the end of the stream, outside it | standing, in what it plays and who plays it | + +Rest and silence naming the same frames makes the table total. A frame's owner before the edit already says whether it sounded, so the first four lines cover every frame the edit keeps at its position. + +An edit shortens a channel as far as its scope reaches. The stream therefore runs through the last frame standing outside that scope. The frames the edit did reach rest along the way. A stream edited down to no frame, or to rests alone, leaves its channel standing by, and the channel keeps the reference and the held dimensions the edit states. A channel written back into play comes back wholly authored. The identifier, the configuration and the working level stand throughout. The setup and the recorded sources stand for every recording still holding a frame, and a recording the edit leaves holding none leaves them, unless no recording holds a frame at all. + +**The scope an edit writes in** follows principle 5: a frame accepts a gesture where its owner is ticked on that channel, and where it rests. The frames a reader wrote answer to a row of their own, so they are ticked and reached like any recording's. A rest belongs to no row, which lets a gesture write a note into silence. Every other frame draws dimmed and reads as it stands. The scope is the same selection the waveform filter reads, so one state answers both, and a reader narrowing what they hear narrows what they change with it. + +### What a removal releases + +Taking a recording out (`without_stem`) releases the frames it held. Each takes its channel's silent instruction and the resting stem id. A channel the removal leaves resting through every frame stands by, and keeps the reference and the held dimensions it was shaped with, as an edit keeps them. The entry and its source leave the record, and a level the removal empties collapses. The ids of the recordings that stay are left alone, so the record, a reader's selection and every recording's color all stay valid. A reconstruction holds at least one recording, so the last one standing keeps its place. + +A frame that stays keeps the instruction it played and the recording that held it. Its samples follow from principle 4: a channel the removal reached is read afresh, so its oscillator runs through the silence the removal left and not through the notes it took away. + +An edit made before the removal changes nothing about it: the channel carries its record whatever was written into it, so the frames the recording held are released the way they would have been. The frames the reader authored answer to no recording and stand through every removal. diff --git a/docs/development/application/undo.md b/docs/development/application/undo.md new file mode 100644 index 000000000..d53478f55 --- /dev/null +++ b/docs/development/application/undo.md @@ -0,0 +1,72 @@ +# History & Undo + +This document describes the undo/redo subsystem of `sampletones_application`. Consult it when adding a gesture that changes project state, or when changing what the history records. The engine lives in `logic/history/` and is owned by `HistoryManager`. Coordinators integrate with it as described in [`architecture.md`](../architecture.md). Undo/redo is session-scoped and upholds two invariants: + +1. **Completeness.** Every mutation of project state belongs to the history. +2. **Reversibility determinism.** Any composition of undos and redos that returns the cursor to an index reproduces that index's exact state. + +## Engine: snapshot + cursor + +`HistoryManager` holds an ordered list of whole-project snapshots and a cursor. The live project always equals a restoration of `entries[cursor]`. Undo and redo move the cursor and reinstall the snapshot there. They leave every stored snapshot as it is, so reversibility determinism holds by construction. A restore installs a fresh copy through `ProjectController.replace_project`, which fires `on_project_replaced`. The composition root owns that signal and fans it out to the two tabs that show the project: the sequencer rebuilds its views exactly as loading a project does, and the Reconstructions tab follows the voice it shows. + +## An entry owns what its gesture replaced + +Each entry is a whole project, and entries share everything a gesture left alone. The project is a tree of shells over values, and `Project.snapshot` copies the shells around the values: + +- A **shell** has scalars and references, and a gesture writes it in place. The shells are the project, its info and settings, the voice collection, each sample and instrument, the song with its order, and each channel's pattern pool. +- A **value** is frozen all the way down. Its sequences are tuples and its mappings are read-only. The values are a reconstruction and every part of it, a pattern and its rows, and an instrument's envelopes. The live project and every entry share them. +- A gesture puts a new value where the old one was. The new value is built from the old one and keeps the very parts the edit left alone. + +An entry therefore owns the values its gesture made: + +| Edited part | What the entry owns | +|---|---| +| Song cells | the edited pattern, which shares the rows the edit left alone | +| Order | the order frames | +| Pattern length | every pattern | +| Voices | the voice shells, which share their documents and envelopes | +| Instrument | its envelope set, which shares the envelopes the edit left alone | +| Reconstruction channel | the instructions and frame owners of that channel | +| Recording removal | the instructions and frame owners of each channel the removal released frames from | +| Project rate | each sample's document with its new setup, which shares the instructions | +| Settings, properties | the settings and info shells | + +A reconstruction edit follows the same rule. `RegenerationService` rebuilds one channel and builds the new document around it. The apply path installs that document through `ProjectController.replace_sample_reconstruction`. An edit is recorded as it lands, in the order the reader made the edits, and an undo waits for the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)). + +**The types enforce the split.** A value's model is frozen, so a write in place raises where it is made. A core test walks every class a project reaches (`tests/unit/sampletones_core/project/test_values.py`). It requires each class to be a declared shell or a frozen value made of values. The history audit (`tests/suite/history/`) replays gestures against a model of the stack. After every step, it checks each stored entry against the state it recorded. + +**Derived data belongs to no entry.** A document's sound is rendered from its instructions. The application keeps every render in one `RenderCache`, which the composition root builds. A render is keyed by the identity of a channel's instructions and by the setup they sound under. It goes when those instructions go, and a byte budget bounds the rest. History therefore keeps instructions only, and documents that share a channel share its render. + +## The open voice across a restore + +The Reconstructions tab knows the voice it shows by its id, which a snapshot and the project file both keep. A restore therefore reaches that voice the way it reaches the sequencer. A sample the restore keeps rebinds to the reconstruction the snapshot shares and redraws, and the waveform re-fits when that reconstruction runs at another NES frequency. A kept instrument redraws its envelopes. A voice the restore takes out closes, and a redo that brings it back leaves the tab empty until the reader opens it again. + +Outside a restore, a change to the project only closes a voice that left it. That leaves the panel to the reader's own edit, which writes the project a moment before the open document takes it. A new, opened or closed project lets the voice go even where its id resolves, because a reopened file brings back the same ids for the project the reader has just put away. + +## Grouping and detection + +**Grouping happens in the coordinators.** Each state-changing coordinator intent runs inside `HistoryManager.transaction(HistoryAction.X)`. The sequencer wraps its hooks with `SequencerHistoryRecorder.undoable`, which also opens `ProjectController.batch()` inside the transaction. Every controller call a gesture makes collapses into one entry, and one gesture is one round of view notifications too. Nested transactions merge into the outermost one. + +**A gesture lands whole or not at all.** A gesture whose outermost transaction ends by an exception is rolled back: the manager reinstalls `entries[cursor]`, the state the gesture started from, records nothing, and lets the exception go on to the entry point that reports it. The reinstall is the one an undo makes, so it reports itself restoring, stops song playback and rebuilds the views. A coalescing run goes on past it, since the entry the run continues stays as it was. What a gesture does outside the project waits for it to land: `HistoryManager.after_landing` runs such an effect once the outermost transaction commits, and a rollback drops it. A cut fills the clipboard this way, and the Reconstructions tab closes a removed voice this way. Between gestures, an effect runs at once. + +A transaction can carry a *coalesce key* that names the gesture's target, such as a grid cell, a sample or a module setting. Consecutive commits with the same action and key replace the top entry and do not append. A continuous interaction, such as a graph drag or repeated edits of one cell, therefore records a single entry. Any undo, redo or jump ends the run, so a state the user navigated to is always preserved. + +**Detection happens in the controller.** `ProjectController._touch()` fires `on_mutation` on every fine-grained mutation as it lands. A batch defers the view notifications and the dirty stamp and leaves this signal immediate, so the check below sees each mutation inside the transaction that caused it. `HistoryManager.handle_mutation` counts the mutations inside a transaction and rejects any that occur outside one. Under strict deployment it raises `UntrackedMutationError`. Otherwise it heals by recording the mutation as its own entry. This makes completeness a checkable property. + +Under strict deployment each committed snapshot also carries a fingerprint, and every restore verifies that the reproduced project matches it. Capture-time fingerprints memoize each reconstruction's hash by object identity, since a reconstruction is a value and its content is fixed for the object's lifetime. That reduces the per-gesture cost to the light structure. Restore-time verification always hashes fresh, so an in-place mutation of shared state is caught and not masked by the memo. + +## Save point and lifecycle + +The manager records the cursor of the last successful save (wired from `ProjectController.on_saved`). A restore that lands exactly on that index reinstates the on-disk content, so the session reports the document clean again. A commit that truncates the saved entry away, or budget eviction that drops it, invalidates the save point, and the session stays dirty until the next save. Coalescing always preserves the saved entry by appending. + +The stack follows the project lifecycle. An open project seeds a baseline entry, and closing every project empties the stack, so the panel reports no history. + +## History detail rendering + +Committed entries are language-independent. An entry stores its action as a `HistoryAction` enum member and its detail as data segments. Language-managed words inside a detail, such as a loop's on/off state, are stored as language keys in `HistoryDetailSegment` entries with a `HistoryDetailRole`. Action labels and word segments alike resolve through `LanguageManager` when the history view model is built, so switching the language re-renders past entries correctly. + +## Configuration + +The entry budget is a persisted user preference (`ApplicationConfig.history.budget`). The render cache's byte budget is a behavior setting (`rendering.cache_megabytes`). Strict checking and log level are deployment settings (`application/deployment.yaml` → `DeploymentConfig`), and the deployment model takes every value from the YAML with no field defaults. The history panel renders a window of rows around the cursor (`layout.sequencer.history.max_rendered_entries`) and repaints rows in place through an index-keyed diff. + +**Strict checking is on where the code is written.** `deployment.yaml` has the development values, so an edit path that reaches the project outside a transaction raises `UntrackedMutationError` at once. That holds 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/runtime_hooks/release_environment.py`. A gap that reaches a release is healed into an `UNTRACKED` entry and is not shown to the user. The gap therefore surfaces where it can be fixed and stays quiet where it cannot. diff --git a/docs/development/application/vocabularies.md b/docs/development/application/vocabularies.md new file mode 100644 index 000000000..701ff0056 --- /dev/null +++ b/docs/development/application/vocabularies.md @@ -0,0 +1,103 @@ +# Identifier Vocabularies + +Two vocabularies name things across `sampletones_application`: the keys every user-visible string is +looked up by, and the identifiers DearPyGui knows a widget by. Both are spelled by a grammar. A pre-commit +check, called a hook here, holds each one to the source across the whole tree. Each keeps in one place a +fact that would otherwise be restated at every use. Consult this document when adding a string the reader +sees, or a widget another module reaches. + +The design truths it realizes are principles 8 and 9 of [`architecture.md`](../architecture.md): all +display text comes from `LanguageManager`, and `tags/` holds only DPG identifiers. This document +holds the grammar behind both. + +--- + +## Display text + +### The grammar + +Every user-visible string is looked up on `LanguageManager` by the key the language file spells: + +``` +page.panel.text_type.element +``` + +The first three segments name members of `Page`, `Panel` and `TextType` (`categories/hierarchy.py`). The +element segment names a member of an element enum, which is any enum deriving from `AbstractElement`. An +element enum is found by what it derives from. One naming a panel's own widgets lives with the other panel +vocabularies under `categories/elements/`. One naming a domain's gestures, such as `HistoryAction`, lives +beside that domain and serves as both the value the domain records and the element its label is looked up +by. + +`en.yaml` is a flat map keyed exactly this way, so the dotted string is the lookup form, +`language_manager["global.dialog.label.ok"]`, and a reader holds a key against the language file by eye. +`categories/key/` owns the grammar. `validate_text_key` checks every key the file holds at load time, and a +lookup that misses raises `MissingTextError` naming the key and the file. Log messages are developer-facing +and exempt. + +### Text resolves where it is displayed + +A class that reads text holds the manager as `self._language_manager`, assigned in its own `__init__`, and +looks each string up at the point of use, so a language change takes effect on the next read. A class that +inherits the manager declares it in its body instead, `_language_manager: LanguageManager`, which states +the same thing to a reader and to the hook. Where the same text is read at more than one site in a class, +one named binding serves them all and the reads stay in step. + +### The forms a lookup takes + +A key assembled at runtime passes its four members instead, as in +`language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element]`. The variable part is annotated as +the concrete element enum it carries (`SequencerOrderElements`, `DialogElements`). That annotation keeps +the key checkable: the `language-keys` hook expands it to the enum's members and holds every key it reaches +against the language file. + +A lookup therefore states its key in one of the forms the hook reads values from: literals, annotated +members, or a conditional between two literal keys. + +```python +language_manager[ + "global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name" +] +``` + +--- + +## Widget tags + +### `compose_tag` is the one composer + +`tags/compose.py` owns `TAG_SEPARATOR` and the joiner, and 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, such as a sample title or a layer label, reads the same however that name arrives cased or spaced. A +part that already holds 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. + +### A runtime name carries its identity beside it + +The composer reads two names that differ only in case or spacing as one segment. A tag built from a name a +user gave, such as a file path or a project title, therefore carries `identity_part(*parts)` beside the +name. The part is a short digest of the name exactly as it arrived, so `Kick.wav` and `kick.wav` name +widgets of their own. The readable name stays in the tag, so a DearPyGui error still says which row it is +about. + +`tags/compose.py` states the rule once over `sampletones_shared.utils.hashing.identity_digest`, which joins +the parts on a separator no name carries. `ui/elements/stems/tags.py` and `ui/elements/tree/tag.py` both +read it. A widget keyed by anything a user names needs it: DearPyGui refuses a duplicate alias, so two names +arriving at one tag break the draw. + +### A whole tag is a `TagName` + +`TagName` is the `str` subclass in `categories/key/tag.py` that names a tag's four parts and +composes them: + +```python +TAG_MAIN_EXPLORER_TREE = TagName( + Page.MAIN, Panel.EXPLORER, Widget.TREE, "explorer" +) # main.explorer.tree +``` + +The spelling is `page[.panel].widget[.element]`. `Panel.IMPLICIT` names a widget that belongs to no panel, +and an element that repeats its panel's name is carried by the panel segment alone. A constant's name is +its composed tag upper-cased, with each separator turned into an underscore, behind the `TAG_` prefix. +Reading either one therefore gives the other, and the `tag-names` hook holds the two together. diff --git a/docs/development/architecture.md b/docs/development/architecture.md index fceecb7c5..e748e4ef3 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -1,14 +1,14 @@ # 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 reasons behind them. Use it 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 as **examples** that anchor a rule. The rules bind every instance, named or not. [`bugs-and-todos.md`](bugs-and-todos.md) tracks known deviations from these contracts. [`guidelines.md`](guidelines.md) has the rules for code and docstrings. [`docs/index.md`](../index.md) lists the subsystems that have a design document of their own. --- ## Overview -`sampletones_application` is a [DearPyGui](https://github.com/hoffstadt/DearPyGui) application that exposes the `sampletones_core` audio-reconstruction engine through a multi-tab GUI. Four layers with clearly bounded responsibilities structure the code — **UI** (widget construction), **view models** (immutable projections), **logic and services** (domain state and background work), and **coordinators** (orchestration) — with dependencies flowing in one direction only. A single composition root (`Application`) constructs and wires all components at startup. +`sampletones_application` is a [DearPyGui](https://github.com/hoffstadt/DearPyGui) application that exposes the `sampletones_core` audio-reconstruction engine through a multi-tab GUI. The code is structured in layers with bounded responsibilities: **UI** (widget construction), **view models** (immutable projections), **logic and services** (domain state and background work) and **coordinators** (orchestration). Dependencies flow in one direction only. A single composition root (`Application`) constructs and wires all components at startup. ```mermaid graph TD @@ -39,376 +39,212 @@ These principles govern every structural decision in the codebase. ### 1. Layering: dependencies flow inward -Each layer imports only from the layers below it. Coordinators, at the top, reach every layer they orchestrate. The UI layer knows only view models and shared utilities. Logic owns domain state and produces view models. Services, at the bottom of the application stack, know only the core libraries and thread-safe utilities — a service is driven through a logic-side `Protocol` and reports through its result-contract types, so even logic reaches a service only through inversion. +Each layer imports only from the layers below it. Coordinators, at the top, reach every layer they orchestrate. The UI layer knows only view models and shared utilities. Logic owns domain state and produces view models. Services, at the bottom of the application stack, know only the core libraries and thread-safe utilities. A service is driven through a logic-side `Protocol` and reports through its result-contract types, so even logic reaches a service only through inversion. -The load-bearing prohibitions: nothing in `logic/` or `services/` imports `ui/` or `coordinators/`, and `services/` imports neither `logic/` nor `view_model/`. The authoritative import matrix is the **May import / Must not import** pair in each Layer Reference section below; the boundary script enforces it (see Enforcement). +The boundaries that carry the weight: nothing in `logic/` or `services/` imports `ui/` or `coordinators/`, and `services/` imports neither `logic/` nor `view_model/`. `sampletones_config/boundaries/rules.yaml` declares which layer may reach which, one rule per layer, and the boundary check holds the tree to it (see Enforcement). ### 2. DPG stays in the visual layers -Calls into `dearpygui` are confined to `ui/`, `shell.py`, and the narrow coordinator surface defined in the Layer Reference. The `logic/`, `services/`, `view_model/`, and `config/` layers remain DPG-free so they can be instantiated and tested without a running GUI context. This extends past `import dearpygui`: the dpg-bound helpers (`DialogsRenderer`, the `dpg_*` wrappers, fonts, tooltips, shortcuts, keyboard routing, frame callbacks) are grouped under `utils/gui/`, and the non-visual layers may use only the dpg-free helpers that live directly under `utils/` (e.g. `utils/callbacks/`). +Calls into `dearpygui` are confined to `ui/`, `shell.py` and the narrow coordinator surface the Layer Contracts name. The `logic/`, `services/`, `view_model/` and `config/` layers remain DPG-free, so they can be instantiated and tested without a running GUI context. + +The rule covers more than `import dearpygui`. The dpg-bound helpers (`DialogsRenderer`, the `dpg_*` wrappers, fonts, tooltips, shortcuts, keyboard routing, frame callbacks) are grouped under `utils/gui/`. The non-visual layers may use only the dpg-free helpers directly under `utils/`, for example `utils/callbacks/`. ### 3. No UI state in logic -Managers and controllers hold domain state only: file paths, dirty flags, domain objects. Widget visibility, button labels, and progress percentages are UI state, and every UI-ready projection is computed by a view model at the moment it is built. +Managers and controllers hold domain state only: file paths, dirty flags, domain objects. Widget visibility, button labels and progress percentages are UI state, and a view model computes every UI-ready projection at the moment it is built. ### 4. View models are immutable snapshots -A view model captures the exact state needed to render one panel at one moment in time — a Pydantic `frozen=True` model produced by the logic layer and consumed by a panel's `update_view()` method. Derived UI flags (button enabled, sub-panel visible) are `@property` computations on the view model, not stored fields. A frozen dataclass is acceptable where the payload does not suit Pydantic validation (`WaveformData` carries numpy arrays). +A view model captures the exact state needed to render one panel at one moment in time. It is a Pydantic `frozen=True` model produced by the logic layer and consumed by a panel's `update_view()` method. Derived UI flags (button enabled, sub-panel visible) are `@property` computations on the view model and are not stored fields. A frozen dataclass is acceptable where the payload does not suit Pydantic validation (`WaveformData` carries numpy arrays). -This means the UI layer can never be in an inconsistent state: it always reflects the last view model it received, and the view model is self-consistent by construction. +The UI layer therefore can never be in an inconsistent state. It always reflects the last view model it received, and the view model is self-consistent by construction. ### 5. Panels communicate via optional callback hooks -A panel never calls coordinator or logic methods directly. Instead it exposes public optional callback attributes (`on_x: Optional[Callback] = None`) that coordinators set during wiring. The panel fires them through `CallbackMixin.call()`, which logs a warning and yields `None` for a hook left unset. +A panel never calls coordinator or logic methods directly. It exposes public optional callback attributes that coordinators set during wiring, and it fires them through `CallbackMixin`. An unset hook is a normal state during construction, so firing it does nothing and reading it yields a default of the expected type. + +Hooks decouple widget construction (during `create_panel()`) from wiring (in the coordinator's constructor). They also let a panel be instantiated without any coordinator. -A hook the panel consults for state rather than notifies of an event is read through `CallbackMixin.query()`, which preserves the hook's declared return type and takes the answer to assume while the hook is unset. A widget parameter or branch fed by such a hook therefore receives a value of its expected type at every moment, including the window before wiring completes. +### 6. DearPyGui's context belongs to the render thread -This decouples widget construction (which happens during `create_panel()`) from the moment wiring takes place (which happens in the coordinator's constructor), and lets panels be instantiated without any coordinator present. +The thread that created the DearPyGui context is the only one that may build, configure or destroy an item. An item freed from another thread is freed with no Python thread state, which crashes the process. Work that reaches the interface from anywhere else therefore arrives on that thread first, through a crossing named for what it carries: -### 6. Background threads deliver results through `CallbackQueue` +- A background result is queued for the render loop to drain. +- A worker's touch of a widget goes through `on_render_thread`. +- A gesture DearPyGui gathered is run at the top of a frame. -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. +A crossing that would hold the frames up puts its waiting on a thread of its own, and work that needs a drawn frame names the frame it is picked up on. [`render-thread.md`](application/render-thread.md) describes the crossings, the helpers that make them and the hazard each one answers. ### 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. +`Application.__init__` constructs the application graph (managers, controllers, shared services, coordinators, the shell) and wires their callbacks. A tab coordinator constructs the panels, logic objects and tab-scoped services it owns. No other component constructs a major component. Every dependency arrives as a constructor argument, and none is obtained through a global lookup. -**Where a run keeps its settings arrives the same way.** The application is given a `UserProfile` — the pair of files its configuration and its session state live in — and hands each path to the manager that reads and writes it. The entry point names the user's own profile through `UserProfile.user()`, which leaves one place that knows the shipped locations and lets a run be pointed at a location of its own. +**Where a run keeps its settings arrives the same way.** The application is given a `UserProfile`, the pair of files its configuration and session state live in, and hands each path to the manager that reads and writes it. The entry point names the user's own profile through `UserProfile.user()`. One place therefore knows the shipped locations, and a run can be pointed at a location of its own. ### 8. All display text comes from `LanguageManager` -Every user-visible string is looked up on `LanguageManager` by the key the language file spells: - -``` -page.panel.text_type.element -``` +Every user-visible string is looked up on `LanguageManager` by the key the language file spells (`page.panel.text_type.element`), and it resolves at the point of use, so a language change takes effect on the next read. `en.yaml` is a flat map keyed exactly this way. That makes the text system the single source of truth, lets a reader hold a key against the language file by eye, and enables future localization. -The first three segments name members of `Page`, `Panel`, and `TextType` (`categories/hierarchy.py`); the element segment names a member of an element enum, which is any enum deriving from `AbstractElement`. An element enum is found by what it derives from, so one naming a panel's own widgets lives with the other panel vocabularies under `categories/elements/`, while one naming a domain's gestures — `HistoryAction` — lives beside that domain and serves as both the value the domain records and the element its label is looked up by. `en.yaml` is a flat map keyed exactly this way, so the dotted string is the lookup form — `language_manager["global.dialog.label.ok"]` — and a reader holds a key against the language file by eye. `categories/key/` owns the grammar: `validate_text_key` checks every key the file holds at load time, and a lookup that misses raises `MissingTextError` naming the key and the file. This makes the text system the single source of truth and enables future localisation. Log messages are developer-facing and exempt. - -Text resolves where it is displayed. A class that reads text holds the manager as `self._language_manager`, assigned in its own `__init__`, and looks each string up at the point of use, so a language change takes effect on the next read. Where the same text is read at more than one site in a class, one named binding serves them all and the reads stay in step. - -A key assembled at runtime passes its four members instead — `language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element]` — with the variable part annotated as the concrete element enum it carries (`SequencerOrderElements`, `DialogElements`). That annotation is what keeps the key checkable: the `language-keys` hook expands it to the enum's members and holds every key it reaches against the language file. A lookup therefore states its key as literals, as annotated members, or as a conditional between two literal keys — the three forms the hook reads values from: - -```python -language_manager[ - "global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name" -] -``` +A lookup states its key in a form the `language-keys` hook can read, so every key the code spells names an entry and every entry in the file is reached. [`vocabularies.md`](application/vocabularies.md) has the grammar, the forms a lookup takes, and where each element enum lives. Log messages are developer-facing and exempt. ### 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/`). - -**`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. - -**A whole tag is a `TagName`**, the `str` subclass in `categories/key/tag.py` that names its four parts and composes them: - -```python -TAG_MAIN_EXPLORER_TREE = TagName( - Page.MAIN, Panel.EXPLORER, Widget.TREE, "explorer" -) # main.explorer.tree -``` - -The spelling is `page[.panel].widget[.element]` — `Panel.IMPLICIT` names a widget belonging to no panel, and an element repeating its panel's name is carried by the panel segment alone. A constant's name is its composed tag upper-cased with each separator turned into an underscore, behind the `TAG_` prefix, so reading either one states the other; the `tag-names` hook holds the two together. +The `tags/` package has 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/`). Every tag reaches its final spelling through one composer, and a constant's name says the tag it composes, which the `tag-names` hook checks. [`vocabularies.md`](application/vocabularies.md) describes the composer, the `TagName` spelling and the rules a fragment follows. ### 10. Exclusive operations expose a lifecycle-accurate active state -Some operations are mutually exclusive — typically because they are resource-intensive (background worker pools) and running two at once would exhaust memory or contend for a device. Each such operation exposes an `is_active` signal derived from its **own state machine**, true from the moment the operation is *requested* through to its teardown, including any preparatory phase before the background work begins. A signal that starts at the moment of request is the only one that covers the operation's full span; anything derived downstream (such as whether a worker has actually started) opens a window in which a competing operation can slip in. +Some operations are mutually exclusive, typically because they are resource-intensive (background worker pools) and running two at once would exhaust memory or contend for a device. Each such operation exposes an `is_active` signal derived from its **own state machine**. The signal is true from the moment the operation is *requested* until its teardown, including any preparatory phase before the background work begins. Only a signal that starts at the request covers the operation's full span. A signal derived downstream, such as whether a worker has started, opens a window in which a competing operation can slip in. -The composition root composes the per-operation signals into a single *busy authority* — the one source of truth, consulted in two places: +The composition root composes the per-operation signals into a single *busy authority*, the one source of truth. It is consulted in two places: -- **UI enablement** — panels disable the controls that would start a competing operation. -- **Start-time guards** — each operation's entry point consults the authority and declines to start while another operation is active, so exclusivity holds even when a control is reached outside the normal UI path. +- **UI enablement.** Panels disable the controls that would start a competing operation. +- **Start-time guards.** Each operation's entry point consults the authority and declines to start while another operation is active, so exclusivity holds even when a control is reached outside the normal UI path. -A new exclusive operation joins by contributing its `is_active` to the authority and adding a start-time guard; no per-call-site bookkeeping is needed. The authority stores nothing — it is recomputed from the live operations on demand. +A new exclusive operation joins by contributing its `is_active` to the authority and adding a start-time guard. No per-call-site bookkeeping is needed. The authority stores nothing and is recomputed from the live operations on demand. ### 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 a `Protocol` with one implementation per target, chosen by a runtime factory. Platform branches are not scattered through the callers. The factory probes availability and environment 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. +Each tool's quirks stay inside its own implementation and are named in that class's docstring. The guarantee callers depend on is enforced once, in the API layer above every backend: a saved file has one of the offered extensions. `utils/file_dialogs/` applies this to native file dialogs, and `sampletones_tools/calibration/referee/` follows the same shape. -Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way. +Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save that offers several file types is answered by the desktop portal, for example, because it alone reports which type was chosen. ### 12. One dispatcher owns the keyboard -DearPyGui gives every key handler the same global reach and no way for one to stop another — or ImGui itself — from also seeing a press. Priority and consume semantics therefore exist only where the application builds them. A single `KeyRouter` (`utils/gui/keyboard/`) owns the one `add_key_press_handler` for the whole application, snapshots the modifier state once into a frozen `KeyEvent`, and offers that event to registered **scopes** from highest priority to lowest. The first active scope whose handler returns `True` claims the press and ends the walk; this software walk is the sole consume mechanism the framework leaves available. - -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 | -|----------|-------|-------------|-----------| -| `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 | -| `SHORTCUT` (40) | application shortcuts (`ShortcutManager`) | always | fires the matching shortcut while no field is being edited, or whenever the shortcut is `field_transparent` | - -Because the router offers a panel the key ahead of the shortcut scope, a panel returns `False` on any combination it does not own — the grid yields every `Ctrl`-modified press — so that field-transparent shortcuts such as `Ctrl+PgDn` / `Ctrl+PgUp` tab-switching reach the shortcut scope even while a grid cursor is set. +DearPyGui gives every key handler the same global reach, so priority and consume semantics exist only where the application builds them. A single `KeyRouter` owns the one `add_key_press_handler` for the whole application. It offers each press to registered **scopes** from the highest priority to the lowest, and the first active scope that claims the press ends the walk. A modal dialog ranks above a sequencer sub-panel, which ranks above the application shortcuts. Each consumer registers one scope that says when it wants keys and which presses it claims. -**A panel scope answers on its own tab.** A cursor and a selection outlive a move to another tab, so a panel is given the predicate that reports whether its tab is the one in front and reads it at the moment of the press, the way focus is read. The composition root resolves the tab and the scope composes the answer into its `active`, which keeps the fact in one place and leaves the router's contract — the scope decides whether it wants the key — as it stands. +A binding is declared once and read by everyone who prints or fires it. `ShortcutId` names the action with the category that answers it, and the scheme under `sampletones_config/keybindings/` decides the combination. A printed key and the handler behind it therefore stay in step by construction. -**Focus is pulled, not pushed.** Whether a text or value field keeps a plain key for itself is one router query, `is_field_focused`, that reads the focused item from DearPyGui at the moment of the press and counts it only while that item is actively being edited. Every input is covered by construction, and the router alone holds the rule. +The router is constructed at the composition root and injected into every consumer (principle 7). [`keyboard.md`](application/keyboard.md) covers the scopes, the focus query, the modal stack, the key vocabulary, and how a scheme is chosen, layered and edited. -The query resolves the focused item to the field behind it. A `dpg.group` reports the state of the widget inside it, and DearPyGui names the outermost such group as the focused item — the instruments panel's sequence input, laid out beside its copy button inside a card body group, reaches the keyboard as that group. An active group therefore answers with the field being edited below it, found by following the one branch that reports focus, so a panel-spanning group costs a key press only the path down to its field. +### 13. A color is a token, resolved where it is drawn -**Modal suppression lives in one place.** The router holds a LIFO stack of modal handlers; `push_modal` / `pop_modal` bracket a dialog's lifetime, and the built-in `MODAL` scope routes each press to the top of the stack. Since `MODAL` outranks the panel and shortcut scopes, the scopes beneath it carry no "a dialog is open" check of their own. +A color is written as a palette token and stays one until it reaches DearPyGui. `BaseColor` 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`. The read happens where the value is handed to a widget, and a consumer keeps the token. -**One vocabulary, one declaration.** The keyboard has one key table (`utils/gui/keyboard/keys.py`), which reads a key both ways — the name a file writes and the code a press carries — and one combination type, `KeyCombination`, which parses that spelling, displays it, and answers whether a press matches it. Above them a binding is declared exactly once: `ShortcutId` names every action a key reaches together with the category that answers it, and the scheme under `sampletones_config/keybindings/` is where the combination is decided. The menu printing an accelerator, the panel acting on a press, and the dispatcher firing the callback all read that one entry, so a printed key and the handler behind it stay in step by construction. +DearPyGui copies a color when it receives it. `PaletteBindings` therefore registers what DearPyGui has already taken, and a palette change is one switch. The `palette-colors` hook checks these rules (see Enforcement). [`palette.md`](application/palette.md) describes the color forms and the switch. -The split is that **the combination is data and the category is code**: which keys reach an action is the reader's to choose, while which scope answers them follows from where the action is handled. A scheme is validated as it loads — every `ShortcutId` is answered, every key name resolves, and one combination reaches one action within a category — and a collision is a `SystemError` at startup, beside the layout and palette failures. +### 14. An action is declared once; whoever shows it prints it -A preference layers over the shipped scheme. `ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it: `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping, and an override naming an action this build has none of, a key the table has none of, or a combination its category already gives away is reported and left out, so one stale entry costs only itself. A change reaches the running application through `ShortcutSource.on_bindings_changed` — the keyboard's analogue of the palette switch (principle 13) — and the dispatcher re-reads the keys while the menus re-print their accelerators. Each registration names the action it fires, which is what leaves a rebind that little to catch up. +An **action** is one `ShortcutId`: the name a key press, a menu item and a context item all reach one behavior by. Declaring one takes the action and the category that answers it, its keys in every shipped scheme, the one call it makes, and the label the keybindings editor lists it by. The `shortcut-actions` check holds every link (see Enforcement). -**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 menu item is a view of an action. `ShortcutManager.add_menu_item(shortcut_id, ...)` takes both the accelerator and the call from the action and keeps the item under it, so a rebind re-prints the key already on screen. One builder, owned by whoever owns the actions, states a set of actions that several menus show, and each menu decides where to print it. A menu whose contents follow a selection states them when it is opened. [`keyboard.md`](application/keyboard.md) covers the kinds of action that state their call differently and the mechanism behind a restated menu. -**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. +### 15. The screen holds one modal at a time -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 - -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 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). +DearPyGui shows one modal window at a time, and a modal built while another stands opens hidden. The screen +therefore belongs to one conversation at a time: a dialog, the modals it hands the screen to, and the ones +its answers raise. A modal asked for from anywhere else, such as the report of a finished job, waits in line +and opens once that conversation has ended. Every modal opens through `GUIWindow`, and one line decides when +each reaches the screen, so a caller raises a dialog the moment it has one. [Dialogs](application/dialogs.md) +describes the conversation and its hand-offs. --- ## Enforcement -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` declares one rule per layer: the prefixes it may reach and the ones it may not. The file says where each boundary runs, and this document says what the boundary is for. The same domain holds the import order of the repository's packages and the layering inside `sampletones_player`. [`packages.md`](packages.md) says what those boundaries mean. -**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: +`sampletones check import-boundary` runs the rules over the source and scripts trees, and the hook audits the entire source tree on every commit. Strengthening a rule therefore surfaces violations in files a commit never touched. That sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook list the remaining work. -| 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) | +**The identifier vocabularies, the declarations that complete them and the shapes a case may take are enforced the same way.** Each is a `sampletones check ` command under `sampletones_tools/checks/`, run over the whole tree as a pre-commit hook. The principle a check holds names it where that principle is stated. The checks are global by nature, because a dead entry and an unread fragment are both absences, so the hooks pass the whole tree and not 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 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. - -**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. +**Behavioral contracts are enforced by review.** Contracts a grep cannot see, such as where state lives, which methods touch DPG and how errors travel, are upheld in code review against this document. A change that alters one lands with the edit that states the new contract. A change that knowingly leaves a distance behind lands with an entry in [`bugs-and-todos.md`](bugs-and-todos.md#architecture). [`documentation.md`](documentation.md#upkeep) holds that rule. The ledger is the memory of what is currently out of line. --- -## Layer Reference +## Layer Contracts ### `ui/` — View layer -**Purpose:** Constructs and updates the DearPyGui widget tree. Panels own their DPG tags and the widget subtree rooted at `self.tag`. +Constructs and updates the DearPyGui widget tree. A panel owns its DPG tags and the widget subtree rooted at `self.tag`. -**Contracts:** -- 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. +- 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`. Afterward it calls DPG 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, and panels receive projections of them. +- A panel leaves its own placement to the coordinator. It does not compose a column tag (`SUF_PANEL_*`) as its parent, and it does not host a sibling panel. Tab layout belongs to the coordinator. Where a section is a card, one card is one panel is one module, and the coordinator declares which cards a tab contains and how they are arranged. +- Only the layout primitives bind structural depth themes. The `TabColumns` scaffold binds each column its declared depth theme: recessed GROUND for a column hosting a stack of floating cards, and raised SURFACE for a full-height column that is itself a single docked surface, such as a file tree or an instrument list. The `card()` context manager binds SURFACE to a card. `well()` binds recessed GROUND to a padded region sunk inside a card, so a list reads as one body and not as content loose on its card. Panels and coordinators bind the semantic and content themes alone, such as a per-channel checkbox tint or the player toolbar. - 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. -- Dialog presentation belongs to coordinators: a panel fires an intent hook, and the owning coordinator renders the dialog via `DialogsRenderer` with text resolved there. Reusable modal *editing* windows subclass `GUIWindow` and follow the ordinary panel contracts. - -**Sub-structure:** - -| 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/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 | - -**May import:** `view_model/`, `utils/`, `categories/`, `tags/`, `layout/`, `constants/`, `sampletones_core` types, `sampletones_shared`. -**Must not import:** `coordinators/`, `logic/`, `services/`, `config/`, `application.py`, `shell.py`, `utils/gui/dialogs` (`DialogsRenderer` is coordinator territory). +- Callback wiring from coordinators sets public `on_x` attributes *after* construction, so a panel tolerates unset hooks until wiring completes. +- Hooks and view models are how a panel reaches state. A widget that queries per-item state *while it draws*, where projecting the whole collection per repaint would be disproportionate, declares one consumer-owned `Protocol` of exactly the queries that draw makes. The file trees do this through `TreeLogicProtocol` for per-node favorite and playability state. The owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. One panel holds one such Protocol. A second one is the sign that the panel does two jobs, and the panel divides. +- Dialog presentation belongs to coordinators: a panel fires an intent hook, and the owning coordinator renders the dialog via `DialogsRenderer` with text resolved there. Reusable modal *editing* windows subclass `GUIWindow`, follow the ordinary panel contracts, and hold to the geometry contract in [dialogs](application/dialogs.md). --- ### `view_model/` — Projection layer -**Purpose:** Bridges the logic layer and the UI layer. A view model is the UI's contract with the logic layer: it specifies exactly what data a panel needs to render itself, pre-computed and immutable. +A view model is the UI's contract with the logic layer: it states exactly what data a panel needs to render itself, pre-computed and immutable. -**Contracts:** -- All view model classes are frozen (see principle 4). Fields are never mutated; a new instance is produced on each logical state change. -- Derived UI flags (`button_enabled`, `panel_visible`, `is_done`) are `@property` computations, not stored fields, to prevent inconsistency. -- View models carry only what is needed for rendering. They must not expose raw domain objects that a panel could mutate. -- Edit payloads — frozen `*Update` models a panel emits through its `on_*_changed` hooks — also live here: they are the UI's outbound contract, the mirror of view models. -- Domain data containers (frozen dataclasses that wrap core types and are used across logic and services) belong in `logic/`. A type belongs in `view_model/` only if its purpose is to carry data across the UI boundary — a panel-feeding snapshot, an edit payload, or a projection a display renders (`WaveformData`). - -**Naming convention:** `ViewModel`, e.g. `ConverterViewModel`, `SequencerTrackerViewModel`. - -**May import:** `constants/`, `sampletones_core` types, `sampletones_shared`, Python standard library. -**Must not import:** `ui/`, `coordinators/`, `logic/`, `services/`, `config/`. +- All view model classes are frozen (see principle 4). A new instance is produced on each logical state change. +- Derived UI flags (`button_enabled`, `panel_visible`, `is_done`) are `@property` computations and not stored fields, so two readings of one state agree by construction. +- A view model carries what a panel renders, and hands out projections a panel can read alone. +- Edit payloads also live here: the frozen `*Update` models a panel emits through its `on_*_changed` hooks. They are the UI's outbound contract, the mirror of view models. +- Domain data containers (frozen dataclasses that wrap core types and are used across logic and services) belong in `logic/`. A type belongs in `view_model/` only if its purpose is to carry data across the UI boundary: a panel-feeding snapshot, an edit payload, or a projection a display renders (`WaveformData`). --- ### `logic/` — Domain layer -**Purpose:** Owns domain state and implements the state-machine transitions that govern it. No knowledge of the UI framework. - -**Key concepts:** - -*Managers* own a domain object's lifecycle (load, save, close). They hold the current object, a `Session` that tracks dirty state, and fire `CallbackMixin` callbacks when the state changes. +Owns domain state and implements the state-machine transitions that govern it, knowing nothing of the UI framework. -*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed. `ProjectController.batch()` widens that grain to a whole gesture: each mutation still applies the moment it is made, while the callbacks it raises wait for the scope to close and then arrive once each, so a gesture writing hundreds of rows rebuilds its subscribers once. +*Managers* own a domain object's lifecycle (load, save, close). They hold the current object, the file it was last loaded from or saved to, and a `Session` that tracks dirty state, and they fire `CallbackMixin` callbacks when the state changes. Whoever saves or remembers a document reads its file from the manager, so a document opened by any route is saved where it came from. -*Logic objects* (e.g. `ConverterLogic`) orchestrate multi-step workflows within a feature area. They subscribe to services and translate service results into view model updates. +*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods and emits a finer-grained callback per mutation kind, so the UI answers exactly what changed. Its `batch()` widens that grain to a whole gesture. Each mutation still applies the moment it is made, while the callbacks wait for the scope to close and then arrive once each. A gesture that writes hundreds of rows therefore rebuilds its subscribers once. -`logic/history/` implements the session-scoped undo engine (`HistoryManager`); its invariants and mechanics are documented in `docs/development/undo.md`. +*Logic objects* (for example `ConverterLogic`) orchestrate multi-step workflows within a feature area. They subscribe to services and translate service results into view model updates. -`logic/reconstruction/browser/` builds the tree of reconstructions both browser tabs render (`BrowserManager`); its pipeline, node vocabulary and shaping rules are documented in `docs/development/browser.md`. - -**Contracts:** -- Logic classes produce view models and may therefore import `view_model/`; they import neither `ui/` nor `coordinators/`. -- Logic classes never call DPG. +- Logic classes produce view models and may therefore import `view_model/`. They call no DPG. - Callbacks are declared as optional attributes and invoked via `CallbackMixin.call()`. -- Session objects are simple state machines; they fire `on_state_changed` when they transition, without knowing who listens. -- A logic object that drives a service declares a logic-side `Protocol` of exactly the calls it needs (e.g. `ConversionServiceProtocol`) and receives the real service from its coordinator or the composition root; structural typing keeps the dependency inverted. - -**May import:** `sampletones_core`, `sampletones_shared`, `view_model/`, `utils/`, `categories/`, `layout/`, `config/`, and the service **result contract modules** (`services/result.py`, `services/*/result.py`) so handlers can type the tagged unions they match on. -**Must not import:** `ui/`, `coordinators/`, service implementation modules. +- Session objects are simple state machines. They fire `on_state_changed` when they transition, without knowing who listens. +- A logic object that drives a service declares a logic-side `Protocol` of exactly the calls it needs (for example `ConversionServiceProtocol`) and receives the real service from its coordinator or the composition root. Structural typing keeps the dependency inverted. --- ### `services/` — Async worker layer -**Purpose:** Executes long-running operations (file conversion, waveform regeneration, export, playback synthesis) on background threads and delivers typed results to the main thread via `CallbackQueue`. +Runs long operations (file conversion, waveform regeneration, export, playback synthesis) on background threads and delivers typed results to the render thread. -**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. -- Services hold no references to panels, view models, or logic objects. - -**May import:** `sampletones_core`, `sampletones_shared`, `utils/callbacks/`. -**Must not import:** `ui/`, `view_model/`, `coordinators/`, `logic/`, `config/`. +- Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)` and `_emit(result)`. +- `_emit` posts the result to `CallbackQueue`, which puts every handler on the render thread (principle 6). +- Result types are a tagged union, so a subscriber matches exhaustively. A long operation reports through `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError` and `ServiceCanceled`, and a service whose reports carry more, such as the playhead or the request they answer, declares a union of its own in its `result.py`. +- 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 in `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws. See [`progress.md`](progress.md). +- A service knows no panel, view model or logic object. --- ### `coordinators/` — Orchestration layer -**Purpose:** Coordinators are the glue between the UI, logic, and service layers. They own the panels and logic objects for one feature area, wire their callbacks, and handle cross-cutting concerns (dialogs, navigation, session state). - -There are two coordinator kinds: +Coordinators are the glue between the UI, logic and service layers. Each owns the panels and logic objects for one feature area, wires their callbacks, and handles the cross-cutting concerns around them: dialogs, navigation, session state. -*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations), `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`), or `EditRouter` (the single edit surface behind the menu bar's Edit menu, which shows the actions of the grid holding the cursor — see `docs/development/sequencer-blocks.md`). +*Domain coordinators* manage a concern that spans the whole application lifecycle. `ProjectCoordinator` handles project file I/O and save confirmations. `PlaybackRouter` is the single transport over the shared output device, acting on the active tab's source or the engaged one (see [`playback.md`](application/playback.md)). `EditRouter` is the single edit surface behind the menu bar's Edit menu, which shows the actions of the grid holding the cursor (see [`sequencer-blocks.md`](application/sequencer-blocks.md)). -*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. +*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()`. Each presents a narrow public API of intent-level methods (`set_input_path`, `display_reconstruction`, ...) and keeps its 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 and injects each panel's parent container via `create_panel(parent)`. It builds widgets alone. Pushing the first view models and refreshing trees runs afterward, from the coordinator's post-build initialization, once the whole tree exists. -**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. -- 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). - -**May import:** `ui/`, `view_model/`, `logic/`, `services/`, `utils/`, `categories/`, `layout/`, `config/`. -**Must not import:** `application.py`, `shell.py`. +- A coordinator touches DPG on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog is raised the moment the coordinator has one, and the modal line decides when it reaches the screen (principle 15). All other presentation goes through `DialogsRenderer`. +- File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs`, a synchronous call that returns once the user picks a path or cancels. It resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`. Each handler body then runs with a real path, and a canceled dialog passes quietly. The backend is chosen at runtime, so a coordinator names no platform (principle 11). +- A coordinator holds no domain state. It delegates reads and writes to the managers and controllers it was given. What it caches is presentation wiring: resolved language strings, panels, logic objects and callbacks. +- Callbacks received from `Application` as constructor parameters are stored and forwarded as they stand. A wrapper is sanctioned only where a contract requires an intent-level guard, such as a busy-authority start-time guard (principle 10) around an operation's entry point, and the guard is all the wrapper does. A wrapper that renames a call, reorders its arguments or adds a step of its own is the coordinator taking on work that belongs to the logic object the call reaches. +- Error dialogs, confirmations and notices are presented here, with text resolved from `LanguageManager` here (see the Error Handling Policy). +- An export format with choices of its own opens its setup where the save dialog would otherwise ask for the file. The composition root builds one mapping from each such format to its `ExportSetup` (`coordinators/export/setup.py`), and every surface offering an export consults it first. A surface names no format, and a format gains a setup in one place. --- -### `application.py` — Composition root - -**Purpose:** Creates every object in the application and wires all callbacks. Nothing else. - -`Application.__init__` is the only constructor that may create multiple different coordinator types. After construction it calls `_setup_gui()` to trigger DPG setup and initial view emission, then `run()` starts the event loop. - -`Application` delegates all domain logic. Its private methods are either event listeners that forward to coordinators or helpers that coordinate two coordinators that cannot reference each other directly. - -**May import:** everything. - ---- - -### `shell.py` — UI shell - -**Purpose:** Manages the DPG context lifecycle, the primary window, the tab bar, the shortcut system, and UI utilities (status bar, FPS timer, audio settings window). It performs no domain operations. +### `application.py` and `shell.py` — The root and the frame -`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. +`Application` creates every object in the application and wires all callbacks, and does nothing besides. It is the only constructor that may create several kinds of coordinator (principle 7). It delegates every domain decision: each of its private methods either forwards an event to a coordinator or joins two coordinators that hold no reference to each other. -**Must not import:** `logic/`, `services/`. The shell reaches domain behaviour only through the coordinators and callbacks it was handed. +`ApplicationShell` owns the DearPyGui context lifecycle, the primary window, the tab bar, the shortcut system and the utilities around them: status bar, FPS timer, audio settings window. It carries out no domain operation, and it imports neither `logic/` nor `services/`. It reaches domain behavior through the coordinators and callbacks it was handed. --- ### Supporting packages -| 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/` | -| `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 | -| `viewport.py` | Manages DPG viewport geometry and fullscreen state | - ---- - -## Data Flow Patterns - -### Pattern A: User action → UI update (synchronous) - -User interaction in a panel fires a DPG callback. The panel invokes its own `on_x` hook, which was wired by the coordinator to a logic method. The logic method mutates state and, if needed, produces a new view model and calls `on_view_changed`. The coordinator (or the logic object itself) calls `panel.update_view(new_view_model)`, which drives DPG calls. - -```mermaid -sequenceDiagram - participant UI as GUIConfigPanel - participant CFG as ConfigManager - participant COORD as MainTabCoordinator - participant DPG as DPG - - UI->>CFG: on_audio_settings_changed(update) - CFG->>COORD: config_change_callback() - COORD->>UI: update_view(ConfigPanelViewModel(...)) - UI->>DPG: set_value(tag, value) ×N -``` - -### Pattern B: Background service result → UI update (asynchronous) - -A service running on a background thread emits a result. `ServiceBase._emit()` posts it to `CallbackQueue` with the configured priority. On the next main-thread frame, `CallbackQueue.process()` dispatches it to the logic object's handler. The handler produces a new view model and the panel updates. - -```mermaid -sequenceDiagram - participant BG as Background Thread - participant SVC as ConversionService - participant CQ as CallbackQueue - participant LOGIC as ConverterLogic - participant VM as ConverterViewModel - participant UI as GUIConverterPanel - - BG->>SVC: _on_progress(task_status, progress) - SVC->>CQ: add(listener, ServiceProgress(...), priority) - Note over BG,CQ: thread boundary crossed here - - loop next frame - CQ->>LOGIC: _on_service_result(ServiceProgress(...)) - LOGIC->>VM: build new ConverterViewModel - LOGIC->>UI: update_view(view_model) - UI->>UI: dpg.set_value / dpg.configure_item - end -``` +`config/` holds `ConfigManager` (the domain generation configuration) and `SessionManager` (the runtime session: last paths, audio device, window geometry). A session file outlives the files and folders it names, so each path it holds is read against the disk where it is used. A dialog opens at the nearest folder still standing, and a file that fails to open is let go of as it fails. The package is presentation-free. It records a load outcome as domain data (`ConfigLoadOutcome`) for `ConfigCoordinator` to present. -### Pattern C: Manager session state change → title/menu update +`categories/` holds `LanguageManager` and the vocabulary a lookup is spelled in (principle 8). It also holds the words a whole conversation is phrased from, in two kinds. A bundle resolves every text one conversation needs in one place, so a coordinator reads it once and hands it to whoever phrases the outcome. A phrasing is a function called where its line is written, so a language chosen mid-run reaches the next reading. `time_estimation` is one: a run's status line calls it on every report. -A manager's session transitions (e.g. reconstruction loaded, project saved) fire `on_state_changed`. The coordinator forwards this to `Application`, which recomputes the title and menu bar view model and pushes updates to the shell. - -```mermaid -sequenceDiagram - participant SES as ReconstructionSession - participant RCOO as ReconstructionCoordinator - participant APP as Application - participant VPORT as ViewportManager - participant MENU as MenuBar - - SES->>RCOO: on_state_changed() - RCOO->>APP: on_session_state_changed() - APP->>VPORT: update_title(...) - APP->>MENU: update(MenuBarViewModel(...)) -``` +`constants/` holds application-scope facts that carry no behavior, one module per subject. A fact shared beyond the application belongs to `sampletones_shared/constants/`. --- @@ -418,68 +254,45 @@ Each layer has a distinct role in the error-handling chain. The rule of thumb is ### Logic and managers — propagate -Logic classes and managers catch an exception only when they can take a concrete recovery action in place (e.g. retrying with a fallback path). I/O errors (`OSError` and subclasses) from file operations propagate directly to the caller. Catching and repackaging an exception without recovery is forbidden by the coding guidelines. +Logic classes and managers catch an exception only when they can take a concrete recovery action in place, for example retrying with a fallback path. I/O errors (`OSError` and subclasses) from file operations propagate directly to the caller. The coding guidelines forbid catching and repackaging an exception without recovery. -When a manager does recover, it records *what happened* as domain data and lets a coordinator present it. For example `ConfigManager` recovers a malformed configuration by loading defaults and appending a `ConfigLoadOutcome` carrying only domain values; `ConfigCoordinator.present_pending_load_outcomes()` later turns each outcome into the matching dialog with text from `LanguageManager`. +When a manager does recover, it records *what happened* as domain data and lets a coordinator present it. For example, `ConfigManager` recovers a malformed configuration by loading defaults and appending a `ConfigLoadOutcome` that carries only domain values. `ConfigCoordinator.present_pending_load_outcomes()` later turns each outcome into the matching dialog with text from `LanguageManager`. -### Services — the only legitimate broad catch +### Services — a broad catch that delivers -Services run tasks on background threads. If an unhandled exception escapes the worker, the thread dies silently and `CallbackQueue` never delivers the result. For this reason, `ServiceBase` subclasses must catch the exception at the outer boundary of the async task, wrap it in `ServiceError`, and emit it through `CallbackQueue`. This is the **only** place where catching non-specific exception types is permitted, and it must sit in the top-level task wrapper rather than in helper methods. +Services run tasks on background threads. An exception that escapes the worker is reported as a last resort (see below), and the coordinator that asked hears nothing back. `ServiceBase` subclasses therefore catch the exception at the outer boundary of the async task, wrap it in `ServiceError`, and emit it through `CallbackQueue`, so the coordinator that asked hears the failure as a result. The catch belongs in the top-level task wrapper and not in helper methods. ### Coordinators — the recovery boundary Coordinators own the decision of what to do when an operation fails. They: - Catch **specific exception types** named by the domain or I/O layer (`OSError`, `LoadReconstructionError`, etc.). -- Present failures to the user via `DialogsRenderer` rather than propagating them further. +- Present failures to the user via `DialogsRenderer` and stop the propagation there. - Handle `ServiceError` results from the tagged union returned by async services. -A coordinator must catch precisely: broad catches (`except Exception`, bare `except`) and deferred typing (`# TODO: specify exception type`) are guideline violations. +A coordinator catches precisely. Broad catches (`except Exception`, bare `except`) and deferred typing (`# TODO: specify exception type`) are guideline violations. -### UI layer — errors arrive as data +### Entry points — the last resort -Panels perform no error handling. All error conditions arrive as data through coordinator-wired callbacks (`on_error: Optional[Callable[[Exception], None]]`), and a panel may display an error state derived from a view model. Dialog presentation likewise belongs to the coordinator: the panel fires an intent hook, the coordinator presents (see the `ui/` contracts). The one catch permitted inside `ui/` is the widget-level input-validation guard — parsing user keystrokes into a value or `None`. Classifying a rendering failure into a typed domain error, and recovering from it, is a coordinator concern: `InstructionsTabCoordinator._render_instruction` catches the concrete plotting failures (`KeyError`, `IndexError`, `ValueError`) and re-raises them as one `LibraryDisplayError`, which its recovery boundary `_on_instruction_loaded` presents. +Every failure ends in a recovery or in a report. A failure no layer recovered from is reported where control entered the application: -### Summary +- `CallbackQueue.run` runs every gesture DearPyGui gathered, every frame callback and every queued delivery. +- `run_background_task` runs every task a worker thread is handed: a widget's `concurrent` work and each task of a latest-wins executor. A failing task ends alone, and the worker goes on to the next one. +- The thread hook hears every other thread. -```mermaid -graph LR - LOGIC["Logic / Manager\n(propagate)"] - SVC["Service\n(wrap → ServiceError\nvia CallbackQueue)"] - COORD["Coordinator\n(catch specific types,\nshow dialog)"] - UI["UI Panel\n(errors as data)"] - - LOGIC -->|exception| COORD - SVC -->|ServiceError| COORD - COORD -->|on_error callback| UI -``` +Each hands the exception to `UnhandledFailures` (`utils/callbacks/failures.py`). It logs the failure with its traceback and posts the report to the render thread through the queue, whichever thread failed. `UnhandledFailurePresenter` shows the error dialog: one plain line saying the last action did not finish, then the error and its traceback. A report standing on the screen or waiting for it absorbs later ones until the reader dismisses it, so a failure repeating every frame is read once. The composition root attaches the presenter and the teardown detaches it, so a failure outside a running interface is logged alone. ---- +These catches and the service task wrapper are the only places a non-specific exception type is caught. They recover nothing: the gesture ends, the interface keeps running, and the reader learns that the action stopped. A failure a coordinator can name and explain is still caught there, with a message of its own. The last resort serves what nobody named, such as a bug or a document breaking its own rules. + +### UI layer — errors arrive as data -## File Layout +Panels perform no error handling. All error conditions arrive as data through coordinator-wired callbacks (`on_error: Optional[Callable[[Exception], None]]`), and a panel may display an error state derived from a view model. Dialog presentation likewise belongs to the coordinator: the panel fires an intent hook, and the coordinator presents (see the `ui/` contracts). The one catch permitted inside `ui/` is the widget-level input-validation guard, which parses user keystrokes into a value or `None`. -``` -sampletones_application/ -├── application.py ← composition root; owns and wires every component -├── shell.py ← DPG context, main window, tabs, shortcuts -├── viewport.py ← viewport geometry + fullscreen -├── paths.py ← single source of truth for filesystem paths -├── ui/ ← elements/ (reusable widgets), panels/ (per feature area), themes/, resources/, menu.py -├── view_model/ ← immutable snapshots + edit payloads; one subpackage per tab, plus shared/ -├── coordinators/ ← one module per coordinator -├── logic/ ← domain state machines; one subpackage per feature area, plus history/ and shared/ -├── services/ ← ServiceBase + one module or subpackage per background worker -├── config/ ← ConfigManager + SessionManager -├── categories/ ← LanguageManager + lookup enums, with the key grammar under key/ -├── constants/ ← application-scope constants, one module per subject -├── layout/ ← LayoutConfig (Pydantic) + YAML loaders -├── tags/ ← TAG_*, SUF_*, PRE_* identifiers and compose_tag only -└── utils/ ← dpg-free helpers; dpg-bound helpers under utils/gui/ -``` +Classifying a rendering failure into a typed domain error, and recovering from it, is a coordinator concern. The instructions tab coordinator, for example, catches the concrete plotting failures and re-raises them as one `LibraryDisplayError`, which its recovery boundary presents. --- -## Naming Conventions Summary +## Naming Conventions | Kind | Convention | Example | |------|-----------|---------| @@ -490,10 +303,8 @@ sampletones_application/ | Manager class | `Manager` | `ReconstructionManager` | | Controller class | `Controller` | `ProjectController` | | 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` | -| 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` | | Logic callback | `on_` attribute | `on_view_changed` | + +The `tag-names` and `language-keys` hooks enforce the spelling of tags and text keys. diff --git a/docs/development/assets.md b/docs/development/assets.md new file mode 100644 index 000000000..68b4b298e --- /dev/null +++ b/docs/development/assets.md @@ -0,0 +1,91 @@ +# Assets + +This document governs `assets/`, the checkout unit that makes the repository's own content: the icon suite +the application ships, the demo tree, and the pictures of the application the guide and the README show. +Consult it before adding something the repository keeps that a person would +otherwise draw, record or arrange by hand, and before changing how one of these is made. What a checkout +unit is, and what it may import, is in [tooling](tooling.md). + +--- + +## Principles + +**1. The repository's content is made from a specification, never by hand.** A thing the repository keeps +and shows, such as an icon, comes from a declaration of what it should be and a maker that renders it. A +change to the thing is a change to its specification, after which the maker runs again. The committed +files are the maker's output and nothing else, so a reader who wants to know why a thing looks as it does +reads the specification. + +**2. One package per thing made.** Each package of the unit makes one kind of thing, says what it makes +and from what, and writes it where the thing is kept. It runs as `python -m assets.`, with a parser +and help of its own, and the Makefile names it. `--output` (`-o`) sends the output elsewhere, which is how a +test or a trial run keeps away from the committed files. + +**3. A maker whose output is the same on every machine holds the committed files.** The icons rasterize +the same on every machine, so the `icons` pre-push hook runs their maker for a push that touches the mark +or the icons, and a committed file that differs from what the mark describes fails the push. CI runs the +same hook. A maker whose output depends on the machine's arithmetic, such as a conversion, writes under +`build/` and is run by hand. A maker whose output may differ by a pixel between machines, as a software +renderer's does, is run by hand too, and the pull request shows what changed. + +## The makers + +### Icons + +`assets/icons/` writes the icon suite from the mark declared in `assets/icons/mark/config`. `mark.yaml` has +the geometry, colors and rasterization settings, validated as a `Mark`, and `template.svg` is the vector the +rendered geometry fills. The suite is the vector `sampletones.svg` and the rasters the application ships, +`sampletones.png` and the multi-resolution `sampletones.ico`, written into the directory the icons ship +from, `src/sampletones_assets/icons/`. `make icons` writes them, and `python -m assets.icons -o DIR` writes +them elsewhere. The whole suite is committed, so every wheel, bundle and test run finds the icons where +they lie. Pillow rasterizes the suite; it is a development dependency, so the maker runs in a checkout alone. + +### Demo tree + +`assets/demo/` writes a documents folder a person can run the application from, with recordings beside +it, so the application has something to show without anyone's own files. Four specifications under +`assets/demo/config` say what it holds: `voices.yaml` declares synthesizer voices in the corpus's own +shape, `recordings.yaml` places them into hits struck once and a short piece of stems written as notes in +beats, `conversion.yaml` gives each hit its channels and the stems their levels, and `project.yaml` arranges +the hits and two instruments into a song. The maker renders the recordings, writes a configuration whose +library and reconstructions folders lie in the tree, builds that library, converts each hit alone and the +stems together, and saves the project. Each reconstruction names its recordings relative to the tree, so +the tree reads the same wherever it is put, and the application reaches them when it runs from inside it: +`cd DIR && sampletones -c config.json`. + +`make demo` writes the tree under `build/demo`, and `python -m assets.demo -o DIR` writes it elsewhere; +the folder must be empty, which keeps a stale tree from mixing with a fresh one. The library takes the +longest part of a run, and the conversions run on whatever the machine offers, so two machines may convert +the same recordings into slightly different documents. + +### Pictures + +`assets/pictures/` draws the pictures the guide and the README show: the application running on the demo +tree, put into a state and read off the screen. A picture is a *scene*, written the way a screen scenario +is ([screens](application/screens.md)): a method under `assets/pictures/scenes/` takes the screen and the +`pictures` writer, drives the application through the automation unit's views and steps, and writes one +picture, cropped to the card, dialog or popup the page names, with a margin around it. The README's picture +is the whole window at twice the width the README shows it at; the guide's pictures come from a window just +tall enough for the Main tab's cards to stand whole, so a crop stays compact. Every picture is lossless, so +what the page shows is what the screen drew. + +`make pictures` runs the scenes. The runner makes the demo tree under `build/demo` where it is missing, +starts the scenes under pytest on a few virtual displays of their own, with the scene naming in place of +the test naming, a screen large enough for the README's window, the records of each scene under +`build/pictures/`, and the scenario homes under `build/worlds/homes/` when the checkout's path holds no hidden +folder, so a path a picture shows reads as one under `/home`. The application's own temporary files stay in +the system's temporary folder, out of the browsers' view. A scene marked `alone` runs by itself after +the others: the file browser lists the folders beside the scene's home, and every other worker's homes +folder is named after the run that made it, so the scene waits until those are gone. The homes stand deep +enough under `build/worlds/`, which nothing under `build/` follows, that the rows of the checkout's own +folders under the home's rows end before a picture bounded by those rows begins. The frame-rate reading is off in every scene, since it +would change the picture on every run. A scene's own window size and world come from +`assets/pictures/worlds.py`. + +A picture is regenerated on purpose, by running the maker again after a change to the interface, and the +pull request shows the pictures that changed. A software renderer lands an anti-aliased edge a fraction of +a pixel differently from one process to the next, which flips a scatter of single pixels along text and +plot edges on every run, and a lossless file would carry every flip as new bytes. The writer therefore +keeps the picture that stands when a new drawing differs from it by that scatter alone, and rewrites it +when any small block of the frame changed as a whole, which is what a changed glyph, a moved control or a +redrawn line does. A run after no change to the interface leaves the repository as it was. diff --git a/docs/development/browser.md b/docs/development/browser.md deleted file mode 100644 index 0bf2e4d1f..000000000 --- a/docs/development/browser.md +++ /dev/null @@ -1,279 +0,0 @@ -# The Reconstruction Browser - -This document governs the tree of reconstructions the **Reconstructions** and **Sequencer** tabs -share: how a reconstructions directory becomes rows, what a row stands for, and what it answers. -Consult it when changing what the browser lists, how a row reads, or what a click on one does. It -complements `docs/development/architecture.md` (layering and ownership) and -`docs/development/guidelines.md` (coding rules). - ---- - -## Principles - -1. **One reading of the disk feeds every view.** A refresh walks the reconstructions directory once - into a `ReconstructionScan`, and every branch is built from that record. The views therefore agree - about what exists by construction, and a folder name is parsed into its configuration fields once - per refresh. -2. **The model carries the shape; the panel carries the widgets.** Which rows exist, what they are - called, which of them fold together and in what order they sit are decided on the tree. Both tabs - 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 - 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 - own. What such a row offers follows from the subtree beneath it. -5. **One thing may stand in several places.** A reconstruction is listed by the configuration that - produced it and again by the audio it was made from, so an action on the thing rather than on the - row asks for every row standing for it (`Tree.find_nodes`, `BrowserManager.nodes_at`) and hands - them to both tabs. -6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the - background worker — tag, label, font, theme, handler, open state — and the main thread creates the - widgets from those specs, spread across frames. -7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows - is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, - and each browser opens in the mode a session left it in. -8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the - tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave - the tree standing as it was, and so does the next run of the application. What a filter unfolds on - top of that shape is remembered as the filter's own, held for as long as the filter is, and handed - back when it goes — apart from a row the reader's own has come to stand on, which stays open so the - view they built stays on the screen. - ---- - -## The pipeline - -`BrowserManager` (`logic/reconstruction/browser/manager.py`) owns the tree and runs a refresh in four -steps: **scan** the directory, **build** each branch from that one scan, **shape** what came out, and -**publish** it through `Tree.set_root`. `BrowserLogic` sits above it as the surface the coordinators -drive, and `get_all_reconstruction_files` reads the scan. - -| Stage | Module | What it does | -|---|---|---| -| 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 | -| 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 | - -The policy the two branches share: a configuration directory sitting at the top level of the -reconstructions directory is the one lifted under groups and transposed into the sample view. A -configuration directory nested inside a plain folder keeps its friendly name where it sits, and a -reconstruction outside every configuration directory appears in the configuration branch, that being -the branch which follows the disk. - -## The node vocabulary - -`sampletones_core/structures/tree/` holds the nodes, all anytree-backed: - -* `TreeNode(name, node_type)` — a row and its kind. `NodeType.ROOT` for the container both branches - hang from, `GROUP` and `SAMPLE` for the headings the browser writes, `DIRECTORY` and `FILE` for what - the disk holds. -* `FileSystemNode(filepath)` — a row standing for a path. Favorites, playability, themes and the path - items all test for this class. -* `ConfigNode(config)` — a filesystem row belonging to a reconstruction configuration, carrying the - parsed `ConfigDirectoryFields`. It subclasses `FileSystemNode` so every reader of a path keeps - working, and the fields travel with the row, which is what lets a label, a tooltip and a font state - the configuration from the node already in hand. -* `ConfigGroupNode` — a heading gathering the configurations that share a stretch of their display - name: the rates they run at, the spectrum they were built from. It keeps `NodeType.GROUP`, so it - folds, prunes, sorts and behaves as any heading does, and it names the rows whose labels are - configuration text rather than words, which is what the configuration font reads. - -`create_directory_node` chooses between `FileSystemNode` and `ConfigNode` from the fields the scan -read. Which row carries the configuration follows the branch: in the configuration branch it is the -directory that names it, and in the sample branch it is the variant leaf, since there the -configuration is what distinguishes one row from the next. - -## The shaping rules - -* **Prune** (`prune_empty_containers`) — a heading the browser wrote that gathers nothing leaves, - deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to - 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, - 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 - second configuration arrives. -* **Order** (`order_children`) — containers ahead of leaves, then `natural_sort_key` over the label, so - a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every - label is final; the branches directly under the container root keep the order the builder states them - 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, - and the variants under a sample. - -## The panels - -The browsers form one line of inheritance, each level owning what it shares: - -* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the - filter they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` - (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row, - the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. -* `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the - controls bringing the tree up to date and folding it away, the tree window, the folder-and-file - 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 - 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 -`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 -date, collects the rows into specs, and hands them to `TreeEmitter`, which clears the old rows and -stages the new ones in budget-sized batches so interactive callbacks run between slices. The -completion callback shows the empty state where one is called for, runs the panel's hook, and releases -the lock. Because a browser is asked to rebuild from either tab and from several places in the -application, exactly one rebuild is in flight at a time. - -**A row's tag** (`compose_node_tag`, `ui/elements/tree/tag.py`) joins the names above it, which reads -the row back to whoever inspects the widget tree, and appends a digest over the exact path of -`(node_type, name)` pairs. Rows the names alone spell alike — a folder and the audio beside it, two -labels differing only in spacing or case — therefore keep tags of their own. A tag is composed rather -than stored, so any holder of a node can address its row: this is how expanding a subtree, repainting a -star and applying a filter reach the widgets. - -**What a row answers** follows its kind. A reconstruction plays on a click, opens on a double click, -and offers its path items, the tab's own actions and the favorite mark. A directory offers its path -items and the favorite mark. A group or a sample stands for no path, so its menu reads the subtree: how -many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows -it by, and — on a sample — the audio its reconstructions were made from, answered through any one of -them. - -**Favorites are paths.** `TreeLogic.is_node_favorite` tests the row's path against the session's set, -and `has_favorite_ancestor` tests the path's parents, so a reconstruction reads as part of a favorite -folder wherever a view puts it — including the sample branch, whose headings carry no path. Since one -path reaches the panel as several rows, `application.py` resolves the toggled path into every row -standing for it and hands them to both tabs, and each row repaints with the ancestry its own path -carries. - -## Filtering - -`TreeFilter` (`ui/elements/tree/filter.py`) holds what a browser is currently asked to show, and the -panel showing it owns the filter. It is stated whole and replaced whole — `with_query`, -`with_favorites_only` — so one place resolves what the browser shows, and `NO_FILTER` is the filter a -browser showing its whole tree holds. - -The two criteria answer different questions, so each lands in a different place: - -| Criterion | What it decides | Where it lands | What a change costs | -|---|---|---|---| -| `favorites_only` | which rows the browser **draws** | `_append_spec` records the rows the mode shows, so `TreeEmitter` creates widgets for those alone | `redraw_tree` collects the rows again from the model in hand, on the tree worker | -| `query` | which of the drawn rows are **shown** | `update_tree_visibility` flips `show` over the rows already on screen, once the typing settles | a resolution of the query, debounced | - -One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibility.py`) takes the -rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row -one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in -memory follows the size of what was found, and a row beneath a match is answered from its own path -upwards. - -**What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows -and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion -names are its **anchors**: for a search, the rows whose label matched; for the favorites mode, a row a -star sits on, and — where no row stands for the starred path — the shallowest rows that path reaches. -In the sample branch the headings carry no path, which is what makes the variants the rows a starred -folder arrives at. - -**A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a -match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points -the reader at a star, so what opens is the rows above it (`_way_down_to`, over the anchors' ancestors) -while the star's own row stands where the reader left it — a starred folder is revealed. A starred -reconstruction inside a starred folder anchors on its own, which is what opens the folder above it. - -**Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds -is a preference stated per kind of favorite, held in `ApplicationConfig.browser` and offered as -**View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred -folder, and everything it brings in where no row stands for it, reads the directories answer. Both are -off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The -panel reads the pair through `TreeLogicProtocol`, once per resolution. - -**The way down opens on the pass the reader asked for, and stands for as long as the mode does.** -Switching the mode on is the reader asking to be shown their favorites, so the pass that switch starts -is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends -it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a -refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the -stars followed stay the ones the switch asked about. Every turn of the mode is a pass's to answer: the -pass that reads the mode off lets the memory go and those rows fold back. A change of preference asks -for nothing; it is answered the next time the reader asks for the mode, which keeps a menu click from -moving the tree the reader is working in. - -**The way down becomes the reader's once their own rows stand on it.** A reader looking at their -favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on -the screen. `_release_mode_rows` therefore reads the model on the pass that finds the mode off — on the -tree worker, beside the other walks a pass makes — and hands the memory the ways down to the reader's -rows; `RowExpansionMemory.release` keeps the rows of the mode's among them, which writes the way down -into the shape a session keeps. What is left held the mode's opening alone, and folds with it. - -**A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on -the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so -declining a row declines its subtree, and one decision covers it while the traversal walks on. - -**Two memories, each holding what one hand opened.** `RowExpansionMemory` owns both and the rules that -join them, holding each row by the tag it is addressed under so a later pass creates it open again. The -reader's rows hold what the reader did — a click, read a frame later once the row has answered it, and -the expansion items and the collapse control, which record what they set — and they are what a session -writes down. The mode's rows hold the way down it opened, and go when the mode does, so a narrowed -browser hands the tree back the way the reader had it, keeping the rows theirs now stand on. Folding a -row is the reader's word on it whichever hand opened it, so `remember` releases the mode's claim along -with the reader's and the row stays folded. A pass writes from the tree worker while a click writes from -the main thread, so one lock covers every answer the memory gives. Both sets are held to the rows the -model states, read afresh on every pass, so a row a moved reconstructions directory left behind leaves -them with it. Which browsers record a shape at all is `_REMEMBERS_EXPANSION`: it decides whether a click -is followed through to the memory, and a browser that keeps none leaves it empty. - -A search unfolds by the same rule from the other end: its matches and the rows above them open for as -long as the query stands, resolved afresh on each pass, and clearing the query folds them back. - -The shape outlives the run as well. A browser is handed the mode and the rows it opens with as it is -built (`initial_favorites_only`, `initial_expanded_rows`). A change of mode is written where it happens, -through `on_favorites_filter_changed`, and the shape is asked for the once, at exit: -`_persist_application_state` takes each tab's rows into `ApplicationState.expanded_rows` under the -panel's tag, so a pass holds what it opened in memory, on the tree worker, and the session file reads it -from there. - -**The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level -at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its -children have been read, and whether its row stands open. They part company — a folder read and then -folded away is loaded and closed — and the open one is the shape a session writes to -`ApplicationState.expanded_directories`. A refresh reads down to each remembered folder through -`_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories -are dropped as the manager is built. - -**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each -row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — and the -anchors the preference follows are read out of that one answer. What it materialises is the starred rows -and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding -hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones -and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A -favorite toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring -one takes it out along with what it held. - -A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back -empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the -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 -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 -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 -own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, -which is how a collapsed card is remembered too. - -**Folding the whole tree away** is the other control every card carries. It reaches the rows through the -model rather than the widget tree, so one pass covers a branch however deep it runs, and it records what -it set — leaving the memory empty, which is the shape a later pass then draws. The explorer folds first -and drops the folders it had read afterwards, so opening one lists it as it stands on disk. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index b5b2a61c7..ae5bc7fee 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -1,46 +1,145 @@ +# Bugs and to-dos + +This is the working ledger of what is still owed: to-dos, deviations from the architecture, and known +bugs. Read it before starting work in an area. Add an entry when a change knowingly leaves something +behind. Each entry says what is owed and why, and the code and the change that closes it hold the rest. + ## To-dos ### Navigation * Interface scale -* Tree navigation using keys +* Keyboard navigation of the trees, and of the converter's list of gathered recordings: a cursor the arrow + keys move, `Home` and `End`, and folders opened and closed from the keyboard. * Waveform LOD for zooming -* Alt for scrolling graphs -* Drag and drop -* 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 +* Drag and drop of browser nodes onto views, such as a reconstruction onto Samples. +* Tabs for several reconstructions in the Reconstruction view. * Application installation progress bar ### Tracker -* Basic shapes as instruments +The first two entries are the settings an imported `.fti` reports as left behind (see +[FamiTracker export](../formats/famitracker.md#c-reading-an-instrument-file)). Each one closed is a +dimension the import starts carrying. + +* Release points. A note-off cuts the channel today, so a release segment has nowhere to play. +* Arpeggio modes. Fixed, relative and scheme arpeggios import as absolute offsets today. +* Compatibility options for the FamiTracker export, so a module plays as the app does where FamiTracker's + own rules differ, such as how it rounds a pulse level and where it places the longer rows of an uneven + tempo. +* A note pasted across channel kinds is clamped: a period landing on a tonal channel reads as its lowest + note, and a pitch landing on noise as the last period. +* Playing from a row starts every channel silent, so an instrument placed without a pitch at that row + sounds nothing live, while a full play sounds it on the pitch the rows before left. +* A rebuilt grid starts without a selection, so following playback and the rebuild after a growing paste + drop it. Keeping the selection across the rebuild closes it. +* The selection stays where it was after a paste and does not become the pasted footprint. It should + become the pasted block. +* A note pasted through the clipboard text names its voice by list position, and one pasted through the + in-app slot names it by id, so the same note can play two voices across projects. One naming scheme for + both routes closes it. +* A drag past the edge and the followed playhead both write the scroll, so with **Follow rows** on they take + turns each frame. One writer of the scroll at a time closes it. ### Workflow -* Waveform construction preview for single-file conversion -* Selection operations on a reconstruction -* Reconstruction trimming +* Waveform construction preview: a waveform drawn from the partial reconstruction while the converter + reconstructs a single file. +* Picking several rows of the converter's list at once, so a group leaves or settles in one gesture. +* Selecting parts of a reconstruction's waveform to crop, trim, cut and paste them along with their + instructions. +* Marking broken files where the browsers list them: a reconstruction that fails to load, one whose + recordings are missing and a recording that cannot be read could stand in a warning or an error color, + so the reader learns before trying to open or play one. ### Features * In-application guide/tutorial * Language selector -* NSF export +* Reading only the bins the refinement asks for, since it transforms every bin the spectrum covers and + uses a handful per frame. +* Keeping the recordings a stopped folder scan has found, so stopping a long walk keeps the count the reader + watched climb. +* Calibrating the pitch refinement's change weight and window, which are chosen by hand today, with + [the calibration](../tools/calibration.md). The weight counts divider steps, which span more cents in a + high register than in a low one, and counting it in cents would weigh every register alike. +* A setting for the lowest frequency the analysis reads. It starts at the triangle's lowest note, which + costs conversion time on music that stays higher. A floor an octave up plays lower music at its third + harmonic, and a floor below the triangle's lowest note needs longer library samples. ### Technical -* API documentation -* Code documentation -* Backward compatibility: library/reconstruction upgrade scheme -* Respecting FamiTracker limitations -* Per-tab undo routing -* In-application console -* Improve performance of browser favorite scan of the entire tree per click +* Screen scenarios on Windows and macOS. They run on Linux alone, and whether DearPyGui opens its window on + GitHub's Windows and macOS runners is unverified. +* An upgrade path for the configuration and the session state. A file an older build left behind is read + as the loader happens to, and no archived files of older builds test it. +* A `project.json` written at format 1.1 before a row's pitch gained its face still carries `transpose`, + which the loader drops, since the upgrade chain is idle on an equal version. Files a release wrote are + carried over. +* The element enums that outlived their keys. The language-keys check treats a key named through an + element enum as the whole enum, so a key no call uses goes unnoticed. Spelling each key at its call site + makes the check exact. +* Per-tab undo routing, so each tab undoes its own document. +* A history of its own for a standalone reconstruction document, one loaded from disk and not opened as a + project sample. An edit to such a document is undoable nowhere ([undo](application/undo.md)), so an edit + that silences a channel or lets a recording go is reversible only by reloading the file. +* A channel edit's history entry owns every instruction of the channel, though the edit changes a few + frames. A rebuild makes each frame of the channel anew, so on a four-channel conversion an entry has + about a quarter of the document. Keeping the old instruction wherever the rebuilt one equals it brings an + entry down to the frames the edit changed ([undo](application/undo.md)). +* Strict history hashes the whole song and the whole new document at every commit. On a four-channel + conversion of 13,677 frames, hashing the document costs more than twice the rebuild of the edited + channel, and the song's hash grows with the song. Development runs and the test suite pay for it. + Memoizing each pattern's and each stream's hash by identity, as each document's is memoized now, brings + a commit down to the parts its gesture replaced. +* Improve performance of the browser's favorite scan of the entire tree per click +* The `fft` and `logfft` analysis floors. Their window spans two cycles of the pulse's lowest note, so the + triangle's lowest octave reaches them by its harmonics alone. Covering it doubles their window and + coarsens their timing. + +## Architecture + +Where the codebase stands apart from [`architecture.md`](architecture.md). A deviation is recorded here +once review has seen it and let it stand, so this section, and not the code, is the memory of what is +currently out of line. An entry leaves when the code meets the contract again. + +* The two sequencer grids implement the same machinery twice. The order and tracker panels each 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. The shared `ui/panels/sequencer/grid/` + package is where these belong, and `# TODO: to abstract` markers stand at the blocks. Pylint's + duplicate-code report names each pair. +* `application.py` and `ui/elements/tree/tree.py` each hold several concerns in one module, past the size + at which the sequencer panels 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. +* The tab coordinators' and the application's unit tests build their object through `__new__` and fill its + private fields by hand, so a case describes a method and not the wired object, and a hook left unset goes + unseen. +* Which recordings a mix is built from is decided in `ui/` until **Add** is pressed. The chooser holds the + pick as its own set and asks the view model how a gesture moves it, so a projection computes state + transitions. The logic layer hears only the answer. Principles 3 and 4 put that machine in `logic/`, + with the chooser drawing what a view model says and reporting the gesture. Moving it is a phase and not + a patch, because the dialog drives the pick today. +* Every gesture in the converter re-derives the whole setup, so its cost grows faster than linearly with + the number of recordings. Holding the rows against the gathering that produced them, and deriving entries + for the recordings a gesture moved, closes it. +* The automation unit reaches two private members: the regeneration hold stands in for + `RegenerationService._run` and reports through `_emit`, and the tracker view reads the caret's box from + `CaretOverlay._rectangle`. Each is a seam a scenario needs, which [screens](application/screens.md) + says the application offers as a public one. ## Bugs -* No refreshing after library generation -* Misaligned dialog boxes sizes at initialization -* Audible noise instructions when matching near-silent samples for FFT γ0 +* A click on the History card's line in force takes its highlight off. The jump goes nowhere, so the card + repaints nothing and shows no line in force until the next step. +* A rate change's retune can land as an entry of its own. When another gesture commits before the + samples' background retune lands, the retuned samples record a second entry, so undoing it leaves the + project at the new rate over samples tuned for the old one. +* A silent triangle renders the middle of its wave, where the console holds the step it stopped on. A + faithful hold needs a DC-blocking output stage at every mix (the reconstruction's render, the sequencer + and the song render), since nothing drains a held level today. +* Another voice inside a harmonic's bin pulls the pitch reading. A partial of another voice within half a + semitone of one of a note's harmonics shares that harmonic's bin and adds to its reading: a 65 Hz bass + under a steady 330 Hz pulse reads about 4 cents off, and within a cent alone. +* The Sample column shows no sample on a frame's first rows, since its reading starts over at each frame. It + offers no transpose or volume there, while playback applies them to the sample the previous frame left + sounding. The rows the tracker shows beside a frame read the same way, each from its own frame's start. diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md deleted file mode 100644 index fb4df14cb..000000000 --- a/docs/development/config-organization.md +++ /dev/null @@ -1,194 +0,0 @@ -# Configuration Organization - -_SampleToNES_ ships its configuration as a YAML data package, `sampletones_config`. This -document states the principles that decide where a configuration value belongs and how it -is read; use it as the reference when adding or moving a value. It sits alongside -`docs/development/architecture.md` (application layering and ownership) and -`docs/development/guidelines.md` (coding rules). - -"Configuration" names three separate things in this codebase. This document governs the -first: - -- **Shipped configuration** — the `sampletones_config` YAML package: layout, theme, - palettes, keybindings, language, behavior, deployment, and calibration. *(This document.)* -- **Runtime user preferences** — mutable state persisted to the user profile - (`sampletones_application/config`, e.g. `PlaybackConfig`, `ShortcutsConfig`, - `ApplicationState`), governed by that package. -- **Project generation settings** — JSON stored beside a project - (`sampletones_core/configs`, `config.json`), documented in - `docs/formats/configuration.md`. - ---- - -## Principles - -### 1. Data and meaning are separate - -`sampletones_config` carries values; the schema that interprets them lives in the package -that reads them. The dependency runs one way — a consumer imports the data package only to -resolve its directory (`CONFIG_DIRECTORY`), and the package itself is pure YAML with an -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`). - -So the data carries the values and the consumer carries the meaning, and the two evolve -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. - -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 -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. - -Keybindings are a domain on the same shape: a scheme is a named set a preference selects by -name, so the directory holds one file per scheme, named after the scheme it declares, and -every scheme answers the same action set — an entry names one `ShortcutId` and each scheme -must have a combination for it. What the directory carries is the combinations, which are a -reader's to choose; the actions and the category each belongs to are code, since they follow -from the scope that handles the press. - -### 3. The config tree mirrors the code - -The layout config is shaped like the code that reads it: its directory tree matches the -`LayoutConfig` model tree, which mirrors the application's feature-area taxonomy across -`ui/panels/`, `logic/`, and `view_model/`. A value's place in the config therefore -predicts its place in the code. Three conventions keep the mirror true: - -- **A feature area is a directory of fragments.** Each area is a directory loaded by - `load_yaml_model_dir`; every `.yaml` supplies the model's ``, and an - optional `root.yaml` carries the loose scalars that own no section file. Two - cross-cutting resources — `fonts.yaml` and `glyphs.yaml` — are single self-contained - files at the `layout/` root, each one resource in one file. -- **File stem = field = model.** `choice.yaml` fills field `choice`, validated by - `ChoiceLayout` in `choice.py`; the three names match within a domain, so one name traces - a value from YAML through field to schema. A stem is unique within its domain: the same - name may recur across domains as a related-but-distinct resource - (`layout/general/plus_minus_buttons.yaml` sizes a widget while - `theme/plus_minus_buttons.yaml` styles it — one widget, two domains, loaded separately). -- **Tabs sit where their coordinators sit.** The four notebook tabs live under - `layout/tabs/` (`main`, `instructions`, `reconstruction`, `sequencer`), matching - `coordinators/tabs/` and aggregated as `LayoutConfig.tabs`; a tab's configuration is - found where its code is. Cross-tab and shared areas stay at the `layout/` root as their - own feature areas: `general/`, the plot-element family `graphs/`, the transport toolbar - `player/`, and the dialogs `project_properties/` and `settings/`. - -### 4. Every value has one home - -A value lives in exactly one place, owned by the concept it describes. A tab's own -geometry — width and height together — lives in that tab's directory. The shared outer -column skeleton (`side`, `center_weight`) lives in `general/`, since it belongs to every -tab equally. The values that drive responsive resizing (`baseline_viewport_width`, -`baseline_viewport_height`, the graph-stack cap) live together in `general/responsive.yaml` -because they are the input to one scheme. Ownership decides the home: whoever owns the -concept holds the value, and it appears once. - -### 5. Storage shape and consumer shape differ - -Principles 1–4 shape the config for **where a value is authored**. What a consumer needs -is often a different shape, and the two are reconciled at the composition root -(`Application.__init__`, where the validated `LayoutConfig` already exists). Each consumer -receives a view built for it: - -- A **feature consumer** — a panel or UI element — receives its model whole: - `GUIConverterPanel(layout: ConverterLayout)`. The model carries all of that panel's own - geometry, so a new field reaches the panel through the model it already holds. -- A **generic primitive** — the responsive math (`expanded_side_width`, - `stacked_graph_height`), the column builders (`ColumnSpec`, `TabColumns`), raw - `dpg.configure_item` — receives the plain integers it computes with, since it blends a - config value with a live runtime measurement (e.g. `dpg.get_viewport_client_width()`) - and works below the level of any layout model. -- A **tab coordinator** receives a per-tab view. A frozen-dataclass DTO in the - `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 - needs (`TreeColors.create`, `PitchStepperStyle.from_general`). - -The type signals which side of the boundary a value is on: a frozen Pydantic model with -`extra="forbid"` is a YAML fragment; a `@dataclass(frozen=True)` is a view derived in code. -The composition root is the one place that knows both shapes, so each deep path from -storage to consumer is written once, in one factory. This is what the DTO layer is for: a -coordinator depends only on the narrowed view handed to it, and the knowledge of where -each value sits in the tree stays in the factory. - ---- - -## Domains - -| Domain | Directory | Schema owner | How it is loaded | -|--------|-----------|--------------|------------------| -| 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` | -| 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 | -| Layout | `layout/` | `LayoutConfig` (`sampletones_application/layout/config.py`) | `load_layout_config` (`layout/loader.py`) | -| Palettes | `palettes/` | `Palette` (`sampletones_application/utils/palette/`) | `PaletteCatalog.load()`, indexed by palette name | -| 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 -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. - -`ShortcutCatalog` answers the same way for a keybinding scheme, with the shipped `default` as -its fallback. A scheme is validated as it is read: every action the application names is -answered, every key name resolves against the key table, and one combination reaches one -action within a category, so a scheme in use resolves any press its category owns. The user's -own rebindings stay on the preference side (`ShortcutsConfig`) and are applied over the -selected scheme at startup, which keeps the shipped file the statement of what a build offers. - -The domain holds one file per keyboard the build ships — `default.yaml` and `macos.yaml` — -and the platform decides which one a profile starts on: `ShortcutsConfig.scheme` takes its -default from `PLATFORM_SCHEME_NAMES`, so the choice is made when the configuration is created -and the name stored there selects the scheme on every run after. - -Layout and theme schemas are `frozen=True, extra="forbid"`, and loading is eager at the -composition root (`Application.__init__` → `load_layout_config`, wrapped as `SystemError`), -so a mismatch between YAML and schema surfaces loudly at startup. - -Behavior loads as its own domain — its own directory, schema owner (`BehaviorConfig`), and -`load_yaml_model` call — and attaches to the layout result as `LayoutConfig.behavior`, so -consumers reach it as `layout.behavior.*`. A single access path serves the ~15 runtime -sites across `application.py` and the tab coordinators that read it, and the ~10 modules -that import `SchedulingBehavior` as a type. - ---- - -## Loading - -Three load mechanisms serve the three grouping schemes: - -- **Field aggregation** (layout, and every domain that mirrors the code). - `load_layout_config` builds `LayoutConfig` field by field — `load_yaml_model` for a - single-mapping file, `load_yaml_model_dir` for a feature-area directory — so the - directory structure and the model structure stay identical (principle 3), validated - strictly with `extra="forbid"`. -- **Tag-graph discovery** (theme). Theme is organized for human navigation, grouped by the - widget family it styles (`button/`, `channels/`, `dialog/`, `header/`, `nodes/`, - `panel/`, `player/`, `tables/`). `ThemeLoader.load_all()` reads every `*.yaml` under - `theme/` recursively, validates each as a `ThemeSpec`, resolves the `extends` inheritance - graph, and registers the results in the `ThemeRegistry` singleton keyed by `tag`. Here - the directory grouping serves people and the `tag` and `extends` fields carry the load - meaning; every theme extends the base `default` unless it names another parent. -- **Name-keyed discovery** (palettes, keybindings). `PaletteCatalog.load()` reads every - `*.yaml` under `palettes/` and indexes it by `Palette.name`, holding each file's stem - against the name it declares so one name traces a palette from a stored preference to the - 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`. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md deleted file mode 100644 index e0de82837..000000000 --- a/docs/development/dependencies.md +++ /dev/null @@ -1,81 +0,0 @@ -# Dependencies - -## Graphical interface - -The graphical user interface is implemented with DearPyGui, a Python wrapper for ImGui (https://www.dearimgui.com/). - -## Core - -The core depends on common Python packages: -* `numpy` -* `scipy` -* `librosa` -* `cupy` (optional; enables the GPU backend, with the build selected for your NVIDIA driver) - -See [GPU acceleration](../guide/installation.md#gpu-acceleration) for enabling it. - -## Serialization - -Instruction libraries and reconstructions are serialized with [MessagePack](https://msgpack.org/) (the `msgpack` package). No external compiler or system dependency is required — it is installed automatically with the package. - -## Audio playback - -Playback goes through PortAudio, reached with the `pyaudio` package. PyPI carries `pyaudio` wheels for Windows, so Linux and macOS compile it on install and need the PortAudio headers and library on the machine. Linux takes them from the distribution packages listed in `scripts/linux/build/dependencies.sh`; macOS takes them from Homebrew through `scripts/macos/build/dependencies.sh`. - -Compiling on macOS also depends on the interpreter's architecture. The python.org installer ships a universal2 build, which compiles extensions for both Apple Silicon and Intel, while Homebrew's `libportaudio` carries the machine's own architecture. Pinning `ARCHFLAGS` to `uname -m` settles it on the native one: `make setup` sets it directly, and the CI workflows take it from `scripts/macos/build/build_env.sh`, which reports it as a `KEY=VALUE` line alongside the PortAudio prefix for a Homebrew installed outside its usual place. - -## Audio rendering - -Audio files are written with libsndfile, reached with the `soundfile` package. Its wheels carry a -prebuilt libsndfile 1.2.2 for every supported platform, so the encoders come with the package and -need nothing installed alongside them. - -Which formats an installation writes is asked of the library at runtime, because libsndfile is built -with a codec set that varies by platform and packaging — the MP3 encoder in particular arrived in -1.2.0 and is present where it was compiled in. The chooser offers the formats the library reports, -so what a user is shown describes the machine it is running on. - -| Format | Sample rates | Quality | -| --- | --- | --- | -| WAV | 8000, 16000, 22050, 44100, 48000, 96000, 192000 Hz | 8, 16, 24 or 32-bit PCM, or 32-bit float | -| MP3 | 8000, 16000, 22050, 44100, 48000 Hz | a bitrate from the ladder its MPEG version defines | - -The bitrates on offer narrow with the sample rate: up to 320 kbps at 44100 and 48000 Hz, 160 kbps at -16000 and 22050 Hz, and 64 kbps at 8000 Hz. libsndfile takes MP3 quality as a compression level -between 0 and 1 and turns it into a rung on that ladder, so a bitrate is reached through the level -its rate maps it to, measured per rate and held in `sampletones_core/audio/writers/bitrate.py`. - -## File dialogs - -Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last. - -`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them. - -## 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 -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` -points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the -`assets` dependency group. - -The whole suite is committed, so a plain checkout carries the icons the application opens its window -with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again -from the mark, and the `icons` pre-push hook writes them for a push that touches either directory, -holding the committed files to what the mark describes. CI runs that same hook. - -Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: -`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is -installed, and PyInstaller follows that import into the bundle. The application reads its icons as -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. - -## Linux (standalone executable) - -Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. - -PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained. - -The executable links against the glibc of the machine that builds it and runs on that version or newer, so a redistributable artifact belongs on the oldest Debian or Ubuntu release being supported. diff --git a/docs/development/documentation.md b/docs/development/documentation.md new file mode 100644 index 000000000..fb270d009 --- /dev/null +++ b/docs/development/documentation.md @@ -0,0 +1,134 @@ +# Writing the documentation + +This document governs the prose in this repository: the README, the guide, the development documents, the +reference pages, and the changelog. Consult it before adding a page, and before editing one. The +conventions for code and docstrings are in [guidelines.md](guidelines.md). + +## Every document has one reader + +Each document serves one reader, and that reader decides the rest: what belongs on the page, how much of +it, and the words it is written in. A passage that serves a different reader belongs in that reader's +document, or nowhere. + +| Document | Reader | What they came for | +|---|---|---| +| `README.md` | someone who just found the project | what it is, and how to run it | +| `docs/guide/` | a beginner using the application | how to do the thing they want to do | +| `docs/tools/` | someone running a measurement command | what the command does, and how to run it | +| `docs/development/`, `docs/concepts/` | a developer or agent about to change the code | the design, and the reasons behind it | +| `docs/formats/`, `docs/api/` | a programmer reading or writing our files | the exact shape of a format or a call | +| `CHANGELOG.md` | the people who use each release | what changed for them | + +## README + +Say what the project is, what it needs, how to install it, and how to run it. Keep it as plain as +possible, and no plainer. A reader decides here whether to try the application at all, so nothing else +competes for their attention. Detail belongs in the guide, and mechanism in the development documents. + +## The guide + +Write for a beginner who wants to get something done, and assume no prior knowledge. Help the reader +act: name the control they click, in the order they would reach it. Keep each topic short — a few +sentences per feature — and leave out how the application works inside. + +Use plain, direct English. Short sentences, one fact each. Everyday verbs, not this repository's own +vocabulary. A term the reader would not know is either avoided or defined in +[the glossary](../glossary.md) and linked from the page that uses it. + +**Apply one test to every sentence: if the reader would know it without being told, it goes.** That covers what a button labeled Cancel does, what the screen already shows, and what the application does by itself. A change to the application earns a sentence in the guide only when the reader needs that sentence to act. Decide this for every change, and expect the answer to often be none. + +A picture shows the control a step names, cropped to its card or dialog, right after the paragraph that +introduces it, with alt text in the same plain words. It stands centered, in the HTML the README uses, at +the width of its file, so every reader sees it at the size it was drawn. Every picture is drawn by the +pictures maker ([assets](assets.md)) and never by hand, so a change to the interface is followed by +`make pictures`. + +Two passages read the way a guide page should. "Voices: samples and instruments" in +[the sequencer guide](../guide/sequencer.md) names each kind in one sentence, then says what the reader +does with it. The three install options at the top of [installation](../guide/installation.md) give each +option a name and one sentence saying who it suits. + +## Tools pages + +One page per command, written for someone about to run it: what the command measures or produces, how to +run it with no options, what it writes, and every custom use. How it works comes last. The guide's +writing rules hold here — plain English, short sentences, a list wherever the page states several things +of one kind. [Tooling](tooling.md) governs what a tool is, and [docs/index.md](../index.md) lists the +pages. + +The opening of [the calibration page](../tools/calibration.md) does this: what it measures, what to use +it for, then how to run it. + +## Development documents + +Write for a developer or an agent about to change the code. This is the reader of +`docs/development/` and of `docs/concepts/` alike. Open by saying what the document governs and when to +consult it, so a reader learns in one paragraph whether they are in the right place. + +What belongs here is what the code cannot say: concepts and definitions, principles, design decisions, +the contracts a layer honors, and the reasons behind them. State what a mechanism achieves, generally. + +**Apply one test to every paragraph: if reading the code would tell you the same thing, it goes.** A walk +through classes and methods, a call order, a source tree, a table of which file holds what — the code +already says all of that, and says it correctly after the next refactor. So does a copy of a keybinding, a +default or a measured weight: name the constant or the file that holds the value, not the value. + +Lead with principles, then mechanics. Keep the three kinds distinct: a principle is a reason, a convention +is a mechanic that serves it, and a description is a fact about how something works. Prefer a few strong +principles to many narrow rules; a growing list of small rules usually means a principle has gone +unstated. + +Write for a reader who never saw the history. A development document is not a devlog: leave out past +states, resolved problems and rejected alternatives. The design as it stands carries its own +justification. Work still owed goes to [the ledger](bugs-and-todos.md), one brief entry each. + +Two documents to read for the register: [the render thread](application/render-thread.md), where every +section is a decision with the hazard it answers and the mechanism stays general, and +[colors and palettes](application/palette.md), which does the same in forty lines. The design principles +in [architecture.md](architecture.md) show the other half of it: each principle is a claim with its +reason, binding code it never names. + +## Reference pages + +Formats and the Python API are consulted rather than read. Be exact and complete about the shape — the +fields, the order, the units — and let tables carry it. A reader arrives knowing what they are looking +for, so the page needs no narrative. + +## The changelog + +`CHANGELOG.md` belongs to the maintainer, who writes it for the people using each release. Leave it +alone. + +## What holds everywhere + +- A page opens by saying who it is for and when to use it. A reader learns in one paragraph whether they + are in the right place. +- A term is defined or linked before its first use. A reader never meets a word the page has not + introduced. +- One fact per sentence, and short sentences. Several clauses joined by semicolons hide the facts inside + them. +- State each fact once, in the document that owns it, and link the sibling document rather than repeating + it. +- A measurement is written so a reader can place it. State it as a comparison — this against that, taken + together — or give the conditions it was taken under: the material, the machine, the build. A bare + figure carries no meaning a reader can check, so it is dropped or turned into the comparison it stands + for. +- A recipe says what it is for before what to type, and each step that could be done another way says + what it buys. Commands alone leave a reader following instructions they cannot check or adapt. +- State things in positive terms: what the design does, not what it avoids or once did. Reach for a + negative only where the contrast teaches something a positive sentence cannot. +- Use American English. A name someone else owns keeps their spelling: `MatchRule.serialise()` is + jeepney's. + +## Upkeep + +A document is part of the change that motivates it. Code that alters a contract a document states lands +together with the edit stating the new contract, and a deviation the change knowingly leaves behind lands +with an entry in [the ledger](bugs-and-todos.md). + +A development document sits beside what it governs: the top of `docs/development/` holds what spans the +repository's packages, `application/` what governs the graphical application, and `release/` what a +release ships and keeps compatible. [docs/index.md](../index.md) lists every document. + +Deleting is an edit like any other. A page whose reader has gone, or whose content the code now carries, +goes with the change that made it so. diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 0d2277aae..aaf1e001f 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -1,8 +1,9 @@ # Coding Guidelines These rules govern the Python in this repository. They complement -`docs/development/architecture.md` (ownership and layering) and -`docs/development/config-organization.md` (configuration). +`docs/development/architecture.md` (ownership and layering), +`docs/development/application/config-organization.md` (configuration) and +`docs/development/documentation.md` (the prose every document is written in). ## General @@ -10,16 +11,19 @@ These rules govern the Python in this repository. They complement 1. Split a function with several meaningful steps into helpers, each with one responsibility. 1. Spell names out in full: `note`, not `n`. 1. Give every semantic value a name — a `Final` constant, promoted to a shared module once the concept is reused. -1. Avoid the _tramp data_ antipattern: threading a value through functions that only pass it along. +1. Hand a value to the function that uses it. Threading it through functions that only pass it along is the _tramp data_ antipattern. 1. Make the inputs logic depends on explicit. The parameters and configuration instances it relies on are required, not optional. Reserve default values for settings seldom changed (e.g. `seed`), and declare each such default as a top-level `Final` constant. -1. Derive booleans rather than storing them. A boolean computed from existing state belongs in a `@property` (or `@computed_field` on a Pydantic model), since a stored flag creates hidden state that drifts out of sync. -1. State type expectations explicitly, and reach attributes by direct access rather than dynamic `getattr` or `hasattr`. +1. Derive booleans from state. A boolean computed from existing state belongs in a `@property` (or `@computed_field` on a Pydantic model), since a stored flag creates hidden state that drifts out of sync. +1. State type expectations explicitly, and reach attributes by direct access, not through dynamic `getattr` or `hasattr`. 1. Prefer protocols over inheritance. 1. Prefer `match` statements over long `isinstance` chains, and for enumeration handling. 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. 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. A further change to that format therefore extends the upgrade step already pending, and one step carries the whole distance from the version the last release shipped. Libraries are rebuilt from their settings, so a change to what library generation produces moves the library version alone. See [data compatibility](release/compatibility.md). +1. A risk named while planning lands as a case or a ledger entry. A plan records intent, and a case and the ledger carry a doubt past the moment it was felt. Where the risk is a behavior that might be wrong, write the case that would catch it. Where it is a distance the change accepts, write the entry in [bugs and todos](bugs-and-todos.md) that names it. 1. Run `pre-commit` on new files after each change. +1. Run the tests relevant to the code a phase touches after that phase. Run the full suite, screen scenarios included (`make test` and `make screens`), after the final phase. CI runs the same passes on every push, so the `pre-push` stage holds only the `icons` hook. ## Ownership @@ -27,14 +31,15 @@ These rules govern the Python in this repository. They complement 1. Put general-purpose, non-model-specific helpers in shared or common modules. 1. Search the repository with `rg` for existing logic before adding a helper. 1. When new code would duplicate existing logic, extract the shared rule first and route both call sites through it. -1. Import a shared helper straight from the module that implements it; a re-export or delegated-import module that exists only to route imports through is disallowed. +1. Import a shared helper straight from the module that implements it. A re-export or delegated-import module that exists only to route imports through is disallowed. 1. An `__init__` exposes only names from within its own tree hierarchy. 1. Give each module a single area of responsibility. 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. Make every source directory a package. Every directory under `src/` with a module anywhere inside it has an `__init__.py`, so each module is reached by its full name alone. +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. That implementation's class docstring is where its quirks are named, so a reader meets them beside the code they explain. ## Type Hints @@ -43,13 +48,14 @@ 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 1. Let a failure crash unless the code can recover from it meaningfully. 1. Handle errors at the execution boundary where possible. -1. Catch an exception only to recover from it; a `try`/`except` that repackages a failure without recovering adds nothing. +1. Catch an exception only to recover from it. A `try`/`except` that repackages a failure without recovering adds nothing. 1. Bare `except` and `except Exception` are forbidden. 1. Scope each `try` to the statements that can actually fail, absent a specific reason to widen it. @@ -59,39 +65,41 @@ These rules govern the Python in this repository. They complement 1. Freeze models whose instances stay constant after construction (`frozen=True`). 1. Use a dataclass for a small internal state object that needs neither serialization nor validation, test-case dataclasses included. +## Messages + +1. Write every message the application shows a user in plain American English: labels, dialogs, status lines and the notices an export adds. Log lines and exception messages are written for developers and may stay technical. +1. Write for a regular user, not a developer. Name what the user sees and does in everyday words, and leave the repository's own vocabulary out. Keep sentences short, one fact each. Every document outside `docs/development/` has the same reader (see [writing the documentation](documentation.md)). +1. Keep every message in the language file, `src/sampletones_config/lang/en.yaml`, and read it by its key where it is shown ([principle 8](architecture.md#8-all-display-text-comes-from-languagemanager)). A translation then replaces the file alone. The `language-keys` hook holds every key the code spells to an entry, and every entry to a reader. + ## Docstrings and Comments 1. A docstring explains the intention of a class or function and the context of its use. -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*. Reframe every negation ("does not", "rather than", "instead of", "without", "never", "cannot", "no longer") into the behavior that actually happens. The positive statement carries the meaning, so a contrast with a rejected alternative adds nothing. 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. Justify an arbitrary choice in the docstring, not in 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. -1. Avoid code comments; they are warranted for tensor shapes, third-party API quirks, or non-obvious invariants. +1. Avoid code comments. They are warranted for tensor shapes, third-party API quirks, or non-obvious invariants. 1. Code comments and docstrings are not for recording changes or progress. 1. Don't write module docstrings. -## Documents +## Documentation -1. A document under `docs/` explains a subsystem to someone about to change it. Open by stating what it governs and when to consult it, so a reader learns in one paragraph whether they are in the right place. -1. Lead with principles, then mechanics. A principle is a design truth you reason from; state the principles first, and let concrete conventions and reference tables follow as the way each principle is realized. -1. Keep principles, conventions, and descriptions distinct. A principle is a reason; a convention is a handy mechanic that serves it; a description is a fact about how something works. A convention promoted to a principle, or a principle buried in a description, misleads the reader about what is load-bearing. -1. Prefer a few strong principles to many narrow rules. When several rules are facets of one idea, state the idea once and derive them. A growing list of ad-hoc rules signals a principle that has gone unstated. -1. State the design in positive terms, as it stands today. This is the docstring rule above applied to prose: describe what the design is and does, not what it avoids, omits, or once was. -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. [Writing the documentation](documentation.md) has the rules for every document in the repository: who each one is written for, what belongs in it, and how it reads. Consult it before adding or editing a page. +1. Bugs and to-dos are brief, preferably one sentence per entry. ## 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. **What only a drawn frame shows is proven by a screen scenario.** A behavior is proven at the lowest tier that sees its outcome, and a scenario in `tests/screens/` covers what a running, rendered application adds: geometry, frame timing, real input and the exit. `make screens` runs the scenarios, and [screen scenarios](application/screens.md) says how they are written. +1. **A test whose assertion is a measured duration lives in `tests/benchmarks/`.** The gated suite runs across several workers and under coverage, which multiplies the cost of the code being measured. Benchmarks run in a pass of their own, serial and uncovered, where the reading is the code's own cost. `make test` runs the covered suite, and `make benchmarks` runs the measured pass on a developer machine. CI runs no benchmarks, because shared runners give unreliable timings. 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 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. **A contract stated in prose is pinned by a case.** A behavior a document or a docstring asserts is a promise to whoever reads it next. Write the case that fails once the promise stops holding, and write it where the contract is stated, so the sentence and the assertion move together. +1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes and layouts are tuned freely, so a case that restates one turns every adjustment into a test edit. Read the value where it is configured, or from the constant that defines it, and assert the behavior around it: the bound it lies within, the round trip it survives, the action it answers. Spell a value out only where the value itself is the contract, such as a file format's constant, and name that reason in the case. +1. **A case runs on every platform.** The separators in a path, the ending of a line, the formatting of a number and the order a directory arrives in belong to where the suite runs, not to the case. Compare a path with a `Path` and not with the string POSIX renders it as, because the suite runs on Windows too. +1. Values that must match by contract are asserted to match and never hardcoded. For example, project metadata at creation or after a save/load round trip is held against its source and never against a version string. +1. Unit tests may mock system boundaries (file I/O, external services, IPC channels) but not the domain logic that is the subject of the test. Integration tests exercise real computation pipelines against real, synthetically built data. +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 and ask before changing either side. diff --git a/docs/development/packages.md b/docs/development/packages.md new file mode 100644 index 000000000..72bd29e9c --- /dev/null +++ b/docs/development/packages.md @@ -0,0 +1,148 @@ +# Package Layers + +_SampleToNES_ is one repository holding several packages under `src/`, ordered so that dependencies run +one way. This document says what each package is for and why the order runs as it does. Read it when +deciding where a module belongs. `sampletones_config/boundaries/graphs.yaml` declares the order itself, in +the form the import-boundary check runs on every commit. + +The layering of `sampletones_application` has its own document, [`architecture.md`](architecture.md), which +the same check enforces. [`progress.md`](progress.md) describes how a long operation reports how far it has +come, inside one process and across worker processes. + +--- + +## The package graph + +```mermaid +graph TD + ENTRY["sampletones\n(entry point)"] + TOOLS["sampletones_tools\n(developer tools)"] + APP["sampletones_application\n(GUI)"] + PLAYER["sampletones_player\n(NES player)"] + CORE["sampletones_core\n(reconstruction engine)"] + ASSETS["sampletones_assets\n(icons and fonts)"] + SHARED["sampletones_shared\n(facts and helpers)"] + CONFIG["sampletones_config\n(shipped YAML)"] + + ENTRY --> APP + ENTRY --> CORE + ENTRY --> TOOLS + TOOLS --> APP + TOOLS --> PLAYER + TOOLS --> CORE + TOOLS --> SHARED + APP --> PLAYER + APP --> CORE + PLAYER --> CORE + CORE --> SHARED + PLAYER --> SHARED + APP --> SHARED + ENTRY --> SHARED +``` + +| Package | What it holds | +|---------|---------------| +| `sampletones_shared` | Facts and helpers any package holds: constants, exception families, paths, the logger, the array backend, and the command type the entry and the tools share | +| `sampletones_config` | The shipped YAML — layout, palettes, themes, keybindings, language, behavior, deployment, and these boundaries themselves — reached as package data and not by import | +| `sampletones_assets` | The application icons and the bundled fonts, reached as package data | +| `sampletones_core` | The reconstruction engine, the project model, playing a song out into instructions, and the tracker export formats | +| `sampletones_player` | The NES player: the register model, the re-clocking schedule, the 6502 driver and the NSF file | +| `sampletones_application` | The DearPyGui front end | +| `sampletones_tools` | Everything a developer runs and the application does not: the calibration harness, the driver toolchain and the register trace, the source checks, the synthetic corpus, the tracker playback check, and the developer commands that run them | +| `sampletones` | The command-line entry: the dispatcher, the commands and the startup self-check | + +**Only the command line reaches the tools package.** `sampletones_tools` has what a developer runs and the +application never imports: the developer commands and the libraries behind them. `sampletones` is its one +importer. It appends the developer commands to the user commands, so the wheel and the bundle carry the +tools and no shipped package depends on them. [Tooling](tooling.md) says what a tool is and what a +developer command does in an installed copy. + +**The reconstruction engine sits below the console player.** A reconstruction is produced, saved and +exported to a tracker with `sampletones_player` absent from the process. That lets the player's format move +while the engine holds still. An export backend that reaches the console (the interface described in +`sampletones_core/exports/backend.py`) is therefore registered from above and not from the engine's own +registry. + +**A song is played out once, for every reader of it.** `sampletones_core/performance/` turns an +arrangement into the instruction each channel sounds on each engine tick. It walks the order frame by +frame. A row's note column starts a voice, and the row's transpose and volume bend what that voice carries. +A voice with a loop point circles, and one without falls silent. One reading answers for both kinds of +voice: a sample plays the frames its conversion found for the channel, and an instrument plays the +frames its envelopes make of it. + +The sequencer renders those instructions to audio, and the player encodes them into register values. What +a listener hears and what the console plays are therefore the same walk read two ways, and not two +implementations of one rule. A voice sounded on its own, such as a preview or an audition at a note a key +names, takes the same two steps a row takes. It lives in `performance/` too, and not beside whichever +surface asked. + +**Equal temperament sits at the bottom.** The MIDI pitch limits and the A4 reference are in +`sampletones_shared/constants/music.py`, and the pitch-to-frequency conversion they govern is in +`sampletones_shared/utils/frequencies.py`. The synthesis package can therefore read them without reaching +up into the engine. `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. + +--- + +## Outside `src/` + +Two units stand beside the source tree and ship in nothing: `automation/`, which operates the running +application from outside, and `assets/`, which makes the repository's own content. Each is named for what +its code does, runs only in a checkout through `make`, and imports the program freely; [tooling](tooling.md) +says what such a unit is. The direction runs one way, `src/` ← `automation` ← `assets` ← `tests`: the tests +import both units, nothing imports the tests, and nothing under `src/` imports either unit. The same +import-boundary check holds this, from `sampletones_config/boundaries/checkout.yaml` and the source-side +declaration in `rules.yaml`. + +## 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. + +- The specification sits at the bottom. It holds the addresses, control bits and offsets the format is + written by. +- The clock, the per-tick register values and the codec stand on it. +- `Song` gathers what a file carries into one value. +- `builder.py` is the one place a song is made, whatever asked for it. +- `nsf/`, `driver/` and `export/` sit at the top, where a song becomes the file the console loads. + +[The console player](player.md) says what the player is for and what holds it correct. + +### The toolchain and the oracle live with the tools + +The driver's assembler and the register trace live in `sampletones_tools/player/`, because exporting needs +neither. The assembler builds the committed `driver.bin` ([the console player](player.md#building-the-driver)). +The tests rebuild the sources wherever cc65 is installed and hold the committed image to them. +`RegisterTrace` says what the driver is expected to write, call by call, and the emulator tests hold the +assembled driver to it. They run it on the console in `sampletones_tools/console/`, which plays any `.nsf` +the way an NSF player does, loaded whole or in switched banks. Exporting reads the assembled binary. The +wheel carries the assembly sources beside it, inside the tools package. +[`dependencies.md`](release/dependencies.md) describes the toolchain the build needs. + +--- + +## Enforcement + +`sampletones_config/boundaries/graphs.yaml` declares both graphs as layer tables: each unit and the units it +may import. 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. Adding an edge to a table opens a new dependency, and removing one +lists the work of closing it. A graph names the repository's own packages, and a third-party import is the +package author's own choice. [Architecture](architecture.md#enforcement) describes the mechanism and the +working idiom that follows from a whole-tree check. + +A graph is checked for well-formedness as it is read. A unit reaching a unit the graph leaves undeclared is +refused. So is a graph whose units reach themselves, since a unit's layers state a level only where the +units stand in an order. + +Token rules hold the shipped packages to the tools edge a second way. A module of +`sampletones_application`, `sampletones_core`, `sampletones_player`, `sampletones_shared` or +`sampletones_assets` that spells `sampletones_tools` at all is reported, so the edge is closed in words as +well as in imports. + +The same check reports every directory under `src/` that has a module anywhere inside it and no +`__init__.py`, since a tool takes such a namespace package for a root it imports from. + +A bootstrap script runs on the system interpreter, so the scripts tree has a rule of its own, +`boundaries/standalone.yaml`. A script imports the standard library and the scripts tree itself. A name in +that tree that stands in for a standard-library module is reported too, since the tree sits on the import +path. [Tooling](tooling.md) states the principle. diff --git a/docs/development/playback.md b/docs/development/playback.md deleted file mode 100644 index 6446cd8b5..000000000 --- a/docs/development/playback.md +++ /dev/null @@ -1,264 +0,0 @@ -# Playback and Transport - -This document governs sound across the application: what may be heard, who decides, and what each -transport command means. Consult it when adding audio a user can start, a surface that starts it, or -a control over what is heard. The contracts here bind every tab and every player. It complements -`docs/development/architecture.md` (which owns the keyboard-routing layer, §12) and -`docs/development/guidelines.md`. - ---- - -## Principles - -1. **The output is one scarce resource, arbitrated by intent.** There is a single device, a single - stream, and a single thing sounding. Audio a user asked a tab to play outranks audio a click - auditioned, so every request states which kind it is and the arbiter — not the caller's - eagerness — decides what is heard. -2. **Ownership is the state.** Which source owns the live stream is the one fact that answers "what - is playing": commands, labels, and indicators all derive what they do and show from it, so they - agree by construction. -3. **A command addresses one target, resolved from where the user is.** Context decides which source - a verb means, and one resolution serves every verb, so the same key means the same thing on a - 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. -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 - reads the document as it stood when it was asked for: every channel sounding, at unity gain, - played through once. -6. **Live state is pulled while sound is produced.** A player reads the settings that shape its - sound as it renders, so a change is heard as the render-ahead buffer drains. 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 - 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. - -## 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 -ownership of it, and is meant to be heard and forgotten. It yields the device to intentional -playback, and it answers to Stop. - -**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. - -Priority ranks the two kinds and settles every contest for the device: starting intentional playback -preempts a sounding preview, and a preview requested while intentional playback holds the device is -declined. - -## Engagement - -A source is **engaged** while it owns the device output, whether it is sounding or held paused. A -source therefore reports itself engaged only while *its own* audio is the one on the device; while a -preview sounds, ownership rests outside every source and each of them reports itself idle. - -Engagement is the ownership test of principle 2 in practice, and it is the single fact the transport -and the toolbar consult. - -## The target - -The transport acts on one **target**, resolved in two steps: the active tab's own source when that -tab has one to play — a loaded reconstruction, a loaded instruction, the song of an open project — -and otherwise whatever source is engaged. The Main tab plays only previews, so its target is always -the source engaged elsewhere, if any. - -In one line: the transport controls the tab you are on when it has something to play, and otherwise -controls what is already sounding. - -Starting a tab's idle source takes the device over, from a preview or from a source engaged -elsewhere, and Stop is how to silence background audio and leave the device idle. Play passes over a -sounding preview whenever the active tab has a source of its own, so a preview answers to Stop alone. - -## The verbs - -The transport's verbs are reached identically from the Playback menu, the toolbar, and the keyboard: - -| Key | Command | Behaviour | -|-----|---------|-----------| -| `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. | -| `Ctrl+Space` | Play from this frame | Sequencer: plays the song from the first row of the frame the tracker shows. | -| `Ctrl+Shift+Space` | Play from here | Sequencer panels: plays the song from the cursor's row. | -| `Escape` | Stop | Silences everything — the engaged source and any preview — from any tab. | - -Because the target prefers the active tab's own source, `Space` controls what the user is looking at -whenever that screen can play something, and reaches the source already sounding on a screen that -plays nothing of its own: the Main tab, an empty Reconstruction or Instructions tab, the Sequencer -before a project is open. So a paused reconstruction resumes with `Space` from the Main tab, while -`Space` on the Sequencer with a project open starts the song. - -## What the surfaces show - -The toolbar's transport strip and the Playback menu describe the target. The Play/Pause/Resume label -and the paused indicator report what the toggle will do; Stop is available while any sound is on the -device, engaged or previewed. So the display carries across tabs that play nothing of their own, -showing the source sounding elsewhere, and shows the local source on tabs that have one. A verb -tied to one screen — playing from the shown frame — is offered on that screen with its document -open. - -The sequencer view reports the playhead too, at the reach the **follow mode** chooses: the sounding -row, the frame that holds it, or the view the user placed. The mode is one setting with two derived -answers — whether the tracker shows the frame being played, and whether it scrolls to keep the -sounding row in sight — and those two are its whole contract, which every surface that follows the -playhead reads. The song player holds the mode and emits it with every position, which is what lets -the menu's check and the grid's scrolling settle in one step when the mode changes mid-playback. - -A mark belongs to what it names. The playhead's position is a frame and a row within it, so the -order grid marks the frame under every mode, while the row's mark reads as the sounding row of the -pattern on screen: the tracker carries it while the frame it shows is the frame that sounds, and the -mark travels with the frame across a structural order edit. Every mode paints on this rule, and the -mode governs where the view sits. - -## Keyboard delivery under field focus - -Playback keys arrive through the application's single key handler (architecture §12). These rules -keep them predictable: - -**Focus is claimed per key.** A focused input keeps the keys it genuinely consumes and yields the -rest. A text or number field consumes `Space` and `Shift+Space` (space is a character it types) and -`Escape` (which cancels the field), so those keys serve the field while it holds focus. A modified -combination stays global and fires from anywhere, which is why playing from the shown frame works -while typing. Playing from the cursor row belongs to the grid: the sequencer grid claims it while the -grid itself holds the keyboard. - -**Interactive widgets release the keyboard.** A selectable cell or a transport button hands focus -back after its click, so the next playback key reaches the router. This keeps `Space` and `Escape` -live in the moment after any click. - -## Silencing channels - -The sequencer's tracker channels can be silenced for listening. One mute set holds the silenced -channels for the open document, and everything else is derived from it: the **active-channel mask** -the song mixes through, and solo — soloing silences the other channels and remembers the mix it -replaced, so soloing the same channel again returns to that mix. Deriving both from one set is what -keeps the gestures consistent: any way of reaching "silence the rest" leaves the same state as any -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 -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 -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 -on the state its pattern has reached. - -Muting is monitoring, and principle 5 governs what follows. The project holds every channel, so -saving, module export, and any rendered output write the full song. The history stack holds project -state alone, so undo, redo, and history jumps carry the mute set across untouched — which is why the -sequencer distinguishes a history restore from a document transition. And the mute set belongs to the -listening session, so opening, creating, or closing a document starts a fresh one with every channel -audible. - -## What the channel holds - -A sample states every dimension of every frame, and its reconstruction names which of those -dimensions the instrument itself wrote. The rest are the channel's: each channel carries a value per -dimension — volume, arpeggio, timbre — and an instrument leaving one empty sounds it at the value the -channel holds. That is what clearing an envelope in the instruments panel means once the sample is -played in a song, and it is the same rule a FamiTracker instrument follows with a sequence left out. - -The value moves as the song plays. Every frame an instrument writes hands its value to the channel, -so the channel keeps the last one written and an instrument that leaves the dimension empty picks it -up. A silent frame states its level alone, leaving pitch and timbre where the channel holds them. - -A pass through the song begins on the values a channel holds from the start — full volume, no -arpeggio offset, the first timbre — so starting the song and looping back to its first row both -sound the same. Seeking within a running song keeps the values, since the channel has reached them. - -## Rendering the song to a file - -A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer` -serves both: the player drives it to feed the device, the render drives it to feed a file writer. -The synthesis is therefore written once, and the file and the playback agree on what the song -sounds like by construction. - -Two things differ between them, and each is stated by whoever asks for the audio. The **document** -is a seam: a kernel reads its project through `ProjectSource`, which the live controller satisfies -for playback and a frozen `ProjectSnapshot` satisfies for a render — so the player follows every -edit as the buffer drains (principle 6), while a render describes one state of the document however -the project moves on. The **rate** is the consumer's: the device for playback, the chosen output -format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a -file is written at the rate its engine ran at. - -A rate is therefore asked for once there is audio to take it, which is the first row a kernel -renders: a device has been chosen by the time playback starts, and a format by the time a render -does. A session on a machine offering no output device opens on that rule, and everything that -writes rather than sounds — editing, exporting a module, rendering to a file — works on it. - -The song's exact length follows from the timing model before a sample is rendered: the order's -length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is -what the progress bar counts against and what a finished file measures. - -Rendering is an exclusive operation (architecture principle 10). It occupies the application from -the moment its dialog opens until that dialog closes, and it joins the same busy authority as -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 -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 -is stopped or fails clears the destination and the spill, so a result names a path where a finished -file stands. - -## Teardown - -The device is torn down once every source holding a stream has released it. A source that streams to -the device writes from a thread of its own, so that source alone can bring the writing to a stop and -hand the stream back — and the hand-back is what leaves the backend safe to terminate. - -`PlaybackRouter.shutdown()` is the seam the application calls as it quits. It reaches every registered -source rather than the engaged one alone, so a source holding a stream is wound down whatever the -transport reports at that moment. - -The device holds a release per stream it handed out and invokes it whenever it needs the output free: -as the backend is torn down, and on a device change, where the release stops the song so the new -device opens cleanly. A stream that outlives its release leaves the running backend in place — the -manager reports the failure and keeps the instance, since the source still writes to memory that -terminating would reclaim. - -## Who governs what - -| Concern | Owner | -|---------|-------| -| The device, its stream, and arbitration between requests | `AudioDeviceManager` (`sampletones_core/audio/`) | -| The ranking that settles a contest for the device | `PlaybackPriority` (`logic/shared/`) | -| The verbs, target resolution, and the registry of sources | `coordinators/playback/router.py` | -| Winding every source down ahead of backend teardown | `PlaybackRouter.shutdown()` (`coordinators/playback/router.py`) | -| A source's engagement reporting | the transport's player protocol, implemented per source | -| Error presentation for a source's failures | `GuardedPlayer` (`coordinators/playback/guard.py`) | -| Keyboard delivery, priority, and field focus | `utils/gui/keyboard/` (architecture §12) | -| The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) | -| A channel name's gestures and menu, in either table | `ChannelSwitch` (`ui/panels/sequencer/channels.py`) | -| The reach the sequencer view follows the playhead at | `FollowMode` (`constants/playback.py`), held by `SongPlayerLogic` (`logic/sequencer/playback/song_player.py`) | -| Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | -| Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | -| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | -| Filling in the dimensions a channel governs, frame by frame | `SampleVoice` (`logic/sequencer/playback/synthesizer/voice.py`) | -| The values a channel holds between frames | `ChannelState` (`logic/sequencer/playback/synthesizer/state.py`) | -| The channel generators and the rates they are built at | `ChannelBank` (`logic/sequencer/playback/synthesizer/bank.py`) | -| How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | -| How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | -| The song's render-ahead buffer | `services/song_player/` | -| 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`) | -| 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/` | - -The sequencer song is an ordinary intentional source alongside the reconstruction and instruction -players: it implements the same protocol and is arbitrated by the same rules. diff --git a/docs/development/player.md b/docs/development/player.md new file mode 100644 index 000000000..20aa9403c --- /dev/null +++ b/docs/development/player.md @@ -0,0 +1,189 @@ +# 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 `sampletones_tools/player/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). [Package layers](packages.md) says where the package sits among the +others. + +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 with no multiply. + +## Principles + +**Python settles every rule, and the driver moves bytes.** Which value silences a channel, how a duty cycle +reaches its bits, how a pitch becomes a period, how the linear counter is held: each of these 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 runs on a 6502 emulator, and its writes are compared against that statement. The oracle +is the contract, and the assembly is an implementation of it. A fault in either one shows up as a +difference and not as a wrong sound. + +**A song is decoded forward.** Reading a tick by multiplying its number would allow two things: skipping +several ticks in one play call, and jumping to the loop point. Decoding several ticks in a row covers the +first, and the header can state the second outright. Giving up indexing 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 in advance. 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 `uv run sampletones codec report` writes what each one saves across a corpus of songs. The +format's constants are settled from that report and not from argument. + +**A change to the codec is measured before it is built.** `uv run sampletones codec study` reads the +projects and stems named on its command line, encodes every song under every candidate change, and writes +the sizes, the times, a verdict per candidate and the manifest that repeats the run under +`Documents/SampleToNES/compression`. A candidate is one of two things. A new way of choosing tokens is +encoded and played back by the production codec itself. A new token grammar is priced in bytes by a study +parser, which first has to reproduce the production parser's bytes on today's grammar. The report prints +the rule that gives a candidate a production layer: it must save a set share of the projects' or of the +reconstructions' bytes, and it must not grow any song beyond a set margin. The study lives under +`sampletones_tools/codec/study`, outside the shipped packages. + +## 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 from a reconstruction's own instructions, from the slices of an export request, or from a +whole project. The three differ only in where the ticks come from. A project is played out row by row +through the same walk the sequencer sounds a song with, and it is the case that seeds the dictionary from +the project's samples. + +**A program states what an export chooses.** `NSFProgram` holds the channels a song sounds, the tick it +returns to, the `CompressionScheme` it is written with and the header text. The builders take the first +three explicitly. The defaults an export writes when nobody chose otherwise are stated once, and the export +dialog opens on the same values. A user's choice replaces those defaults, so the service that runs an +export stays free of anything the format decides. + +A project has no tuning of its own, because each sample was reconstructed against one. The samples +therefore state the tuning by agreeing on it. A project whose samples disagree is refused, so nothing +sounds 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, beside a bend holding the steps a bent tick stands away +from that pitch. 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. + +**A plane repeats from its own byte.** Four of the planes write a byte the APU only +partly reads — two bits a pulse control byte wants set, the top nibble of a noise control +byte, three bits above a noise period — and those spare bits carry the ticks the value +repeats for. A rest costs one byte however long it lasts, and the planes that rest longest +are exactly the ones with room to say so. The division follows from the register, so the +block states none of it and the driver knows it by plane. + +**The triangle names its silence in the pitch it plays.** The channel sounds at one level, +so the index its value plane carries is enough to say whether it sounds at all: an index +above every pitch the table holds silences the linear counter. That spares a whole plane, +in the block and on the tick path both. + +**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, and that a phrase may state +the count its tokens play it at most often so those tokens carry none. + +**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 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 the block holds.** An absent plane's source is +seeded into page zero, which no song occupies, and the advance passes it by, so it stands at +the zero every plane starts from. A bend plane is stepped only on a tick its channel's new +value flags, since it holds a value for those ticks alone. 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 symbol it last played, the ticks that symbol still covers, the +bits its own byte counts in, 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 driver's arithmetic is the bend and the repeat.** A tone channel's value plane +resolves to a divider through the timer table, and on a flagged tick 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. An unflagged tick +adds nothing. The other is a subtraction: a tick a symbol still covers steps the plane's +count down and returns, which makes the common tick cheaper than reaching a new symbol. +Everything that keeps either 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: the codec against its +golden decoder over a corpus, the byte layout against a hand-built song, the assembly's equates against +`specification/`, and the assembled image on a 6502 emulator against `RegisterTrace.from_song`. On top sits +a whole export: a project exported, played on the emulator, and read back as the instructions the sequencer +sounds. + +One more check covers what the others cannot. The trace says the right registers were written. The audio +comparison re-renders a captured trace against the reconstruction's own approximation and says the result +is the waveform the reconstruction was built as. + +## Building the driver + +The driver binary is committed as `sampletones_player/driver/binary/driver.bin`, so exporting an +`.nsf` needs no assembler and the application ships the binary alone. `uv run sampletones driver` +rebuilds it from the sources under `sampletones_tools/player/assembly/` and prints the layout the +build produced. Editing the assembly means running that command again and committing what it writes. + +The link line names this project's own configuration and object files and states the CPU outright. That +keeps the shipped image entirely ours: 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 stay in +step. + +Installing cc65 is covered in [dependencies](release/dependencies.md). diff --git a/docs/development/progress.md b/docs/development/progress.md new file mode 100644 index 000000000..2be200d28 --- /dev/null +++ b/docs/development/progress.md @@ -0,0 +1,130 @@ +# 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 several packages. The operations that report live in `sampletones_core` and +`sampletones_player`. The line a report crosses between processes is +`sampletones_core/parallelization/channel/`. The layer that draws it is `sampletones_application/services/` +and `logic/`. [`packages.md`](packages.md) describes the layering between those packages, and +[`architecture.md`](architecture.md) describes the application's own layers. + +--- + +## 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`. `announce` 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. 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 the name says what the counts +mean. `ExportStage` and `ReconstructionStage` are the vocabularies. + +Where the stages differ in what they cost, the enum says 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, and they are justified by what they achieve: a bar +that tracks the time a run actually takes. They are counts and not fractions. A reading divides them once +at the point of use, so a finished run arrives 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 and every row, because that is what it knows. +Whoever carries the report decides how often that is worth passing on. `ReportRate` +(`sampletones_shared/utils/progress.py`) spaces reports over `PROGRESS_STEPS` whatever the stage counts in. +It always takes a stage's first reading and its last, and it treats a count that falls as a step, the same +as one that rises. + +Both carriers use it: `StageProgress` in the application services, and `JobReporter` in the conversion. +`JobReporter` throttles on the worker side, so the line between processes carries only what a bar can be +redrawn at. + +### 4. A run reports in items when it has many, and in the part done of one item when it has one + +An operation counts the items it is measured in: files, samples, jobs. Where it is measured in many items, +that count is the reading. The items are what a reader recognizes, and a run with several under way at +once has no one item to follow. Where it is measured in one item, the count stands at nothing out of one +for the run's whole length. The reading is then the part of that one item done, which the item reports +itself. + +Both layers carry the same pair, and each tells through `is_single` which of the two it is: + +| Layer | Type | The counts | The work under way | +|-------|------|-----------|--------------------| +| Core | `TaskProgress` | `completed` / `total` | `steps`, one per running task | +| Application | `ServiceProgress` | `completed` / `total` | `partial`, in items | + +Both derive `fraction` from them. The layer that turns a run's account into a result states the unit that +run reads in (`ConversionService` does it for a conversion). The count a status line prints, the stage it +names, the bar it draws and the estimate beside it therefore all answer in that one unit. The logic layer +reads them under `logic/main/converter/`, and `ETAEstimator` (`parallelization/progress.py`) makes the +estimate from what a run has covered. + +### 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 and not on any one way of crossing it. + +`ProcessProgressChannel` is the implementation. A manager stands beside the pool and owns both ends: a +queue that reports travel up, and a flag that a withdrawal travels down. Both live 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 on its own. That makes one channel serve every platform. + +--- + +## Mechanics + +**Reading the channel belongs to a thread of its own.** A run's monitor thread waits on the results the +pool hands back, so a separate thread (`ProgressPump`) reads the channel. It takes everything already +waiting in one turn and announces once. That holds the announcements to the rate it reads at, however many +steps the tasks file in between. + +### A finished task stays finished + +A task's reports travel a line the run reads at its own pace, so a report filed before a task's result +arrived may be read after it. The run records the tasks it has counted (`TaskSteps`) and lets their later +reports go. That keeps a task's own progress and the run's completed count from describing the same work +twice, and it keeps the reading inside the run it describes. + +### The pool's and the channel's lifetime + +The run's monitor thread (`TaskProcessor`) is the one owner of its pool. However the run ends, the monitor +ends the pool: a finished pool winds down, and a canceled or failed one is stopped. It then reaps the +workers, closes the channel, and only then announces the outcome. + +`cancel()` withdraws the run and leaves the rest to the monitor. `shutdown()` cancels a run still going and +returns once the monitor has finished. The owner of a run (`InstructionsLibraryManager`, +`ConversionService`) lets it go only after `shutdown()` returns, so an operation reads as active until no +worker of it is left. + +The channel is a process of its own. It opens on the first task that asks for a line and closes after the +pool, since the workers hold proxies to it. A run whose tasks report nothing never opens one. + +--- + +## Testing + +Progress is one path with two halves, and each is tested where it is cheap to test. A stage's share, the +spacing between reports and a late report are unit-tested beside the code that decides them. The pipeline +reporting its stages and the line carrying a report between processes are held by integration suites. + +The two cross-process suites run real worker processes and put something cheap in place of the work: +counting in one, a walk through the stages in the other. They measure the wiring and not a reconstruction. +Both hold their worker at a chosen point until the test has taken the reading it asserts on +(`tests/suite/release.py`). An assertion about work under way is therefore made while that work is provably +under way, and does not rest on the scheduler. diff --git a/docs/development/release/compatibility.md b/docs/development/release/compatibility.md new file mode 100644 index 000000000..d6c0c31b4 --- /dev/null +++ b/docs/development/release/compatibility.md @@ -0,0 +1,63 @@ +# Data Compatibility + +This document governs the data versions of the stored formats of _SampleToNES_: reconstruction files (`.stn`), instruction libraries (`.ins`) and project documents (`project.json`). Reconstructions and projects are upgraded, and libraries are rebuilt. Consult it when changing a serialized shape, adding a format version, or diagnosing a file that loads as incompatible. + +Three terms recur. A **payload** is a file's serialized content before any model reads it. A **step** is one upgrade from a version to the next. A **chain** is the steps of one format, in order. + +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 has one 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, a `MetadataContract`, holds each file to it before the rest of the file is read. A file at another version is therefore refused for its version, and the refusal names both versions. + +### 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`. Each step lives in a module named after the version it writes: `compatibility/reconstruction/v2_2.py` has the step that writes reconstruction data version 2.2. + +**A step reshapes only what the model reads.** A model reads the fields it declares, so a key the current shape has no field for is never looked at. A step that renames or removes such a key does nothing. Steps leave those keys where they stand and spend their effort on the sections the document is read from. + +### 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. A further change to the stored shape therefore extends the step already pending and does not add a second one. That step widens to carry the whole distance from the version the last release shipped. What a user's files travel is 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, 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`, the JSON document for `project.json`. A transform reshapes that payload. It renames the fields whose names changed between versions and adjusts the values they hold where the shape demands it. + +### A library is rebuilt from its settings + +A library is derived data: its `InstructionsLibraryConfig` and the generators determine it wholly, and generating one costs about what measuring its entries costs. A library written at another version is therefore rebuilt. `LibraryState` reads the version from the metadata that leads the file, and a file the load contract would refuse reads as out of date. A conversion, whether in the application, headless or in calibration, rebuilds the library it needs without asking. Opening one from the _Instructions_ tab asks first. Reconstructions and projects carry their own configuration, so what a user made stands apart from the libraries it was converted with. + +The library version names what generation produces. Any change to the generators or to feature extraction bumps it, and the bump alone carries the change to every stored library. The corpus keeps libraries all the same. A library a release wrote is held to reading as out of date, and a library archived at the version this build writes is held to what this build generates for the same tones. + +### 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 says the version its shape now matches and a later save writes that version. The load path leaves the bytes of every other payload as they are. + +## Adding an upgrade + +Compare the format's version constant with the one the last release shipped, and take the route that comparison names. + +**The constant stands where the release left it.** The change opens a new step. Bump the constant, add the step module named after the new version with a transform from the previous version to it, and append the step to that format's chain. Cover the step with unit tests and against the archived file its base version names. The archived file is what makes the tests trustworthy: a step tested only on a payload the test builds is held to the fields its writer thought to name. A library change takes the version bump alone, since a library is rebuilt. + +**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, describe 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, because the pending step already carries users from the shipped version. + +The engine stamps the new version once the chain runs, so a step module declares only its own transform. + +## The corpus + +A step tested against a payload the test builds itself is held only to the fields whoever wrote the test thought to name. `tests/data/compatibility` therefore keeps one stored document per format per shipped data version, written by the build that shipped it. `tests/integration/compatibility` opens each one through that format's own load entry point and holds the loaded model to the current shape. + +Two of those tests hold the corpus itself together. A file has the version its name says, and every registered step reads a version the corpus keeps. The second makes opening a step and archiving the file it reads one act and not two. + +The corpus states its own rules, and how a version is written into it, in `tests/data/compatibility/README.md`. diff --git a/docs/development/release/dependencies.md b/docs/development/release/dependencies.md new file mode 100644 index 000000000..6027810e1 --- /dev/null +++ b/docs/development/release/dependencies.md @@ -0,0 +1,69 @@ +# Dependencies + +This document says why each dependency exists and what it needs from the machine. Consult it when adding a dependency, changing the build, or diagnosing an install that fails. `pyproject.toml` states every Python dependency and the version each is held to. This page covers what `pyproject.toml` cannot say: system libraries, build-time tools and the reason each one is there. + +Dependencies fall into groups by who needs them: what ships with the application, what only the build needs, and what only developers need. Each section below says which group it belongs to. + +## Graphical interface + +The interface is built with [DearPyGui](https://github.com/hoffstadt/DearPyGui), a Python wrapper for [Dear ImGui](https://www.dearimgui.com/). It ships with the application. + +## Core + +The reconstruction engine stands on the usual numerical stack, with `cupy` as an optional GPU backend. The GPU backend is installed as the extra that matches the machine's NVIDIA driver. See [GPU acceleration](../../guide/installation.md#gpu-acceleration) for enabling it. + +## Serialization + +Instruction libraries and reconstructions are serialized with [MessagePack](https://msgpack.org/) (the `msgpack` package). It needs no external compiler or system dependency and installs automatically with the package. + +## Audio playback + +Playback goes through PortAudio, reached with the `pyaudio` package. PyPI has `pyaudio` wheels for Windows only, so Linux and macOS compile it on install and need the PortAudio headers and library on the machine. `scripts/system_dependencies.py` installs them: the distribution packages through apt on Linux, and PortAudio through Homebrew on macOS. + +On macOS the compile architecture is pinned to the machine's own (`ARCHFLAGS`). Homebrew's PortAudio is native to the machine, while the python.org interpreter is universal2, and pinning makes the two agree. `make setup` sets it, and the CI workflows take it from `scripts/build_environment.py`. + +## Audio rendering + +Audio files are written with libsndfile, reached with the `soundfile` package. Its wheels carry a prebuilt libsndfile for every supported platform, so the encoders come with the package and need nothing installed alongside them. + +The application asks the library at runtime which formats it can write, because the codec set varies by platform and packaging. The MP3 encoder, for example, is present only where libsndfile was built with it. The chooser therefore offers the formats the library reports, so what a user is shown describes the machine it runs on. + +The writers (`sampletones_core/audio/writers/`) say which sample rates and qualities each format offers. libsndfile takes MP3 quality as a compression level and not a bitrate, and the bitrates a rate can carry narrow as the rate falls. `writers/bitrate.py` therefore maps a level to the bitrate it reaches at each rate. + +## File dialogs + +Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, so a save settles its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last. + +`jeepney` is declared for Linux only, so the modules that speak to the portal are imported only where it is installed. The application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, so the Linux runs of the suite cover them. + +## Application icon + +The icon suite is generated from a mark declared in `assets/icons/mark/config` and committed, so a plain checkout has the icons the application opens its window with. [Assets](../assets.md) describes the maker that writes it. + +Pillow rasterizes the suite. It is declared in the `assets` dependency group, which the `dev` group includes. It serves the assets unit alone, which the build environment never installs, and the bundle script passes `--exclude-module PIL` as well. `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is installed, and PyInstaller follows that import into the bundle. The application reads its icons as files, so the exclusion keeps Pillow's extension modules and the imaging libraries that come with them out of every bundle. `scripts/verify_bundle.py` holds the release bundles to it. + +## Calibration + +The calibration harness scores renders with referees of its own, built on `numpy` and `scipy`. The `calibration` dependency group adds [Zimtohrli](https://github.com/google/zimtohrli), a psychoacoustic model, as a second opinion. PyPI has its wheels for Windows, Intel macOS and x86-64 Linux, and other systems compile it on install. The group stays out of `dev`. `uv sync --group calibration`, with the extras the environment already uses named beside it, installs it. See [Calibration](../../tools/calibration.md). + +## NES player driver + +Assembling the console player needs `ca65` and `ld65` from [cc65](https://cc65.github.io/). On Debian and Ubuntu that is `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 exporting an NSF needs no assembler ([the console player](../player.md#building-the-driver) says how it is built). + +cc65 is distributed under the zlib license. 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 + +[py65](https://github.com/mnaberez/py65) is a 6502 emulator. The console in `sampletones_tools/console/` runs an `.nsf` on it the way an NSF player does, against memory that watches the APU's address range. That lets the suite hold the image to what a correct driver writes ([the console player](../player.md)). The tools package ships with every install, so py65 is a runtime dependency: the wheel names it and the bundles carry it. Its BSD license leaves the project's own terms untouched, and `THIRD-PARTY-LICENSES.txt` has its text. + +Listening to a real APU needs [ffmpeg](https://ffmpeg.org/) with the `libgme` demuxer, which is a build option and not a given. `uv run sampletones nsf render` asks the installed ffmpeg which demuxers it has, and names this system's install command before it decodes anything. + +CI runs the driver on py65, which arrives with the package's own dependencies, and `scripts/system_dependencies.py` has what building and running the application needs. cc65 and ffmpeg stay on the machine of whoever assembles the driver or renders a wave. A workflow that did either would put them in those scripts. The application itself needs neither: an export is written by the package's own code, from the committed `driver.bin`. + +## Linux (standalone executable) + +Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. `make system-deps` installs them (or run `python3 scripts/system_dependencies.py`), and the script has the full list. + +PortAudio is required. Tk backs the file dialogs where neither a portal nor a desktop tool answers, and `make release` requires it so the shipped executable stays self-contained. + +The executable links against the glibc of the machine that builds it and runs on that version or newer. A redistributable artifact therefore belongs on the oldest Debian or Ubuntu release being supported, so it runs on every newer one. diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md deleted file mode 100644 index 142b26523..000000000 --- a/docs/development/sequencer-blocks.md +++ /dev/null @@ -1,277 +0,0 @@ -# Sequencer blocks - -A **block** is a rectangle of one sequencer grid, lifted out of the song so it can be -written back somewhere else. Copy, cut, paste and delete are the four gestures over it, -and both grids — the tracker's pattern rows and the order's frames — carry the same set. - -This document states the rules those gestures follow, how a block leaves the app as text, -how a selection is drawn, and how a grid's actions reach the menus and the keyboard that -fire them. The layering they sit in is -[Architecture](architecture.md); the conventions the code is held to are the -[coding guidelines](guidelines.md). - -## Three vocabularies, kept apart - -A gesture crosses three representations, and each has one owner: - -| Term | Where it lives | What it names | -|------|----------------|---------------| -| **Cursor** | `ui/panels/sequencer/input/` | Where the reader is typing, plus the anchor a selection was started from | -| **Region** / **Cell** | `view_model/sequencer/region.py` | The rectangle a gesture acts on, and the single cell a paste is anchored at — grid coordinates, inclusive bounds | -| **Block** | `logic/sequencer/tracker/`, `logic/sequencer/order/` | The values themselves, keyed by offsets from the cell they were read at | - -A region names *where*; a block carries *what*. A block holds offsets rather than -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 - 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, - readable as a single flat index. Navigation and selection walk the flat index; an edit - addresses the pair. - -## A cell reaches a block in one of three states - -The state is carried by the block's map alone, so every consumer reads it the same way: - -| State | In the map | Written as | -|-------|-----------|------------| -| A value | Key present, holding it | That value | -| Empty | Key present, holding `None` | Emptiness — the target is cleared | -| Mixed | Key absent | Nothing — the target keeps what it had | - -Mixed is what an aggregate cell reads when the channels beneath it disagree, the same -`?` the grid displays. Display and clipboard route through one rule, -`sampletones_shared/utils/agreement.py::Agreement`, so a block states about a cell -exactly what the table it was read from shows there. - -Absence is also what settles the order's growth (below): a column a block says nothing -about reaches nothing. - -## Kind alignment is arithmetic - -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 -subcolumn — so the type states the rule: the anchor decides *where* a block lands and the -block decides *which kind* goes where. - -## A paste is a run of the single-cell edits - -The writers resolve every cell to a method the grid already has: -`SequencerTrackerLogic.place_note` / `cut_note` / `set_cell_subcolumn` / -`clear_cell_subcolumn`, and `SequencerOrderLogic.write_entry`. Nothing about the aggregate -column's fan-out is restated in a writer, so a pasted cell means exactly what the same -value typed by hand means. That is why each write is explainable, and why the aggregate's -rules have one home. - -Two consequences follow from the order the writes are taken in: - -- Within a position, the aggregate row is written before the channels beneath it, so a - 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. - -## The order grows to what a paste reaches - -A block pasted past the last frame appends frames, and the rule is stated in terms of -writes rather than the block's shape: the order grows to the last position a write -actually lands at. A `?`-only overrun column appends nothing; one holding an empty cell -appends the frame it silences. Rows clipped at **Noise** take their columns' growth with -them. - -Growth runs before the first write, so one history entry covers the appended frames and -the values in them, and a single undo takes both back. Delete keeps the order's length: -emptied trailing frames stand as silent ones. - -## A shift reads the columns behind a region - -Transpose and volume move whole cells, while a region names its edges as subcolumns. A shift -therefore reads the columns a region covers (`TrackerRegion.columns`) and reaches each of their -channels once, at every row the region spans. Two consequences follow: a nudge raised with the -cursor on a volume subcolumn still moves that cell's transpose, and a region covering the sample -column together with a channel beneath it moves that channel a single step, since the sample column -stands for the channels a value typed in it writes to. - -Each cell reaches the grid through the single-cell adjustment that already governs it, the way a -pasted cell does, so a shift lands exactly the writes the same nudge repeated by hand would make — -the transpose and volume ranges included. - -## A block states itself as text - -A copy also writes the block to the desktop's clipboard, as the lines the grid prints — a -tracker block: - -``` -SampleToNES/1 tracker rows=2 slots=3..5 -00 +05 3 -.. -02 . -``` - -and an order block: - -``` -SampleToNES/1 order rows=1 positions=0..1 -00 03 -``` - -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 -a protocol ([Architecture](architecture.md), principle 11). The sequencer coordinator wires -the two. - -**A field prints what the grid prints in its cell**, which is what carries the three states -across: a value reads as its value, an empty cell as the dots beneath it, and a mixed one as -the marks filling its field. The marks fill the whole width, so every line measures the same -and a block pasted into a message still reads as a grid; reading takes any run of them. - -**The header is a declaration the body is held to.** It names the grid, the count of rows, and -the span of slots or positions the block stands on, and a body whose lines or fields disagree -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 -nothing to place. - -A field the form has no reading for refuses the whole text, so a parse answers with a block or -with nothing. Digits are read in either case, and transpose and volume are held to the ranges a -row accepts, so text typed by hand lands the values the grid would. - -### Which block a paste writes - -A copy writes both clipboards, and a paste reads the desktop's text first: it stands while it -parses as a block for *that* grid, and any other text leaves the grid's own block in hand. So a -block copied in a second instance pastes here, and a copy taken in this one survives whatever -else the desktop picks up afterwards. `can_paste_block` asks the same question through a -`ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one -string compare. - -## A grid declares its actions once - -Where they are shown is decided by whoever asks for them. Each grid builds its whole -action set from one **target** — the cell a gesture is aimed at, paired with the region -that gesture acts on — and three doors resolve that target their own way: - -| Door | Aims at | Anchors a paste at | -|------|---------|--------------------| -| The keyboard | the cursor's cell | the cursor | -| A context menu | the cell it was raised on | the clicked cell | -| The menu bar's **Edit** menu | the cursor's cell | the cursor | - -The region behind a target is `region_at` on the shared input state: the selection when the -cell falls inside it (`Region.covers`), and the cell alone otherwise. So copying one cell -needs no selection made first, and a menu raised inside a selection acts on the whole of it. - -One builder means an action added to a grid appears at every door, and the accelerator -**Edit** prints is the one that grid answers to, since a binding is declared once and every -reader of it reads that entry ([Architecture](architecture.md), principle 12). - -`EditRouter` (`coordinators/edit/`) is the menu-side counterpart of the `KeyRouter` the -keyboard runs through. Each surface states whether it owns the editing gestures at this -moment — the same predicate its key scope answers with, so the menu offers what the next -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 -learns the commands exist. - -**`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share -a combination inside a shortcut category, so this branch is the route; it also matches -tracker convention. - -## One gesture, one history entry - -Cut, delete and paste each record exactly one entry, whichever door fired them, and none of -them coalesces: a block gesture is already a whole gesture, and folding two consecutive -pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction, -since it mutates nothing. - -A shift coalesces, because a nudge is a step of one gesture rather than a whole one. The block it -covers is its coalescing target, so a streak over one selection leaves a single step to undo and a -shift after the cursor moves or the selection is reached out starts the next entry. Transpose and -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 -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 -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 -items name too, so a key and an item reach the same rectangle. In the tracker a shape ends at -the frame's last row, so standing one carries the grid to where the cursor landed — the same -reveal a `Shift+End` reach makes. - -## Dragging a range out - -Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what -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 -belong to the body that was replaced. - -Both panels read the cell under a held pointer off their own geometry, because DearPyGui -reports no hover for the cells a held pointer passes over. A drag carried past an edge -reads as the edge, so it selects up to it. - -The tracker's row lookup is arithmetic: it takes the first row's top edge and divides by -`layout.tracker.row_height`. That holds only while the rows are evenly pitched, which is -what `CellPadding.y = 0` and `ItemSpacing.y = 0` in `theme/tables/pattern.yaml` are for. -A vertical padding there would drift the lookup further down the grid. The order's -position lookup is arithmetic in the same way, taking its pitch from the first two -columns; its channel lookup walks the rows, because the master row stands apart from the -channels beneath it. - -### A drag past the edge carries the view - -A pointer held past the cells on screen travels the grid under it, so a selection reaches -further than the viewport holds. `grid/scroll/` states this in three pieces: a `ScrollAxis` -naming the one DearPyGui axis a table scrolls along and the pointer coordinate that runs past -its edges, a `TravelBand` saying where the cells stand along that axis, and the `DragTravel` -that reads the two each frame. The tracker travels vertically and the order horizontally, both -from the same class. - -Three rules make the travel feel like one gesture: - -- **The pointer report drives it.** A held pointer keeps reporting wherever it is carried to, - including past the window, so the travel runs off the same report the drag itself reads. -- **The frame's own duration paces it**, so the same stretch of grid passes under the pointer - however fast the frames arrive. The pace answers how far past the edge the pointer stands, - rising from a floor to a ceiling over a few cells' overshoot: a nudge creeps, a reach covers - the grid. -- **Each step is added to the offset last issued.** A table reports the scroll it was drawn - with rather than the one just set, so a travel reading it back would re-issue an offset it - has already reached. It rests as soon as the pointer stands within the band again, at the - press that opens the next gesture, and on a rebuild — and the travel that follows sets out - from the offset the grid is drawn with. - -## Accepted limitations - -- **A rebuilt table has no selection.** Both grids reconstruct their input state on - rebuild, so following playback and the rebuild after a growing paste leave the cursor - and drop the selection. The rows a region named belong to the body that was replaced. -- **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 - 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 - 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 - while a held pointer travels the grid, so the two take turns each frame. diff --git a/docs/development/tooling.md b/docs/development/tooling.md new file mode 100644 index 000000000..8cad8fd1c --- /dev/null +++ b/docs/development/tooling.md @@ -0,0 +1,143 @@ +# Tooling + +This document governs how the repository is run: the `sampletones` command and what it offers, the +tools package behind its developer commands, the scripts under `scripts/`, and the `Makefile`. Read +it before adding a command, a tool, a script or a make target. Which packages may import which is +[package layers](packages.md). The libraries and tools the scripts reach for are +[dependencies](release/dependencies.md). + +## Principles + +**1. One entry.** Everything a person runs by hand is a `sampletones` command with its own parser and +help, always named. `sampletones` alone is `sampletones run`, a file is opened with +`sampletones open `, and a recording is converted with `sampletones convert `. A command's +name says what it does, in plain words. + +**2. Runnable code comes in kinds that differ by who runs it and what it may import.** + +- The *application* is what a user installs. +- A *tool* runs inside the project environment, through `uv run`. It may import any package, and only the + command line reaches it. +- A *bootstrap script* runs on the system interpreter, before or beside the environment. It creates the + environment, installs system packages, builds the standalone bundle, cleans the tree, and runs the + tests, the linters and the formatters the environment provides. +- A *checkout unit* runs only in a checkout with the development environment, through `make`, and ships in + nothing. It is a top-level directory beside `src/`, `scripts/` and `tests/`, named for what its code does, + checked like source, and free to import the program and the development dependencies. `automation/` + operates the running application ([screens](application/screens.md)); `assets/` makes the repository's + own content ([assets](assets.md)). A package of such a unit that a person runs carries its own parser and + help, as `python -m .`, and the Makefile names it. + Code is placed by what it is for: the engine a unit borrows, such as pytest or the application itself, + never decides its home. + +A bootstrap script imports the standard library and the other bootstrap modules and nothing else, so it +runs on a machine that has Python 3.12 or newer and nothing more. Importing `scripts/bootstrap/` checks +the version before anything else, so an older interpreter is told the version and where to download it. + +A bootstrap script installs nothing into the interpreter it runs on. Every package a build installs lands +in `.venv-build`, a virtual environment of its own, and pip is told to refuse any interpreter outside one. +System packages are a step of their own, `make system-deps`, and it is the only step that asks for +administrator rights. + +The import boundary check holds the tree to the rule. `sampletones_config/boundaries/standalone.yaml` names +the scripts, and an import beyond the standard library and the tree fails the hook. + +**3. What earns a place on the command.** An operation is a *user command* when its input and output are +the user's own files and it needs nothing beyond the installed package. It is a *developer command* when +it reads or writes the repository or measures the code on this machine, so it needs a checkout and the +project environment. It is a *bootstrap script* when it must run without the environment. A test is run by +pytest, and no command wraps it. A tool the tests exercise is a function they call. + +**4. A tool is a library with a thin face.** The work is a function that takes values and returns values. +The command module parses the arguments, calls the function and prints. A command module imports the +standard library, the command type and its constants at module level, and its implementation inside `run`, +so listing the commands loads no tool. A bootstrap script has the same shape. Pure functions assemble the +commands and take the decisions, and `main` wires in the real runner and the real environment. The tests +call the functions with a runner that records what it was asked to run, so a build is verified without +building. + +**5. The import graph is declared once and checked.** `sampletones` is the top of the graph, and +the bootstrap tree is a root of its own under the standard-library rule. [Package layers](packages.md) +holds the graph and its enforcement. + +**6. Tests mirror the tree.** `tests/unit//` mirrors `src//` and +`tests/unit/scripts/` mirrors `scripts/`. Code and its tests move in one change. + +**7. One script per operation, the same on every system.** What differs between systems sits behind one +`Platform` protocol with an implementation per system, chosen by a factory from `platform.system()`. It +covers the launcher's name and extension, the icon, the package manager, and the compiler flags audio +playback needs. A script never branches on the operating system itself, and a system the project does not +build on is refused by name. + +The Makefile is the developer's index, one line per target. A target names the script that does the work +and passes its flag. The `run` and `calibration` targets name the `sampletones` command they start with no +options, and the `tracker-playback` target passes the inputs its command takes: the Bitphase source folder +its `BITPHASE` variable names, the `FamiTracker.exe` its `FAMITRACKER` variable names, and the project +files in `PROJECT`, if any. The `icons` and `demo` targets run makers of the assets unit. It runs each target it is +given. `install.sh` and `install.bat` at the root +exist for the double-click path and call the same bundle script. + +**8. A developer command works from what it is given, in every copy of the program.** The wheel and the +bundle carry the tools package, so every developer command exists wherever `sampletones` is installed, and +each one reaches files the same way in all of them: + +- *Inputs* arrive on the command line or in a file a run wrote, so a run starts from what the person + running it has. +- *Outputs* go where `--output` (`-o`) names. A measurement given no `-o` writes a timestamped directory + under the user's Documents. `codec report` and the sample emitters always take `-o`. +- *The repository* is reached through the checkout guard. A command that reads or writes the repository, + or needs a development dependency, runs from a checkout. `driver` rewrites the files the package ships + from a checkout, and a measurement runs anywhere. What exists only to make the repository's own content + is a checkout unit rather than a command ([assets](assets.md)). +- *Package data* is read from the package it ships in, which holds in a checkout, in the wheel and in the + bundle. + +## The commands + +`src/sampletones/` is the entry package. Its dispatcher builds one parser over the commands and runs the +one named. The registries under `commands/` and `sampletones_tools/` list the user commands and the +developer commands. A command is a frozen `Command`: its name, one line of help, the function that adds its +options to a parser, and the function that runs it over the parsed arguments. `--version` and `--help` are +flags of the entry itself, and `sampletones --help` lists what a build offers. + +These conventions hold the command surface together: + +- Each command turns its arguments into a frozen record, field by field, before it works. +- A command with actions, such as `codec`, reads the action first and builds the record that action takes. +- A command that writes files names where they go `--output` (`-o`). +- An option that names an input says what it reads, so `--config` is a configuration file wherever it + appears. +- A refused value is reported on a line of its own, through `describe_failure`. + +The headless runs behind `convert` and `library` live in `sampletones_core/headless/`, where the +calibration reuses them. + +## The tools package + +`src/sampletones_tools/` holds every tool the running application does not use, in subpackages by subject. +Two helpers carry out principle 8: + +- `sampletones_tools/checkout.py` holds `require_checkout(command)`. It passes when the repository root has + `pyproject.toml` beside `src/`, and otherwise the command exits and names `uv run sampletones ` + in a checkout. +- `package_directory` in `sampletones_shared/paths/package.py` places a package from the import system's + own record, where PyInstaller unpacks each package's data beside its modules. + +A test guards principle 4. It imports the registry in a subprocess and asserts that only the command, +registry and package modules of the tools load and no heavy library does. A startup failure in any tool +module would break every invocation, the GUI included. + +A tool that writes a page ships that page's files as they are read. `calibration/board/static/` holds the +markup, the stylesheet and the script, copied out byte for byte, and the builder writes the palette, the +faces and the run's own contents beside them. The stylesheet names color tokens and the script names no +measurement, so a page is edited in place and drawn in the application's palettes. A test holds every +shipped file to reaching nothing beyond the page, which lets one open from a file. + +## The bootstrap scripts + +`scripts/` holds one script per operation (principle 7). What they share sits in `scripts/bootstrap/`, one +fact in one place. `layout.py` holds the repository root and every path, notice and clean target a script +names. `project.py` holds what `pyproject.toml` says, read with `tomllib`, which it holds the file to. + +The tests are run as named passes: the suite, the doctests, the screen scenarios and the benchmarks. Each pass is a make target. +CI runs the doctests, the suite and the screen scenarios as steps of their own, so a failure names its pass. The benchmarks run locally, where timings are reproducible. diff --git a/docs/development/undo.md b/docs/development/undo.md deleted file mode 100644 index e056846be..000000000 --- a/docs/development/undo.md +++ /dev/null @@ -1,87 +0,0 @@ -# History & Undo - -This document describes the undo/redo subsystem of `sampletones_application`. The engine lives in `logic/history/` and is owned by `HistoryManager`; coordinators integrate with it as described in `docs/development/architecture.md`. Undo/redo is session-scoped and upholds two invariants: - -1. **Completeness** — every mutation of project state belongs to the history. -2. **Reversibility determinism** — any composition of undos and redos that returns - the cursor to an index reproduces that index's exact state. - -## Engine: snapshot + cursor - -`HistoryManager` holds an ordered list of whole-project snapshots and a cursor; -the live project always equals a restoration of `entries[cursor]`. Undo and redo -move the cursor and reinstall the snapshot there — they never mutate a stored -snapshot, so reversibility determinism holds by construction. Restore installs a -fresh copy through `ProjectController.replace_project`, which fires -`on_project_replaced` to rebuild the tabs exactly as loading a project does. - -A snapshot (`snapshot_project`) deep-copies the light structure (song, settings, -metadata, sample shells) but **shares each `Reconstruction` by reference**. -Reconstruction edits are copy-on-write: `RegenerationService` emits a *new* -reconstruction and the apply path installs it via -`ProjectController.replace_sample_reconstruction`, so a shared reconstruction -never mutates in place and snapshots never duplicate the multi-megabyte audio -arrays. Producing the fresh reconstruction deep-copies the edited one once, on -the regeneration worker's background thread. - -## Grouping vs. detection - -- **Grouping — coordinators.** Each state-changing coordinator intent runs inside - `HistoryManager.transaction(HistoryAction.X)` (the sequencer wraps its hooks via - `_undoable`). All controller calls a gesture makes collapse into one entry; - nested transactions coalesce. A transaction may also carry a *coalesce key* - naming the gesture's target (a grid cell, a sample, a module setting): - consecutive commits sharing the same action and key replace the top entry - instead of appending, so a continuous interaction — a graph drag, repeated - edits of one cell — records a single entry. Any undo, redo, or jump breaks - the run, so a state the user navigated to is always preserved. `_undoable` - opens `ProjectController.batch()` inside the transaction, so one gesture is - one entry and one round of view notifications alike. -- **Detection — the controller.** `ProjectController._touch()` fires `on_mutation` - on every fine-grained mutation, as it lands — a batch defers the view - notifications and the dirty stamp, leaving this signal immediate so the check - below sees each mutation inside the transaction that caused it. - `HistoryManager.handle_mutation` counts those - inside a transaction and rejects any that occur outside one: under strict - deployment it raises `UntrackedMutationError`; otherwise it self-heals by - recording the mutation as its own entry. This makes completeness a checkable - property. Under strict deployment each committed snapshot also carries a - fingerprint, and every restore verifies the reproduced project matches it. - Capture-time fingerprints memoize each reconstruction's hash by object - identity (copy-on-write keeps the content fixed for the object's lifetime), - collapsing the per-gesture cost to the light structure; restore-time - verification always hashes fresh, so an in-place mutation of shared state is - caught rather than masked by the memo. - -## Save point and lifecycle - -The manager records the cursor of the last successful save (wired from -`ProjectController.on_saved`); a restore that lands exactly on that index -reinstates the on-disk content, so the session reports the document clean -again. A commit that truncates the saved entry away — or budget eviction that -drops it — invalidates the save point, and the session stays dirty until the -next save. Coalescing always preserves the saved entry by appending. The stack -follows the project lifecycle: an open project seeds a baseline entry, and -closing every project empties the stack, so the panel reports no history. - -## History detail rendering - -Committed entries are language-independent. An entry stores its action as a -`HistoryAction` enum member and its detail as data segments; language-managed -words inside a detail (e.g. a loop's on/off state) are stored as -`HistoryDetailWordSegment` keys. Action labels and word segments alike resolve -through `LanguageManager` when the history view model is built, so switching -the language re-renders past entries correctly. - -## Configuration - -The entry budget is a persisted user preference -(`ApplicationConfig.history.budget`, default 500, lower bound 1). Strict -checking and log level are deployment knobs -(`application/deployment.yaml` → `DeploymentConfig`); the deployment model is -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. - -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..393dfe259 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -1,256 +1,390 @@ # Bitphase export format -This document is the reference for how _SampleToNES_ writes -[Bitphase](https://github.com/paator/bitphase) files. It describes the two files the -`sampletones_core.formats.bitphase` package produces — the `.btp` document and the -`.json` instrument preset — and the Bitphase capacity limits the exporter respects. -Read it before changing anything under `formats/bitphase/`; the sibling +This document is the reference for how _SampleToNES_ writes [Bitphase](https://github.com/paator/bitphase) +files: the `.btp` document and the `.json` instrument preset. It also lists the Bitphase capacity limits +the exporter respects. Read it when you write or check a `.btp` document or a `.json` preset. The sibling [FamiTracker export](famitracker.md) document covers the other tracker. +[The tracker playback check](../tools/tracker-playback.md) plays exported documents through Bitphase's +own engine and lists every tick they sound differently from the app. -The target is Bitphase's **NES (2A03) chip**: five channels (two squares, triangle, -noise, DPCM), with the DPCM channel always silent by design. Every constant referenced -here has a named counterpart under `sampletones_core/formats/bitphase/specification/` -(grouped by unit: `chip`, `channels`, `instruments`, `patterns`). +The target is Bitphase's **NES (2A03) chip**: five channels (two squares, triangle, noise, DPCM). The DPCM +channel is always silent. Every constant named here has a counterpart under +`sampletones_core/formats/bitphase/specification/`, grouped by unit (`chip`, `channels`, `instruments`, +`patterns`). -Bitphase plays a note by three columns acting together, and that shapes the whole -mapping: an **instrument** supplies the per-tick register values, a **table** supplies -the per-tick pitch movement, and the **note column** supplies the pitch they move -around. A reconstruction's volume and duty envelopes become the instrument, its -arpeggio envelope becomes the table, and its reference pitch becomes the note. +Bitphase plays a note with three columns acting together. An **instrument** supplies the per-tick register +values. A **table** supplies the per-tick pitch movement. The **note column** supplies the pitch they move +around. This shapes the whole mapping: a reconstruction's volume and duty envelopes become the instrument, +its arpeggio envelope becomes the table, and its reference pitch becomes the note. ## A. File formats ### A.1 `.btp` — the document -A `.btp` is the document's JSON under gzip — no header and no version field. The -exporter writes it without separator padding and with a fixed gzip timestamp, so -exporting an unchanged document twice yields identical bytes. Written by -`formats/bitphase/btp.py`. - -Bitphase's loader reads each field on its own and falls back to a default for any it -misses, so a document that carries every field below loads exactly as it was written. +A `.btp` is the document's JSON under gzip, with no header and no version field. The exporter writes it +without separator padding and with a fixed gzip timestamp, so exporting an unchanged document twice yields +identical bytes. ``` Project { name, author, songs[], loopPointId, patternOrder[], tables[], patternOrderColors{}, instruments[] } -Song { patterns[], tuningTable[], initialSpeed, chipType, chipVariant, - chipFrequency, interruptFrequency, a4TuningHz, virtualChannelMap{} } +Song { patterns[], tuningTable[], initialSpeed, tempo, defaultPatternLength, + chipType, chipVariant, chipFrequency, interruptFrequency, a4TuningHz, + virtualChannelMap{} } Pattern { id, length, channels[], patternRows[] } -Channel { rows[], label } +Channel { rows[], label, effectColumnCount } Row { note: { name, octave }, effects[], instrument, table, volume } -Table { id, rows[], loop, name } -Instrument { id, chipType, rows[], loop, name } +Effect { effect, delay, parameter, tableIndex } +Table { id, rows[], loop, name, additive } +Instrument { chipType, name, macros{ field: { values[], loop } }, id } ``` -Instruments and tables belong to the **project** rather than to a song, so every song -addresses the same lists. `patternOrder` names the pattern each order position plays, -and `loopPointId` is the order position playback returns to. +The project owns the instruments and tables, so every song addresses the same lists. `patternOrder` names +the pattern each order position plays. `loopPointId` is the order position playback returns to. + +**Field names are camelCase**, as in Bitphase's own files. -**Field names are camelCase.** The Pydantic models under `formats/bitphase/model/` -carry snake_case attributes and serialize through a camelCase alias generator, so the -Python side reads like the rest of the codebase while the file reads like Bitphase's. +A channel has as many effect columns as its widest row, up to four, and Bitphase gives a channel the same +count in every pattern it appears in. An effect reads its argument from a table whenever `tableIndex` is +zero or above, and from `parameter` otherwise, so an effect driven by its parameter writes `tableIndex` +as `-1`. An empty value counts as naming the first table, and the effect is then dropped. ### A.2 `.json` — the instrument preset -Bitphase's instruments panel saves and loads a single instrument at runtime through a -file picker. The file holds `{ chipType, name, loop, rows }`, indented the way Bitphase -writes its own, so a preset written here reads like one saved from the tracker. Written -by `formats/bitphase/preset.py`. +Bitphase's instruments panel saves and loads a single instrument through a file picker. The file holds +`{ chipType, name, macros }`, indented the way Bitphase writes its own, so a preset written here reads +like one saved from the tracker. The panel writes the macros into the instrument slot the reader has +selected, and that slot gives the instrument its id and its chip. -A preset carries rows alone, so its pitch movement rides in each row's `toneAdd` -(section C.3) rather than in a table. +A preset has macros only, so its pitch movement goes in the `toneAdd` each tick takes (section C.3) +instead of a table. ## B. The NES instrument -An instrument advances **one row per engine tick** while a note sounds, so a row -carries every register value the channel takes for that tick. From -`formats/bitphase/model/instrument.py`, matching Bitphase's `NesInstrumentRow`: - -| Field | Range | Runtime meaning | What the exporter writes | -| --- | --- | --- | --- | -| `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) | -| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item, or a full level where the slice leaves its volume to the channel | -| `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself | -| `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note | -| `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset | -| `toneAccumulation` | bool | sums `toneAdd` across ticks | `false`, since each item is an absolute offset | -| `retrigger` | bool | restarts the waveform phase this tick | `false`, so the waveform runs continuously | -| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | the square channel's hardware sweep | disabled | - -**Looping.** Playback returns to the instrument's `loop` row once it runs off the end, -which is the only mode there is. A looping slice therefore sets `loop = 0` so its -envelopes repeat from the start while the note is held; a one-shot sets `loop = len - 1` -and 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. - -**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 -describes. Playback combines a row's level with the pattern's volume column through a -PT3 volume table, where a full-level row comes out at the column's own level, so those -rows sound at whatever level the channel carries — the same reading FamiTracker gives a -disabled volume sequence. A slice describing no frame at all is what writes a single -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). +An instrument is a set of **macros**, one per field, and each macro gives that field a value per engine +tick while a note sounds. A field with no macro takes the default below, so an instrument writes a macro +only for the fields a reconstruction decides. The fields match Bitphase's own: + +| Field | Range | Default | Runtime meaning | What the exporter writes | +| --- | --- | --- | --- | --- | +| `volumeOrRate` | 0–15 | 15 | the literal channel volume while `envelope` stays off | the volume envelope, or one full level where the slice leaves its volume to the channel | +| `pulseWidth` | 0–3 | 2 | square duty cycle; on the noise channel, the lowest bit selects the short LFSR | the duty-cycle envelope (squares), the short or long mode (noise), or one `0` where the slice leaves its duty to the channel; the triangle writes no macro | +| `toneAdd` | −4096–4095 | 0 | period offset added to the period the note resolves to (squares and triangle), or steps added to the note (noise) | the bend the slice sounds (section C.4), and the contour with it in a preset | +| `envelope` | bool | `false` | reads `volumeOrRate` as a hardware decay rate | no macro, so each value is the volume itself | +| `soundLength` | 0–511 | 0 | length counter in ticks; `0` holds the note | no macro, so the volume envelope alone shapes the note | +| `toneAccumulation` | bool | `false` | sums `toneAdd` across ticks | no macro, so each value is a whole offset | +| `retrigger` | bool | `false` | restarts the waveform phase this tick | no macro, so the waveform runs continuously | +| `sweep` / `sweepRate` / `sweepShift` | bool / 0–7 / −7–7 | `false` / 0 / 0 | the square channel's hardware sweep | no macro, so the sweep stays off | + +**Every field has its own counter.** A macro has the values one field takes and the index they repeat +from, and Bitphase advances each macro on its own. A field whose value never changes therefore costs one +value, however long the other fields run. + +**Looping.** Playback returns to the macro's `loop` index once the values run out. That is the only mode. +A macro whose `loop` is `0` repeats whole, and one whose `loop` is its last index stays on the last value. +A dimension the reconstruction gives a repeat point repeats from that point. A dimension that plays +through writes its last index, and the value it ends on is what the note rests on: silence where the +volume envelope ends on a note-off item, the channel's own level where the slice leaves the volume +alone. + +**An instrument's slices.** Bitphase bakes a channel's registers tick by tick. An +[instrument](../glossary.md#instrument) therefore reaches a document as one slice per +channel it sounds on. Each slice reads the dimensions that channel offers and moves around the pitch the +instrument states. The envelopes are one set for every channel, so the slices differ only in what each +channel reads of them. + +**A held volume.** A slice whose volume envelope has no item leaves its level to the channel. The exporter +writes one full `volumeOrRate`. Playback combines that level with the pattern's volume column, and a full +level comes out at the column's own level (section E). The slice therefore sounds at whatever level the +channel has, which is how FamiTracker reads a disabled volume sequence. A slice that describes no frame at +all writes a single silent value, the smallest instrument Bitphase plays. + +**Every note starts where a song does.** Bitphase reads every field from the instrument a note plays, +from its first tick, and every note cell the exporter writes names its slice's own table. A slice that +leaves a dimension to the channel writes the value _SampleToNES_ starts a note on: a full level, a flat +table, no tone offset, and a `0` pulse width, which is the long mode on noise. The pulse width needs its +macro, because a field with none takes Bitphase's default of `2`. The note therefore sounds the same +whatever played before it, as it does in the app and in FamiTracker. + +**A table runs beside the macros.** The table advances one step per tick on a counter of its own, so the +contour keeps the length and the repeat point the arpeggio envelope was written at, whatever the macros +beside it do. ## C. Pitch ### C.1 The tuning table -A song carries a 96-entry `tuningTable`, one channel period per note index, built by -`formats/bitphase/tuning.py` as a port of Bitphase's `generate12TETTuningTable`: +A song has a 96-entry `tuningTable`, one channel period per note index. It is a port of Bitphase's +`generate12TETTuningTable`: ``` frequency = a4TuningHz * 2 ^ ((index - 45) / 12) period = round(chipFrequency / 16 / frequency) clamped to 1..2047 ``` -Rounding matches JavaScript's `Math.round` (half away from zero on positives), so a -table built here equals the one Bitphase derives from the same settings. The exporter -writes NTSC (1 789 773 Hz) at concert pitch; PAL (1 662 607 Hz) and Dendy -(1 773 448 Hz) are named in `specification/chip.py`. - -**A note index is the absolute pitch less 24**, which puts indices 0–95 over pitches -24–119 — the same span the FamiTracker exporter clamps to. A pattern cell stores that -index as a semitone and an octave, which playback resolves back with -`name - 2 + (octave - 1) * 12`. - -The triangle channel's period is written from the same table, so a written note sounds -an octave below — the convention SampleToNES and FamiTracker already share. +Rounding matches JavaScript's `Math.round` (half away from zero on positives), so a table built here +equals the one Bitphase derives from the same settings. The exporter writes NTSC (1 789 773 Hz). PAL +(1 662 607 Hz) and Dendy (1 773 448 Hz) are named in `specification/chip.py`. + +**The song plays at the work's tuning.** `a4TuningHz` is the frequency the reconstruction's tuning gives +pitch 69, the pitch at index 45. A tuning whose reference names another pitch is read out at pitch 69 all +the same. The table is built from that frequency. Bitphase builds the table again from `a4TuningHz` and +`chipFrequency` when it loads a song (`resolveTuningTable` in `src/lib/chips/nes/schema.ts`), so the +exporter writes the two fields in step. An instrument or a reconstruction takes its own tuning. A project +takes the tuning its samples were reconstructed at, and a project with no sample plays at A4 = 440 Hz. One +table sounds one tuning, so a project whose samples were reconstructed at different tunings is refused, as +the [NSF export](../development/player.md#the-song-a-file-carries) refuses it. + +**A note index is the absolute pitch less 24.** Indices 0–95 cover pitches 24–119, the span a FamiTracker +note cell covers too. A pattern cell stores the index as a semitone and an octave, which playback resolves +back with `name - 2 + (octave - 1) * 12`. At concert pitch the nine indices below pitch 33 ask for a +period longer than the register holds. _SampleToNES_ plays pitches 24–119 and sounds each of those nine +at the longest timer the register holds, `0x7FF`, as FamiTracker does. Bitphase's tuning table holds a +period at 2047 at most, which is timer 2046, so those notes sound one step higher there. Its engine +plays periods up to 2048, so a bend reaches `0x7FF` (section C.4). + +The triangle channel's period comes from the same table, so a written note sounds an octave below. +_SampleToNES_ and FamiTracker share that convention. ### C.2 Tables carry the contour -A table holds one semitone offset per tick, and playback adds `rows[position]` to the -channel's note every tick. That is a direct match for a reconstruction's arpeggio -envelope in absolute mode, so the contour crosses over verbatim on the pitched -channels. +A table has one semitone offset per tick, and playback adds `rows[position]` to the channel's note every +tick. It repeats from `loop` once the steps run out, by the rule a macro repeats by. That matches a +reconstruction's arpeggio envelope in absolute mode, so the contour crosses over verbatim on the pitched +channels, with the repeat point the envelope was written at. A table whose `additive` flag is set adds +each step to the one before it; a contour measures every step from the note, so the flag stays clear. -A pattern's `table` column names a table by `id + 1`; `0` leaves the attached table -alone and `-1` detaches it. +A pattern's `table` column names a table by `id + 1`. `0` leaves the attached table alone and `-1` +detaches it. -**Noise** derives its period from the note index rather than from the tuning table: -playback reads `period = 15 - (index mod 16)`. Every period therefore repeats once per -sixteen indices, and the exporter picks a base index far enough below the top of the -table for a whole cycle of offsets to stay in range: +**Noise** derives its period from the note index, not from the tuning table: playback writes +`15 - (index mod 16)` to the period register, whose timers run fastest first. _SampleToNES_ counts its +sixteen periods from the slowest, so a period `p` reaches the register as `15 - p`, which is what the NSF +player writes too. The note index therefore carries the period itself. Every period repeats once per +sixteen indices, and the exporter picks a base index far enough below the top of the table for a whole +cycle of offsets to stay in range: ``` -base index = 48 + ((15 - initial_period) mod 16) lands in 48..63 -table offset = (-arpeggio_step) mod 16 lands in 0..15 +base index = 48 + (initial_period mod 16) lands in 48..63 +table offset = arpeggio_step mod 16 lands in 0..15 ``` -so `15 - ((base + offset) mod 16)` is the period the reconstruction chose, wrapped into -the sixteen the channel holds. +So `(base + offset) mod 16` is the period the reconstruction chose, wrapped into the sixteen the channel +has, and the register holds `15` less that period. ### C.3 Presets fold the contour into the period -An instrument preset carries no table, so its pitch movement is expressed as the -per-tick `toneAdd` each row applies to the note's own period. The offsets are measured -against the pitch the slice was reconstructed at, under the tuning a freshly created -Bitphase document plays — NTSC at concert pitch. The noise channel takes its period -from the note, so its preset rows hold a flat offset. +An instrument preset has no table, so its pitch movement is the per-tick `toneAdd` each tick applies to +the note's own period. One offset carries the contour and the bend together. The offsets are measured +against the pitch the slice was reconstructed at, under the tuning a freshly created Bitphase document +plays: NTSC at concert pitch. A preset loads into a document that keeps its own tuning, so a preset stays +at concert pitch whatever the reconstruction was tuned at. On the noise channel the offset moves the +note, which carries the period itself, so a noise preset's offset is the contour step and the bend added +together, in period steps. + +### C.4 The bend rides the tone offset + +A reconstruction says which note a frame plays and how far from that note it sounds, in steps of the +channel's own timer. That distance is the [bend](../glossary.md#bend), written as a fine dimension of one +step per unit and a coarse one of sixteen. Bitphase counts a period where _SampleToNES_ counts a timer, +and the two differ by one step across the table, so a distance in steps crosses over unchanged. The bend +is the `toneAdd` macro, one value per tick. + +| What the engine does | What the exporter writes | +| --- | --- | +| moves the note by the table step, then reads that note's period | each value is measured from the note its own contour step reaches, so a transposed trigger keeps its bend | +| adds `toneAdd` to that period | the two bend dimensions added together, one value per tick | +| leaves `toneAccumulation` clear | a whole offset per tick, not a step added to a running one | +| holds the period within 0–2048, silences a channel at zero, and loads the timer with the period less one | an offset that keeps the timer within 1–`0x7FF`, as in-app playback bends it, measured from the period the song's own table gives the note | + +The squares and the triangle add the offset to the period. The noise channel adds it to the note, which +carries the period itself, so a noise slice's bend is written as it stands: one period step per unit. A +slice that sounds every tick on its own note writes no `toneAdd`, so a document pays only for the bends +it sounds. ## D. Tempo as a groove -A Bitphase song states a **speed** — the engine ticks each row lasts — where a _SampleToNES_ -project states a tempo and a speed together. The row rate the pair asks for is fractional at -most tempi, so the exporter carries it as a [groove](../glossary.md#groove): whole tick counts, -one per row of a pattern, averaging out to that rate with the longer rows on the bar and the -beat. `sampletones_core/timing/` builds them and in-app playback reads the same groove, so a -document plays the rows the sequencer played. At 60 Hz, speed 6 and tempo 210, a 16-row -pattern in common time comes to +A Bitphase song has a **speed**, the ticks each row lasts. A _SampleToNES_ project has a tempo and a speed +together. The row rate the pair asks for is fractional at most tempi, so the exporter writes it as a +[groove](../glossary.md#groove): whole tick counts, one per row, averaging out to that rate, placed as +[song timing](../concepts/timing.md) places them. In-app playback reads the same timing, so a document +plays the rows the sequencer played. For example, at 60 Hz with speed 6 and tempo 210, a 16-row pattern +in common time lasts 68 4/7 ticks, so the song's first two frames play: ``` -5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks, a rate of 30/7 per row +5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks +5 4 4 4 5 4 4 4 5 4 4 4 5 4 4 4 68 ticks ``` -**The groove reaches the engine as a table.** A speed effect that names a table reads one of -its entries per pattern row, which is what carries a per-row tick count into a song: +A Bitphase song has a **tempo** too. Above `0`, Bitphase spreads the ticks over the rows the way +FamiTracker's tempo does, with a running count from row to row. At `0`, the speed alone sets each row. +The exporter writes `0` and places the ticks itself, by bar and by beat. + +**The groove reaches the engine as speed effects.** A speed effect sets the ticks a row lasts from its +row on, so the exporter writes one wherever a row lasts differently from the row played before it: | Part | What the exporter writes | | --- | --- | -| `initialSpeed` | the ticks the pattern's first row lasts | -| The table | one entry per pattern row, `loop = 0`, taking the id above the last slice table | -| The effect | `S` with `delay = 0` and an empty parameter, naming that table | -| Its place | the first row of the DPCM channel, in every pattern | +| `tempo` | `0`, so the speed alone sets the ticks of every row | +| `initialSpeed` | the ticks the song's first row lasts | +| The effect | `S` with `delay = 0`, the row's ticks as its parameter, and `tableIndex = -1` | +| Its place | the DPCM channel, on every row whose length differs from the row before it | -A speed effect applies from whichever channel carries it, so the groove rides the DPCM channel -this exporter leaves silent and every sounding channel keeps the one effect column the chip -gives it. The table advances an entry per row and resumes from where a trigger placed it, so -triggering it again at each pattern start holds every row on the entry that describes it, -however the order jumps. +The order comes round to its first pattern after the last, so the song's last row counts as the row +before its first. A speed effect applies from whichever channel has it, so the groove rides the DPCM +channel, which this exporter leaves silent. Every sounding channel keeps its own effect column free. +Bitphase finds the speed a row it starts playing from lasts by reading back to the last speed effect, so +a song started anywhere plays every row at its length. -**A tempo the speed column states writes neither.** Where every row lasts alike — tempo 150 at -60 Hz, where the rate is the speed itself — `initialSpeed` carries the tempo whole, and the -document holds one table per slice with every effect column empty. +**A tempo the speed column can state needs no groove.** Where every row lasts alike, as with tempo 150 at +60 Hz where the rate is the speed itself, `initialSpeed` has the tempo whole. The document then has one +table per slice, and every effect column is empty. ## E. What the exporter builds per scope -A `.btp` holds a whole document, so every scope lands in one file; a preset holds one -instrument, so a reconstruction lands as a set of them beside the name the export was -given, one per slice. +A `.btp` is a whole document, so every scope lands in one file. A preset is one instrument, so a +reconstruction lands as a set of presets beside the name the export was given, one per slice. | Scope | `.btp` | `.json` preset | | --- | --- | --- | -| One generator slice | a playable document holding that instrument | one file | +| 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 | — | -**Instrument and reconstruction documents are playable.** Each slice becomes an -instrument and the table that carries its contour, and one pattern triggers every slice -at row 0 on the channel it was reconstructed for, so opening the document and pressing -play sounds the reconstruction. The pattern is sized to cover the longest instrument, -and where one instrument outlasts a single pattern the order gains resting positions -until it has played through. - -**A project flattens its order.** A SampleToNES order frame points each channel at its -own pattern, where a Bitphase order position names one pattern spanning every channel. -Each frame therefore becomes a pattern of its own carrying that frame's channels side -by side, with `patternOrder = [0..n-1]`. The arrangement crosses over whole; it simply -shares fewer patterns. - -Row cells follow from the columns: an instrument command writes the note from -`initial_pitch + transpose`, the instrument number, the table column and the row's -volume; a note-off writes note name `1`; a blank line leaves every column alone. - -**The volume column names silence.** In Bitphase you type `0` to silence a channel and -leave the cell blank to carry its level forward — and the file stores those two as `-1` and -`0`. The volume field is declared `allowZeroValue`, so Bitphase parses a typed `0` to `-1` -and prints a stored `-1` back as `0`, while a stored `0` shows as a blank cell; its engine -reads `-1` as volume zero. So a row asking for silence writes `-1`, a row naming a level -writes it verbatim, and a row with an empty volume cell writes `0` — which is the same cell -you would see in the tracker either way. +**Instrument and reconstruction documents are playable.** Each slice becomes an instrument plus the table +that carries its contour. One pattern triggers every slice at row 0 on the channel it was reconstructed +for, so opening the document and pressing play sounds the reconstruction. The pattern is sized to cover the +longest instrument. Where an instrument outlasts a single pattern, the order gains resting positions until +it has played through. + +**A project flattens its order.** A SampleToNES order frame points each channel at its own pattern, while +a Bitphase order position names one pattern spanning every channel. Each frame therefore becomes a pattern +of its own with that frame's channels side by side, and `patternOrder = [0..n-1]`. The arrangement crosses +over whole and shares fewer patterns. + +Row cells follow from the columns. An instrument command writes the note from `initial_pitch + transpose`, +the instrument number, the table column and the volume column below. A note-off writes note name `1`. A +blank line leaves every column alone. + +**The note keeps the song's pitch range.** In-app playback holds every tick's transposed pitch within +24–119, the span of the tuning table. Playback moves the written note by the table's step each tick and +holds the result at index 0 (pitch 24) and index 95 (pitch 119), so a note within the span is written as it +is and a higher one is written at 119. Bitphase writes no note below index 0, so a lower one is raised +only as far as bringing the table's highest step to pitch 24: + +| Row | What the exporter writes | +| --- | --- | +| a flat slice transposed below 24 | pitch 24, the note in-app playback sounds | +| a contour whose steps stay at its note or below | the note whose highest step lands on 24 | +| a contour rising above a note below 24 | pitch 24, where the ticks the contour raises sound higher than in-app playback | + +**A transpose row moves the note sounding.** In-app playback reads a row that states a transpose and no +note as a new pitch for the note already sounding: the voice goes on from the tick it reached, and every +tick from that row on sounds at the new transpose. A note-on starts over at its own transpose. Bitphase +restarts the instrument on a note or an instrument cell, and a table cell alone attaches a table at its +first step while the instrument plays on. So the row writes a table cell and an effect: + +| Column | What the exporter writes | +| --- | --- | +| Table | a copy of the note's table with every step moved by `N`, the distance from the note already written to the note a note-on at the new transpose would write | +| Effect | `5` (ornament position), `delay = 0`, the parameter naming the step the note's table has reached, which the first step leaves out | +| Note, instrument | empty, so the instrument goes on | + +`N` is measured from the note the note-on wrote, whatever an earlier row moved the note to, so each row's +pitch is the transpose it states. From that tick the channel sounds what a note-on at the new transpose +would sound, the low-note rule above included. On noise `N` is measured the same way, between the two +note indices the period mapping of section C.2 writes, so the moved table walks the period around the +sixteen the channel has and follows that mapping wherever it places a period. A row returning to the +note's own transpose names the note's own table, a row keeping the transpose in force writes nothing, +and rows moving one slice by one distance share one table. + +The note's table advances a step per tick from the note on, so the step the effect names counts the ticks +every row since the note lasts, the groove's uneven rows and the frames between included. The effect +reads its parameter as a byte, so it names steps 0–255. A row reaching a later step names a copy that +opens on that step: its steps run from there and circle over the steps the note's table circles +over, and the row writes no effect. + +A transpose row reached while no note sounds moves nothing and writes nothing, and neither does one +following a note-on that was written as a note cut. + +**An instrument placed without a pitch takes the channel's.** In-app playback starts such an instrument +on the pitch the channel is sounding, whichever voice sounded it, and leaves a silent channel silent. Each +frame is a pattern of its own, so the cell writes the note the channel was sounding in that frame beside +the instrument, and a row that starts nothing writes an empty cell. + +**A note starts at the full level.** In-app playback starts a note whose row states no volume at the full +level. Playback carries the level a channel last took into every note after it. Such a note therefore +writes `15` wherever playback reaches it carrying another level, and keeps an empty cell where the +channel stands at the full level already. The exporter follows each channel's level through the order the way +Bitphase plays it: frame by frame, then from the loop point, the first frame, with the level the order +ended on. A note-on written as a note cut takes the same level. + +**The column scales the instrument's level.** Playback combines the two levels the way in-app playback +does. On the pulse channels it reads a PT3 volume table, which rounds their product over the full level to +the nearest step. On noise it rounds the product down, and plays the quietest level wherever that comes +out silent while both levels sound. Either way a full instrument level plays at the column's own level. + +**The triangle sounds above half volume.** In-app playback sounds the triangle while a row's volume is 8–15 +and silences it at 0–7. Playback enables the triangle while the PT3 product of the pattern level and the +instrument level is above zero, and a triangle slice writes a full instrument level, so any pattern level +above zero sounds it. A triangle row at 0–7 therefore writes the silencing `-1`, and a row at 8–15 writes +its level. + +**The volume column names silence.** In Bitphase you type `0` to silence a channel and leave the cell +blank to carry its level forward. The file stores those two as `-1` and `0`. The volume field is declared +`allowZeroValue`, so Bitphase parses a typed `0` to `-1` and prints a stored `-1` back as `0`, and a +stored `0` shows as a blank cell. Its engine reads `-1` as volume zero. A row asking for silence therefore +writes `-1`, a row naming a level writes it verbatim, and a row with an empty volume cell writes `0`. Each +is the same cell you would see in the tracker. ## 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 | +| Values per instrument macro | 1–512 | writes the opening values of a longer dimension, keeps a volume's closing silence, and reports what it left out | +| Rows per table | unbounded | writes the contour whole | +| Effect columns per channel | 1–4 | writes one: the speed effects on the DPCM channel, the ornament position on a transpose row | | Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 | -| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables, one of which a groove takes | -| Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | -| Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, and `-1` where a row asks for silence | +| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables, counting the moved tables transpose rows name | +| Ornament position | the effect parameter is a byte, so steps 0–255 | names a copy of the table opening on a later step | +| Note range | the 96-entry tuning table, pitch 24–119 | keeps the song's range, 24–119, raising a lower note only as far as its table's highest step reaching 24 (section E) | +| A4 tuning | 220–880 Hz, the range the song settings offer (`src/lib/chips/nes/schema.ts`) | writes the work's tuning, and raises past that range | +| Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, `15` on a note the channel reaches at another level, and `-1` where a row asks for silence or the triangle's level is 0–7 | | Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` | | Order positions | unbounded | matches | | Speed | 1–255 | the groove's tick counts, bounded to that range | -| DPCM channel | present | rests, apart from the groove trigger each pattern's first row carries | +| DPCM channel | present | rests, apart from the speed effects the groove sets | + +A row that names a voice on a channel the voice has no instrument for plays nothing in the song, so the +exporter writes a note cut on it and reports the row by its frame, channel and row. The project export +dialog lists those rows. + +Tables and instruments are numbered together, and each slice takes one of each. The table column is +therefore what a wide document reaches first, and the exporter raises an error instead of writing a +document whose later voices cannot be named. The moved tables transpose rows name take the ids above the +slices, and a document needing more of them than the column names is refused the same way. + +**The macro limit is the one a reconstruction meets by itself.** A dimension reaches it at 512 frames, +which is 8.5 s at 60 Hz. Each field is counted on its own, so a flat duty or a held level costs one value. +In a `.btp` the contour the table carries keeps its whole length. A preset folds the contour into +`toneAdd`, so there the contour stops at 512 values too. A volume dimension keeps its closing silence as +its last value, because the note has to end. [The FamiTracker export](famitracker.md#b-the-2a03-instrument) +meets its own limit by the same rule. + +**The export reports what it left out.** Every scope reports the instruments a macro shortened: an +instrument by the values it kept of the values it had, a reconstruction and a project by how many +instruments were shortened. A project counts each slice as one instrument. -Tables and instruments are numbered together — each slice takes one of each — so the -table column is what a wide document reaches first, and the exporter raises rather than -writing a document whose later voices cannot be named. A song whose rows vary spends one -of those ids on its groove, so the slices a document holds are those the table column can -still name. +## G. Data without a counterpart -## G. What does not cross over +**`ProjectInfo.comment`** has no counterpart in a Bitphase document, which has a name and an author only, +so the exporter leaves the comment out. -**`ProjectInfo.comment`** has no counterpart in a Bitphase document, which carries a name and -an author only, so the exporter leaves the comment behind. +**A field a reconstruction does not decide gets no macro.** The hardware envelope, the length counter, the +phase retrigger, the sweep and the tone accumulator each take the default in section B. Bitphase plays +the DPCM samples an instrument assigns to its notes (`dpcmSamples`, `dpcmAssignments`). The exporter +writes no sample and no note on the DPCM channel, so that channel rests. -`interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings -panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate -outside that pair plays correctly while leaving that one selector unmatched. +`interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings panel offers 50 and +60 Hz beside a custom value, and its loader and timeline accept any rate. A rate outside that pair plays +correctly. diff --git a/docs/formats/configuration.md b/docs/formats/configuration.md index e0b119bde..bc8faf20e 100644 --- a/docs/formats/configuration.md +++ b/docs/formats/configuration.md @@ -1,15 +1,16 @@ # Configuration file -The generation [configuration](../guide/configuration.md) is stored as a JSON -file, `config.json`, in the documents folder (see -[Where your files live](../guide/files.md)). The interface reads and writes it, -and you can edit it by hand to reach settings the interface does not expose. This -page documents its structure; [Reconstruction algorithms](../concepts/reconstruction.md) -explains what the settings do and lists the defaults. +The generation [configuration](../guide/configuration.md) is stored as a JSON file, `config.json`, in the +documents folder (see [Where your files live](../guide/files.md)). The interface reads and writes it, and +you can edit it by hand to reach settings the interface does not expose. This page documents its +structure. [Reconstruction algorithms](../concepts/reconstruction.md) explains what the settings do. -The file has three sections — `general`, `library`, and `generation` — plus a -`metadata` block that records the application version and is managed -automatically. Unknown keys are rejected, so every key must be one of those below. +The file has three sections, `general`, `library` and `generation`, plus a `metadata` block. The block +records the application version and is managed automatically. Unknown keys are rejected, so every key +must be one of those below. + +A key you leave out keeps its shipped value. The shipped values of the `generation` section are in +`sampletones_core/configs/generation.yaml`, which sets them for every copy of the program. ## `general` @@ -18,11 +19,11 @@ Audio preprocessing and housekeeping. | Key | Meaning | Values | | --- | --- | --- | | `normalize` | normalize the input before matching | `true` / `false` | -| `quantize` | quantize (bit-crush) the input | `true` / `false` | +| `quantize` | quantize the input to fewer volume steps | `true` / `false` | | `quantization_levels` | number of levels when quantizing | integer ≥ 3 | | `min_pitch`, `max_pitch` | lowest and highest pitch the reconstruction may use | 1–127 | -| `coefficient_percentile` | percentile of frame levels used to set the [working level](../concepts/reconstruction.md) | 0–100 | -| `coefficient_audibility_floor` | floor applied when estimating the working level | 0 < value ≤ 1 | +| `coefficient_percentile` | percentile of the frames' RMS levels used to set the [working level](../concepts/reconstruction.md) | 0–100 | +| `coefficient_audibility_floor` | fraction of the loudest frame's level below which a frame is left out of the working level | 0 < value ≤ 1 | | `max_workers` | worker processes used during reconstruction | integer ≥ 1 | | `library_directory` | where instruction libraries are stored | path | | `reconstructions_directory` | where reconstructions are written (alias: `output_directory`) | path | @@ -38,27 +39,27 @@ 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` -Which channels are used, and how candidates are scored. It carries a few -top-level keys and groups the scoring controls into `calculation`, `weights`, -`metric`, and `decoder`. +How candidates are scored. It has one top-level key and groups the scoring controls into `calculation`, +`weights`, `metric`, `decoder` and `refinement`. + +The channels a conversion uses, how hard each is pushed and how many a recording may sound at once are +set per recording in the converter. The [stems setup](reconstructions.md) a reconstruction carries +records them. | Key | Meaning | Values | | --- | --- | --- | -| `generators` | channels used | list of `pulse1`, `pulse2`, `triangle`, `noise` | -| `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` | ### `generation.calculation` | Key | Meaning | Values | | --- | --- | --- | -| `find_best_phase` | align each candidate to the target's phase before scoring | `true` / `false` | -| `fast_difference` | compare spectral features only, skipping a re-analysis of the residual | `true` / `false` | +| `find_best_phase` | align each candidate whose frames repeat one waveform shape to the target's phase before scoring | `true` / `false` | | `phase_aligner` | how the best phase is found | `sliding_rmse` / `cross_correlation` | ### `generation.weights` @@ -75,7 +76,9 @@ top-level keys and groups the scoring controls into `calculation`, `weights`, | `spectral_distance` | per-bin spectral distance | `squared` / `absolute` / `beta_divergence` | | `beta` | β for the β-divergence | ≥ 0 | | `perceptual_exponent` | exponent on the loudness weighting | ≥ 0 | -| `temporal_level_floor` | floor for the temporal term's normalization | > 0 | +| `temporal_level_floor` | floor for the temporal term's normalization, as a share of what one channel plays at full volume | > 0 | +| `silence_floor` | the power a frame is measured from when its own bins all lie under it, so a silent frame's score stays finite | > 0 | +| `dynamic_range_decibels` | how far under a frame's loudest bin the comparison reaches | > 0 | ### `generation.decoder` @@ -85,11 +88,18 @@ top-level keys and groups the scoring controls into `calculation`, `weights`, | `top_k` | candidates kept per channel per frame | integer ≥ 1 | | `pitch_weight`, `volume_weight`, `timbre_weight`, `on_off_weight` | Viterbi transition costs for changing each dimension | ≥ 0 | +### `generation.refinement` + +| Key | Meaning | Values | +| --- | --- | --- | +| `confidence` | share of a frame's energy its harmonics must hold for its pitch reading to count | 0–1 | +| `change_weight` | divider steps of reading error worth avoiding one change of bend | ≥ 0 | +| `window` | frames on either side whose readings a frame may settle on | integer ≥ 0 | + ## Editing the file -Keep to the keys above — unknown keys are rejected. The interface overwrites -`config.json` when you change a setting there, so the keys it does not expose (the -`metric`, `decoder`, phase aligner, and similar) are the ones you will typically -hand-edit. To keep several setups side by side, save them as separate files and -load one with `--config` (see [Command line](../guide/command-line.md)) or from the +Use only the keys above, because unknown keys are rejected. The interface overwrites `config.json` when +you change a setting there. The keys it does not expose, such as `metric`, `decoder` and the phase +aligner, are the ones you typically edit by hand. To keep several setups side by side, save them as +separate files. Load one with `--config` (see [Command line](../guide/command-line.md)) or from the **Reconstruction ▸ Load generation settings...** menu. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index b528b94f5..0682d34df 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -1,28 +1,25 @@ # 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. - -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 -FamiStudio forks. The module is single-chip 2A03: five channels (two pulse, triangle, -noise, DPCM), with the DPCM channel and DPCM sample bank always empty by design. - -All multi-byte integers are **little-endian**. Field types below use `uint8`, -`int8`, `uint32`, `int32`; strings are noted per field. Every constant referenced -here has a named counterpart under `sampletones_core/formats/famitracker/specification/` -(grouped by unit: `file`, `blocks`, `channels`, `sequences`, `instruments`, -`patterns`, `parameters`), and every block has its own writer function so this -specification is readable straight from the code. +This document is the reference for how _SampleToNES_ writes and reads FamiTracker files. Read it when you +write or check an `.fti` instrument file or an `.ftm` module file. +[The tracker playback check](../tools/tracker-playback.md) has FamiTracker export modules to NSF files and +lists every tick they sound differently from the app. + +The target is **vanilla FamiTracker 0.4.6** (`FILE_VER = 0x0440`). Files written to this specification +load in stock FamiTracker and in the 0CC, Dn-FamiTracker and FamiStudio forks. The module is single-chip +2A03 with five channels: two pulse, triangle, noise and DPCM. The DPCM channel and the DPCM sample bank +are always empty. + +All multi-byte integers are **little-endian**. Field types are `uint8`, `int8`, `uint32` and `int32`. +Strings are noted per field. Every constant named here has a counterpart under +`sampletones_core/formats/famitracker/specification/`, grouped by unit (`file`, `blocks`, `channels`, +`sequences`, `instruments`, `patterns`, `parameters`). ## A. Binary formats ### A.1 `.fti` — instrument file -An `.fti` holds a single 2A03 instrument: its five sequences inline, then an empty -DPCM section. Written by `sampletones_core/formats/famitracker/instrument.py`. +An `.fti` holds a single 2A03 instrument: its five sequences inline, then an empty DPCM section. | Field | Type | Value | | --- | --- | --- | @@ -44,14 +41,12 @@ Each **sequence record**: | item count | `uint32` | number of items | | loop point | `int32` | item index to loop from, or `-1` | | release point | `int32` | item index for note release, or `-1` | -| setting | `uint32` | sequence setting (arpeggio mode etc.); `0` = default | +| setting | `uint32` | sequence setting (arpeggio mode and so on); `0` is the default | | items | `int8` × count | one signed byte per tick | ### A.2 `.ftm` — module file -An `.ftm` is a file header followed by a sequence of named, versioned blocks and a -final `END` marker. Written by `sampletones_core/formats/famitracker/module.py`, one function -per block. +An `.ftm` is a file header, then a sequence of named, versioned blocks, then the `END` marker. **File header** @@ -60,7 +55,7 @@ per block. | magic | 18 bytes | `FamiTracker Module` | | version | `uint32` | `0x0440` | -**Block header** (precedes every block payload) +**Block header** (before every block payload) | Field | Type | Value | | --- | --- | --- | @@ -68,58 +63,124 @@ per block. | version | `int32` | block version | | size | `int32` | payload byte length | -The payload size is known only after the payload is built, so blocks are buffered -before their header is emitted. After the last block, the file ends with the 3-byte -marker `END`. - -**Blocks** (in write order), with their versions: - -- **`PARAMS`** (v6): expansion chip `uint8` (`0` = 2A03/none) · channel count `int32` - (`5`) · machine `int32` (`0` = NTSC, `1` = PAL) · engine speed `int32` (`0` = machine - default, otherwise a refresh rate in Hz) · vibrato style `int32` · highlight first - `int32` · highlight second `int32` · speed split point `int32` (the row where the - tempo/speed interpretation splits, `speed_split_point`). -- **`INFO`** (v1): title, author and copyright, each a fixed **32-byte** NUL-padded - string, in that order. -- **`HEADER`** (v3): track count as `uint8` holding `count − 1`; then each track's - title as a NUL-terminated string; then, for each channel, a channel id `uint8` - followed by one effect-column count per track, each a `uint8` holding `count − 1`. - Channel ids: square1 `0`, - square2 `1`, triangle `2`, noise `3`, DPCM `4`. -- **`INSTRUMENTS`** (v6): instrument count `int32`; then per instrument: index - `int32`, type `uint8` (`1` = 2A03), body, name length `uint32`, name bytes. The 2A03 - body is: sequence count `int32` (`5`); per sequence an enabled `uint8` and a - sequence index `uint8`; then the DPCM key-assignment table across the note range, - all zero here. -- **`SEQUENCES`** (v6): sequence count `int32`. Pass one, per sequence: index `int32`, - type `int32` (volume `0`, arpeggio `1`, pitch `2`, hi-pitch `3`, duty `4`), item - count `uint8`, loop point `int32`, then the items `int8` each. Pass two, per - sequence: release point `int32`, setting `int32`. Instruments reference these - pooled sequences by index — the module stores each sequence once. -- **`FRAMES`** (v3): per song — frame count `int32`, speed `int32`, tempo `int32`, - pattern length `int32`, then the order table: for each frame, one pattern index - `uint8` per channel. -- **`PATTERNS`** (v5): per non-empty pattern — song index `int32`, channel `int32`, - pattern index `int32`, row-item count `int32`; then per stored row: row number - `int32`, note `int8`, octave `int8`, instrument `int8`, volume `int8`, then per - effect column an effect `int8` and a parameter `int8`. -- **`DPCM SAMPLES`** (v1): sample count `uint8` (`0`). -- **`COMMENTS`** (v1): display-on-open flag `int32`, then the comment as a - NUL-terminated string. - -**Pattern cell encoding.** Note: `0` = empty, `1`–`12` = C–B, `13` = release, -`14` = halt (note cut); octave `0`–`7`. Empty instrument `0x40`, empty volume `0x10`, -empty effect `0`. A pitch converts to a cell by `note = pitch % 12 + 1` and -`octave = pitch // 12 − 2`, matching `pitch_to_name` in -`sampletones_core/utils/frequencies.py`. +The payload size is known once the payload is built, so each block is buffered before its header is +written. After the last block comes the 3-byte marker `END`. + +**Blocks**, in write order: + +| Block | Version | Payload | +| --- | --- | --- | +| `PARAMS` | 6 | The parameters table below | +| `INFO` | 1 | Title, author and copyright, each a 32-byte NUL-padded string, in that order | +| `HEADER` | 3 | The header table below | +| `INSTRUMENTS` | 6 | The instruments table below | +| `SEQUENCES` | 6 | The sequences table below | +| `FRAMES` | 3 | The frames table below | +| `PATTERNS` | 5 | The patterns table below | +| `DPCM SAMPLES` | 1 | Sample count `uint8`, always `0` | +| `COMMENTS` | 1 | Display-on-open flag `int32`, then the comment as a NUL-terminated string | + +`PARAMS` payload: + +| Field | Type | Value | +| --- | --- | --- | +| expansion chip | `uint8` | `0` (2A03, no expansion) | +| channel count | `int32` | `5` | +| machine | `int32` | `0` NTSC, `1` PAL | +| engine speed | `int32` | `0` for the machine's default, otherwise a refresh rate in Hz | +| vibrato style | `int32` | `1` | +| highlight first | `int32` | `4` | +| highlight second | `int32` | `16` | +| speed split point | `int32` | the row where the tempo and speed interpretation splits (`speed_split_point`) | + +`HEADER` payload: + +| Field | Type | Notes | +| --- | --- | --- | +| track count | `uint8` | the count minus 1 | +| track title, one per track | NUL-terminated string | | +| channel id, one per channel | `uint8` | square1 `0`, square2 `1`, triangle `2`, noise `3`, DPCM `4` | +| effect-column count, one per track after each channel id | `uint8` | the count minus 1 | + +`INSTRUMENTS` payload: + +| Field | Type | Notes | +| --- | --- | --- | +| instrument count | `int32` | | +| index, per instrument | `int32` | | +| type | `uint8` | `1` (2A03) | +| body | — | the 2A03 body below | +| name length | `uint32` | | +| name | bytes | | + +The 2A03 body is the sequence count `int32` (`5`), then per sequence an enabled `uint8` and a sequence +index `uint8`. The DPCM key-assignment table across the note range follows, all zero. + +`SEQUENCES` payload: + +| Pass | Field | Type | Notes | +| --- | --- | --- | --- | +| | sequence count | `int32` | | +| 1, per sequence | index | `int32` | | +| 1 | type | `int32` | volume `0`, arpeggio `1`, pitch `2`, hi-pitch `3`, duty `4` | +| 1 | item count | `uint8` | | +| 1 | loop point | `int32` | | +| 1 | items | `int8` each | | +| 2, per sequence | release point | `int32` | | +| 2 | setting | `int32` | | + +Instruments reference the pooled sequences by index, so the module stores each sequence once. + +`FRAMES` payload, per song: + +| Field | Type | Notes | +| --- | --- | --- | +| frame count | `int32` | | +| speed | `int32` | | +| tempo | `int32` | | +| pattern length | `int32` | | +| order table | `uint8` | for each frame, one pattern index per channel | + +The speed and tempo are the project's own. FamiTracker spreads a tempo between two tick counts with its +own running count, so the longer rows of a module fall where that count lands. [Song +timing](../concepts/timing.md#5-the-exports) compares it with the app's placement. + +`PATTERNS` payload, per non-empty pattern: + +| Field | Type | Notes | +| --- | --- | --- | +| song index | `int32` | | +| channel | `int32` | | +| pattern index | `int32` | | +| row-item count | `int32` | | +| row number | `int32` | starts each stored row | +| note | `int8` | per stored row | +| octave | `int8` | per stored row | +| instrument | `int8` | per stored row | +| volume | `int8` | per stored row | +| effect | `int8` | per stored row, one for each effect column | +| effect parameter | `uint8` | per stored row, one for each effect column | + +**Pattern cell encoding** + +| Field | Values | +| --- | --- | +| note | `0` empty, `1`–`12` C–B, `13` release, `14` halt (note cut) | +| octave | `0`–`7` | +| instrument | `0x40` when empty | +| volume | `0x10` when empty | +| effect | `0` when empty, `20` for `Qxy` (note slide up), `21` for `Rxy` (note slide down) | +| effect parameter | for `Qxy` and `Rxy`, the speed `x` in the high four bits and the semitones `y` in the low four | + +A pitch converts to a cell by `note = pitch % 12 + 1` and `octave = pitch // 12 − 2`. This matches +`pitch_to_name` in `sampletones_core/utils/frequencies.py`. ## B. The 2A03 instrument -Both file formats describe the same instrument model. A 2A03 instrument is a name -plus five **sequences**, one per dimension, advanced one item per engine tick while a -note sounds. The `.fti` file stores the sequences inline; the `.ftm` module pools -them in the `SEQUENCES` block and references them by index, so identical sequences -are stored once. +Both file formats describe the same instrument model. A 2A03 instrument is a name plus five +**sequences**, one per dimension. Each sequence advances one item per engine tick while a note sounds. +The `.fti` file stores the sequences inline. The `.ftm` module pools them in the `SEQUENCES` block and +references them by index, so identical sequences are stored once. The five sequence kinds, in slot order (`SequenceKind` in `specification/sequences.py`): @@ -127,106 +188,236 @@ 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: - -- **items** — the signed per-tick values (`int8`); -- **loop point** — the item index playback returns to after the last item, or `-1` - to stop at the end; -- **release point** — the item index playback jumps to when the note is released, - or `-1` for none; -- **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. - -**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. - -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 -one-item sequence sets the value once and holds it. A dimension arrives empty when the -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 -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 -place in the instrument table, so the instruments an export writes are the channels -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 +Each sequence has: + +| Part | Meaning | +| --- | --- | +| items | the signed per-tick values (`int8`) | +| loop point | the item index playback returns to after the last item, or `-1` to stop at the end | +| release point | the item index playback jumps to when the note is released, or `-1` for none | +| setting | the sequence mode; for arpeggio, `0` selects absolute (the offsets are added to the played note) | + +**Bend and arpeggio.** FamiTracker walks an instrument's sequences in slot order. 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 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 accumulate on the running period. + +_SampleToNES_ writes and reads a bend as the per-tick offset. The writer therefore keeps an arpeggio +running for as long as the bend acts. A bend that repeats, or ends on an offset it then holds, acts for as +long as the note sounds: the arpeggio and the bend both circle on their last items, so the tracker adds the +held offset on every tick. A bend ending on no offset acts until its last item. An arpeggio shorter than +that is extended to the bend's length by holding its final note. An instrument with a bend and no +arpeggio gets one holding a single zero at loop point 0. What one +step is worth follows the note it bends: under a cent at the lowest notes, widening to a whole semitone at +the highest, where the divider grid is already coarser than the note grid. + +**Looping.** Each sequence has a loop point: the item it repeats from while a note is held. A sequence +written without one has the loop point `-1` and plays its items once. Every sequence has its own point, so +a two-item duty cycle can circle 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 the value it wrote stays applied. The driver holds that value for as long as the note +sounds (`CSeqInstHandler::UpdateInstrument`). Every dimension therefore keeps 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 less than the padding. + +**The release.** A volume envelope whose frames end audible gets one silent item after them, and that item +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 that ended audible would sound to the end of the song. Every generator +writes the release item, so a volume dimension is one item longer than the frames it describes. + +**The item limit.** A FamiTracker sequence holds up to 252 items. An envelope keeps whatever length it was +written at, and a writer applies its own format's limit at export. A dimension over the limit is written +as its opening items. A volume dimension keeps its release as the last item, because the note has to end, +so the release displaces the last sounding item that would not fit. A reconstruction reaches the limit at +252 frames, since its volume carries the release past them. At the default 60 Hz that is 4.2 s. Every +scope reports what it left out: an instrument by the items it kept of the items it had, a reconstruction's +instruments and a module by how many instruments were shortened. [The Bitphase +export](bitphase.md#f-bitphase-capacity-limits) shortens a dimension by the same rule, at its own limit. + +**Empty dimensions.** An empty dimension is written as a disabled sequence. This differs from a sequence +with a single zero: a disabled slot leaves that dimension to the channel, while a one-item sequence sets +the value once and holds it. FamiTracker starts every note of a disabled slot where _SampleToNES_ starts +one: the instrument volume full, so the note plays at the volume column; the note unmoved by an arpeggio +or a bend; and the channel's default duty. A `Vxx` effect sets that duty, and an export +writes none, so it stays at 0. A dimension is empty when the reconstruction records it as one +the channel governs. Clearing the envelope in the instruments panel produces that state (see +[Reconstructions](reconstructions.md)). + +**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 has a stream for every channel. +A stream that describes no frame is a channel standing by (see +[Reconstructions](reconstructions.md#contents)). It gets no place in the instrument table, so the +instruments an export writes are the channels 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 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 always empty. + +An [instrument](../glossary.md#instrument) is one set of envelopes that every channel +reads, as in FamiTracker itself. It becomes a single instrument, however many channels play it. The noise +channel reads it the way FamiTracker and Bitphase do: the duty item's lowest bit selects the short mode, +and each pitch item moves the period one step, as an arpeggio step does. Each +dimension is written at the length it was typed at and has its own loop point, so a tracker that advances +every sequence on its own counter sounds it the way the engine here plays it. Every channel that names the +instrument reaches that one instrument, each against the initial pitch it reads: its note on the tonal +channels, its period on noise. + +**Where a row's note comes from.** A voice has a reference, the place where its zero is, and a row has a +step from it. A pattern cell therefore holds `reference + transpose`, wrapped into the sixteen periods on +noise. A sample's reference is the offset origin its conversion chose. An instrument's reference +is its initial pitch. + +The conversion chooses the origin once, when the reconstruction is built, and stores it as that channel's +reference pitch (see [Reconstructions](reconstructions.md#contents)). For the pitched channels +`center_pitch` picks it, as the midpoint of the contour's `(lowest, highest)` range. The noise channel +takes the first sounding period. Every later export reports the 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. The pattern cell holds the contour's midpoint, so 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 | +On the tonal channels the note keeps the song's pitch range. FamiTracker writes notes from C-0 to B-7 +(pitch 24–119), moves the written note by the arpeggio's item each tick, and clamps the result to the same +notes (`CChannelHandler::TriggerNote`). In-app playback holds every tick's transposed pitch within those +notes too. A note below A-0 asks for a longer period than the register holds, so on NTSC it plays the +longest, `0x7FF`, about 12 cents flat of A-0, in FamiTracker and in-app alike. On PAL the periods reach a +semitone or more below A-0. + +So a note within C-0..B-7 is written as it is, and a higher one is written at B-7. FamiTracker writes no +note below C-0, so a lower one is raised only as far as bringing the arpeggio's highest item to C-0: + +| Row | What the exporter writes | +| --- | --- | +| a flat instrument transposed below C-0 | C-0, the note in-app playback sounds | +| an arpeggio whose items stay at its note or below | the note whose highest item lands on C-0 | +| an arpeggio rising above a note below C-0 | C-0, where the ticks the arpeggio raises sound higher than in-app playback | + +**A transpose row slides the note sounding.** In-app playback reads a row that states a transpose and no +note as a new pitch for the note already sounding: the voice goes on from the tick it reached, and every +tick from that row on sounds at the new transpose. A note-on starts over at its own transpose. `Qxy` and +`Rxy` move the channel's note by `y` semitones at once (`CChannelHandler::SetupSlide`) and glide the +period toward it at `2x + 1` units a tick. While an instrument's arpeggio in absolute mode runs, it +reloads the period from the note every tick (`CSeqInstHandler::ProcessSequence`), which makes the move +instant. So a transpose row writes: + +| Column | What the exporter writes | +| --- | --- | +| Effect | `Qxy` or `Rxy` at the highest speed, `x = F`, sliding from the note the channel holds to the note a note-on at the new transpose would write | +| Note, instrument | empty, so the instrument goes on | + +Each slide is measured from the note the channel holds once the slides before it applied, so a note's +rows never drift from the transposes they state, and the low-note rule above holds for the note a slide +reaches. On noise the note is the period, and both notes lie within the sixteen periods, so every noise +slide is within reach. A row keeping the note where it stands writes no slide. + +A halted sequence reloads nothing, and the glide is then heard instead. Every instrument a slide reaches +therefore keeps its arpeggio running: an arpeggio playing its items once circles on its last item, and an +instrument writing none takes one item at its own note, repeating. A bend is added to the period the +arpeggio reloads, and a halted bend adds nothing, so a bend playing its items once circles on its last +item too and holds the offset it ends on. An instrument no slide reaches keeps its sequences as they +are, so a module without transpose rows is unchanged. + +A slide moves the note by fifteen semitones at most, and a module stores a pattern once for every frame +that plays it. A row moving the note further, or a cell of a shared pattern that another frame reaches +needing a different slide, is written without its slide there. The first frame reaching a cell with a +note sounding decides its slide. The export reports each such row by its frame, channel and row, beside +the rows written as note cuts, and the rows after it slide from the note the channel holds. + +**An instrument placed without a pitch takes the channel's.** In-app playback starts such an instrument +on the pitch the channel is sounding, whichever voice sounded it, and leaves a silent channel silent. The +cell writes that note beside the instrument, so FamiTracker triggers the instrument where the song does, +and a row that starts nothing writes an empty cell. A module stores a pattern once for every frame that +plays it, so the first frame reaching the cell decides its note, and a later frame reaching it after +another pitch, or silent, is reported beside the rows above. + +**What a row's volume cell holds.** A row naming a volume writes it. In-app playback starts a note whose +row states no volume at the full level, while FamiTracker carries the level a channel last took into every +note after it. Such a note therefore writes `15` wherever FamiTracker reaches it carrying another level, +and keeps an empty cell where the channel stands at the full level already. The exporter follows each +channel's level through the order the way FamiTracker plays it: frame by frame, then from the first frame +again with the level the order ended on. A pattern is stored once for every frame that plays it, so its +note writes `15` when any of those frames reaches it at another level. A note-on written as a note cut +takes the same level. + +The triangle sounds in-app while a row's volume is 8–15 and falls silent at 0–7. FamiTracker sounds the +triangle while both its instrument volume and its volume column are above zero +(`CTriangleChan::RefreshChannel`). A triangle row at 0–7 therefore writes `0`, and a row at 8–15 writes +its level. + +## C. Reading an instrument file + +An `.fti` is read as well as written. The file, laid out in section A.1, comes into the voice pool as +an [instrument](../glossary.md#instrument). + +A voice has all five dimensions, each with the item it repeats from, so they come across as they stand. +The voice takes the name in the file. A file without a name leaves the voice named after the file itself. +The arpeggio is read as offsets from the pitch an instrument rests at, because a tracker +instrument sounds at whatever note a row names it with. + +A sequence that loops from one of its items gives that dimension the point. A sequence that halts 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 sequence's items is read as no point. + +**Settings the voice does not hold.** A tracker instrument can say more than a voice holds. The import +reports each of these once it lands, so a reader learns what the file carried: + +| In the file | In the voice | +| --- | --- | +| a bend outrunning its arpeggio | each item as the offset it says, 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 | + +`bugs-and-todos.md` lists these under **Tracker**. + +A sequence with an item outside the range its dimension holds raises `InvalidInstrumentValuesError`. The +file is read before the pool is touched, so a rejected file leaves the project and its history unchanged. + +## D. FamiTracker capacity limits + +FamiTracker bounds several quantities, and only this writer applies those bounds. A project keeps whatever +a reader wrote, such as an envelope of any length or a pool of any size, and meets a limit when a file is +built. The writer guards each limit, so every file it writes loads. It raises on a project structure +FamiTracker has no room for, and it shortens an envelope that outruns a sequence while keeping the release +that ends its note. + +| Quantity | FamiTracker limit | Project bound | 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 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 in every scope, the module included | | 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 slide | 15 semitones per row (`Qxy`, `Rxy`) | a transpose of −86..86 | writes the slide a transpose row needs, and reports a row needing more, or a shared pattern's cell needing different slides in different frames (section B) | +| Note range | C-0..B-7 (pitch 24–119) | a reference of 33–119 plus a transpose reaching either end of that span | keeps the song's range, C-0..B-7, raising a lower note only as far as its arpeggio's highest item reaching C-0 (section B) | | Title / author | 32 bytes each | 64 characters | truncates to 32 bytes | | Comment | free text (COMMENTS block) | 65536 characters | carried in full | | Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings | -| DPCM samples | 64 | not modelled | always empty by design | +| DPCM samples | 64 | not modeled | always empty | + +A row that names a voice on a channel the voice has no instrument for plays nothing in the song, so the +exporter writes a note cut on it and reports the row by its frame, channel and row. The project export +dialog lists those rows, the transpose rows written without their slide, and the instrument rows written +at another frame's pitch, each under a heading of their own. -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 -127 leaves no room for it, which the exporter reports rather than emitting a corrupt -order. When the domain model grows to enforce these limits, the editor can prevent -reaching a state the exporter would reject. +The exporter reserves one empty pattern index per channel (`max used index + 1`) for order slots the +song leaves unset. A channel that already uses indices up to 127 raises an error. -## 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 -`specification/memory.py` names every field the measurement counts. The instruments panel and -the samples context menu display the result, so the cost of a sample is readable before an -export. +Compiling a module into an NSF lays each instrument out across two regions of the driver's data. An +instrument's sequences size both regions. -The **instrument region** holds the instrument list — one pointer per instrument — followed by -each instrument's body: a sequence-enable bitmask, then one pointer per populated sequence. The -**sequence region** holds one chunk per sequence: a four-field header followed by the items. +The **instrument region** holds the instrument list, one pointer per instrument, followed by each +instrument's body: a sequence-enable bitmask, then one pointer per populated sequence. The **sequence +region** holds one chunk per sequence: a four-field header followed by the items. | Field | Bytes | Region | | --- | --- | --- | @@ -236,29 +427,30 @@ each instrument's body: a sequence-enable bitmask, then one pointer per populate | item count · loop point · release point · setting | 1 each | sequence | | item, per tick | 1 | sequence | -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. -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. - -These two figures are the ones FamiTracker itself prints while creating an NSF — -`Instruments used: N (X bytes)` and `Sequences used: M (Y bytes)` — which is how a measurement -is held against the tracker. - -**Version.** The figures are vanilla FamiTracker 0.4.6, the target section A names. The 0CC and -Dn-FamiTracker forks open each instrument body with a channel-type byte, so an instrument costs -one byte more there. - -**Pooling narrows a module's total.** The `SEQUENCES` block stores each distinct sequence once -(section A.2), so a module holding two instruments with the same volume envelope pays for that -chunk once. A per-instrument or per-sample figure states that instrument's own cost, and a -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. +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 only populated sequences are charged. A reconstruction that bent no note +charges 3 sequences on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle. 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. The largest instrument FamiTracker stores takes 777 bytes: three +sequences at the 252-item limit. + +FamiTracker itself prints these two figures while creating an NSF, as `Instruments used: N (X bytes)` and +`Sequences used: M (Y bytes)`. An instrument within the bounds under **Length** below can be checked +against them. + +**Version.** The figures are for vanilla FamiTracker 0.4.6, the target named at the top of this document. +The 0CC and Dn-FamiTracker forks open each instrument body with a channel-type byte, so an instrument +costs one byte more there. + +**Pooling.** The `SEQUENCES` block stores each distinct sequence once (section A.2), so two instruments +with the same volume envelope pay for that chunk once. A per-instrument or per-sample figure is that +instrument's own cost, so a module total is at most the sum of them. Within one instrument each kind +appears once, so its own sequences are charged once each. + +**Length.** The application's figure counts each dimension at the whole length its envelope carries, so a +long sample reads at its full size. A loop point on one dimension adds a byte and no padding. An export +shapes the envelopes to the file (section B): it keeps the first 252 items of a sequence, writes an +arpeggio under a bend that needs one, and gives an instrument a note slide reaches a one-item arpeggio of +its own. Within the item limit, and apart from those arpeggios, the figure is what a voice's +**Export instrument...** writes. diff --git a/docs/formats/instruction-libraries.md b/docs/formats/instruction-libraries.md index 6cc8eb725..03756e50e 100644 --- a/docs/formats/instruction-libraries.md +++ b/docs/formats/instruction-libraries.md @@ -1,58 +1,69 @@ # Instruction libraries -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 -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. +An instruction library is stored as a single `.ins` file. It has, for every possible instruction, the +waveform its channel produces and that waveform's [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. Libraries are generated from the _Instructions_ tab (or with -`sampletones --generate`) and stored in the documents folder. +`sampletones library`) and stored in the documents folder. ## Contents -A library is keyed by the configuration that produces it and holds one entry per -instruction. +A library is keyed by the configuration that produces it and has one entry per instruction. ### Per-instruction data 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; * **volume** (0–15) for pulse and noise; * **duty_cycle** (0–3) for pulse, or the **short** (0–1) flag for noise; -* **waveform** — one full period of the rendered wave (the longest noise samples - are trimmed to one second); +* **waveform** — one full period of the rendered wave. The longest noise samples + are trimmed to two seconds, which holds the whole stretch a constant-Q spectrum + is read over; * **spectrum** — the waveform's precomputed frequency content. ### Configuration key -Each library corresponds to one configuration. The parameters that change the -rendered waveforms or their spectra — sample rate, NES frequency, FFT window -size, transformation gamma, and spectrum method — form its key, so changing any -of them selects (or generates) a different library. What gamma and the spectrum -method mean is covered in [Reconstruction algorithms](../concepts/reconstruction.md) -(§3.2–3.3). +Each library corresponds to one configuration. The parameters that change the rendered waveforms or their +spectra form its key: sample rate, NES frequency, FFT window size, transformation gamma and spectrum +method. Changing any of them selects, or generates, a different library. +[Reconstruction algorithms](../concepts/reconstruction.md) explains gamma and the spectrum method. ## File format -Libraries are stored as `.ins` files in the documents folder, with the -configuration embedded in the file name: +A file holds a deflated [MessagePack](https://msgpack.org/) payload, with the framing described in +[Reconstructions](reconstructions.md#storage-and-export), and has its configuration in the file name: ``` -sr_44100_nf_60_ws_13579_tg_0_sm_cqt_ch_384e710987cb958adf2b214df1267d10.ins +sr_44100_nf_60_ws_27157_tg_0_sm_cqt_ch_384e710987cb958adf2b214df1267d10.ins ``` | Fragment | Meaning | | --- | --- | | `sr_44100` | sample rate 44100 Hz | | `nf_60` | NES frequency 60 Hz | -| `ws_13579` | FFT window size (samples) | +| `ws_27157` | FFT window size (samples) | | `tg_0` | transformation gamma 0 | | `sm_cqt` | spectrum method (`fft` / `logfft` / `cqt`) | | `ch_384e…` | a hash of the library configuration section | + +## Versioning + +Each file records the library data version it was written with, in the metadata that leads the file, so +the version reads from the first bytes without loading the entries. A library is derived data: its +settings and the generators determine it wholly. A library written at the version this build writes is +used as it stands. Any other is rebuilt from its settings the first time it is needed. A conversion +rebuilds it without asking, and opening one from the _Instructions_ tab asks first. See +[Data compatibility](../development/release/compatibility.md). + +The version names what generation produces. A change to the generators or to feature extraction bumps +it, and every stored library is then rebuilt. Installs of different versions that share one library +folder rebuild each other's libraries as each needs them. + +The current data version is 2.1. diff --git a/docs/formats/nsf.md b/docs/formats/nsf.md new file mode 100644 index 000000000..ca547a584 --- /dev/null +++ b/docs/formats/nsf.md @@ -0,0 +1,262 @@ +# 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 how an `.nsf` is written or read. [The player](../development/player.md) explains why a song is +stored this way and how the driver is held to it. [Song compression](../concepts/compression.md) explains +the compression scheme. The layout is here. + +An `.nsf` differs from 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, and an +`.nsf` carries its own player. The file has three parts: a header, the assembled driver, and the song the +driver plays. The header names where the program loads and which routines the console calls. + +Every constant named here has a counterpart under `sampletones_player/specification/`. + +## 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: + +| Offset | Size | Field | Value | +|---|---|---|---| +| `0x00` | 5 | magic | `NESM\x1a` | +| `0x05` | 1 | version | `1` | +| `0x06` | 1 | song count | `1` | +| `0x07` | 1 | first song | `1` | +| `0x08` | 2 | load address | where the driver loads | +| `0x0A` | 2 | init address | the driver's `init` | +| `0x0C` | 2 | play address | the driver's `play` | +| `0x0E` | 32 | title | UTF-8 text ending in a NUL | +| `0x2E` | 32 | artist | as the title | +| `0x4E` | 32 | copyright | as the title | +| `0x6E` | 2 | NTSC play period | microseconds per video frame | +| `0x70` | 8 | bankswitch | zeros | +| `0x78` | 2 | PAL play period | `20000` microseconds | +| `0x7A` | 1 | region | `0`, NTSC | +| `0x7B` | 1 | expansion chips | `0`, none | +| `0x7C` | 1 | NSF2 features | `0` | +| `0x7D` | 3 | NSF2 length | zeros | + +Each text field holds `STRING_TEXT_SIZE` bytes of text, cut on a character boundary. + +The driver's image starts with two jumps, the entry points. `init` is at the load address and `play` is +three bytes later, whatever the driver's own length. The header can therefore give both addresses without +assembling anything. The song follows the code directly. That address is the one thing a build decides. + +The console calls `init` once and then `play` once per video frame. The header asks for the NTSC frame +period. The song's speed does not depend on the header's play period. + +**The program area is 32 KB**, from `$8000` upward, and the song block gets what the driver leaves of it. +A song that outgrows that space is reported as an export failure, and no shortened file is written. + +## B. The song block + +A song reaches the console as one **token stream** per plane. The driver decodes each stream a tick at a +time against a **dictionary** of phrases and a **timer table** of pitches. Every channel has a control +plane and a value plane, and a tone channel has a bend plane besides. Every offset below is a `uint16` +counted from the block's first byte, so the whole block plays from wherever the file loads it. + +``` ++0 header ++51 timer table + phrase table: count, then one offset per phrase + phrase bodies: each a length byte, a count 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, or `$FFFF` for an absent plane | +| +31 | `PLANE_COUNT`×2 | where each plane's stream is re-entered once the song comes round, or `$FFFF` for an absent plane | + +All fields are little-endian, and the header runs to `SONG_HEADER_SIZE` bytes. + +**The step lets one data set play at every rate.** A reconstruction advances its envelopes at the rate it +was built at, and the console calls `play` at the video frame rate. The step is the first rate measured +against the second, held as a whole byte and a 16-bit fraction. The driver adds it to an accumulator on +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 has the timer register value each pitch sounds at: the low byte of each pitch in pitch order, +then the high byte of each. One pointer reaches both halves, which the driver's lookup uses. + +A plane names a pitch as its **index**, the distance above the lowest pitch the tuning covers, and not as +a divider. A tick's divider is written as the index of the pitch it is counted from, beside a bend: the +steps from that pitch's own divider (section C). Pitches beyond the divider's range share the timer they +clamp to, and the lowest pitch sounding a timer represents the whole group. + +The table comes from the tuning the exported work was built at, computed by the 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, a count 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**. The ids that fit inside a token's opcode are the cheap ones, so the phrases a song +relies on most are listed first. + +**A phrase states the count its tokens play it at most often.** The count byte holds that count less one, +the way a token states one. A token playing the phrase at that count names the phrase alone and spends a +byte fewer. + +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 when it +saves the streams more bytes than the entry costs. + +### B.4 The token streams + +Each plane is a byte sequence of 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 symbols +01nnnnnn b0..bn the n+1 symbols that follow, one each +10dppppp cccccccc phrase p, for c+1 symbols +11dppppp cccccccc tt phrase p, for c+1 symbols, every value plus tt +``` + +`p == $1F` is an escape: the phrase's id is the byte that follows. That reaches every id in the table +while the low ids stay a byte cheaper. The shift `tt` is **added within the byte** and wraps. That is one +addition on the 6502, and the encoder does the same addition. + +**The `d` bit says the count is the phrase's own.** A token carrying it names the phrase and no count, and +plays the count the table states for that phrase. The count byte `cccccccc` is then absent. That bit is +what leaves five bits for an id, so a phrase opcode names ids up to `$1F` outright where a hold or a +literal counts to `$3F`. + +**A token's count is a duration, and it may run past the phrase.** Past its last value the plane holds +that value, so a note whose envelope has finished keeps sounding. A count shorter than the body cuts the +note off. One entry therefore serves every length a figure is played at and, with the shift, every pitch. + +**A token counts symbols.** On a plane whose byte carries a repeat count (§C) one symbol +covers as many ticks as it states, so a token's reach in ticks is the ticks its symbols +carry between them. On every other plane a symbol is a tick. + +### B.5 Where a song comes round + +A song that repeats re-enters its streams partway through. The tick it returns to therefore begins a +symbol, and a token, on every plane, and that token names its values outright instead of relying on the +value the plane had reached. A bend plane is re-entered at the value its channel's flags have reached by +that tick (section C), which begins a token of its own. Coming round then means pointing each plane at the +byte the header names and clearing what it was playing. + +The tick a song returns to is the export's choice: its first tick, the first tick of an order frame, or +none. With none, the header has `$FFFF` and the song stops at its end. + +## C. What the planes hold + +The planes are written in this order, and each group belongs to one channel: + +| Plane | Carries | Reaches | +|---|---|---| +| pulse 1 control | duty cycle and volume, under a repeat count | `$4000` | +| pulse 1 value | pitch index, and a flag for a bent tick | `$4002`, `$4003` | +| pulse 1 bend | divider offset of each flagged tick | `$4002`, `$4003` | +| pulse 2 control | duty cycle and volume, under a repeat count | `$4004` | +| pulse 2 value | pitch index, and a flag for a bent tick | `$4006`, `$4007` | +| pulse 2 bend | divider offset of each flagged tick | `$4006`, `$4007` | +| triangle value | pitch index, a flag for a bent tick, and the index that rests | `$4008`, `$400A`, `$400B` | +| triangle bend | divider offset of each flagged tick | `$400A`, `$400B` | +| noise control | volume, under a repeat count | `$400C` | +| noise value | period and mode, around a repeat count | `$400E` | + +Splitting a channel's registers apart gives each plane something to repeat: a volume envelope and a pitch +line are separate series that turn over at their own rates. + +**Every channel's planes are in every block.** A channel an export leaves out has its silent values from +the first tick to the last, which a hold covers in a few bytes. The driver therefore reads the same layout +whichever channels a song sounds. + +**A plane counts its repeats in the bits its register ignores.** A pulse control byte holds a duty cycle +and a volume around two bits the hardware wants set. A noise control byte holds a volume under the same +two. A noise period byte holds a mode bit above three the register reads nothing from. Those spare bits +carry the ticks the value repeats for, so a run costs one byte however long it lasts. The register byte is +the symbol masked and ored with the bits the hardware fixes, and counting one repeat off subtracts +`COUNT_STEP`. Every other plane spends its whole byte and reads a symbol a tick. The division follows from +the plane, so the block states none of it. + +**A plane standing at the value it is seeded to throughout is absent.** The driver seeds every plane +before its first token: to the bits its register fixes where it has any, and to the index that rests on +the triangle's value plane. A plane that never leaves that value takes no stream, and both its header +entries are `ABSENT_STREAM` (`$FFFF`). A tone channel that never bends spends nothing on its bend plane, a +pulse or noise channel that never sounds spends nothing on its control plane, and a song without a +triangle spends nothing on its value plane. + +**The triangle names its silence in the pitch it plays.** The channel sounds at one level, so a tick +states whether it sounds through the index its value plane names. The index standing above every pitch the +table covers silences the linear counter, and the divider stays where the channel last sounded. That is a +whole plane the block does without. + +**The noise channel has no bend plane.** It selects one of sixteen fixed periods, so there is no finer +grid for a bend to reach, and its block leaves the plane out. + +**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. A channel holding one pitch across a rest +therefore keeps its phase running, as a rendered channel does. + +**A value plane names a pitch, not a divider.** A note is written as its distance above the lowest one the +song reaches. That lets `TRANSPOSED_PHRASE` move a whole phrase by adding to it, and a divider offset +added to an index would mean nothing. A tone channel's **bend** plane carries the offset: signed bytes in +two's complement, added to the divider the value plane's note resolves to. + +**A bend plane has a value only where a note bends.** A value byte's low seven bits index the pitch table. +Its top bit, `BEND_FLAG`, says the tick reads its offset from the bend plane, and an unflagged tick sounds +its pitch's own divider. The bend plane has one value per flagged tick, in order, and the driver advances +it on those ticks alone. The encoder flags each note from its first bent tick to its last, so a vibrato +passing through zero keeps the value plane still. A channel that never bends has an empty bend plane, +which is an absent one. The flag sits above every index, so a transposed phrase keeps it. + +A bent frame sounds the divider its note's own divider is moved to. **The value plane names the frame's +own note**, and the bend plane holds the steps from that note's divider. A row transposes a note and keeps +its bend's steps, so a bent figure has the same bend bytes at every pitch it is played at, and one +dictionary entry serves it. A bend past the signed byte is counted from the pitch lying nearest the +divider, and a divider halfway between two pitches goes to the higher one. Those steps stay inside half +the widest gap between neighboring pitches, 57 at the default tuning, so every divider the register holds +reaches the planes. + +The driver sign-extends that byte and adds it across both halves of the timer. That addition, and the +repeat a symbol counts down, are the whole of the arithmetic it performs on a song's behalf. Python does +everything that makes the sum land in range: `bent_timer` keeps the divider within +`[MIN_TIMER, MAX_TIMER]`, the rule the generators render a bent frame by. The timer's high half therefore +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` | +| Symbols one hold covers | 1–64 (`MAX_HOLD_TICKS`) | +| Symbols one literal covers | 1–64 (`MAX_LITERAL_BYTES`) | +| Symbols one phrase token covers | 1–256 (`MAX_PHRASE_TICKS`) | +| Ticks one symbol covers | 1 to the plane's own count, 16 at the widest | +| Values one phrase holds | up to 255 (`MAX_PHRASE_LENGTH`) | +| Phrases one dictionary holds | up to 255 (`MAX_PHRASE_IDS`) | +| Phrase ids a token's opcode names | up to 31 (`CHEAP_PHRASE_IDS`) | + +A song that reaches past the space behind the driver, or past what an offset field can hold, raises +`SongTooLargeError` naming the part that overflowed. A project that offers more phrases than a dictionary +holds keeps the ones that spare the streams most, and says so. diff --git a/docs/formats/projects.md b/docs/formats/projects.md index 570d3275f..ffe1c35a9 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -1,10 +1,9 @@ # 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. The +sequencer works with it, and you hand it over to 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 @@ -13,10 +12,11 @@ A `.stp` file is a zip archive with two kinds of member: * **`project.json`** — the project document (below). * **`reconstructions/.stn`** — one [reconstruction](reconstructions.md) per sample, stored as its own `.stn` member and referenced from the document by its - id. + id. The archive deflates its members, so a member has the reconstruction's payload as it stands. -Keeping the reconstructions in separate members lets `project.json` stay small -while the larger audio data travels alongside it in the same archive. +The reconstructions sit in separate members, so `project.json` stays small and the larger reconstruction data +travels beside it in the same archive. An [instrument](../glossary.md#instrument) has no audio, so the +document holds it whole. ### `project.json` @@ -26,29 +26,56 @@ 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 has its `items`, one per tick, and a `loop_point`: the item they repeat from while a note +is held. It is `null` where the items play once and the last item holds for as long as the note sounds. +See [loop point](../glossary.md#loop-point). + ### `song` 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. +| Field | Contents | +| --- | --- | +| `rows_per_pattern` | the row count every pattern in the song shares | +| `order` | an ordered list of frames, each mapping every channel to the pattern index it plays, or empty for a silent slot | +| `channels` | per channel, the `name` of the channel it drives and its pool of `patterns`, each pattern a list of rows | + +A row has the `command` its note column holds (the `voice_id` to start, or a note-off), its `pitch` +and its `volume`. The channel a voice sounds on is the one whose pool has the row. + +A pitch states its `kind` and its `value`: + +| `kind` | `value` | +| --- | --- | +| `note` | the pitch the channel sounds, or the noise period on the noise channel | +| `step` | the semitones from the voice's own reference, or the periods on the noise channel | + +A row stating no pitch leaves the field out. ## Detached reconstructions -The reconstructions inside a project are -[detached](reconstructions.md#detached-reconstructions) from their original -source-audio paths, so a project stays portable — it carries everything it needs -and no path that would only mean something on the author's machine. +The reconstructions inside a project are [detached](reconstructions.md#detached-reconstructions) from +their original source-audio paths. A project therefore stays portable: it has everything it needs and no +path that means something only on the author's machine. ## Versioning -`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. +`project.json` records the project format version it was written with. A file at the supported version +loads as it stands. A file at an older version the upgrade chain reaches is migrated in memory to the +current shape first. Any other file is declined for its version, before the rest of it is read. [Data compatibility](../development/release/compatibility.md) +describes the chain. 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. diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index d91c4adb8..67c12aa3c 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -1,76 +1,117 @@ # Reconstructions -A reconstruction is one converted audio sample: the NES -[approximation](../glossary.md#approximation) of an original recording together -with the per-channel instruction streams that produce it. It is stored as a -`.stn` file. [Reconstruction algorithms](../concepts/reconstruction.md) explains -how one is produced; this page documents the file. +A reconstruction is one converted audio sample: the per-channel instruction streams that +produce the NES [approximation](../glossary.md#approximation) of an original recording. It is +stored as a `.stn` file. [Reconstruction algorithms](../concepts/reconstruction.md) explains +how one is produced. This page documents the file. + +The file has what is played, not what it sounds like. The audio is rendered from the instructions +whenever it is needed. That keeps the file small and keeps what the application shows in step with what +an export plays. ## Contents -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); -* **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 - method, gamma, and the rest; -* **coefficient** — the [working level](../glossary.md#working-level-coefficient), - the single scale factor applied to the input so its loudness fit the NES - channels' range. Storing it lets the reconstruction and the original be shown - and played on a common scale; -* **approximation** — the rendered NES audio: the sum of every channel's output, - the closest match to the original; -* **per-channel approximations** — the audio each channel contributes on its own, - one waveform per channel that sounds; -* **per-channel instructions** — the instruction stream each channel plays, one - [instruction](../glossary.md#instruction) per frame. This is the data a - FamiTracker export is built from. A reconstruction holds a stream for every one - of the four channels (`pulse1`, `pulse2`, `triangle`, `noise`), and a stream of - no frames is a channel standing by: it is written by no export and costs - nothing, while staying open to edit, so writing an envelope into it puts the - channel in play and clearing every envelope takes it out again; -* **per-channel reference pitch** — the note each channel's arpeggio offsets are - measured against, chosen once when the reconstruction is built and stored with - the instructions it describes. An export reads the offsets against this pitch, - so editing an arpeggio moves the frames around a base that stays put (see - [FamiTracker export](famitracker.md)); -* **per-channel held dimensions** — the envelopes each channel leaves to the - player. An instruction states a value for every dimension of its frame, so this - 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. - -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 -to the player, which is the record a channel edited down to empty envelopes reaches -as well. A file naming a stream for the channels it plays alone reads as the whole -four, with the rest coming back standing by. +A `.stn` file is one MessagePack map: + +| Field | Contents | +| --- | --- | +| `metadata` | the application name and version, and the reconstruction data version checked on load (see [Versioning](#versioning)) | +| `id` | a unique identifier for the reconstruction | +| `config` | a frozen snapshot of the [generation configuration](../guide/configuration.md) it was made with: sample rate, NES frequency, spectrum method, gamma, and the rest | +| `coefficient` | the [working level](../glossary.md#working-level-coefficient), the single scale factor applied to the input to fit the NES channels' range. It lets the reconstruction and the original be shown and played on a common scale | +| `instructions_data` | one entry per channel (below) | +| `stems_data` | the stems setup, the recordings behind it, and which stem holds each frame (below) | + +### `instructions_data` + +One entry per channel: + +| Field | Contents | +| --- | --- | +| `channel_name` | `pulse1`, `pulse2`, `triangle` or `noise` | +| `instructions` | the stream the channel plays, one [instruction](../glossary.md#instruction) per frame. A FamiTracker export is built from this | +| `initial_pitch` | the note the channel's arpeggio offsets are measured against, chosen when the reconstruction is built. An export reads the offsets against this pitch, so editing an arpeggio moves the frames around a fixed base (see [FamiTracker export](famitracker.md)) | +| `held_features` | the dimensions left to the channel. An export writes them as disabled slots, and every note sounds them at the value a song starts on | + +A channel whose every frame rests is **standing by**, and its stream is stored as no frames at all. A +file that has streams only for the channels it plays reads as all four channels, and the rest come back +standing by. + +### `stems_data` + +| Field | Contents | +| --- | --- | +| `config` | the stems setup the conversion ran under: one entry per recording, and the hierarchy of levels | +| `sources` | one per entry: the `stem_id` it was converted as, the `name` it is known by, and the `path` it was read from, absent once [detached](#detached-reconstructions) | +| `assignments` | per channel, the `stem_ids` holding each frame, parallel to that channel's stream | +| `scale` | the [recording scale](../glossary.md#recording-scale): the factor the conversion divided every recording it read by, measured on the whole set before any recording left. Reading the recordings at it keeps each one at the level it held in the conversion. Absent in a file upgraded from data version 2.1, which holds one recording that reads at its own peak | + +Every reconstruction has this record. A conversion from a single file records one stem covering every +channel it plays. Every entry holds a frame on some channel, unless no entry does: a recording left holding +none leaves the record, with its source and its place in the hierarchy. The recordings that stay keep their +ids. Two ids name no recording. `-1` is a resting frame. `-2` is a frame the reader wrote by +hand: it answers to no recording and survives every removal. A frame rests where its channel is silent: +where no source took it, where a source's channel count or the hierarchy left it free, or where decoding +settled on a silent instruction. + +A recording's name belongs to the document and its path to this machine. A detached reconstruction +therefore keeps every name and has no location. + +### The stems setup as JSON + +`sampletones convert --stems` reads the same setup, written as JSON with the same fields. It has one +entry per recording, in the order the recordings are given. Each entry names the channels it may occupy, +the ones it bends, the `drives` it pushes each of them at and the `channel_cap` channels it may sound at +once. A hierarchy lists the stem ids by precedence level. A drive settles which instruction each frame +records while the conversion runs, so a reconstruction plays the instructions it names. An entry without +`drives` is read at unit drive on every channel it holds, and an entry without `channel_cap` may sound +all four. In this example, the first recording is on the pulses, with its second pulse pushed harder and +held to one channel a frame. The second recording is on the rest as it stands: + +```json +{ + "entries": [ + { + "id": 0, + "settings": { + "channels": ["pulse1", "pulse2"], + "bends": ["pulse1", "pulse2"], + "drives": {"pulse1": 1.0, "pulse2": 2.5}, + "channel_cap": 1 + } + }, + {"id": 1, "settings": {"channels": ["triangle", "noise"], "bends": ["triangle"]}} + ], + "hierarchy": {"levels": [[0], [1]]} +} +``` ## Detached reconstructions -A reconstruction normally remembers the path to its source audio. Embedding one -in a [project](projects.md) makes it part of a shareable artifact, where an -absolute path on the author's machine means nothing to anyone else. Detaching -clears that path while keeping the approximation and instructions intact, so the -reconstruction stays self-contained and a saved project stays portable. +A reconstruction normally remembers the file each of its recordings was read from. Embedding one in a +[project](projects.md) makes it part of a shareable artifact, where an absolute path on the author's +machine means nothing to anyone else. Detaching lets those locations go and keeps the instructions, the +stems assignment and every recording's name. The reconstruction stays self-contained, a saved project +stays portable, and the document still says which recordings it was built from. ## Versioning -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. +Each file records the reconstruction data version it was written with, and the application version +beside it for reference. A file at the supported version loads as it stands. A file at an older version +the upgrade chain reaches is migrated in memory to the current shape first. Any other file is declined. +[Data compatibility](../development/release/compatibility.md) describes the chain. + +The current data version is 2.2. A file at data version 2.1 carries no stems record. Its upgrade records +one recording covering the channels the run handed out, and leaves the recording scale unmeasured. ## Storage and export -`.stn` files live in the documents folder. They are binary -([MessagePack](https://msgpack.org/)) with the audio arrays embedded, so a file -is self-contained. The instruction streams can be exported to a tracker — one -instrument per channel, or a whole module — as described in -[FamiTracker export](famitracker.md) and [Bitphase export](bitphase.md). +`.stn` files live in the documents folder. A file is a deflated [MessagePack](https://msgpack.org/) +payload and is self-contained: the instructions, the stems assignment and the frozen configuration are +everything needed to play it. The payload names every field of every frame, and a reconstruction has one +frame per channel per frame of audio. The names repeat for every frame, so they deflate to a small +fraction of the file. A payload stored plainly opens as it stands, so a file written by an earlier build +still opens. + +The instruction streams can be exported to a tracker, as one instrument per channel or as a whole module. +See [FamiTracker export](famitracker.md) and [Bitphase export](bitphase.md). diff --git a/docs/glossary.md b/docs/glossary.md index 8c53c90bd..5a981d0c4 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,201 +1,288 @@ # Glossary -Recurring terms used across the documentation, grouped by area. Other documents -link here rather than redefining a term in place. +Short definitions of the terms used across the documentation, grouped by area. Other pages link here +instead of explaining a term again. ## NES sound hardware ### 2A03 (APU) -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. +The NES's sound chip (Ricoh 2A03). Its audio part, the APU (Audio Processing Unit), makes all of the +console's sound. _SampleToNES_ emulates its four melodic and percussive channels. DPCM sample playback is +outside its scope. -### 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 units: `pulse1`, `pulse2`, `triangle` and `noise`. A *generator* is a +different thing: the kind of oscillator an instruction library covers (`pulse`, `triangle`, `noise`), +and the classes that implement it. ### Pulse (square) -A channel that plays a square wave with a selectable duty cycle and 15 volume -levels. The chip has two of them (`pulse1`, `pulse2`). +A channel that plays a square wave. Its duty cycle is selectable and it has 15 volume levels. The chip +has two independent pulse channels: `pulse1` and `pulse2`. The chip silences a pulse whose +[divider](#divider) is below 8, so a bend that goes higher than that at the top notes goes quiet. ### Triangle -A channel that plays a triangle wave of fixed shape and fixed volume; only its -pitch varies. Its timer divides the APU clock by 32 while the pulse channels divide -by 16, and all three read the same period table, so a triangle note sounds an octave -below the note it is written as: a triangle instruction of pitch P sounds at pitch -P−12. FamiTracker uses the same convention, so an exported note cell plays at the -pitch _SampleToNES_ played it. +A channel that plays a triangle wave of fixed shape and volume. Only its pitch varies. Its timer divides +the APU clock by 32 where the pulse timers divide by 16, and all three read the same period table. A +triangle note therefore sounds an octave below the pulse note with the same period, so a triangle +instruction of pitch P sounds at pitch P−12. FamiTracker uses the same convention, so an exported note +plays at the pitch _SampleToNES_ played it. Below [divider](#divider) 2 the chip's triangle steps above +hearing, and its output rests at the middle of the wave. ### Noise -A channel that plays pseudo-random noise from an LFSR, with 16 period settings, -15 volume levels, and a short/long mode. The period setting divides the APU clock -into the LFSR's shift rate, `APU_CLOCK / NOISE_PERIODS[index]`, spanning 440.0 Hz at -index 0 to 447443.2 Hz at index 15. +A channel that plays pseudo-random noise from an [LFSR](#lfsr). It has 16 period settings, 15 volume +levels and a short and a long mode. ### Duty cycle -The fraction of each period a pulse wave stays high (one of four settings). It -sets the pulse channel's timbre. +The fraction of each period a pulse wave stays high, in one of four settings. It sets the pulse channel's +timbre. + +### Divider + +The number a tone channel counts down from. It sets the pitch, and a smaller divider gives a higher note. ### LFSR -*Linear-feedback shift register* — the circuit that produces the noise channel's -pseudo-random pattern. Its short/long mode changes the pattern's length, and so its -character: long mode repeats every 32767 shifts and reads as noise, short mode -repeats every 93 and turns a high period setting into an audible tone — index 15 -sounds at 447443.2 ÷ 93 ≈ 4811 Hz. Short mode also carries a strong DC asymmetry, its -output bit set 17.2% of the time against long mode's 50%, and that bias is what gives -it its metallic timbre. +*Linear-feedback shift register*: the circuit that makes the noise channel's pseudo-random pattern. Its +mode sets the pattern's length and so its character. Long mode repeats every 32767 shifts and sounds like +noise. Short mode repeats every 93 shifts, so a high period setting sounds as a tone of metallic timbre. ### NES frequency -How many times per second a program updates the channels — for example 60 Hz on -NTSC or 50 Hz on PAL. _SampleToNES_ supports 15–300 Hz, and this rate sets the -reconstruction's frame rate. +How many times per second a program updates the channels, for example 60 Hz on NTSC or 50 Hz on PAL. +_SampleToNES_ accepts 15–300 Hz, and the rate sets the reconstruction's frame rate. ### NTSC / PAL -The two console video standards. Their refresh rates (about 60 Hz and 50 Hz) are -the two most common NES frequencies. +The two console video standards. Their refresh rates, about 60 Hz and 50 Hz, are the most common NES +frequencies. ## Reconstruction ### Reconstruction -The result of approximating an audio sample with the NES channels: one -instruction stream per channel plus the rendered audio. Saved as a `.stn` file. -See [Reconstruction algorithms](concepts/reconstruction.md). +The result of approximating an audio sample with the NES channels: one instruction stream per channel +plus the rendered audio. Saved as a `.stn` file. See +[Reconstruction algorithms](concepts/reconstruction.md). ### Instruction -A single command to one channel for one frame — on or off, *pitch*, *volume*, *duty -cycle* or *noise period*. It is the unit the reconstruction chooses per frame. +A command to one channel for one frame: on or off, *pitch*, *volume*, *duty cycle* or *noise period*. +The reconstruction picks one per channel for each frame. ### Frame -A short, fixed-length slice of the input audio. Within a frame, each channel -holds one instruction. A frame's length is the sample rate divided by the NES -frequency. +A short slice of the input audio. All frames have the same length: the sample rate divided by the NES +frequency. Each channel plays one instruction in a frame. The sequencer also uses the word for one +position in the [order](#order): the patterns the song plays at that point. + +### Tick + +One update of the channels, at the NES frequency. A tick lasts as long as a frame. Envelopes advance one +item per tick, and a tracker row lasts one or more ticks. + +### Stem + +One recording that goes into a reconstruction. Every conversion is a stems conversion. A single file is +one stem that takes every channel it is given, and several recordings share the channels between them. A +reconstruction records which channels each stem was given and which frames it played, so you can hear, +edit or remove one stem on its own. See [Stems reconstruction](concepts/stems.md) and +[Converting audio](guide/converting.md). + +### Level (stems) + +The rank a stem takes when the channels are shared out. A stem on level 1 is offered channels before one +on level 2, so a lead part can take the channels it needs before a background part does. + +### Hierarchy + +The precedence order of the levels in a stems setup. The levels pick channels in that order. See +[Stems reconstruction](concepts/stems.md). + +### Drive + +How hard a stem pushes a channel while it converts. It is set per channel when a conversion is set up. +`1.00` is the level the recording was measured at. A higher drive reaches for louder notes, which saturates +the part and gives it a distortion-like edge. ### Instruction library -A precomputed catalogue 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 +A precomputed catalog of every possible instruction, with 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). ### Approximation -The mixed, rendered audio a reconstruction produces — the NES channels' closest -match to the original sample. +The mixed, rendered audio a reconstruction produces: the NES channels' closest match to the original +sample. ### Working level (coefficient) -A single scale factor applied to the input so its typical loudness lands in the -amplitude range the NES channels can reproduce. +A single scale factor applied to the input, so its typical frame plays at the level one NES channel +renders at full volume. + +### Recording scale + +The factor a conversion divides every recording it reads by, drawn from the peak of their mix. A +reconstruction records it, so each recording keeps the level it held in the conversion. See +[Stems reconstruction](concepts/stems.md#2-a-stem-is-matched-against-its-own-recording). ## Analysis and scoring ### Spectrum (feature, histogram) -A frame's frequency content — the representation matching compares, rather than -the raw waveform (two sounds that sound identical can have very different -waveforms). +A frame's frequency content. Matching compares spectra instead of raw waveforms, because two sounds that +sound alike can have very different waveforms. ### FFT / log-FFT / CQT -Three ways to compute a frame's spectrum, trading time resolution against -frequency resolution. CQT (the constant-Q transform) resolves low pitches finely -and is the default. See [Reconstruction algorithms](concepts/reconstruction.md). +Three ways to compute a frame's spectrum. They trade time resolution against frequency resolution. CQT +(the constant-Q transform) resolves low pitches finely and is the default. See +[Reconstruction algorithms](concepts/reconstruction.md). ### Gamma -A setting from 0 to 100 that reshapes the spectrum before comparison: 0 keeps -the raw power spectrum, 100 makes it logarithmic, and values in between -interpolate. Higher gamma emphasizes quiet detail relative to loud peaks. +A setting from 0 to 100 that reshapes the spectrum before comparison. 0 keeps the raw power spectrum, 100 +makes it logarithmic, and values between blend the two. Higher gamma emphasizes quiet detail over loud +peaks. ### Criterion -The score that rates how well a candidate instruction matches a target frame. It -blends a spectral term (frequency shape) with a temporal term (waveform shape). +The score that rates how well a candidate instruction matches a target frame. It blends a spectral term +(frequency shape) with a temporal term (waveform shape). ### β-divergence -The default per-bin spectral distance inside the criterion — a -Kullback–Leibler-style measure of how far one spectrum is from another. +The default per-bin spectral distance in the criterion. It is a Kullback–Leibler-style measure of how far +one spectrum is from another. ### ERB / K-weighting -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. +Perceptual weightings that make each frequency bin count as much as the ear hears it. ERB spaces bins by +auditory critical bands. K-weighting applies a loudness curve. + +### Column + +The candidates one channel may sound in a frame, best first. The decoder reads the columns into one +candidate per frame. + +### Mix + +The combined sound of the picks a stem already holds in a frame. A new candidate is scored as it would +sound beside the mix. + +### Pick + +The choice of one candidate for one channel in a frame. A frame is assigned pick by pick for as long as a +pick lowers the frame's cost. + +### Resting + +The state of a channel that no stem holds in a frame. It plays its null instruction, which keeps every +channel's stream in step with the frames. + +### Standing by -### Selector +The state of a channel whose stream sounds in no frame, whatever silenced it: a conversion, an edit or a +removal. Its stream holds no frame. No export writes it and it costs nothing, and it stays open to edit. +See [Reconstructions](formats/reconstructions.md#instructions_data). -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. +### Decoder + +The strategy that reads a channel's per-frame candidates into the stream it plays. The setting is +`generation.decoder.selector`. The **greedy** decoder plays each frame's best candidate. The **Viterbi** +decoder, the default, favors continuity: it changes a channel only when the gain in match quality +outweighs the cost of the change. ### Calibration -A repeatable experiment that tunes the criterion's settings by reconstructing a -fixed test set and scoring the results. See [Calibration](concepts/calibration.md). +A repeatable experiment that tunes the criterion's settings by reconstructing a fixed test set and +scoring the results. See [Calibration](tools/calibration.md). + +### Referee / corpus / render / variant + +Terms from calibration. A *referee* is an independent audio-distance judge that scores a reconstruction +against its original. The *corpus* is the fixed set of synthetic test sounds every configuration is run +against. A *render* is one reconstruction of a corpus sound, written as an audio file for listening. A +*variant* is one configuration a run measured. + +## Song compression + +### Plane + +One register of one channel across the whole song, stored as one byte per tick. Each plane is a series of +its own, such as a volume envelope or a pitch line. See [Song compression](concepts/compression.md). -### Referee / corpus +### Token -Terms from calibration: a *referee* is an independent audio-distance judge that -scores a reconstruction against its original; the *corpus* is the fixed set of -synthetic test sounds every configuration is run against. +The unit a plane is written in. A token is a hold, a literal or a phrase, and the driver reads tokens +forward one tick at a time. + +### Phrase + +A run of values a plane plays, stored once in the song's dictionary and named by tokens wherever it +occurs. Its position in the dictionary is its id. ## Tracker and export ### FamiTracker -A [_tracker application_](http://famitracker.com/) for composing music for the -NES 2A03. _SampleToNES_ exports instruments and modules that it (and its forks) -can load. +A [_tracker application_](http://famitracker.com/) for composing music for the NES 2A03. _SampleToNES_ +exports instruments and modules that it and its forks can load. ### Bitphase -A [_web tracker_](https://github.com/paator/bitphase) whose chips include the NES -2A03. _SampleToNES_ exports documents and instrument presets it can load. See -[Bitphase export](formats/bitphase.md). +A [_web tracker_](https://bitphase.app/) whose chips include the NES 2A03. _SampleToNES_ +exports documents and instrument presets it can load. See [Bitphase export](formats/bitphase.md). ### Tracker / sequencer -A pattern-based music editor. _SampleToNES_'s built-in sequencer arranges -reconstructed samples into a song. +A pattern-based music editor. _SampleToNES_'s built-in sequencer arranges reconstructed samples into a +song. + +### Sequence (envelope) + +A per-tick list of values that one dimension of a sound follows while a note is held. The dimensions are +volume, arpeggio, pitch, hi-pitch, and duty or noise mode. An **arpeggio** sequence steps the note itself +up and down. The **pitch** and **hi-pitch** sequences bend it in fine and coarse steps (see +[Bend](#bend)). A FamiTracker instrument has one sequence per dimension, and _SampleToNES_ edits the same +shapes. -### Sequence +### Bend -In a FamiTracker instrument, a per-tick envelope for one dimension: volume, -arpeggio, pitch, hi-pitch, or duty/noise mode. +How far a frame sounds from the note it names, counted in steps of the channel's [divider](#divider). +The **pitch** sequence counts one step per item and the **hi-pitch** sequence sixteen, and the two add +up. A step is well under a cent at the lowest notes and widens 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 16 periods have no finer grid. + +### Row + +One line of a pattern. It says what each channel starts at that moment, and lasts one or more ticks. ### Pattern -A block of tracker rows spanning the channels. A song plays its patterns in an -order. +A block of tracker rows spanning the channels. A song plays its patterns in an order. ### Metric highlight -The row grouping a song is counted in. The **first highlight** is the beat — the -rows one beat spans — and the **second highlight** is the bar that gathers beats. -The tracker tints the row that opens each, and the beat is what a tempo counts: +The row grouping a song is counted in. The **first highlight** is the beat: the number of rows one beat +spans. The **second highlight** is the bar, which gathers beats. The tracker tints the row that opens +each. A tempo counts beats: `beats_per_minute = 60 × nes_frequency / (ticks_per_row × first_highlight)`. ### Groove -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 -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. +The number of ticks each row of a pattern lasts. A row lasts a whole number of ticks, so a tempo between +two counts is played by varying the count from row to row. Every bar starts on the tick nearest its exact +moment, and the meter places the longer rows on the strongest positions of the bar and of each beat. +A frame plays the groove its place in the song gives it, so two frames of one pattern can differ by a +tick. [Song timing](concepts/timing.md) explains the rules. ### Order @@ -203,39 +290,69 @@ The list that arranges patterns into the song's timeline. ### Module -A complete FamiTracker song, saved as an `.ftm` file — its settings, -instruments, patterns, and order together. +A complete FamiTracker song, saved as an `.ftm` file: its settings, instruments, patterns and order +together. ### Document -A complete Bitphase project, saved as a `.btp` file — its songs, instruments, -tables, patterns, and order together. +A complete Bitphase project, saved as a `.btp` file: its songs, instruments, tables, patterns and order +together. ### Table -In Bitphase, a per-tick list of semitone offsets a pattern cell attaches to a -channel, which carries the pitch contour a FamiTracker arpeggio sequence would. +In Bitphase, a per-tick list of semitone offsets that a pattern cell attaches to a channel. It carries +the pitch contour a FamiTracker arpeggio sequence would. + +### Voice + +Anything a tracker row can name: a **sample** or an **instrument**. A project keeps its voices in one +list. A row says which voice to start and the pitch it plays at: a note, or a step from the voice's own +pitch. ### 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. It carries the instruction stream its +conversion found for each channel. + +### Sample column + +The tracker's leftmost data column. It places a sample across every channel the sample's reconstruction +covers, and clears the rest of the row. It takes samples only, because an instrument sounds on the one +channel that names it. Its cell summarizes what those channels hold and reads `?` where they disagree. +A pitch or a volume typed in it changes the channels still playing a sample, and it takes none where no +sample is playing. 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 +One set of envelopes a channel reads while a note sounds, saved as an `.fti` file. As a [voice](#voice), +an instrument goes on whichever channel suits it, as in FamiTracker. A sample has one +instrument per channel it plays. Bitphase takes the same envelopes as a `.json` instrument preset. See +[The sequencer](guide/sequencer.md), [FamiTracker export](formats/famitracker.md) and [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. As a +voice, an instrument has one for the tonal channels and a period for the noise channel, so the same +envelopes sound on any of the four channels. The row that places the instrument sets the note it sounds +at. A sample's matching value is its per-channel [reference pitch](formats/reconstructions.md#contents). + +### Loop point + +The item a single envelope repeats from while a note is held. It lets an attack be followed by a +sustained tail. Each envelope has its own loop point, so a two-item duty cycle can circle on its own +period beside a longer volume envelope. An envelope without one holds its last item while 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/command-line.md b/docs/guide/command-line.md index c4b495b0b..13449d239 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -1,39 +1,60 @@ # Command line -You can run _SampleToNES_ from a terminal — to reconstruct without opening the -interface, to generate a library, or to open a file directly in the app. Run it -with no arguments to launch the GUI as usual. +You can run _SampleToNES_ from a terminal. Use it to convert recordings in the terminal, to build +an instruction library, or to open a file in the app. -The command is `sampletones` when installed from source; a standalone build is the -executable you made (`./bin/sampletones` on Linux, `bin\sampletones.exe` on Windows). +## Getting the command -## Common tasks +How you run the command depends on how you installed _SampleToNES_: -* **Launch the interface** — `sampletones` -* **Reconstruct a file** — `sampletones input.wav -o output.stn` -* **Reconstruct a folder** — `sampletones path/to/folder` reconstructs every audio - file inside it. -* **Open a file in the app** — `sampletones song.stp` opens the interface preloaded - with it; a `.stn` reconstruction or `.ins` library works the same way. -* **Use a specific configuration** — add `--config my-config.json`; otherwise your - saved configuration is used (`config.json`, or built-in defaults if you have not - saved one yet). -* **Generate a library and exit** — `sampletones --generate --config my-config.json` -* **Check the version** — `sampletones --version` -* **Check that a build works** — `sampletones --self-check` +- **Release download**: run `sampletones` in the extracted folder. On Windows the file is + `sampletones.exe`. +- **PyPI**: `uv tool install sampletones` or `pipx install sampletones` puts `sampletones` on your + path. +- **From source**: `make setup` puts `sampletones` on your path. A standalone build you make + yourself is `./bin/sampletones` on Linux and `bin\sampletones.exe` on Windows. -## Options +[Installation](installation.md) explains each way. -| Option | Purpose | +## Commands + +| Command | What it does | | --- | --- | -| `path` | (positional) an audio file or folder to reconstruct, or a `.stn` / `.ins` / `.stp` file to open in the app. Omit it to launch the interface. | -| `--output`, `-o` | output path for a reconstruction | -| `--config`, `-c` | path to a configuration `.json` (default: your saved `config.json`) | -| `--generate`, `-g` | build the instruction library for the configuration, then exit | -| `--version`, `-v` | print the version and exit | -| `--self-check` | verify that this build's imports, bundled resources, and configuration files are all usable, then exit | -| `--help`, `-h` | show the full option list | - -GPU acceleration is selected at setup, not per run: `make setup` detects a supported -NVIDIA driver and installs the matching build (`make setup GPU=0` forces the CPU -backend) — see [Installation](installation.md). +| `sampletones` or `sampletones run` | Starts the app. | +| `sampletones open ` | Starts the app with a `.stp` project, a `.stn` reconstruction or an `.ins` library open. | +| `sampletones convert ...` | Converts recordings into one `.stn` reconstruction, or converts every recording in a folder. | +| `sampletones library` | Builds the instruction library for a configuration, then exits. | +| `sampletones self-check` | Checks that the app can start: its code, its fonts and icons, and its configuration files. | +| `sampletones --version` or `sampletones -v` | Prints the version. | + +Add `--help` to any command to see its options. + +Two more commands measure the app: [`calibration`](../tools/calibration.md) and +[`tracker-playback`](../tools/tracker-playback.md). + +## Options + +- `--config ` or `-c ` uses a configuration file. It works with `run`, `open`, + `convert` and `library`. Without it, the app uses your saved configuration, or the built-in + defaults until you save one. +- `--output ` or `-o ` sets where `convert` saves the reconstruction. Without it, the + reconstruction goes to the reconstructions folder of your configuration. +- `--channels ` sets the channels `convert` may use, for example `--channels pulse1,pulse2`. + Without it, `convert` uses pulse 1, triangle and noise. +- `--stems ` mixes several recordings into one reconstruction. The file lists the + [stems](../glossary.md#stem), one per recording, with the channels each may use. + +A `convert` command takes either `--channels` or `--stems`. + +## Common tasks + +- **Convert one file**: `sampletones convert input.wav -o output.stn` +- **Convert a folder**: `sampletones convert path/to/folder`. Every recording in the folder + becomes its own reconstruction, saved in your reconstructions folder. +- **Mix several recordings into one reconstruction**: + `sampletones convert bass.wav lead.wav --stems stems.json`. The stems file lists the recordings in + the same order. [Reconstructions](../formats/reconstructions.md) shows it. +- **Build a library**: `sampletones library --config my-config.json` + +GPU support is chosen when you install _SampleToNES_. [Installation](installation.md) explains +how. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5ed3630de..9212d8e91 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,43 +1,39 @@ # Configuration -_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 -on the **Main** tab; the rest live in the configuration file, for when you want to -go deeper. - -## From the interface - -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; -- **Normalize audio** and **Quantize audio** preprocessing; -- the **Sample rate** and **NES frequency**; -- the **Generation method** and **Feature scaling**, which set how the audio's - frequency content is measured and weighted (see - [Reconstruction algorithms](../concepts/reconstruction.md)); -- the **Workers** count and the library and output folders. - -Changing any of these updates your configuration, which is saved to `config.json` -(see [Where your files live](files.md)). - -## In the configuration file - -The configuration holds more than the interface shows. The finer controls — the -[selector](../concepts/reconstruction.md) (greedy or Viterbi), the phase aligner, -the scoring weights and distance metric, the number of candidates kept per frame, -and so on — can be edited directly in `config.json`. The -[configuration file reference](../formats/configuration.md) lists every section -and key, and [Reconstruction algorithms](../concepts/reconstruction.md) explains -what they do and lists the defaults. - -You can also load and save whole configurations from the **Reconstruction** menu -(**Load generation settings...** and **Save generation settings...**), or point the -app at one on the [command line](command-line.md) with `--config`. - -## Deployment settings - -A couple of settings — the log level and strict history checking — are decided when -the application is packaged, not by you, so they are not part of your -configuration. They exist for development and support. +_SampleToNES_ converts audio with one set of settings, the **generation configuration**. It covers the +sample rate, the NES frequency, how the audio is analyzed and how sounds are compared. You change the +everyday ones on the **Main** tab. The configuration file has the rest. + +## Settings on the Main tab + +Three cards on the **Main** tab have the everyday settings: + +- **General settings**: the **Sample rate** and **NES frequency** a library is built for, + **Normalize audio**, which evens out the loudness of the recording before it is matched, and + **Quantize audio**, which coarsens it to fewer volume steps first. +- **Source settings**: the channel settings of the recording you selected. See + [settings for one recording](converting.md#settings-for-one-recording). +- **Advanced settings**: **Method** and **Feature scaling**, which set how the app measures the + frequencies in the audio, the number of **Workers**, and the library and output folders. Choose + **View ▸ Show advanced settings** to show this card. [Reconstruction + algorithms](../concepts/reconstruction.md) explains **Method** and **Feature scaling**. + +
+ The General settings card +
+ +
+ The Advanced settings card +
+ +The app saves your changes to `config.json`. See [Where your files live](files.md). + +## The configuration file + +`config.json` holds the rest. The [configuration file +reference](../formats/configuration.md) lists every setting, and [reconstruction +algorithms](../concepts/reconstruction.md) explains what each one does and the value it starts at. + +To load or save a whole configuration, use **Reconstruction ▸ Load generation settings...** and +**Reconstruction ▸ Save generation settings...**. On the [command line](command-line.md), add +`--config` to a command. diff --git a/docs/guide/converting.md b/docs/guide/converting.md new file mode 100644 index 000000000..219844014 --- /dev/null +++ b/docs/guide/converting.md @@ -0,0 +1,134 @@ +# Converting audio + +The **Main** tab (`F1`) turns audio files into [reconstructions](../concepts/reconstruction.md). You +gather the recordings you want on the **Converter** card, decide whether each one becomes its own +reconstruction or they all mix into one, choose which NES channels each recording may use, and start +the conversion. + +## Choosing what to convert + +The **Converter** card lists the recordings a conversion uses. Add them from the **Filesystem** +browser: + +- Double-click an audio file, or Ctrl-click it, to add it. You can also right-click it and choose + **Add as stem**. A [stem](../glossary.md#stem) is one recording that goes into a reconstruction. +- Ctrl-click a folder, or right-click it and choose **Add folder**, to add every recording inside it, + including those in subfolders. +- **Reconstruction ▸ Reconstruct file...** and **Reconstruct directory...** (also in the browser's + right-click menu) put the chosen files in the list so that each becomes its own reconstruction. If + the list held a mix, the app asks first. + +
+ The Filesystem browser with the right-click menu of a recording +
+ +Turn on **Playback ▸ Autoplay** (`Ctrl+P`) to play a recording with a single click. This lets you +listen through a folder before adding anything from it. With Autoplay off, right-click a recording and +choose **Play**. + +**x** removes a row from the list. Removing a folder removes every recording in it. + +Click a row to select it, and the **Source settings** card shows that recording. Press `Del` to +remove the selected row. + +## One reconstruction each, or one mix from all + +**Output**, at the top of the **Converter** card, sets what the conversion makes: + +- **One per recording** — each recording in the list becomes its own reconstruction. Recordings added + with a folder keep that layout in the saved files. +- **One from all** — all recordings are mixed into a single reconstruction. Folders are replaced by + the recordings inside them. + +A mix can hold up to eight recordings. If you switch to **One from all** with more than eight +recordings in the list, a dialog asks which ones to mix. The same dialog opens when you add a folder +with more recordings than the mix has room for. Double-click a row in the dialog to hear the +recording. + +When a mix has two or more recordings, the rows are grouped into [**levels**](../glossary.md#level-stems). +Levels decide which recordings get channels first: recordings on level 1 choose before those on +level 2, so a lead melody can take what it needs before a background part. + +
+ The Converter card with three recordings on three levels +
+ +Drag a row onto another row to put them on the same level. Drag it into the gap between levels to give +it a level of its own. You can also right-click a row to use the same commands. + +**Order** sets how the levels take turns: + +- **Round robin** — the levels take channels in turns. +- **Strict** — one level gets all its channels before the next level chooses. + +## Choosing which channels a recording uses + +The NES has four sound [channels](../glossary.md#channel): **Pulse 1**, **Pulse 2**, **Triangle** and +**Noise**. Every recording in the list has a checkbox for each channel. Check the channels that the +recording may use. Press `1` to `4` to switch a channel on or off for the selected row. + +A folder's checkboxes act on every recording inside it: clicking one sets the channel for all of them. To change one recording on its own, open the folder first — click the marker +next to the folder name, or double-click the name — and each recording inside has its own checkboxes. + +## Settings for one recording + +The **Source settings** card sets how the recording you selected uses its channels. It has one line +per channel: + +- **on** repeats the checkbox in the list, so you can also switch a channel there. +- [**bend**](../glossary.md#bend) tunes each note to the recording's exact pitch (not available on noise). +- [**drive**](../glossary.md#drive) sets how hard the recording pushes that channel while it converts. `1.00` is the + original level. Higher values give louder, more distorted notes, up to `5.00`. Ctrl-click the slider + to type a value. + +
+ The Source settings card for one recording +
+ +**Channels at once**, below the lines, sets how many of its channels the recording may sound in a +single frame. Set it to 1 to hear the recording on one channel at a time. It never sounds more +channels than it uses. + +A folder shows the setting its recordings share, and **mixed** where they differ. + +With no row selected, the card sets the starting settings for recordings you add. + +## Running a conversion + +Click the button under **Output** to start the conversion. It reads **Cancel** while the conversion +runs. + +**Destination:** shows where the result is saved. Reconstructions go into folders named after the +channels their recordings use. Click the path to open the location in your file manager. If the +conversion would replace an existing reconstruction, the app asks you first. + +When the conversion finishes, click **Load** to open the result on the **Reconstruction** tab, where +you can [listen to it and export it](reconstruction.md). After several reconstructions, the +button reads **Open**. It takes you to the **Reconstruction** tab, where you pick one in the **Browser**. + +
+ The Converter card after a conversion, asking to load the result +
+ +If the open reconstruction has unsaved changes, **Load** asks what to do with them. If the conversion +replaced that same file, click **Cancel** and use **Save reconstruction as...** to keep your changes. + +The first conversion with new settings takes longer, because it builds the [instruction +library](../concepts/instruction-library.md) for them. + +## Building a library yourself + +The **Instructions** tab (`F4`) builds and browses the [instruction +library](../concepts/instruction-library.md), the catalog of NES tones that a conversion searches. A +conversion builds the library it needs by itself, so you rarely need this tab. Use it to build a +library before a long session, or to explore the sounds your settings can make. + +
+ The Instructions tab with a library open and one tone drawn +
+ +**Generate library** builds a library for your current settings. A library marked **[!]** comes from +another version of _SampleToNES_. Click it to rebuild. + +Select an [instruction](../glossary.md#instruction) to see and hear a single NES tone. Click the +waveform to play the tone from that point. diff --git a/docs/guide/files.md b/docs/guide/files.md index 06ec3d403..ab5ef72ea 100644 --- a/docs/guide/files.md +++ b/docs/guide/files.md @@ -1,54 +1,52 @@ # Where your files live -_SampleToNES_ keeps your work in a **SampleToNES** folder inside your documents -folder: +_SampleToNES_ saves your work in a **SampleToNES** folder inside your documents folder: - Windows: `C:\Users\\Documents\SampleToNES` - macOS: `/Users//Documents/SampleToNES` - Linux: `/home//Documents/SampleToNES` -Inside it: +The folder contains: -- `instructions/` — the generated [instruction libraries](../formats/instruction-libraries.md) (`.ins`) -- `reconstructions/` — saved [reconstructions](../formats/reconstructions.md) (`.stn`) -- `projects/` — saved [projects](../formats/projects.md) (`.stp`) -- `config.json` — your generation [configuration](configuration.md) +- `instructions/`: your [instruction libraries](../formats/instruction-libraries.md) (`.ins`) +- `reconstructions/`: your [reconstructions](../formats/reconstructions.md) (`.stn`) +- `projects/`: your [projects](../formats/projects.md) (`.stp`) +- `config.json`: your [configuration](configuration.md) -You can point the library and output folders elsewhere from the **Main** tab's -**Advanced settings**, or from the right-click menu in its **Filesystem** browser. +To use other folders for libraries and reconstructions, open **Advanced settings** on the **Main** +tab, or right-click in the **Filesystem** browser. ## File types -| Type | What it is | Where it lives | -| --- | --- | --- | -| `.ins` | [instruction library](../formats/instruction-libraries.md) | `instructions/` | -| `.stn` | [reconstruction](../formats/reconstructions.md) | `reconstructions/` | -| `.stp` | [project](../formats/projects.md) | `projects/` | -| `.fti` | FamiTracker instrument (exported) | wherever you choose | -| `.ftm` | FamiTracker module (exported) | wherever you choose | -| `.json` | Bitphase instrument preset (exported) | wherever you choose | -| `.btp` | Bitphase project (exported) | wherever you choose | - -The `.fti` and `.ftm` files are what you load into -[FamiTracker](../formats/famitracker.md), and `.json` and `.btp` are what -[Bitphase](../formats/bitphase.md) reads; the rest are _SampleToNES_'s own formats. - -## Exported files - -The extension names the tracker an export is written for: `.fti` and `.ftm` go to -FamiTracker, `.json` and `.btp` to Bitphase. The save dialog offers the file types -that fit what you are exporting and fills in the extension of the type it is set to. -Exporting one channel offers both trackers, so switching the type there switches the -tracker; typing an extension yourself picks the tracker directly. - -What you name in the dialog also names what a tracker lists: - -| Export | You name | What is written | -| --- | --- | --- | -| **Instruments** panel ▸ **Export instrument...** | the file | that file, its instrument carrying the name you gave | -| **Reconstruction ▸ Export instruments** | the batch | one file per channel beside that name, each named ` (channel)` | -| **File ▸ Export** | the file | that file, holding the whole song | - -So exporting a `Kick` reconstruction to FamiTracker instruments writes -`Kick (pulse1).fti`, `Kick (triangle).fti`, and one file for every other channel the -reconstruction uses, all in the folder you chose. +| Type | What it is | +| --- | --- | +| `.ins` | [instruction library](../formats/instruction-libraries.md) | +| `.stn` | [reconstruction](../formats/reconstructions.md) | +| `.stp` | [project](../formats/projects.md) | +| `.fti` | [FamiTracker instrument](../formats/famitracker.md) | +| `.ftm` | [FamiTracker module](../formats/famitracker.md) | +| `.json` | [Bitphase instrument preset](../formats/bitphase.md) | +| `.btp` | [Bitphase project](../formats/bitphase.md) | +| `.nsf` | [NSF program](../formats/nsf.md) | + +The first three are your own work, saved in the folders above. The rest are exports, and the save +dialog asks where each one goes. [FamiTracker](../formats/famitracker.md) opens `.fti` and `.ftm`, +[Bitphase](../formats/bitphase.md) opens `.json` and `.btp`, and an `.nsf` plays in an NSF player or +on a NES. + +## Naming exported files + +The save dialog lists the file types that fit your export, and adds the extension of the type you +pick. When you export one channel, the dialog lists all three instrument types, so you can choose the +program the file is for. + +The name you type also names the instrument in the tracker. What the app saves depends on the export: + +- **Export instrument...** in the **Instruments** panel saves one file. The name you type is the file's + name, and the instrument inside it has the same name. +- **Reconstruction ▸ Export instruments** saves one file per channel, each named ` (channel)`. +- **File ▸ Export** saves one file with the whole song. + +For example, exporting a reconstruction named `Kick` to FamiTracker instruments saves +`Kick (pulse1).fti`, `Kick (triangle).fti`, and one file for each other channel the reconstruction +uses. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 342022fc3..28b380e7a 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,45 +1,46 @@ # Getting started -Two quick paths through _SampleToNES_: turning a sound into FamiTracker -instruments, and building a whole song. Both assume it is already -[installed](installation.md). +This page shows two quick ways to start: turning a sound into FamiTracker instruments, and building +a song. First, [install](installation.md) _SampleToNES_. ## Reconstruct a sound into FamiTracker instruments 1. Launch the app and open the **Main** tab. -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. -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 - automatically ("Generating instructions library..."), then the reconstruction - runs. -5. When it finishes, click **Load** to open the result on the **Reconstructions** - tab. -6. Choose **Reconstruction ▸ Export instruments ▸ FamiTracker instruments...** and - name the export. One `.fti` file is generated per instrument: `Kick (pulse1).fti`, - `Kick (triangle).fti`, and so on. +2. In the **Filesystem** browser, double-click an audio file, for example `Kick.wav`. WAV, MP3, FLAC, + OGG, AIFF and AU files work. To reconstruct every audio file in a folder, Ctrl-click the folder + instead. +3. Optional: click the recording in the list and check the channels it may use under **Source + settings**. +4. Optional: change **General settings**, such as the sample rate. +5. Click the button under **Output** to start the conversion. The first conversion takes longer. +6. When it finishes, click **Load** to open the result on the **Reconstruction** tab. +7. Choose **Reconstruction ▸ Export instruments ▸ FamiTracker instruments...** and name the export + `Kick`. The app writes one `.fti` file per channel: `Kick (pulse1).fti`, `Kick (triangle).fti`, + and so on. + +
+ The Converter card after a conversion, asking to load the result +
That is the shortest path from a sound to instruments you can load in FamiTracker. -The [interface guide](interface.md) covers the **Main** and **Reconstructions** -tabs in full. +[Converting audio](converting.md) and [working with a +reconstruction](reconstruction.md) cover the **Main** and **Reconstruction** tabs +in full. ## Build a song and export a module 1. Choose **File ▸ New project**. The app switches to the **Sequencer** tab. 2. Have one or more reconstructions ready — make them as above, or open existing ones. -3. Add each as a sample: in the Sequencer's **Reconstructions** browser on the - 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**. -5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**, - and **NES frequency** under **Module options**. +3. Add each as a [sample](../glossary.md#sample-sequencer): in the Sequencer's **Browser**, 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. To + assign a sample to a channel, right-click a cell and choose **Set voice**. +5. Arrange the piece in the [**Order**](../glossary.md#order) grid. Under **Module options**, set the song's length + and speed. See [timing](sequencer.md#timing-and-properties). 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`. + file. **Bitphase project...** next to 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/installation.md b/docs/guide/installation.md index 74efb5882..f0206baf6 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -1,77 +1,76 @@ # Installation -There are two ways to run _SampleToNES_: a **standalone build** (the easiest, and -all most people need) and **running from source** (for development, and on macOS). -GPU acceleration is optional. +You can install _SampleToNES_ in several ways. If you are unsure, download a release. -## Requirements +- **Download a release.** This is the easiest way on Windows and Linux. +- **Install from PyPI.** This works on Windows, macOS and Linux. +- **Run from source.** Use this to change the code or to build the app yourself. -Everyone needs: +## Download a release -- A supported operating system: Windows, macOS, or Linux -- [Python 3.12 or newer](https://www.python.org/downloads/) +1. Open the [releases page](https://github.com/JakimPL/SampleToNES/releases). +2. Download the file for your system: Windows or Linux. +3. Extract the file. +4. Start `sampletones` in the extracted folder. On Windows, double-click `sampletones.exe`. -Some setups need a little more — each is covered in the relevant section below: +On Linux, you may need to make the file executable first: `chmod +x sampletones`. -- **On Linux**, a few system packages are required to build or run: the Tk - file-dialog and PortAudio audio libraries. Install them with `make system-deps`. -- **On macOS**, audio playback is compiled against PortAudio on install, so the - library comes from [Homebrew](https://brew.sh): `make system-deps` installs it. - Tk and the graphics libraries arrive with the official Python installer. -- **On Windows**, the official Python installer and the packaged dependencies - cover everything. -- **Running from source** also needs [uv](https://docs.astral.sh/uv/). -- **GPU acceleration** needs an NVIDIA GPU with a current driver. The matching CuPy - build is installed for you, so the driver is all you need — on Linux and Windows alike. +## Install from PyPI -## Standalone build +You need [Python 3.12 or newer](https://www.python.org/downloads/). -A ready-to-run executable built on your machine. You only need Python 3.12. +On Linux and macOS, install the audio and file dialog libraries first: -### Windows +```sh +sudo apt-get install libportaudio2 libasound2 python3-tk # Debian and Ubuntu +brew install portaudio # macOS +``` -1. Install Python 3.12. -2. Double-click `install.bat`. It builds `bin\sampletones.exe`. -3. Double-click `bin\sampletones.exe` to start. +Then install _SampleToNES_ in an environment of its own, and start it: -### Linux +```sh +uv tool install sampletones # or: pipx install sampletones +sampletones +``` -1. Install the audio and file-dialog system packages: `make system-deps` (or run - `./scripts/linux/build/dependencies.sh`). -2. Install Python 3.12, then run `./install.sh` in a terminal. It builds a - `bin/sampletones` executable. -3. Run `./bin/sampletones` to start. +You can also run `pip install sampletones` inside an active virtual environment. ## Run from source -For development, and the way to run on macOS. Requires [uv](https://docs.astral.sh/uv/) -— and, on Linux and macOS, the system packages from the requirements above: +You need: + +- [Python 3.12 or newer](https://www.python.org/downloads/) +- [uv](https://docs.astral.sh/uv/) +- `make` + +Get the code and set it up: ```sh -make system-deps # Linux and macOS: install the system libraries -make setup # create the environment and install the sampletones command -make run # run the app +git clone https://github.com/JakimPL/SampleToNES.git +cd SampleToNES +make system-deps # Linux and macOS: installs the system libraries +make setup # creates the environment and installs the sampletones command +make run # starts the app ``` -To update the global command after pulling new changes, re-run `make setup`. +After you pull new changes, run `make setup` again. -## GPU acceleration +On Windows and Linux, you can also build a standalone app from the source code: -_SampleToNES_ can use an NVIDIA GPU (via [CuPy](https://cupy.dev/) and CUDA) to -speed up instruction-library generation and reconstruction. `make setup` detects -your NVIDIA driver and installs the matching CuPy build automatically: +- **Windows**: double-click `install.bat`. It builds `bin\sampletones.exe`. +- **Linux**: run `make system-deps`, then `./install.sh`. It builds `bin/sampletones`. -```sh -make setup # installs GPU support when a supported driver is present -make setup GPU=0 # forces the CPU (NumPy) backend -``` +## GPU acceleration + +_SampleToNES_ can use an NVIDIA graphics card to build libraries and convert recordings faster. It +needs an NVIDIA card with a current driver, on Windows or Linux. On macOS, the app runs without GPU acceleration. -A current NVIDIA driver is all you need: the CUDA components ship with the CuPy -build, on Linux and Windows alike. Detection reads the driver's CUDA version — -version 12 and newer use the default build, version 11 uses a legacy build. On -macOS, _SampleToNES_ runs on the CPU. +- **From source**: `make setup` checks your NVIDIA driver and installs the matching GPU support. + `make setup GPU=0` installs the app for the CPU alone. +- **From PyPI**: add the `gpu` extra, `uv tool install "sampletones[gpu]"`. If your driver supports + CUDA 11 only, use the `gpu-cuda11` extra instead. --- -Once it runs, [Getting started](getting-started.md) walks through your first +Once the app runs, [Getting started](getting-started.md) walks you through your first reconstruction and your first song. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 6ff99732e..e80918a3f 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -1,143 +1,31 @@ # 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_ has four tabs. Use `F1` to `F4` to switch between them: -This page covers the **Main**, **Instructions**, and **Reconstructions** tabs and -the menus around them. The **Sequencer** has [its own page](sequencer.md). +- [**Main**](converting.md) (`F1`) — turn audio files into [reconstructions](../concepts/reconstruction.md). +- [**Reconstruction**](reconstruction.md) (`F2`) — listen to a reconstruction, edit its instruments, and export it. +- [**Sequencer**](sequencer.md) (`F3`) — arrange reconstructions into a song. +- [**Instructions**](converting.md#building-a-library-yourself) (`F4`) — build and browse the [instruction library](../concepts/instruction-library.md) a conversion uses. -## Main +
+ The menu bar, the play controls and the four tabs +
-The **Main** tab turns an audio file into a -[reconstruction](../concepts/reconstruction.md), and it is where most sessions -begin. +You usually work through the tabs in this order. Convert your files on **Main**. When the conversion finishes, click **Load** to open the result on **Reconstruction**. From there, **Add to Sequencer** adds the reconstruction to a song. -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. +The **Instructions** tab is optional. Use it to explore single [instructions](../glossary.md#instruction), the smallest sounds the chip makes. -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. +## The menus -## Reconstructions +Two menu items are easy to miss: -The **Reconstructions** tab is where you audition a reconstruction against the -original, fine-tune it, and export it. +- **View ▸ Show advanced settings** adds the **Advanced settings** card to the **Main** tab. See [Configuration](configuration.md). +- **Playback ▸ Audio settings...** sets the device you listen through. Its sample rate is separate from **Sample rate** on the **Main** tab, which sets how audio is converted. -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. +Project properties belong to a project and are covered in the [sequencer guide](sequencer.md). -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. +## Keyboard shortcuts -**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. +**View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists every shortcut and lets you change any of them. Click a shortcut and press the keys you want. If another action already uses those keys, the app asks before reassigning them. **Reset to defaults** restores the original shortcuts. -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)). - -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 -live](files.md#exported-files). - -## Instructions - -The **Instructions** tab generates and browses the -[instruction library](../concepts/instruction-library.md) for your current -settings, and lets you inspect individual instructions. - -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. - -## 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. - -### 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**. - -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). +On macOS, the shortcuts use Command where other platforms use Control. diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md new file mode 100644 index 000000000..b5df0b0d0 --- /dev/null +++ b/docs/guide/reconstruction.md @@ -0,0 +1,124 @@ +# Working with a reconstruction + +The **Reconstruction** tab (`F2`) is where you listen to a +[reconstruction](../glossary.md#reconstruction), compare it against the audio it was made from, edit +the instruments it plays, and export it. Open one from the **Browser**, or click **Load** after [a +conversion](converting.md) on the **Main** tab. + +## Listening to a reconstruction + +The **Browser** groups reconstructions in two ways: + +- **By configuration** groups them by the settings they were made with. +- **By sample** groups every version of the same source audio together. + +
+ The Browser with reconstructions grouped by configuration +
+ +To keep frequently used reconstructions within reach, right-click a reconstruction or a folder and +choose **Mark as favorite**. Check **Favorites only** to show only those items. + +Opening another reconstruction while the open one has unsaved changes asks you to **Save** or +**Discard** them. + +The **Source** card switches playback between **Reconstruction** and **Original audio**, so you can +compare the two. Type a new rate in **NES frequency** and press `Enter` to change the reconstruction's speed. A +reconstruction that belongs to a project follows the project's rate, so the field is locked. Save it to +a file to unlock the field. + +
+ The Source card +
+ +Keys `1` to `4` switch the channels of the **Waveform** card. `Ctrl+1` to `Ctrl+4` work while you +type in a field. + +On the waveform: + +- Click to play from that point, or to move the playback position while paused. +- Drag to move the view. +- Double-click to show the whole waveform. +- Scroll to zoom. Hold **Alt** and scroll to move sideways, or **Alt** and **Shift** to move up and + down. + +
+ The Waveform card with the bars showing which recording played where +
+ +## Hearing what each recording contributed + +The **Stems** card lists the recordings a reconstruction was built from, grouped by the +[level](converting.md#one-reconstruction-each-or-one-mix-from-all) each one was given. A row has a +checkbox for each channel the recording used, and the checkbox at the front switches all of them. +Double-click a row to show the recording in your file browser. Right-click a row to **Mute** the +recording, hear it alone with **Solo**, or copy its name or path. + +
+ The Stems card with three recordings on three levels +
+ +Uncheck a box to hear the reconstruction without that recording on that channel. The waveform, +playback and WAV export follow the checkboxes. + +The colored bars under the waveform show which recording played each stretch of each channel. + +The **Instruments** panel shows and edits only the recordings you have checked. To change one +recording's part of a channel, uncheck the others first. + +Notes you write where no recording played go to an **Edits** row. + +**x** at the end of a row removes that recording, after you confirm. + +## Editing instruments + +The **Instruments** panel shows what each channel plays, as one +[sequence](../glossary.md#sequence-envelope) per dimension. Edit a sequence by dragging its bars or typing values. + +
+ The Instruments panel showing the sequences of Pulse 1 +
+ +Each channel has its own set: + +- **Pulse 1** and **Pulse 2** — volume, arpeggio, pitch, hi-pitch and [duty + cycle](../glossary.md#duty-cycle). +- **Triangle** — volume, arpeggio, pitch and hi-pitch. +- **Noise** — volume, arpeggio and duty cycle. + +Type `|` before a value to mark where the sequence repeats while a note is held. `15 14 | 12 10` plays +the attack once and then loops the last two values. + +A sequence that is too long for an export is marked. Point at it to see how many values each export +keeps. + +Clear a sequence to play its default on every note. An empty volume sequence plays at the volume the +pattern sets. An empty arpeggio, pitch or hi-pitch sequence keeps the note where the pattern puts it. An +empty duty cycle sequence plays duty 0 on a pulse channel and the long mode on noise. + +You can also edit an [**instrument**](../glossary.md#instrument) here: right-click it in the +**Voices** list and choose **Edit**. See [voices](sequencer.md#voices-samples-and-instruments). + +For an instrument, **Audition** lets you choose **Pulse**, **Triangle** or **Noise** and play the +instrument on that channel with the note keys. + +## Exporting + +You export a reconstruction from the **Reconstruction** menu: + +- **Export instruments ▸ FamiTracker instruments...** writes one `.fti` file per channel. +- **Export instruments ▸ Bitphase presets...** writes the same instruments as `.json`. +- **Export instruments ▸ NSF program...** opens the **Export NSF program** window and writes a single + `.nsf` file that plays the whole reconstruction on a NES. The window is described [in the + sequencer guide](sequencer.md#exporting-the-song). +- **Export to WAV...** renders the audio using the channel and recording checkboxes you have set. + +
+ The Reconstruction menu with Export instruments unfolded +
+ +**Export instrument...** in the **Instruments** panel writes the channel you are looking at, in the +format you choose in the save dialog. See [where your files +live](files.md#naming-exported-files). + +To use a reconstruction in a song, right-click it and choose **Add to Sequencer**. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 1f47a3fdd..70fcaa40b 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -1,239 +1,300 @@ # 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 -[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, -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 -clears every row that references it. +The **Sequencer** tab (`F3`) is a [tracker](../glossary.md#tracker--sequencer). You use it to arrange +voices into a song on the four NES [channels](../glossary.md#channel). You can play the song back, +export it, or render it to audio. + +The sequencer works on a [project](../formats/projects.md). Choose **File ▸ New project** to start +one, or open an existing `.stp` file. + +## Voices: samples and instruments + +A song plays [**voices**](../glossary.md#voice). There are two kinds: + +- A **sample** is a reconstruction that the song plays. +- An **instrument** is a sound you shape yourself. Use instruments for melodies and bass lines. + +Both kinds are in the **Voices** list, numbered together. + +
+ The Voices list with two samples and two instruments +
+ +The **Voice** menu adds a voice in four ways: + +| Menu item | What it adds | +|-----------|--------------| +| **New instrument** | An instrument that plays a note at full volume, ready to place and hear | +| **Add sample from file...** | A reconstruction file from anywhere on your disk, as a sample | +| **Import instrument...** | A FamiTracker instrument file (`.fti`), as an instrument | +| **Add to Sequencer** | The reconstruction open on the **Reconstruction** tab, as a sample | + +Right-click below the rows of the **Voices** list to open the same menu. **Add to Sequencer** is also +on the **Reconstruction** tab and in the right-click menu of any reconstruction in the **Browser**. + +If a reconstruction's [NES frequency](../glossary.md#nes-frequency) differs from the project's, the +app asks you to confirm with **Add anyway**. + +Right-click a voice to rename, duplicate, move or remove it. Three commands need a word: + +- **Edit** opens the voice on the **Reconstruction** tab, where you change how it sounds. See + [editing instruments](reconstruction.md#editing-instruments). +- **New instrument from**, on a sample, copies what the sample plays on one channel into a new + instrument you can edit. +- **Export instrument...** saves the voice as an `.fti` file. A sample has one instrument per + channel, so the app asks which channel to export. + +The same commands are on the **Edit** menu. + +Removing a voice clears every row that uses it. + +Importing an `.fti` file keeps its volume, arpeggio and duty cycle sequences. A message lists any +other settings the file carried. See [reading an instrument +file](../formats/famitracker.md#c-reading-an-instrument-file). ## 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. +The **Tracker** grid is the [pattern](../glossary.md#pattern) editor. Each row is one step in time. +The grid has a **Sample** column and a column for each channel: **Pulse 1**, **Pulse 2**, +**Triangle** and **Noise**. -## Arranging the song +
+ The Tracker grid with the caret in the Pulse 1 column +
-A song plays a sequence of patterns, and the **Order** grid sets that sequence — -one column per position, with a row for the master and each channel. Type an entry -to place a pattern, or right-click a frame for the rest: **Duplicate** repeats the -frame with the patterns it already plays, **Clone** gives the copy patterns of its -own so you can change it on its own, and **Insert frame**, **Clear frame**, -**Remove**, the moves, and **Play from this frame** do what they say. +Click a cell and type its value. Right-click a cell for the same commands as a menu, including +**Note off**. -## Working on a block +Click a voice in the **Voices** list, then type pitches: each pitch you type places that voice in +the cell too. Click below the list to stop placing the voice. -Both grids take a **selection** — a rectangle of cells you copy, cut, paste, and -delete in one go. Hold `Shift` and press the arrow keys to reach out from the -cursor, or drag the pointer across the cells; `Shift`+click carries the selection to -the cell you click. Dragging past the edge of a grid scrolls it along, so a selection -can run further than the screen shows. Any plain move, and `Escape`, puts the -selection away again. +Dimmed rows belong to the neighboring frames. To change them, click that frame in the **Order** grid. -| Key | Action | -|-----|--------| -| `Shift`+arrows | Reach the selection out a cell at a time | -| `Shift+Home` / `Shift+End` | Reach it to the first or the last row (tracker) or position (order) | -| `Ctrl+A` | Select the whole frame, or the whole order | -| `Ctrl+Shift+A` | Select the column you are in (tracker), or your channel's row (order) | -| `Ctrl+Alt+A` | Select the subcolumn you are in (tracker) | -| `Ctrl+C` | Copy | -| `Ctrl+X` | Cut — copy, then empty what was selected | -| `Ctrl+V` | Paste, starting at the cursor | -| `Del` | Empty the selection | +The [**Sample** column](../glossary.md#sample-column) places a sample on every channel the sample +uses, and clears the other channels of the row. It takes samples only. To place an instrument, use +the column of the channel you want it on. -Copy, cut, paste and delete act on the cell the cursor stands on when nothing is -selected, so copying one cell needs no selection first. All four sit on each grid's -right-click menu: raised inside a selection they act on the whole of it, raised -anywhere else on the cell you clicked. Each grid keeps its own copy, so a tracker -block pastes into the tracker and an order block into the order. +A pitch or a volume typed in the **Sample** column applies to the channels playing a sample. -The **Select** keys work from the cell you are on and reach the whole length of the -grid. They sit on the right-click menu too. +A `?` in the **Sample** column means the channels of that row play different voices. -A paste is anchored: the block starts at the cell you paste onto and lands the rest -down and to the right of it. +## Typing a pitch -In the **Tracker**, a block keeps the kinds of the cells it came from — a transpose -lands in a transpose, a volume in a volume, whichever column you paste onto — and -whatever reaches past the last row or the last column is left out. A cell reading -`?`, where the **Sample** column's channels disagree, passes over its target and -leaves what was there; an empty cell empties it. +A pitch cell holds a note, such as `C-4`, or a step, such as `+03`. A step counts semitones above (`+`) or +below (`-`) the voice's own pitch: `+00` plays a sample as recorded and `+12` plays it an octave +higher. Either kind of cell works with either kind of voice. -In the **Order**, a block pasted past the last frame grows the song to hold it, and -one reaching past the **Noise** row stops there. The **Master** row copies the index -its channels share and reads `?` when they differ, which pasted leaves each channel -as it was. +Type a note on your keyboard like a piano. The bottom two rows of keys play one octave: `Z` `S` `X` +`D` `C` and so on. The row above plays the white keys of the next octave: `Q` `W` `E` `R` `T` `Y` +`U`. **Octave**, above the grid, sets the octave of the bottom row. -Emptying cells keeps the rows and frames they sit in, and every block action is one -step in the history, so a single **Undo** takes it all back. +Type a step with the digit keys: `0` `3` writes `+03`, and `-` `1` `2` writes `-12`. -A copy also goes to your desktop's clipboard as plain text, so a block carries between -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 -that number there. +The noise channel has sixteen sounds instead of notes. A note key picks one, and a step moves to +another. -## Transposing and shading +## Arranging the song -In the **Tracker**, transpose and volume move whatever the selection covers, so a -run of rows nudges together. +A song plays patterns in a sequence. The [**Order**](../glossary.md#order) grid sets that sequence. +Each column is one position in the song, called a **frame**. The grid has a row for the **Master** +and a row for each channel. -| Key | Action | -|-----|--------| -| `Ctrl+Up` / `Ctrl+Down` | Transpose a semitone | -| `Ctrl+Shift+Up` / `Ctrl+Shift+Down` | Transpose an octave | -| `Alt+Up` / `Alt+Down` | Volume a step | -| `Alt+Shift+Up` / `Alt+Shift+Down` | Volume four steps | +
+ The Order grid with four frames +
+ +Type a pattern number in the **Order** grid to place a pattern. Right-click a frame to insert, clear +or remove frames, and to repeat one: -Control carries pitch, Alt carries volume, and Shift makes the step the bigger one. -With nothing selected they act on the cell the cursor stands on, and the same -commands sit on the right-click menu with these keys beside them. +- **Duplicate** repeats the frame with the same patterns, so a change to one shows in both. +- **Clone** repeats the frame with new copies of its patterns, so you can change them separately. ## Playing the song -The transport below the grid plays the song, and the keyboard drives playback -throughout the tab: +The play controls beside the menus play the song. These keys work anywhere on the tab: | Key | Action | |-----|--------| -| `Space` | Play, or pause and resume what is playing | +| `Space` | Play, or pause and resume | | `Shift+Space` | Play from the start | -| `Ctrl+Space` | Play from the frame currently shown | -| `Ctrl+Shift+Space` | Play from the cursor's row in the pattern grid | -| `Escape` | Stop | -| `Ctrl+L` | **Loop song** — start the song over each time it reaches the end | +| `Ctrl+Space` | Play from the frame on screen | +| `Ctrl+Shift+Space` | Play from the cursor's row | +| `Esc` | Stop | +| `Ctrl+L` | **Loop song**: start the song again when it ends | -`Escape` silences everything, including a sample preview. The same commands sit on -the **Playback** menu and the transport buttons. +
+ The play controls +
-## Following the playhead +`Esc` also stops a sample preview. A grid's right-click menu plays from the row or frame you clicked. -**Playback ▸ Follow playback** chooses how far the view travels with the sounding -row. Each mode carries a key of its own, so you can change your mind while the song -plays, and the choice is remembered for the next time you launch: +## Following the playback -| Mode | Key | Where the view goes | +**Playback ▸ Follow playback** chooses whether the view moves with the song. The app remembers your +choice. + +| Mode | Key | What the view does | |------|-----|---------------------| -| **Follow rows** | `Ctrl+F` | Scrolls the pattern grid to keep the sounding row on screen, and shows the frame being played | -| **Follow patterns** | `Ctrl+Shift+F` | Shows the frame being played, and leaves the scroll where you put it | -| **Don't follow** | `Ctrl+Alt+F` | Holds the view where you put it | - -The **Order** grid marks the frame being played under every mode, and the tracker -marks the sounding row of the frame it shows — so a held view still shows the -playhead each time the song passes through the frame you are editing. -**Follow rows** is the one that moves the grid while you play, which is what makes -the other two the modes to type in: they hold the view still under your cursor while -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 -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 -wherever you see it. +| **Follow rows** | `Ctrl+F` | Keeps the playing row in the middle of the tracker, and shows the playing frame | +| **Follow patterns** | `Ctrl+Shift+F` | Shows the playing frame, and keeps your scroll position | +| **Don't follow** | `Ctrl+Alt+F` | Stays where you put it | + +Use **Follow patterns** or **Don't follow** to type while the song plays. + +## Muting channels | Gesture | Action | |---------|--------| -| Click a channel's name | Silence it, or bring it back | -| `Ctrl`+click a channel's name | Solo it — silence the other three; `Ctrl`+click again returns the mix you had | -| Click **Sample** (tracker) or **Master** (order) | Silence every channel, or bring them all back | -| Right-click any name | The same actions as a menu | - -The **Playback ▸ Channels** submenu carries the same mix: a check marks each channel -that sounds, and **Unmute all channels** returns the whole set. `1` to `4` do the -same from the keyboard, one key per channel, wherever the grids are not holding your -cursor — inside them the digits enter values. - -Muting is for listening only. The song keeps every channel, so saving, exporting a -module, and undo all work on the full arrangement, and a mute survives undo and -redo. Toggling during playback is heard within about a quarter second. Opening, -creating, or closing a project starts a fresh listening session with every channel -audible. +| Click a channel's name, in either grid | Mute or unmute the channel | +| `Ctrl`+click a channel's name | Solo the channel: silence the rest. `Ctrl`+click again to restore the previous mix | +| Click **Sample** (tracker) or **Master** (order) | Mute or unmute every channel | +| Right-click a name | The same commands as a menu | + +Keys `1` to `4` mute and unmute a channel when the cursor is outside the grids. Inside the grids the +digit keys type values, so use `Ctrl+1` to `Ctrl+4`, which work everywhere. + +Muting changes only what you hear. Exports and renders include every channel. + +## Selecting, copying and pasting + +You can select a block of cells in both grids, then copy, cut, paste or delete it. + +To select cells: + +- Hold `Shift` and press the arrow keys. +- Drag across the cells with the mouse. +- `Shift`+click a cell to extend the selection to it. + +Any plain arrow key, or `Esc`, clears the selection. + +| Key | Action | +|-----|--------| +| `Shift`+arrows | Extend the selection by one cell | +| `Shift+Home` / `Shift+End` | Extend the selection to the first or last row (tracker) or position (order) | +| `Ctrl+A` | Select the whole frame, or the whole order | +| `Ctrl+Shift+A` | Select your column (tracker), or your channel's row (order) | +| `Ctrl+Alt+A` | Select the part of the column the cursor is in (tracker) | +| `Ctrl+C` | Copy | +| `Ctrl+X` | Cut | +| `Ctrl+V` | Paste at the cursor | +| `Del` | Empty the selection | + +With nothing selected, these commands act on the cell under the cursor. Each grid's right-click +menu has them too. + +Each grid pastes only what was copied in that grid. + +A paste starts at the cursor and fills down and to the right: + +- In the **Tracker**, each cell keeps its kind. A volume pastes into a volume column, wherever you + paste. Cells past the last row or column are dropped. +- In the **Order**, a paste past the last frame adds frames to the song. A paste stops at the + **Noise** row. + +A copy also goes to your clipboard as text, so you can paste a block into another open window of +_SampleToNES_. Voices are copied by number, so check that the other project has the same voices. + +## Transpose and volume + +In the **Tracker**, transpose and volume keys change every cell in the selection. + +| Key | Action | +|-----|--------| +| `Ctrl+Up` / `Ctrl+Down` | Transpose by a semitone | +| `Ctrl+Shift+Up` / `Ctrl+Shift+Down` | Transpose by an octave | +| `Alt+Up` / `Alt+Down` | Change the volume by one step | +| `Alt+Shift+Up` / `Alt+Shift+Down` | Change the volume by four steps | + +The right-click menu has the same commands. + +## Undoing a change + +You can undo every change. The **History** panel lists your changes, and a click on one goes back to +that point. Undo also covers voice edits on the **Reconstruction** tab. + +
+ The History panel listing three changes +
## Timing and properties -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 -frequency** first (with a **Don't ask again** option). +**Module options** sets the song's timing: **Rows** per pattern, **Speed** (the number of [ticks](../glossary.md#tick) +each row lasts), **Tempo**, and the **NES frequency** the song plays at. Speed and tempo together set how fast +the rows go by. Changing **NES frequency** changes how existing voices play, so the app asks first. + +
+ The Module options card +
+ +**File ▸ Project properties...** sets the title, the author and the comment. The exported module +includes them. It also sets the [meter](../glossary.md#metric-highlight): + +- **First highlight** is the number of rows in a beat. +- **Second highlight** is the number of rows in a bar. + +
+ The Project properties dialog +
+ +The defaults, 4 and 16, give four beats of four rows in a bar. For waltz time, set **Second highlight** to 12, which gives three beats. + +The tempo counts beats, so the meter also changes how fast the song feels. A row lasts a whole number of +ticks, so the app makes some rows a tick longer than others, on the strong beats. A tempo that asks +for rows shorter than one tick plays slower than set, as in FamiTracker and Bitphase. [Song +timing](../concepts/timing.md) has the details. -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. +## Exporting the song -**First highlight** and **Second highlight** are that metre: 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. +**File ▸ Export** writes the song in three formats: -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 -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. +- **FamiTracker module...** saves an `.ftm` file. See [FamiTracker + export](../formats/famitracker.md) for its contents and limits. +- **Bitphase project...** saves a `.btp` file. +- **NSF program...** saves an `.nsf` file, which the NES or an NSF player plays directly. -## Undo and export +A voice plays only on the channels it covers. If a row places it on another channel, the +FamiTracker and Bitphase files cut the note there, and the export dialog lists those rows. -Every change is undoable. The **History** panel on the right shows the stack, with -**Undo** and **Redo** (also on the **Edit** menu); click any entry to jump straight -to that point. +A transpose on a playing note is exported too. FamiTracker moves a note by at most 15 semitones in +one row, and the dialog lists the rows that stay at the old pitch. -When the song is ready, **Export as FamiTracker module** (or **File ▸ Export -FamiTracker module...**) writes the `.ftm`. See -[FamiTracker export](../formats/famitracker.md) for what the module contains and -the limits it respects. +Sequences longer than FamiTracker and Bitphase allow are cut short, and the dialog says how many +instruments were shortened. + +An NSF program holds 32 KB, and the app warns you when a song is too long. See [NSF +export](../formats/nsf.md) and [song compression](../concepts/compression.md). + +**NSF program...** opens the **Export NSF program** window. Click **Export** without changing +anything to save the whole song, repeating from the start. + +
+ The Export NSF program window +
+ +| Setting | What it does | +|---------|--------------| +| **Title**, **Artist**, **Copyright** | The text an NSF player shows. Each one holds up to 31 characters | +| **Channels** | The channels the program plays. A channel you clear stays silent. Select at least one | +| **Repeat** | **Play once** stops at the end. **From the start** plays the song again. **From a frame** goes back to the order frame you type in **Frame** | +| **Level** | How much the song is compressed. **None** exports fastest and makes the largest file. **Full search** is the slowest and makes the smallest file | ## Rendering to audio -A module is for a tracker. To get a file anyone can play, use **File ▸ Render -song...** (`Ctrl+Shift+E`), which writes the whole song as audio. +**File ▸ Render song...** (`Ctrl+Shift+E`) saves the whole song as an audio file that any player +opens. -The dialog holds the choices: +
+ The Render song window +
| Setting | What it does | |---------|--------------| -| **Format** | **WAV** for the full-quality file, **MP3** for a smaller one | -| **Sample rate** | How many samples a second the file holds; 44100 Hz is the usual choice | -| **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has | -| **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it | -| **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was | -| **File** | Where it is written. **Browse...** opens the save dialog, clicking the path shows where the file is going in your file manager, and the folder you pick is offered again next time | - -**Length** tells you how long the file will be before you start. **Render** begins, -and a bar reports how far it has got; **Cancel** stops it and leaves the file -unwritten. When it finishes, _SampleToNES_ shows the file it wrote — click the path -to open its folder. - -A render takes the song itself, once through, with every channel sounding: muting -and **Loop song** are for listening and stay out of the file. It is one of the long -jobs that run alone, so the item is unavailable while a conversion or a library -generation is going, and those wait for a render in the same way. +| **Format** | **WAV** for full quality, **MP3** for a smaller file | +| **Sample rate** | Samples per second. 44100 Hz is the usual choice | +| **Bit depth** (WAV) | How much detail each sample carries. 16-bit is the usual choice; 8-bit sounds rougher, like the NES | +| **Bitrate** (MP3) | How much data a second of audio takes. A higher bitrate sounds better and makes a larger file. The choices depend on the sample rate | +| **Normalize peak** | Makes the song louder until its loudest moment reaches full volume, keeping the balance between channels | + +Click **Render** to start. When the render finishes, click the path to open its folder. + +A render plays the song once with every channel, ignoring mutes and loops. diff --git a/docs/images/guide/configuration/advanced-settings.webp b/docs/images/guide/configuration/advanced-settings.webp new file mode 100644 index 000000000..0625bd94c Binary files /dev/null and b/docs/images/guide/configuration/advanced-settings.webp differ diff --git a/docs/images/guide/configuration/general-settings.webp b/docs/images/guide/configuration/general-settings.webp new file mode 100644 index 000000000..6e2d2eec3 Binary files /dev/null and b/docs/images/guide/configuration/general-settings.webp differ diff --git a/docs/images/guide/converting/converter-done.webp b/docs/images/guide/converting/converter-done.webp new file mode 100644 index 000000000..5071bfe94 Binary files /dev/null and b/docs/images/guide/converting/converter-done.webp differ diff --git a/docs/images/guide/converting/converter-levels.webp b/docs/images/guide/converting/converter-levels.webp new file mode 100644 index 000000000..c90fc2efb Binary files /dev/null and b/docs/images/guide/converting/converter-levels.webp differ diff --git a/docs/images/guide/converting/filesystem-menu.webp b/docs/images/guide/converting/filesystem-menu.webp new file mode 100644 index 000000000..9c0f108b8 Binary files /dev/null and b/docs/images/guide/converting/filesystem-menu.webp differ diff --git a/docs/images/guide/converting/instructions.webp b/docs/images/guide/converting/instructions.webp new file mode 100644 index 000000000..92a2757d4 Binary files /dev/null and b/docs/images/guide/converting/instructions.webp differ diff --git a/docs/images/guide/converting/source-settings.webp b/docs/images/guide/converting/source-settings.webp new file mode 100644 index 000000000..c90cf104c Binary files /dev/null and b/docs/images/guide/converting/source-settings.webp differ diff --git a/docs/images/guide/interface/tabs.webp b/docs/images/guide/interface/tabs.webp new file mode 100644 index 000000000..e46dbef63 Binary files /dev/null and b/docs/images/guide/interface/tabs.webp differ diff --git a/docs/images/guide/reconstruction/browser.webp b/docs/images/guide/reconstruction/browser.webp new file mode 100644 index 000000000..23e0c416c Binary files /dev/null and b/docs/images/guide/reconstruction/browser.webp differ diff --git a/docs/images/guide/reconstruction/export-menu.webp b/docs/images/guide/reconstruction/export-menu.webp new file mode 100644 index 000000000..2c93feb52 Binary files /dev/null and b/docs/images/guide/reconstruction/export-menu.webp differ diff --git a/docs/images/guide/reconstruction/instruments.webp b/docs/images/guide/reconstruction/instruments.webp new file mode 100644 index 000000000..19624f9eb Binary files /dev/null and b/docs/images/guide/reconstruction/instruments.webp differ diff --git a/docs/images/guide/reconstruction/source.webp b/docs/images/guide/reconstruction/source.webp new file mode 100644 index 000000000..846855e9c Binary files /dev/null and b/docs/images/guide/reconstruction/source.webp differ diff --git a/docs/images/guide/reconstruction/stems.webp b/docs/images/guide/reconstruction/stems.webp new file mode 100644 index 000000000..91e43f10d Binary files /dev/null and b/docs/images/guide/reconstruction/stems.webp differ diff --git a/docs/images/guide/reconstruction/waveform.webp b/docs/images/guide/reconstruction/waveform.webp new file mode 100644 index 000000000..a7400d2f4 Binary files /dev/null and b/docs/images/guide/reconstruction/waveform.webp differ diff --git a/docs/images/guide/sequencer/history.webp b/docs/images/guide/sequencer/history.webp new file mode 100644 index 000000000..c80c1ecf2 Binary files /dev/null and b/docs/images/guide/sequencer/history.webp differ diff --git a/docs/images/guide/sequencer/module-options.webp b/docs/images/guide/sequencer/module-options.webp new file mode 100644 index 000000000..f02525175 Binary files /dev/null and b/docs/images/guide/sequencer/module-options.webp differ diff --git a/docs/images/guide/sequencer/nsf-window.webp b/docs/images/guide/sequencer/nsf-window.webp new file mode 100644 index 000000000..49b4c95a7 Binary files /dev/null and b/docs/images/guide/sequencer/nsf-window.webp differ diff --git a/docs/images/guide/sequencer/order.webp b/docs/images/guide/sequencer/order.webp new file mode 100644 index 000000000..0a2f6f9c0 Binary files /dev/null and b/docs/images/guide/sequencer/order.webp differ diff --git a/docs/images/guide/sequencer/playback.webp b/docs/images/guide/sequencer/playback.webp new file mode 100644 index 000000000..1d3ac7ccb Binary files /dev/null and b/docs/images/guide/sequencer/playback.webp differ diff --git a/docs/images/guide/sequencer/project-properties.webp b/docs/images/guide/sequencer/project-properties.webp new file mode 100644 index 000000000..5f691940f Binary files /dev/null and b/docs/images/guide/sequencer/project-properties.webp differ diff --git a/docs/images/guide/sequencer/render.webp b/docs/images/guide/sequencer/render.webp new file mode 100644 index 000000000..954b6806c Binary files /dev/null and b/docs/images/guide/sequencer/render.webp differ diff --git a/docs/images/guide/sequencer/tracker.webp b/docs/images/guide/sequencer/tracker.webp new file mode 100644 index 000000000..422de943c Binary files /dev/null and b/docs/images/guide/sequencer/tracker.webp differ diff --git a/docs/images/guide/sequencer/voices.webp b/docs/images/guide/sequencer/voices.webp new file mode 100644 index 000000000..20a113f04 Binary files /dev/null and b/docs/images/guide/sequencer/voices.webp differ diff --git a/docs/images/sampletones.png b/docs/images/sampletones.png deleted file mode 100644 index 3a95d82e5..000000000 Binary files a/docs/images/sampletones.png and /dev/null differ diff --git a/docs/images/sampletones.webp b/docs/images/sampletones.webp new file mode 100644 index 000000000..7699554c2 Binary files /dev/null and b/docs/images/sampletones.webp differ diff --git a/docs/index.md b/docs/index.md index 1b02957d0..8b1580dc6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,71 +1,102 @@ # SampleToNES documentation -_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). -This is the documentation for using it, understanding how it works, and building -on it. - -The sections below are grouped by what you want to do. They assume different -starting points: the guide needs no prior knowledge, the concepts and formats -sections assume you have used the application, and the API and development -sections are written for programmers. +_SampleToNES_ rebuilds an audio sample from the sound channels of the NES's 2A03 chip: two pulse +waves, a triangle and noise. You can arrange the results into a song. You can export them to +[FamiTracker](glossary.md#famitracker), to [Bitphase](glossary.md#bitphase), or as an `.nsf` program +the NES plays. + +The sections below are grouped by what you want to do. The guide needs no prior knowledge. The concepts +and formats sections assume you have used the application. The API and development sections are for +programmers. ## Using the application The [**guide**](guide/) walks through the application from installation onward. -- [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration. +- [Installation](guide/installation.md) — the ways to install it, 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 interface](guide/interface.md) — the four tabs, the menus and the keyboard shortcuts. +- [Converting audio](guide/converting.md) — the Main tab: adding recordings, choosing channels and running a conversion. +- [Working with a reconstruction](guide/reconstruction.md) — the Reconstruction tab: listening, editing instruments and exporting. +- [The sequencer](guide/sequencer.md) — the tracker: writing a song from samples and instruments, exporting it 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. ## How it works -The [**concepts**](concepts/) section explains the ideas behind the -reconstruction. It is written to be read without the source code. +The [**concepts**](concepts/) section explains the ideas behind the reconstruction. You can read it +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. -- [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. +- [Stems reconstruction](concepts/stems.md) — how the channels are shared between several stems. +- [Instruction library](concepts/instruction-library.md) — the catalog of NES sounds the search draws from. +- [Song compression](concepts/compression.md) — how a song fits into the space an NES program has for it. +- [Song timing](concepts/timing.md) — how a tempo becomes the ticks each row lasts. +- [Project](concepts/project.md) — a song and the reconstructions it is built from. + +## Tools + +The [**tools**](tools/) section covers the commands that measure _SampleToNES_ or produce examples. +Each page says how to run the command with no options, what it writes and every custom use. + +- [Calibration](tools/calibration.md) — how well the reconstruction reproduces reference sounds, with every reconstruction written out to listen to. +- [Tracker playback check](tools/tracker-playback.md) — whether a song exported to a tracker plays there the way the app plays it, tick by tick. ## File formats 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 -The [Python API](api/index.md) covers using `sampletones` as a library, with -worked examples. +The [Python API](api/index.md) page shows how to use `sampletones` as a library, with worked +examples. ## Development -The [**development**](development/) section is for contributors. +The [**development**](development/) section is for contributors. The pages at its top cover the +whole repository. The pages about the graphical application and about releases each have a directory. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. -- [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. -- [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. +- [Package layers](development/packages.md) — the packages of the repository, and the order they import each other in. +- [Tooling](development/tooling.md) — the `sampletones` command, the tools package, the checkout units and the bootstrap scripts, with what each runs on and what it may import. +- [Assets](development/assets.md) — the unit that makes the repository's own content: the icon suite, the demo tree, and the pictures of the application in the guide and the README. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. -- [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. +- [Writing the documentation](development/documentation.md) — who each document is written for, and how it reads. +- [Console player](development/player.md) — the 6502 driver an `.nsf` carries, the codec that fits a song beside it, and how both are verified. +- [Progress](development/progress.md) — how a long operation reports progress, within one process and across worker processes. - [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. + +### The application + +- [Undo engine](development/application/undo.md) — the design of undo and redo. +- [Sequencer blocks](development/application/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. +- [Keyboard and actions](development/application/keyboard.md) — how a press reaches behavior, and how an action is declared and shown. +- [Identifier vocabularies](development/application/vocabularies.md) — the keys display text is looked up by, and the tags DearPyGui knows a widget by. +- [Colors and palettes](development/application/palette.md) — how a color is written, composed, and handed to DearPyGui. +- [The render thread](development/application/render-thread.md) — how work reaches DearPyGui from another thread, and what each crossing costs. +- [Dialogs](development/application/dialogs.md) — how a dialog gets its size, where it opens, and when it reaches the screen. +- [Playback](development/application/playback.md) — the audio transport shared by every view, and rendering the song to a file. +- [Reconstruction browser](development/application/browser.md) — how a reconstructions directory becomes the tree both browser tabs show, and what narrows it. +- [Stems in the application](development/application/stems.md) — the Stems card, and what an edit or a removal does to the per-frame record. +- [Editing the open reconstruction](development/application/reconstruction-edits.md) — how the Reconstructions tab takes the reader's edits one step at a time, and what waits for them. +- [Screen scenarios](development/application/screens.md) — tests that run the whole application on a display and press its controls the way a user does. +- [Configuration](development/application/config-organization.md) — how the YAML configuration package is laid out. + +### Releases + +- [Data compatibility](development/release/compatibility.md) — how a file saved by an older version is upgraded to the current format when it loads. +- [Dependencies](development/release/dependencies.md) — the libraries _SampleToNES_ builds on. ## Glossary -The [glossary](glossary.md) defines the recurring terms — NES hardware, the -reconstruction pipeline, and tracker concepts — that the other documents link to. +The [glossary](glossary.md) defines the terms the other pages link to: NES hardware, the reconstruction +pipeline and tracker concepts. diff --git a/docs/tools/calibration.md b/docs/tools/calibration.md new file mode 100644 index 000000000..90d39afbc --- /dev/null +++ b/docs/tools/calibration.md @@ -0,0 +1,186 @@ +# Calibration + +Calibration measures how well _SampleToNES_ reconstructs a fixed set of reference sounds. It +converts each sound with each spectrum method, scores every result against the original, and +writes a page you listen to the results on. + +Use it to: + +- See which spectrum method suits which kind of sound. +- Check whether a new version or a changed setting reconstructs better or worse. +- Hear what a score means before trusting it. + +## Run it + +From a copy of the source code: + +``` +make calibration +``` + +In an installed copy: + +``` +sampletones calibration +``` + +The run takes a while, because it reconstructs every reference sound once for each variant. A first run +also builds the instruction libraries it needs. + +With no options, the run measures the program's default settings under the packaged suite: + +- Every spectrum method: `fft`, `logfft` and `cqt`. +- The channels pulse 1, triangle and noise. +- Every other setting at its default value. + +A *variant* is one configuration the run measured, named after its distinguishing settings. For +example, `cqt-pe1` is the `cqt` method at perceptual exponent 1. A *render* is one reference sound as a +variant reconstructed it. + +The reference sounds are identical on every run, so figures from different versions compare. + +The results go into a new folder inside the `calibration` folder of your +[SampleToNES folder](../guide/files.md). The folder is named by the date and time the run started, for +example `run-20260915-124501`. When the run ends, the command prints a link to the report and opens the +listening page. + +## What a run writes + +| Path | Contents | +|---|---| +| `index.html` | the listening page, which opens by double-click | +| `report.md` | the scores, as tables | +| `report.csv` | every score, one per row, for your own analysis | +| `renders//` | each reconstruction as a FLAC file, and a JSON file with its scores and the frames each channel plays | +| `renders///` | the same reconstruction cut by channel, one file per combination | +| `recordings/` | each reference sound, as the run prepared it for reconstruction | +| `corpus/` | the reference sounds as generated | +| `page/` | the stylesheet, the script and the palette the page reads | + +The audio is FLAC, which is lossless and about a third of the size the same audio takes as WAV. A +default run writes about 35 MB. A run of four channels writes more, because a reconstruction of four +channels is cut into fourteen combinations and one of three channels into six. + +## Listen on the page + +Every run writes `index.html` and opens it when it ends. The page has one row per reference sound and +one column per variant. Each cell has: + +- **play**, which sounds the whole reconstruction from its beginning. +- The score under it. The line below the score says how much of the distance between silence and the + recording the reconstruction covers, or **past silence** where silence scores closer than the + reconstruction does. +- One bar per channel, showing the frames that channel plays, in the channel's own color. + +Beside each bar, **S** plays that channel alone and **M** plays every other channel. You hear what one +channel contributes and what the rest sound like without it. Both are separate recordings the run wrote, +so switching between them is exact. + +Space pauses and resumes, ← and → move across one +sound's versions, and ↑ and ↓ move to another sound. + +The page is drawn in the program's own colors. `--palette NAME` draws it in another of the +program's palettes, and `--no-open` leaves it closed and prints its link alone. + +A render plays at the same level as its recording, so a quieter render sounds quieter. You can also +play any file in the run folder in an audio player of your own. + +Beside each render, a JSON file of the same name has: + +- `judgments`: every referee's score for the render, with the readings behind it. +- `silence`: what complete silence would score against the same recording. +- `timelines`: one character per frame for each channel, `1` where that channel plays. + +A render that scores worse than silence is a sign to listen before trusting the number. + +## Read the report + +A *referee* is a method that scores a reconstruction against its original: zero for identical signals, +higher for a larger audible difference. [How it judges](#how-it-judges) describes them. + +`report.md` has one section per referee. Each section starts with a table: one row per variant, one +column per category of reference sound, and the overall mean last. Lower is better. + +The `mr-loudness-dB` section adds a table for each of its readings: + +- `missing`: content the original has and the reconstruction lacks. A high value sounds dull. +- `added`: content the reconstruction brings in. A high value sounds buzzy or noisy. +- `level`: how much louder the reconstruction plays than the original, in decibels. A negative value + means quieter. + +The score is `missing` plus `added`. `level` is reported separately. + +## Custom runs + +Each option replaces one part of the default run. The rest stays as the suite sets it. + +| Option | What it changes | Example | +|---|---|---| +| `--config FILE` | Measures the settings in a configuration file instead of the defaults | the `config.json` in your [SampleToNES folder](../guide/files.md) holds your saved settings | +| `--methods LIST` | The spectrum methods measured | `--methods cqt` | +| `--channels LIST` | The channels every variant reconstructs with | `--channels pulse1,pulse2,triangle,noise` | +| `--perceptual-exponents LIST` | The perceptual exponents measured, one variant each | `--perceptual-exponents 0.5,1` | +| `--temporal-weights LIST` | The temporal loss weights measured, one variant each | `--temporal-weights 0.1,0.3` | +| `-o DIR`, `--output DIR` | The folder the run writes into | `-o before` | +| `--palette NAME` | The palette the listening page is drawn in | `--palette light` | +| `--no-open` | Leaves the page closed | `--no-open` | + +Lists are comma separated. Several lists multiply: `--methods fft,cqt --perceptual-exponents +0.5,1` measures four variants, and the run takes about four times as long as one variant. + +From a copy of the source code, put `uv run` in front: `uv run sampletones calibration --methods cqt`. + +## Compare runs + +1. Run calibration into a folder of its own, for example `-o before`. +2. Change what you want to compare: install another version, or pass another `--config`. +3. Run calibration again into another folder, for example `-o after`. +4. Build one page over both: + +``` +sampletones calibration --board before after -o comparison +``` + +The page holds one tab per variant, with the runs side by side and the change between them in the +last column. It carries the audio it plays, so the `comparison` folder moves as one piece. Without +`-o` the page lands in a timestamped folder under `calibration/pages`. + +`--board` reads finished runs and measures nothing, so it takes none of the options that measure. + +To compare two versions of the source code, run each version from its own copy of the repository, +for example a `git worktree`, into its own folder. + +## How it judges + +### The reference sounds + +The reference sounds are short synthetic clips, generated the same way on every run. Each category +tests one kind of decision: + +| Category | Sounds | What it tests | +|---|---|---| +| `tone` | sines across the pitch range | pitch | +| `timbre` | pulse waves of several duty cycles | the shape of a tone | +| `noise` | white noise, and dark noise | noise without a pitch | +| `mix` | a tone under hiss, and a bass under hi-hat ticks | a tone and noise together | +| `transient` | a snare, a kick, a plucked tone | attacks and decays | +| `dynamics` | a crescendo, and a loud burst dropping to a quiet hiss | following the level | +| `polyphony` | a chord, and a melody over a snare | several voices at once | + +### The referees + +Referees score in their own way, separate from the reconstruction's own scoring, so a comparison stays +fair when that scoring is what changed. + +Both built-in referees split each signal into bands spaced the way hearing spaces pitch, at several +time resolutions, and compare the energy in each band in decibels. + +- **`mr-auditory-dB`** averages the difference over every band equally. It reads the balance of + tone against noise across the whole spectrum. An empty band counts as much as a full one, so a clip + that adds noise to a lone tone scores worse than silence. The report lists this referee first. +- **`mr-loudness-dB`** weighs each band by how loud it plays, so the parts you hear carry the score + and silent bands barely count. It first brings the reconstruction to the original's level and + reports the level difference on its own. Silence scores worst, and a clip with the right tone and + some added noise scores between. +- **`zimtohrli`** is a model of human hearing from Google. It joins the other two where it is + installed. See [dependencies](../development/release/dependencies.md#calibration). diff --git a/docs/tools/tracker-playback.md b/docs/tools/tracker-playback.md new file mode 100644 index 000000000..63bbddd40 --- /dev/null +++ b/docs/tools/tracker-playback.md @@ -0,0 +1,159 @@ +# Tracker playback check + +The tracker playback check tells you whether a song exported to a tracker plays there the way +_SampleToNES_ plays it. It exports your projects, or a set of small projects that comes with +_SampleToNES_, and plays each exported file with the tracker's own playback code. It then compares, +[tick](../glossary.md#tick) by tick and channel by channel, what the sound chip plays with what +_SampleToNES_ plays. The report lists each difference. + +Each tracker the check plays through is a *target*: + +- `bitphase` exports a `.btp` document and plays it with the engine of a copy of the + [Bitphase](../glossary.md#bitphase) source code. +- `famitracker` exports an `.ftm` module, has [FamiTracker](../glossary.md#famitracker) export it to an + `.nsf` file, and plays that file the way a NES does. + +Use it to: + +- Check that your song plays in the tracker the way you wrote it. +- Check an export after you change the exporter or update the tracker. +- Find the tick, the row and the channel where the tracker plays a song differently. + +The check needs the tracker installed, so it is not part of the automatic tests. + +## What it needs + +For the `bitphase` target: + +- [Node.js](https://nodejs.org), so that the `node` program runs in a terminal. +- A copy of the Bitphase source code with its packages installed. Clone or download + `https://github.com/paator/bitphase` and run `pnpm install` in that folder. + +For the `famitracker` target: + +- `FamiTracker.exe`, version 0.4.6, from [famitracker.com](http://famitracker.com). +- On Linux and macOS, [Wine](https://www.winehq.org), so that the `wine` program runs in a terminal. On + Debian and Ubuntu, that is `sudo apt install wine`. On macOS, `brew install --cask wine-stable`. + +## Run it + +Each run needs one target. In an installed copy, name the target and what it needs, and run the +command twice to check both: + +``` +sampletones tracker-playback bitphase --directory path/to/bitphase +sampletones tracker-playback famitracker --executable path/to/FamiTracker.exe +``` + +To check your own song, add `--project` with its project file. Repeat it to check several songs in one +run: + +``` +sampletones tracker-playback famitracker --executable path/to/FamiTracker.exe --project my-song.stp +``` + +From a copy of the source code, `make tracker-playback` runs the targets you give it. `PROJECT` takes +one or more project files, separated by spaces: + +``` +make tracker-playback BITPHASE=path/to/bitphase FAMITRACKER=path/to/FamiTracker.exe PROJECT=my-song.stp +``` + +Without `--project`, the run plays the corpus that comes with _SampleToNES_: + +- Small projects that each exercise one thing a song can do: + - Notes on every channel. + - Volume rows and transpose rows. + - Note-offs. + - Instruments with looping envelopes. + - Samples with arpeggios and bends. + - Noise at several periods, in both modes. + - A tempo whose rows last unequal ticks. + - An order that revisits patterns. + - Notes below A-0, and bends past the lowest and highest note the chip plays. +- A longer arrangement, rebuilt from rendered sounds, at its own tempo and at a faster one. + +The results go into a new folder inside the `tracker-playback` folder of your +[SampleToNES folder](../guide/files.md). The folder is named by the date and time the run started, for +example `run-20260930-124501`. When the run ends, the command prints each project's verdict and a link +to the report. + +## What a run writes + +| Path | Contents | +|---|---| +| `report.md` | the verdict for each project, and a table of every difference | +| `documents/` | each project as the export wrote it, and what the tracker's playback recorded of it | + +For the `bitphase` target, `documents/` has `.btp`, ready to open in Bitphase, and +`.json`, what the sound chip held on every tick, for your own analysis. + +For the `famitracker` target, `documents/` has: + +- `.ftm`, ready to open in FamiTracker. +- `.nsf`, the file FamiTracker exported. It sounds slightly different from a normal export: + the markers the check adds shift the triangle and noise a little and add a faint click on each row. + The `.ftm` has no markers. +- `.json`, what the sound chip held on every tick, for your own analysis. + +Files are named after the project file. A second project with the same name gets `-2` appended. + +## Reading the report + +The first table has one row per project: how many ticks each side plays, and the verdict. The verdict +is `matches`, a number of differences, or `the rows or the length differ`. A section per project +follows. It says which file the project came from, or what a corpus project +exercises. It also says what the export reported leaving out, such as an instrument it shortened. A +table then lists each difference. A line names: + +- The channel. +- What differs. `audible` means the channel sounds on one side only. Otherwise the difference is in + the `period`, the `volume`, or the `timbre`, which is the duty cycle on a pulse channel and the + short mode on the noise channel. +- The first tick it shows on, with its frame and row. +- What each side sounds there. +- How many ticks show it. + +Ticks that differ in the same way on the same channel are counted together. Under each difference, +further lines show later rows where both sides play new values. This separates two causes that change +the same field. The `examples_per_difference` setting in `settings.yaml`, in the tool's +`tracker_playback/config` folder, sets how many lines a difference shows. + +`counted down` after a sound means the chip's envelope or length counter keeps lowering the volume +after the registers are set. Other sounds hold the volume their registers set. + +A channel that is silent on both sides shows no difference. The report also says when the two sides +place a tick on different rows, or play a different number of ticks. + +With an uneven tempo, FamiTracker spreads the extra ticks differently, so its rows start on other +ticks than in _SampleToNES_. Expect row mismatches and the differences that follow from them. The +export is not at fault. + +## Options + +- `bitphase --directory `: the folder with the Bitphase source code. The `bitphase` target needs it. +- `famitracker --executable `: `FamiTracker.exe`. The `famitracker` target needs it. +- `--project `: a project file to check. Repeat it to check several. Without it, the run plays + the corpus. +- `--output ` or `-o `: the folder the run writes into, in place of the timestamped + one. + +## How it works + +Each project is exported by the same code the app's export runs. The target then plays the file with +the tracker's own playback code and records the sound chip's registers on each tick. + +- For `bitphase`, a script that comes with _SampleToNES_ plays the document through the Bitphase + copy's own loader and renderer, and records each write its engine makes to the chip. +- For `famitracker`, FamiTracker exports the module to an NSF from its command line. The NSF holds + FamiTracker's own sound driver, the program a NES runs to play the song. The check runs it on an + emulated NES processor, the way an NSF player does, and records each write the driver makes to the + chip. + +To tell where each row starts, the module FamiTracker exports has a marker on every row of its DPCM +channel, which _SampleToNES_ leaves empty. The marker sets the DPCM channel's output level to one of a +few low values, and the check reads it. The other channels' registers stay as the export wrote them. + +The reference side is _SampleToNES_'s own playback of the project, turned into chip register values. +Each side is reduced to what the chip plays on every channel on every tick: sounding or silent, timer +or noise period, volume, and duty cycle or noise mode. The two are then compared. diff --git a/install.bat b/install.bat index 1c375bb96..4d4861042 100644 --- a/install.bat +++ b/install.bat @@ -1,13 +1,7 @@ @echo off setlocal -set SCRIPT_DIR=%~dp0 - -call "%SCRIPT_DIR%scripts\windows\lib\root.bat" || exit /b - -call "%SCRIPT_DIR%scripts\windows\build\python.bat" || exit /b -call "%SCRIPT_DIR%scripts\windows\build\venv.bat" || exit /b -call "%SCRIPT_DIR%scripts\windows\build\sampletones.bat" %* || exit /b -call "%SCRIPT_DIR%scripts\windows\build\build.bat" %* || exit /b - +python "%~dp0scripts\bundle.py" %* +set STATUS=%ERRORLEVEL% pause +exit /b %STATUS% diff --git a/install.sh b/install.sh index 73eea2e26..2bdbee4e3 100755 --- a/install.sh +++ b/install.sh @@ -1,16 +1,3 @@ #!/usr/bin/env bash -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -if [[ "$(uname -s)" == "Darwin" ]]; then - exec bash "${SCRIPT_DIR}/scripts/macos/build/no_bundle.sh" "./install.sh" -fi - -source "${SCRIPT_DIR}/scripts/linux/lib/root.sh" - -source "${SCRIPT_DIR}/scripts/linux/build/python.sh" -source "${SCRIPT_DIR}/scripts/linux/build/venv.sh" -bash "${SCRIPT_DIR}/scripts/linux/build/sampletones.sh" "$@" -bash "${SCRIPT_DIR}/scripts/linux/build/build.sh" "$@" +exec python3 "$(dirname "$0")/scripts/bundle.py" "$@" diff --git a/pyproject.toml b/pyproject.toml index ac47fab5a..d7d900362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sampletones" -version = "0.3.1" +version = "0.3.2" description = "Approximate audio samples with the NES 2A03 oscillators and export them as FamiTracker instruments" readme = "README.md" requires-python = ">=3.12" @@ -41,6 +41,7 @@ dependencies = [ "librosa>=0.11,<0.12", "numpy>=2.0,<3", "pebble>=5.0,<6", + "py65>=1.2,<2", "pydantic>=2.9,<3", "pyaudio>=0.2.14,<0.3", "rich>=13.0,<16", @@ -76,6 +77,7 @@ gpu-cuda11 = [ [dependency-groups] assets = ["pillow>=11,<13"] +calibration = ["zimtohrli>=0.2.1,<0.3"] dev = [ { include-group = "assets" }, "black==26.5.1", @@ -87,7 +89,10 @@ dev = [ "pytest==9.1.1", "pytest-cov==7.1.0", "pytest-xdist==3.8.0", + "python-xlib==0.33", + "pyvirtualdisplay==3.0", "types-PyYAML==6.0.12.20260518", + "types-python-xlib==0.33.0.20260724", ] [build-system] @@ -104,8 +109,9 @@ packages = [ "src/sampletones_assets", "src/sampletones_config", "src/sampletones_core", + "src/sampletones_player", "src/sampletones_shared", - "src/sampletones_synthesis", + "src/sampletones_tools", ] [tool.black] @@ -116,40 +122,50 @@ target-version = ["py312"] profile = "black" line_length = 120 known_first_party = [ + "bootstrap", "sampletones", "sampletones_application", "sampletones_assets", "sampletones_config", "sampletones_core", + "sampletones_player", "sampletones_shared", - "sampletones_synthesis", + "sampletones_tools", ] [tool.pytest.ini_options] addopts = "--import-mode=importlib" +pythonpath = [".", "scripts"] [tool.coverage.run] +core = "sysmon" source = [ "sampletones_application", "sampletones_assets", "sampletones_core", + "sampletones_player", "sampletones_shared", - "sampletones_synthesis", + "sampletones_tools", ] [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", + "src/sampletones_tools", + "scripts", + "automation", + "assets", ] exclude = ["tests"] disallow_subclassing_any = true @@ -160,6 +176,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/run.bat b/run.bat deleted file mode 100644 index 170ec8572..000000000 --- a/run.bat +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -uv run sampletones %* diff --git a/run.sh b/run.sh deleted file mode 100755 index 1716dccd6..000000000 --- a/run.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash -set -e -uv run sampletones "$@" diff --git a/scripts/archive_bundle.py b/scripts/archive_bundle.py new file mode 100644 index 000000000..19a586392 --- /dev/null +++ b/scripts/archive_bundle.py @@ -0,0 +1,92 @@ +import argparse +import sys +import zipfile +from pathlib import Path +from typing import Final, List, Sequence + +from bootstrap.layout import BUNDLES, DISTRIBUTION, repository_root +from bootstrap.platforms.factory import current_platform +from bootstrap.platforms.protocol import Platform +from bootstrap.project import Project, read_project + +ARCHIVE_COMPRESSION: Final[int] = zipfile.ZIP_DEFLATED +ARCHIVE_SUFFIX: Final[str] = ".zip" + + +def archive_root(project: Project, label: str) -> str: + """The directory every archived entry sits under, which names the archive too. + + Args: + project: The project, whose name and release tag lead the name. + label: The platform the bundle was built for, such as ``windows-x86_64``. + + Returns: + str: The name, such as ``sampletones-v0.3.0-windows-x86_64``. + """ + return f"{project.name}-{project.tag}-{label}" + + +def bundle_entries(source: Path) -> List[Path]: + """Every file and directory inside a built bundle, ordered so repeated runs archive alike.""" + return sorted(source.rglob("*")) + + +def archive_name(path: Path, *, source: Path, root: str) -> str: + """The location a bundle path takes inside the archive, gathered under a single root directory.""" + return f"{root}/{path.relative_to(source).as_posix()}" + + +def write_archive(source: Path, archive: Path, *, root: str) -> List[Path]: + """Archives a built bundle directory, placing every entry under ``root``. + + Each entry is read where it lies, which keeps the archive available while a virus scanner or a + process that ran the executable holds a handle inside the directory, and carries over the + permission bits that let the launcher run once the archive is extracted. + """ + archive.parent.mkdir(parents=True, exist_ok=True) + entries = bundle_entries(source) + with zipfile.ZipFile(archive, "w", ARCHIVE_COMPRESSION) as bundle: + for path in entries: + bundle.write(path, archive_name(path, source=source, root=root)) + + return entries + + +def archive_release(root: Path, platform: Platform, *, label: str) -> int: + """Archives the release bundle built under ``root`` into its bundles directory. + + Args: + root: The repository the bundle was built in. + platform: The system the bundle was built for, which places the bundle's directory. + label: The platform's name in the archive, such as ``windows-x86_64``. + + Returns: + int: ``0`` once the archive is written, ``1`` where the release bundle is missing. + + Raises: + SystemExit: If the system builds no bundle. + """ + project = read_project(root) + source = platform.bundling().launcher(root / DISTRIBUTION, name=project.name, release=True).parent + if not source.is_dir(): + print(f"::error::Bundle directory {source} is missing") + return 1 + + name = archive_root(project, label) + archive = root / BUNDLES / f"{name}{ARCHIVE_SUFFIX}" + entries = write_archive(source, archive, root=name) + print(f"Archived {len(entries)} entries from {source} into {archive}") + return 0 + + +def main(argv: Sequence[str]) -> int: + """Archives the release bundle into the bundles directory, named by the version and the platform.""" + parser = argparse.ArgumentParser(description="Archive the release bundle under a versioned root.") + parser.add_argument("--label", required=True, help="the platform the bundle was built for, such as windows-x86_64") + arguments = parser.parse_args(list(argv)) + + return archive_release(repository_root(), current_platform(), label=arguments.label) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py deleted file mode 100755 index c6484eb94..000000000 --- a/scripts/assets/icons.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 - -""" -Writes the application icon suite from the packaged mark definition. - -The mark, its template and the code drawing them live in `sampletones_assets/mark`; this -script points them at the directory the icons are shipped from. - -Usage: - python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons -""" - -import argparse -import sys -from pathlib import Path -from typing import Final, Sequence - -from sampletones_assets.mark.specification import Mark -from sampletones_assets.mark.suite import write_icon_suite - -REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2] -ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons" - - -def main(argv: Sequence[str]) -> int: - """Writes the icon suite and reports each file it produced.""" - - parser = argparse.ArgumentParser( - description="Write the application icon suite from the mark definition.", - ) - parser.add_argument( - "--directory", - type=Path, - default=ICONS_DIRECTORY, - help="directory receiving the icon files", - ) - arguments = parser.parse_args(list(argv)) - - for path in write_icon_suite(arguments.directory, Mark.load()): - print(f"Wrote {path}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/bootstrap/__init__.py b/scripts/bootstrap/__init__.py new file mode 100644 index 000000000..e29480584 --- /dev/null +++ b/scripts/bootstrap/__init__.py @@ -0,0 +1,3 @@ +from bootstrap.interpreter import REQUIRED_VERSION, require_python + +require_python(REQUIRED_VERSION) diff --git a/scripts/bootstrap/cuda.py b/scripts/bootstrap/cuda.py new file mode 100644 index 000000000..ec43e3868 --- /dev/null +++ b/scripts/bootstrap/cuda.py @@ -0,0 +1,142 @@ +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Mapping, Optional, Sequence, Tuple + +from bootstrap.platforms.protocol import Platform +from bootstrap.project import GPU_CUDA11_EXTRA, GPU_EXTRA + +NVIDIA_SMI: Final[str] = "nvidia-smi" +CUDA12_MAJOR: Final[int] = 12 +CUDA11_MAJOR: Final[int] = 11 +CUDA_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"CUDA Version\s*:?\s*(\d+)\.(\d+)") +QUERY_ARGUMENTS: Final[Tuple[Tuple[str, ...], ...]] = ((), ("-q",)) + + +@dataclass(frozen=True, kw_only=True) +class CudaDetection: + """The CuPy extra the host's NVIDIA driver maps to, and what was found to choose it. + + Attributes: + extra: The optional-dependency extra matching the driver, or ``None`` for the CPU backend. + reason: One line saying what was found and what it selects. + """ + + extra: Optional[str] + reason: str + + +def find_nvidia_smi(platform: Platform, environment: Mapping[str, str]) -> Optional[Path]: + """The driver's ``nvidia-smi``: on ``PATH``, or at a location the system's driver installs it. + + Args: + platform: The system, which names the locations off ``PATH``. + environment: The variables those locations are read from. + + Returns: + Optional[Path]: The executable, or ``None`` where no NVIDIA driver is installed. + """ + located = shutil.which(NVIDIA_SMI) + if located is not None: + return Path(located) + + return next((candidate for candidate in platform.nvidia_smi_locations(environment) if candidate.exists()), None) + + +def _run_nvidia_smi(nvidia_smi: Path, arguments: Sequence[str]) -> Optional[str]: + try: + completed = subprocess.run( + [str(nvidia_smi), *arguments], + capture_output=True, + text=True, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + + if completed.returncode != 0: + return None + + return completed.stdout + completed.stderr + + +def query_driver_cuda_version(nvidia_smi: Path) -> Optional[Tuple[int, int]]: + """The newest CUDA version the driver supports, as ``(major, minor)``. + + ``nvidia-smi`` reports the driver's newest supported CUDA version, which governs CuPy wheel + selection: CuPy wheels bind to the driver, whatever CUDA Toolkit the system carries. The + default table carries the value, and ``-q`` provides a structured fallback for formats that + omit it. + """ + for arguments in QUERY_ARGUMENTS: + output = _run_nvidia_smi(nvidia_smi, arguments) + if output is None: + continue + + match = CUDA_VERSION_PATTERN.search(output) + if match is not None: + return int(match.group(1)), int(match.group(2)) + + return None + + +def select_extra(cuda_version: Optional[Tuple[int, int]]) -> Optional[str]: + """The optional-dependency extra matching a driver's CUDA version. + + CUDA 12 and newer drivers map to the ``gpu`` extra (``cupy-cuda12x``): a CUDA 12 wheel runs on + every 12.x driver through Enhanced Compatibility and on newer drivers through backward + compatibility. CUDA 11 drivers map to the legacy ``gpu-cuda11`` extra. Older drivers map to + ``None``, keeping the CPU backend. + """ + if cuda_version is None: + return None + + major = cuda_version[0] + if major >= CUDA12_MAJOR: + return GPU_EXTRA + + if major == CUDA11_MAJOR: + return GPU_CUDA11_EXTRA + + return None + + +def _describe(*, cuda_version: Optional[Tuple[int, int]], extra: Optional[str]) -> str: + if cuda_version is None: + return "NVIDIA driver detected, though its CUDA version was unreadable; keeping the CPU (NumPy) backend." + + major, minor = cuda_version + if extra is None: + return ( + f"Detected CUDA {major}.{minor}, which predates the supported CuPy builds; " + "keeping the CPU (NumPy) backend." + ) + + return f"Detected an NVIDIA driver supporting CUDA {major}.{minor}; selecting the '{extra}' extra." + + +def detect(platform: Platform, environment: Mapping[str, str]) -> CudaDetection: + """The CUDA capability of the host and the CuPy extra that matches it. + + Args: + platform: The system the detection runs on. + environment: The variables the driver's locations are read from. + + Returns: + CudaDetection: What was found and the extra it selects. + """ + if platform.cpu_backend_reason is not None: + return CudaDetection(extra=None, reason=platform.cpu_backend_reason) + + nvidia_smi = find_nvidia_smi(platform, environment) + if nvidia_smi is None: + return CudaDetection( + extra=None, + reason="No NVIDIA driver detected (nvidia-smi is absent); keeping the CPU (NumPy) backend.", + ) + + cuda_version = query_driver_cuda_version(nvidia_smi) + extra = select_extra(cuda_version) + return CudaDetection(extra=extra, reason=_describe(cuda_version=cuda_version, extra=extra)) diff --git a/scripts/bootstrap/files.py b/scripts/bootstrap/files.py new file mode 100644 index 000000000..d38000af7 --- /dev/null +++ b/scripts/bootstrap/files.py @@ -0,0 +1,22 @@ +import shutil +from pathlib import Path + + +def remove_path(path: Path) -> bool: + """Removes what stands at ``path``, a directory with everything under it or a single file. + + Args: + path: The file or directory. + + Returns: + bool: Whether anything stood there. + """ + if path.is_dir(): + shutil.rmtree(path) + return True + + if path.exists(): + path.unlink() + return True + + return False diff --git a/scripts/bootstrap/interpreter.py b/scripts/bootstrap/interpreter.py new file mode 100644 index 000000000..f1ae54587 --- /dev/null +++ b/scripts/bootstrap/interpreter.py @@ -0,0 +1,32 @@ +import sys +from typing import Final, Tuple + +REQUIRED_VERSION: Final[Tuple[int, int]] = (3, 12) +DOWNLOADS: Final[str] = "https://www.python.org/downloads/" + + +def require_python(version: Tuple[int, int]) -> None: + """Holds the interpreter running the script to ``version`` or newer. + + Importing ``bootstrap`` runs it first, so an older interpreter is told the version it needs + before a script reaches the standard library that version brings. + + Args: + version: The oldest major and minor version the scripts run on. + + Raises: + SystemExit: If the interpreter is older, naming where a newer one is downloaded. + """ + if tuple(sys.version_info[:2]) >= version: + return + + major, minor = version + raise SystemExit( + f"ERROR: Python {major}.{minor} or newer is required.\n" + f"Please install Python {major}.{minor}+ from {DOWNLOADS}" + ) + + +def running_version() -> str: + """The version of the interpreter running the script, as ``major.minor.micro``.""" + return ".".join(str(part) for part in sys.version_info[:3]) diff --git a/scripts/bootstrap/layout.py b/scripts/bootstrap/layout.py new file mode 100644 index 000000000..4be482b5e --- /dev/null +++ b/scripts/bootstrap/layout.py @@ -0,0 +1,33 @@ +from pathlib import Path +from typing import Final, Tuple + +REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +PROJECT_FILE: Final[str] = "pyproject.toml" +SOURCE_DIRECTORY: Final[str] = "src" +BENCHMARKS_DIRECTORY: Final[str] = "tests/benchmarks" +SCREENS_DIRECTORY: Final[str] = "tests/screens" +DISTRIBUTION: Final[str] = "bin" +BUNDLES: Final[str] = "bundles" +BUILD_ENVIRONMENT: Final[str] = ".venv-build" +RELEASE_HOOK: Final[str] = "scripts/runtime_hooks/release_environment.py" +NOTICES: Final[Tuple[str, ...]] = ("LICENSE", "THIRD-PARTY-NOTICES.md", "THIRD-PARTY-LICENSES.txt") +BUILD_TOOLS: Final[Tuple[str, ...]] = ("PIL",) +CLEAN_ARTIFACTS: Final[Tuple[str, ...]] = (DISTRIBUTION, BUNDLES, "build", "dist", "htmlcov", ".coverage") +CLEAN_PATTERNS: Final[Tuple[str, ...]] = ("*.spec",) +CACHE_DIRECTORIES: Final[Tuple[str, ...]] = ("__pycache__",) +CACHE_DIRECTORY_SUFFIXES: Final[Tuple[str, ...]] = (".egg-info",) +CACHE_FILE_SUFFIXES: Final[Tuple[str, ...]] = (".pyc",) +ENVIRONMENTS: Final[Tuple[str, ...]] = (".git", ".venv", BUILD_ENVIRONMENT) + + +def repository_root() -> Path: + """The repository the scripts belong to, which every build and clean-up runs against. + + Raises: + FileNotFoundError: If the scripts lie outside a SampleToNES checkout. + """ + project = REPOSITORY_ROOT / PROJECT_FILE + if not project.is_file() or not (REPOSITORY_ROOT / SOURCE_DIRECTORY).is_dir(): + raise FileNotFoundError(f"SampleToNES project root not found (expected {project} beside {SOURCE_DIRECTORY})") + + return REPOSITORY_ROOT diff --git a/scripts/bootstrap/passes.py b/scripts/bootstrap/passes.py new file mode 100644 index 000000000..ef21e7e2c --- /dev/null +++ b/scripts/bootstrap/passes.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Mapping, Sequence, Tuple + +from bootstrap.processes import Runner + + +@dataclass(frozen=True) +class Pass: + """One named command a script announces and runs from the repository root. + + Attributes: + name: What the step is called in the report and on the command line. + announcement: The line printed as the step starts. + command: The program and its arguments, run from the repository root. + """ + + name: str + announcement: str + command: Tuple[str, ...] + + +def run_pass( + current: Pass, + *, + root: Path, + runner: Runner, + environment: Mapping[str, str], +) -> int: + """Announces one pass and runs its command from the repository. + + Args: + current: The pass. + root: The repository, which the command runs in. + runner: What runs the command. + environment: The variables the command sees. + + Returns: + int: The status the command exited with. + """ + print(current.announcement) + return runner(current.command, cwd=root, environment=environment, quiet=False) + + +def run_passes( + passes: Sequence[Pass], + *, + root: Path, + runner: Runner, + environment: Mapping[str, str], +) -> List[str]: + """Runs every pass in order and names the ones that failed. + + Every pass runs whatever the earlier ones reported, so one run shows everything that is + wrong. + + Args: + passes: The steps, in order. + root: The repository, which every command runs in. + runner: What runs the commands. + environment: The variables the commands see. + + Returns: + List[str]: The names of the passes that exited with a failure, in order. + """ + failed: List[str] = [] + for current in passes: + if run_pass(current, root=root, runner=runner, environment=environment) != 0: + failed.append(current.name) + + return failed diff --git a/tests/unit/sampletones_synthesis/oscillators/__init__.py b/scripts/bootstrap/platforms/__init__.py similarity index 100% rename from tests/unit/sampletones_synthesis/oscillators/__init__.py rename to scripts/bootstrap/platforms/__init__.py diff --git a/scripts/bootstrap/platforms/bundling.py b/scripts/bootstrap/platforms/bundling.py new file mode 100644 index 000000000..02eaad2ac --- /dev/null +++ b/scripts/bootstrap/platforms/bundling.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Bundling: + """What building a standalone bundle takes on one system. + + Attributes: + icon: The icon file the bundle is stamped with, relative to the repository. + executable_suffix: What the launcher's file name ends with. + pyaudio_advice: How to supply PortAudio to the build interpreter. + tkinter_advice: How to supply Tk to the build interpreter for a release bundle. + tkinter_warning: What a development bundle built without Tk does about file dialogs. + """ + + icon: str + executable_suffix: str + pyaudio_advice: str + tkinter_advice: str + tkinter_warning: str + + def launcher(self, distribution: Path, *, name: str, release: bool) -> Path: + """The executable PyInstaller writes under ``distribution``. + + Args: + distribution: The directory the bundle is written into. + name: The bundle's name. + release: Whether the bundle is a release, which is a directory beside its launcher. + + Returns: + Path: The launcher. + """ + executable = f"{name}{self.executable_suffix}" + if release: + return distribution / name / executable + + return distribution / executable diff --git a/scripts/bootstrap/platforms/factory.py b/scripts/bootstrap/platforms/factory.py new file mode 100644 index 000000000..8b5d4df71 --- /dev/null +++ b/scripts/bootstrap/platforms/factory.py @@ -0,0 +1,37 @@ +import platform +from typing import Dict, Final + +from bootstrap.platforms.linux import LINUX, Linux +from bootstrap.platforms.macos import DARWIN, MacOS +from bootstrap.platforms.protocol import Platform +from bootstrap.platforms.windows import WINDOWS, Windows + +PLATFORMS: Final[Dict[str, Platform]] = { + LINUX: Linux(), + WINDOWS: Windows(), + DARWIN: MacOS(), +} + + +def platform_named(system: str) -> Platform: + """The platform a system name selects. + + Args: + system: The name ``platform.system()`` reports. + + Returns: + Platform: The platform. + + Raises: + SystemExit: If SampleToNES supports no system of that name. + """ + chosen = PLATFORMS.get(system) + if chosen is None: + raise SystemExit(f"ERROR: {system} is not a system SampleToNES builds on; supported: {', '.join(PLATFORMS)}.") + + return chosen + + +def current_platform() -> Platform: + """The platform the script runs on.""" + return platform_named(platform.system()) diff --git a/scripts/bootstrap/platforms/linux.py b/scripts/bootstrap/platforms/linux.py new file mode 100644 index 000000000..4a1a0ac4d --- /dev/null +++ b/scripts/bootstrap/platforms/linux.py @@ -0,0 +1,80 @@ +from pathlib import Path +from typing import Dict, Final, Mapping, Optional, Sequence, Tuple + +from bootstrap.platforms.bundling import Bundling +from bootstrap.platforms.unix import unix_interpreter + +LINUX: Final[str] = "Linux" +PNG_ICON: Final[str] = "src/sampletones_assets/icons/sampletones.png" +SYSTEM_PACKAGES: Final[Tuple[str, ...]] = ( + "libportaudio2", + "libasound-dev", + "libpulse-dev", + "portaudio19-dev", + "python3-tk", + "tk-dev", + "tcl-dev", + "libgl1", + "libegl1", + "libx11-6", + "libx11-xcb1", + "libxcursor1", + "libxi6", + "libxinerama1", + "libxrandr2", + "libxrender1", + "libxxf86vm1", + "libgl1-mesa-dri", + "xvfb", +) +BUNDLING: Final[Bundling] = Bundling( + icon=PNG_ICON, + executable_suffix="", + pyaudio_advice=( + "Run 'make system-deps' to install the PortAudio packages, then 'make build' to reinstall the dependencies." + ), + tkinter_advice="Run 'make system-deps' to install python3-tk, then build again.", + tkinter_warning=( + "This bundle opens file dialogs through zenity or kdialog, which the machine running it has to " + "provide. Run 'make system-deps' to install python3-tk and carry Tk as a self-contained fallback." + ), +) + + +class Linux: + """A Debian-based Linux: packages through apt, and a launcher named after the project alone.""" + + @property + def name(self) -> str: + return LINUX + + @property + def cpu_backend_reason(self) -> Optional[str]: + return None + + def interpreter(self, environment: Path) -> Path: + return unix_interpreter(environment) + + def bundling(self) -> Bundling: + return BUNDLING + + def missing_package_manager(self) -> Optional[str]: + return None + + def system_packages(self) -> Sequence[Sequence[str]]: + return ( + ("sudo", "apt-get", "update"), + ("sudo", "apt-get", "install", "-y", *SYSTEM_PACKAGES), + ) + + def setup_variables(self, base: Mapping[str, str], *, machine: str) -> Dict[str, str]: + del machine + return dict(base) + + def build_flags(self, *, machine: str) -> Sequence[str]: + del machine + return () + + def nvidia_smi_locations(self, environment: Mapping[str, str]) -> Sequence[Path]: + del environment + return () diff --git a/scripts/bootstrap/platforms/macos.py b/scripts/bootstrap/platforms/macos.py new file mode 100644 index 000000000..bd6d64c95 --- /dev/null +++ b/scripts/bootstrap/platforms/macos.py @@ -0,0 +1,107 @@ +import shutil +import subprocess +from pathlib import Path +from typing import Dict, Final, Mapping, Optional, Sequence + +from bootstrap.platforms.bundling import Bundling +from bootstrap.platforms.unix import unix_interpreter + +DARWIN: Final[str] = "Darwin" +HOMEBREW: Final[str] = "brew" +HOMEBREW_SITE: Final[str] = "https://brew.sh" +PORTAUDIO: Final[str] = "portaudio" +ARCHFLAGS: Final[str] = "ARCHFLAGS" +NO_BUNDLE: Final[str] = ( + "ERROR: a standalone bundle is built on Linux and Windows.\n" + "On macOS, SampleToNES runs from source:\n" + "\n" + " make system-deps\n" + " make setup\n" + " make run\n" + "\n" + "See docs/guide/installation.md for the full steps." +) +CPU_BACKEND: Final[str] = "NVIDIA CUDA is available on Linux and Windows; on macOS, keeping the CPU (NumPy) backend." + + +class MacOS: + """macOS: PortAudio through Homebrew, and the application run from source. + + Homebrew's PortAudio carries the machine's own architecture while a python.org interpreter + compiles for both, so the setup and a build pin ``ARCHFLAGS`` to the native one. The CPU backend + is the one it runs, as NVIDIA ships CUDA for Linux and Windows. + """ + + @property + def name(self) -> str: + return DARWIN + + @property + def cpu_backend_reason(self) -> Optional[str]: + return CPU_BACKEND + + @staticmethod + def homebrew_prefix(package: str) -> str: + """Where Homebrew installed ``package``, or empty where Homebrew or the package is absent.""" + if shutil.which(HOMEBREW) is None: + return "" + + completed = subprocess.run( + [HOMEBREW, "--prefix", package], + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + return "" + + return completed.stdout.strip() + + @staticmethod + def architecture_flag(machine: str) -> str: + """The flag compiling an extension for the machine's own architecture alone.""" + return f"-arch {machine}" + + def interpreter(self, environment: Path) -> Path: + return unix_interpreter(environment) + + def bundling(self) -> Bundling: + raise SystemExit(NO_BUNDLE) + + def missing_package_manager(self) -> Optional[str]: + if shutil.which(HOMEBREW) is not None: + return None + + return ( + "ERROR: Homebrew is required to install the macOS system dependencies.\n" + f"Install it from {HOMEBREW_SITE}, then run this script again." + ) + + def system_packages(self) -> Sequence[Sequence[str]]: + return ((HOMEBREW, "install", PORTAUDIO),) + + def setup_variables(self, base: Mapping[str, str], *, machine: str) -> Dict[str, str]: + return {**base, ARCHFLAGS: self.architecture_flag(machine)} + + def build_flags(self, *, machine: str) -> Sequence[str]: + """The flags compiling audio playback against Homebrew's PortAudio on the native architecture. + + Raises: + SystemExit: If Homebrew reports no PortAudio prefix. + """ + prefix = self.homebrew_prefix(PORTAUDIO) + if not prefix: + raise SystemExit( + "ERROR: Homebrew is required to locate the PortAudio headers and library.\n" + "Run 'make system-deps' first." + ) + + return ( + f"CFLAGS=-I{prefix}/include", + f"LDFLAGS=-L{prefix}/lib", + f"{ARCHFLAGS}={self.architecture_flag(machine)}", + ) + + def nvidia_smi_locations(self, environment: Mapping[str, str]) -> Sequence[Path]: + del environment + return () diff --git a/scripts/bootstrap/platforms/protocol.py b/scripts/bootstrap/platforms/protocol.py new file mode 100644 index 000000000..e5bfdf9c0 --- /dev/null +++ b/scripts/bootstrap/platforms/protocol.py @@ -0,0 +1,73 @@ +from pathlib import Path +from typing import Dict, Mapping, Optional, Protocol, Sequence + +from bootstrap.platforms.bundling import Bundling + + +class Platform(Protocol): + """What building and running SampleToNES has to know about the system it happens on.""" + + @property + def name(self) -> str: + """The name ``platform.system()`` reports for the system.""" + + @property + def cpu_backend_reason(self) -> Optional[str]: + """Why the system runs the CPU backend whatever its hardware, or ``None`` where CUDA can run.""" + + def interpreter(self, environment: Path) -> Path: + """The interpreter a virtual environment at ``environment`` runs. + + Args: + environment: The virtual environment's directory. + + Returns: + Path: The interpreter. + """ + + def bundling(self) -> Bundling: + """What building a standalone bundle takes on the system. + + Returns: + Bundling: The icon, the launcher's name and the advice a build gives. + + Raises: + SystemExit: If the system builds no bundle, naming how SampleToNES runs there. + """ + + def missing_package_manager(self) -> Optional[str]: + """What stands in the way of installing system packages, or ``None`` where nothing does.""" + + def system_packages(self) -> Sequence[Sequence[str]]: + """The commands that install the system packages the application needs, in order.""" + + def setup_variables(self, base: Mapping[str, str], *, machine: str) -> Dict[str, str]: + """The variables the development setup runs under: ``base`` and what the system adds to it. + + Args: + base: The caller's variables. + machine: The processor architecture ``platform.machine()`` reports. + + Returns: + Dict[str, str]: The variables. + """ + + def build_flags(self, *, machine: str) -> Sequence[str]: + """The ``KEY=VALUE`` lines a build exports so audio playback compiles against PortAudio. + + Args: + machine: The processor architecture ``platform.machine()`` reports. + + Returns: + Sequence[str]: The lines, empty where the build needs none. + """ + + def nvidia_smi_locations(self, environment: Mapping[str, str]) -> Sequence[Path]: + """Where the NVIDIA driver installs ``nvidia-smi`` outside ``PATH``. + + Args: + environment: The variables the locations are read from. + + Returns: + Sequence[Path]: The candidates, in the order they are tried. + """ diff --git a/scripts/bootstrap/platforms/unix.py b/scripts/bootstrap/platforms/unix.py new file mode 100644 index 000000000..b6e97c559 --- /dev/null +++ b/scripts/bootstrap/platforms/unix.py @@ -0,0 +1,9 @@ +from pathlib import Path +from typing import Final, Tuple + +UNIX_INTERPRETER: Final[Tuple[str, str]] = ("bin", "python") + + +def unix_interpreter(environment: Path) -> Path: + """The interpreter a virtual environment at ``environment`` runs on Linux and macOS.""" + return environment.joinpath(*UNIX_INTERPRETER) diff --git a/scripts/bootstrap/platforms/windows.py b/scripts/bootstrap/platforms/windows.py new file mode 100644 index 000000000..9f67f0fba --- /dev/null +++ b/scripts/bootstrap/platforms/windows.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Dict, Final, Mapping, Optional, Sequence, Tuple + +from bootstrap.platforms.bundling import Bundling + +WINDOWS: Final[str] = "Windows" +WINDOWS_INTERPRETER: Final[Tuple[str, str]] = ("Scripts", "python.exe") +ICO_ICON: Final[str] = "src/sampletones_assets/icons/sampletones.ico" +EXECUTABLE_SUFFIX: Final[str] = ".exe" +NVIDIA_SMI_LOCATIONS: Final[Tuple[Tuple[str, str], ...]] = ( + ("SystemRoot", "System32/nvidia-smi.exe"), + ("ProgramFiles", "NVIDIA Corporation/NVSMI/nvidia-smi.exe"), +) +BUNDLING: Final[Bundling] = Bundling( + icon=ICO_ICON, + executable_suffix=EXECUTABLE_SUFFIX, + pyaudio_advice="Run install.bat from the project root to reinstall the dependencies.", + tkinter_advice="Install Python from python.org or the Microsoft Store, which both include Tk, then build again.", + tkinter_warning=( + "This bundle opens no file dialogs. Install Python from python.org or the Microsoft Store to include Tk." + ), +) + + +class Windows: + """Windows: the official Python installer carries what the application needs. + + The NVIDIA driver installs ``nvidia-smi.exe`` into fixed system locations that ``PATH`` may + leave out, so CUDA detection tries those too. + """ + + @property + def name(self) -> str: + return WINDOWS + + @property + def cpu_backend_reason(self) -> Optional[str]: + return None + + def interpreter(self, environment: Path) -> Path: + return environment.joinpath(*WINDOWS_INTERPRETER) + + def bundling(self) -> Bundling: + return BUNDLING + + def missing_package_manager(self) -> Optional[str]: + return None + + def system_packages(self) -> Sequence[Sequence[str]]: + return () + + def setup_variables(self, base: Mapping[str, str], *, machine: str) -> Dict[str, str]: + del machine + return dict(base) + + def build_flags(self, *, machine: str) -> Sequence[str]: + del machine + return () + + def nvidia_smi_locations(self, environment: Mapping[str, str]) -> Sequence[Path]: + return tuple( + Path(environment[variable]) / relative + for variable, relative in NVIDIA_SMI_LOCATIONS + if environment.get(variable) + ) diff --git a/scripts/bootstrap/preflight.py b/scripts/bootstrap/preflight.py new file mode 100644 index 000000000..1757f71f7 --- /dev/null +++ b/scripts/bootstrap/preflight.py @@ -0,0 +1,84 @@ +from pathlib import Path +from typing import Final, Mapping + +from bootstrap.platforms.bundling import Bundling +from bootstrap.processes import Runner + +PYAUDIO: Final[str] = "pyaudio" +TKINTER: Final[str] = "tkinter" + + +def can_import( + python: Path, + module: str, + *, + runner: Runner, + cwd: Path, + environment: Mapping[str, str], +) -> bool: + """Whether the interpreter at ``python`` imports ``module``. + + Args: + python: The interpreter. + module: The module's name. + runner: What runs the probe. + cwd: The directory the probe runs in. + environment: The variables the probe sees. + + Returns: + bool: Whether the import succeeds. + """ + status = runner( + (str(python), "-c", f"import {module}"), + cwd=cwd, + environment=environment, + quiet=True, + ) + return status == 0 + + +def check_build_interpreter( + python: Path, + bundling: Bundling, + *, + release: bool, + runner: Runner, + cwd: Path, + environment: Mapping[str, str], +) -> None: + """Holds the build interpreter to what a bundle has to carry. + + Audio playback is required of every bundle. Tk is required of a release bundle, so the + shipped executable opens file dialogs on its own; a development bundle built without it is + warned about what it leans on instead. + + Args: + python: The build environment's interpreter. + bundling: What building a bundle takes on the system, whose advice a refusal gives. + release: Whether the bundle is a release. + runner: What runs the probes. + cwd: The directory the probes run in. + environment: The variables the probes see. + + Raises: + SystemExit: If the interpreter cannot play audio, or a release lacks Tk. + """ + print("Checking the build environment...") + if not can_import(python, PYAUDIO, runner=runner, cwd=cwd, environment=environment): + raise SystemExit( + "ERROR: the build interpreter cannot import pyaudio, so the bundle would carry no audio playback.\n" + f"{bundling.pyaudio_advice}" + ) + + print(f"{PYAUDIO}: available") + if can_import(python, TKINTER, runner=runner, cwd=cwd, environment=environment): + print(f"{TKINTER}: available") + return + + if release: + raise SystemExit( + "ERROR: the build interpreter cannot import tkinter, so a release bundle would depend on the " + f"machine running it for file dialogs.\n{bundling.tkinter_advice}" + ) + + print(f"WARNING: the build interpreter cannot import tkinter. {bundling.tkinter_warning}") diff --git a/scripts/bootstrap/processes.py b/scripts/bootstrap/processes.py new file mode 100644 index 000000000..6d40f468a --- /dev/null +++ b/scripts/bootstrap/processes.py @@ -0,0 +1,74 @@ +import shlex +import subprocess +import sys +from pathlib import Path +from typing import Mapping, Protocol, Sequence + + +class Runner(Protocol): + """Runs a command to completion and answers with its exit status.""" + + def __call__( + self, + command: Sequence[str], + *, + cwd: Path, + environment: Mapping[str, str], + quiet: bool, + ) -> int: ... + + +def run( + command: Sequence[str], + *, + cwd: Path, + environment: Mapping[str, str], + quiet: bool, +) -> int: + """Runs a command in ``cwd`` under ``environment`` and answers with its exit status. + + A quiet run keeps the command's output to itself, which is what a probe asks for. Whatever + the script printed reaches the terminal before the command's own output, so a log read + through a pipe keeps the announcements ahead of what they announce. + + Args: + command: The program and its arguments. + cwd: The directory the command runs in. + environment: The variables the command sees. + quiet: Whether the command's output is captured instead of shown. + + Returns: + int: The command's exit status. + """ + sys.stdout.flush() + completed = subprocess.run( + list(command), + cwd=str(cwd), + env=dict(environment), + check=False, + capture_output=quiet, + ) + return completed.returncode + + +def expect_success( + runner: Runner, + command: Sequence[str], + *, + cwd: Path, + environment: Mapping[str, str], +) -> None: + """Runs a command the operation depends on. + + Args: + runner: What runs the command. + command: The program and its arguments. + cwd: The directory the command runs in. + environment: The variables the command sees. + + Raises: + SystemExit: If the command exits with a status other than zero. + """ + status = runner(command, cwd=cwd, environment=environment, quiet=False) + if status != 0: + raise SystemExit(f"ERROR: {shlex.join(command)} exited with status {status}") diff --git a/scripts/bootstrap/project.py b/scripts/bootstrap/project.py new file mode 100644 index 000000000..2428c7cbc --- /dev/null +++ b/scripts/bootstrap/project.py @@ -0,0 +1,94 @@ +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Final, Tuple + +from bootstrap.layout import PROJECT_FILE, SOURCE_DIRECTORY + +BUILD_EXTRA: Final[str] = "build" +GPU_EXTRA: Final[str] = "gpu" +GPU_CUDA11_EXTRA: Final[str] = "gpu-cuda11" +DEVELOPMENT_GROUP: Final[str] = "dev" +NAMED_EXTRAS: Final[Tuple[str, ...]] = (BUILD_EXTRA, GPU_EXTRA, GPU_CUDA11_EXTRA) +NAMED_GROUPS: Final[Tuple[str, ...]] = (DEVELOPMENT_GROUP,) +ENTRY_SEPARATOR: Final[str] = ":" +MODULE_SEPARATOR: Final[str] = "." +MODULE_SUFFIX: Final[str] = ".py" +TAG_PREFIX: Final[str] = "v" + + +@dataclass(frozen=True) +class Project: + """What ``pyproject.toml`` states that a build, a setup and a release gate read. + + Attributes: + name: The distribution's name, which the command and the bundle carry too. + version: The version a release is tagged with. + entry_module: The module the command runs, read from ``[project.scripts]``. + packages: The import packages the wheel carries. + extras: The optional-dependency extras. + groups: The dependency groups. + """ + + name: str + version: str + entry_module: str + packages: Tuple[str, ...] + extras: Tuple[str, ...] + groups: Tuple[str, ...] + + @property + def entry_script(self) -> str: + """The entry module as a path from the repository root, which PyInstaller starts from.""" + return f"{SOURCE_DIRECTORY}/{self.entry_module.replace(MODULE_SEPARATOR, '/')}{MODULE_SUFFIX}" + + @property + def tag(self) -> str: + """The tag a release of this version is published under, such as ``v0.3.0``.""" + return f"{TAG_PREFIX}{self.version}" + + +def parse_project(document: Dict[str, Any]) -> Project: + """The project a parsed ``pyproject.toml`` states, held to the names the scripts rely on. + + Args: + document: The file as ``tomllib`` reads it. + + Returns: + Project: The project. + + Raises: + SystemExit: If the file lacks the command, an extra or a group the scripts name. + """ + table = document["project"] + name: str = table["name"] + scripts: Dict[str, str] = table.get("scripts", {}) + if name not in scripts: + raise SystemExit(f"ERROR: {PROJECT_FILE} names no '{name}' command under [project.scripts].") + + project = Project( + name=name, + version=table["version"], + entry_module=scripts[name].split(ENTRY_SEPARATOR, 1)[0], + packages=tuple( + Path(package).name for package in document["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] + ), + extras=tuple(table.get("optional-dependencies", {})), + groups=tuple(document.get("dependency-groups", {})), + ) + missing = [extra for extra in NAMED_EXTRAS if extra not in project.extras] + missing.extend(group for group in NAMED_GROUPS if group not in project.groups) + if missing: + raise SystemExit(f"ERROR: {PROJECT_FILE} lacks what the scripts install: {', '.join(missing)}.") + + return project + + +def read_project(root: Path) -> Project: + """The project the repository at ``root`` states. + + Raises: + SystemExit: If the file lacks the command, an extra or a group the scripts name. + """ + with (root / PROJECT_FILE).open("rb") as handle: + return parse_project(tomllib.load(handle)) diff --git a/scripts/bootstrap/venv_build.py b/scripts/bootstrap/venv_build.py new file mode 100644 index 000000000..7aab0b423 --- /dev/null +++ b/scripts/bootstrap/venv_build.py @@ -0,0 +1,86 @@ +import sys +from pathlib import Path +from typing import Dict, Final, Mapping, Sequence + +from bootstrap.layout import BUILD_ENVIRONMENT +from bootstrap.platforms.protocol import Platform +from bootstrap.processes import Runner, expect_success + +PIP_REQUIRE_VIRTUALENV: Final[str] = "PIP_REQUIRE_VIRTUALENV" + + +def ensure_build_venv( + root: Path, + platform: Platform, + *, + runner: Runner, + environment: Mapping[str, str], +) -> Path: + """The interpreter of the virtual environment a bundle is built in, created under ``root`` where it is missing. + + Every package a build installs lands here, so the interpreter running the script stays as + it was found. An environment is present once its interpreter is; a directory an interrupted + creation left behind is cleared and created again. + + Args: + root: The repository. + platform: The system, which places the interpreter inside the environment. + runner: What runs the command creating the environment. + environment: The variables the command sees. + + Returns: + Path: The environment's interpreter. + """ + directory = root / BUILD_ENVIRONMENT + python = platform.interpreter(directory) + if python.is_file(): + print("Virtual environment already exists.") + return python + + print("Creating virtual environment...") + expect_success( + runner, + (sys.executable, "-m", "venv", "--clear", str(directory)), + cwd=root, + environment=environment, + ) + print("Virtual environment created.") + return python + + +def install( + root: Path, + python: Path, + *, + extras: Sequence[str], + runner: Runner, + environment: Mapping[str, str], +) -> None: + """Installs the package with ``extras`` into the environment ``python`` runs. + + Pip is told to refuse any interpreter outside a virtual environment, so an install reaches + the build environment alone. + + Args: + root: The repository, which is the package installed. + python: The build environment's interpreter. + extras: The optional-dependency extras installed with the package. + runner: What runs the commands. + environment: The variables the commands see. + """ + guarded: Dict[str, str] = {**environment, PIP_REQUIRE_VIRTUALENV: "1"} + print("Installing dependencies...") + expect_success( + runner, + (str(python), "-m", "pip", "install", "--upgrade", "pip"), + cwd=root, + environment=guarded, + ) + print(f"Installing with extras: {','.join(extras)}") + expect_success( + runner, + (str(python), "-m", "pip", "install", f".[{','.join(extras)}]"), + cwd=root, + environment=guarded, + ) + print("sampletones Python package installed successfully.") diff --git a/scripts/build_environment.py b/scripts/build_environment.py new file mode 100644 index 000000000..e749fd293 --- /dev/null +++ b/scripts/build_environment.py @@ -0,0 +1,21 @@ +import argparse +import platform as running +import sys +from typing import Sequence + +from bootstrap.platforms.factory import current_platform + + +def main(argv: Sequence[str]) -> int: + """Prints the variables a build exports so audio playback compiles, one ``KEY=VALUE`` per line.""" + parser = argparse.ArgumentParser(description="Print the build environment audio playback compiles under.") + parser.parse_args(list(argv)) + + for line in current_platform().build_flags(machine=running.machine()): + print(line) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/bundle.py b/scripts/bundle.py new file mode 100644 index 000000000..88b2a94a0 --- /dev/null +++ b/scripts/bundle.py @@ -0,0 +1,212 @@ +import argparse +import os +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Mapping, Sequence, Tuple + +from bootstrap.files import remove_path +from bootstrap.interpreter import running_version +from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES, RELEASE_HOOK, repository_root +from bootstrap.platforms.bundling import Bundling +from bootstrap.platforms.factory import current_platform +from bootstrap.platforms.protocol import Platform +from bootstrap.preflight import check_build_interpreter +from bootstrap.processes import Runner, expect_success, run +from bootstrap.project import BUILD_EXTRA, GPU_EXTRA, Project, read_project +from bootstrap.venv_build import ensure_build_venv, install + +SELF_CHECK: Final[str] = "self-check" + + +@dataclass(frozen=True) +class BundleOptions: + """How a bundle is built. + + Attributes: + release: Whether the bundle is a release: a directory beside its launcher, carrying the + release deployment configuration and the notices. + gpu: Whether the bundle carries GPU support. + """ + + release: bool + gpu: bool + + +def extras(options: BundleOptions) -> Tuple[str, ...]: + """The optional-dependency extras a bundle is built with.""" + if options.gpu: + return (BUILD_EXTRA, GPU_EXTRA) + + return (BUILD_EXTRA,) + + +def pyinstaller_command( + python: Path, + bundling: Bundling, + project: Project, + options: BundleOptions, +) -> List[str]: + """The PyInstaller invocation that writes the bundle. + + Every package the wheel carries brings its data files along at its own package path, so the + frozen application reads them through ``importlib.resources`` the way an installed one does. + + Args: + python: The build environment's interpreter. + bundling: What building a bundle takes on the system. + project: The project the bundle is built from. + options: How the bundle is built. + + Returns: + List[str]: The command, run from the repository root. + """ + command = [ + str(python), + "-m", + "PyInstaller", + "--name", + project.name, + "--onedir" if options.release else "--onefile", + "--noconfirm", + "--distpath", + DISTRIBUTION, + "--icon", + bundling.icon, + ] + for package in project.packages: + command.extend(("--collect-data", package)) + + command.extend(("--copy-metadata", project.name)) + for module in BUILD_TOOLS: + command.extend(("--exclude-module", module)) + + if options.release: + command.extend(("--runtime-hook", RELEASE_HOOK)) + + command.append(project.entry_script) + return command + + +def remove_previous(distribution: Path, bundling: Bundling, name: str) -> None: + """Removes what an earlier build left under ``distribution``, a release's directory or a single file.""" + for previous in ( + bundling.launcher(distribution, name=name, release=True).parent, + bundling.launcher(distribution, name=name, release=False), + ): + if remove_path(previous): + print(f"Removed the previous artifact: {previous}") + + +def copy_notices(root: Path, bundle: Path) -> None: + """Places the license and notice files beside a release bundle's launcher.""" + for notice in NOTICES: + shutil.copyfile(root / notice, bundle / notice) + + print(f"Bundled notices: {', '.join(NOTICES)}") + + +def build_bundle( + root: Path, + platform: Platform, + options: BundleOptions, + *, + runner: Runner, + environment: Mapping[str, str], +) -> Path: + """Builds the standalone bundle in a build environment of its own and verifies it starts. + + Args: + root: The repository. + platform: The system the bundle is built on and for. + options: How the bundle is built. + runner: What runs the commands. + environment: The variables the commands see. + + Returns: + Path: The launcher the bundle offers. + + Raises: + SystemExit: If the system builds no bundle, a step fails, or PyInstaller produced no launcher. + """ + bundling = platform.bundling() + project = read_project(root) + if options.release: + print("Release build: onedir bundle, injecting release deployment configuration") + + python = ensure_build_venv( + root, + platform, + runner=runner, + environment=environment, + ) + install( + root, + python, + extras=extras(options), + runner=runner, + environment=environment, + ) + check_build_interpreter( + python, + bundling, + release=options.release, + runner=runner, + cwd=root, + environment=environment, + ) + distribution = root / DISTRIBUTION + remove_previous(distribution, bundling, project.name) + print("Building executable...") + expect_success( + runner, + pyinstaller_command(python, bundling, project, options), + cwd=root, + environment=environment, + ) + launcher = bundling.launcher(distribution, name=project.name, release=options.release) + if not launcher.is_file(): + raise SystemExit(f"Build failed: PyInstaller produced no executable at {launcher}.") + + print("Verifying the bundle...") + status = runner((str(launcher), SELF_CHECK), cwd=root, environment=environment, quiet=False) + if status != 0: + raise SystemExit(f"Build failed: {launcher} did not pass its self-check.") + + if options.release: + copy_notices(root, launcher.parent) + + print(f"Build complete: {launcher}") + return launcher + + +def main(argv: Sequence[str]) -> int: + """Builds the standalone bundle, as a development build or a release.""" + parser = argparse.ArgumentParser(description="Build the standalone SampleToNES bundle.") + parser.add_argument( + "--release", + action="store_true", + help="build a release: a directory bundle with the release deployment configuration and the notices", + ) + parser.add_argument( + "--gpu", + action="store_true", + help="build with GPU support", + ) + arguments = parser.parse_args(list(argv)) + options = BundleOptions(release=arguments.release, gpu=arguments.gpu) + + print(f"Detected Python version: {running_version()}") + build_bundle( + repository_root(), + current_platform(), + options, + runner=run, + environment=os.environ, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/calibration.py b/scripts/calibration.py deleted file mode 100644 index c129d1800..000000000 --- a/scripts/calibration.py +++ /dev/null @@ -1,109 +0,0 @@ -import argparse -from datetime import UTC, datetime -from pathlib import Path -from typing import Final - -from sampletones_core.calibration.config.corpus import CorpusConfig -from sampletones_core.calibration.corpus.synthesis import build_corpus -from sampletones_core.calibration.corpus.writer import write_corpus -from sampletones_core.calibration.referee.factory import build_referees -from sampletones_core.calibration.report import write_csv, write_markdown -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, - SpectrumMethod, -) -from sampletones_shared.logger import logger -from sampletones_shared.paths.user import USER_PATH_DOCUMENTS - -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) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Reconstruct the calibration corpus and score it with referees.", - ) - parser.add_argument( - "--config", - type=Path, - default=None, - help="Base configuration path.", - ) - parser.add_argument( - "--output", - type=Path, - default=None, - help="Output directory of the run.", - ) - parser.add_argument( - "--methods", - type=str, - default=DEFAULT_METHODS, - help="Comma-separated spectrum methods.", - ) - parser.add_argument( - "--perceptual-exponents", - type=str, - default=DEFAULT_PERCEPTUAL_EXPONENTS, - help="Comma-separated values of metric.perceptual_exponent.", - ) - parser.add_argument( - "--temporal-weights", - type=str, - default="", - help="Comma-separated values of weights.temporal_loss_weight; empty keeps the base blend.", - ) - parser.add_argument( - "--generators", - type=str, - default=DEFAULT_GENERATOR_NAMES, - help="Comma-separated channel generators 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") - - base = Config.load(arguments.config) if arguments.config else Config.default() - base = base.model_copy( - update={ - "generation": base.generation.model_copy( - update={"generators": generators}, - ) - }, - ) - output = arguments.output or DEFAULT_OUTPUT_ROOT / datetime.now(UTC).strftime("run-%Y%m%d-%H%M%S") - output.mkdir(parents=True, exist_ok=True) - - methods = [SpectrumMethod(name.strip()) for name in arguments.methods.split(",") if name.strip()] - if not methods: - parser.error("--methods requires at least one spectrum method") - - exponents = [float(value) for value in arguments.perceptual_exponents.split(",") if value.strip()] - temporal_weights = [float(value) for value in arguments.temporal_weights.split(",") if value.strip()] - - sample_rate = base.library.sample_rate - items = build_corpus(sample_rate, config=CorpusConfig.load()) - item_paths = write_corpus(items, output / "corpus", sample_rate) - referees = build_referees(sample_rate) - variants = build_variants(base, methods, exponents, temporal_weights) - - channel_names = ", ".join(generator.value for generator in generators) - logger.info( - f"Evaluating {len(variants)} variants x {len(items)} items x {len(referees)} referees on {channel_names}" - ) - rows = evaluate_variants(variants, items, item_paths, referees) - - write_csv(rows, output / "report.csv") - write_markdown(rows, output / "report.md") - logger.info(f"Report written to {output}") - - -if __name__ == "__main__": - main() diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py deleted file mode 100755 index 14c61f15a..000000000 --- a/scripts/checks/import_boundary.py +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env python3 - -""" -Enforces layer-boundary import rules across the sampletones_application package. - -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. - -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. - -Usage: - python scripts/checks/import_boundary.py [files...] # check specific files - python scripts/checks/import_boundary.py --all # run all rules against the source tree -""" - -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 + ".") - - -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 - - -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.", - ) - parser.add_argument( - "files", - nargs="*", - type=Path, - help="modules to check", - ) - parser.add_argument( - "--all", - action="store_true", - help=f"check every module under {APP_ROOT.name}/ instead of named files", - ) - parser.add_argument( - "--package", - type=Path, - default=APP_ROOT, - help="package the rule globs are written against", - ) - 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) - if not violations: - return 0 - - print("Layer boundary violation(s) found:", file=sys.stderr) - for kind, location in violations: - print(f" [forbidden: {kind}] {location}", file=sys.stderr) - - print(f"\nFound {len(violations)} violation(s) in total.", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py deleted file mode 100755 index d6a55601c..000000000 --- a/scripts/checks/language_keys.py +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env python3 - -""" -Checks the language file and the lookups that read it against each other, in both directions. - -Every lookup on a `LanguageManager` states a key, so the check reads each one and holds it against -`en.yaml`. A key spelled entirely from literals must name an entry, and every entry must be reachable -from some lookup — a key part arriving in a variable stands for each member of the enum it is -annotated with, which is why a dynamic part names a concrete element enum. An element enum is found -by what it derives from, so one lives wherever its domain lives and the check reads it there. - -Three things are reported: - broken lookup — a literal key the language file holds no entry for - unresolved part — a key part the check reads no values from - unreached entry — an entry no lookup asks for - -Usage: - python scripts/checks/language_keys.py -""" - -import argparse -import importlib -import inspect -import sys -from enum import Enum, EnumMeta -from pathlib import Path -from types import ModuleType -from typing import Callable, Dict, Final, List, Mapping, NamedTuple, Optional, Sequence, Type - -import yaml - -from sampletones_application.categories import hierarchy -from sampletones_application.categories.abstract import AbstractElement -from sampletones_application.paths import LANG_EN -from sampletones_application.tags.compose import TAG_SEPARATOR -from sampletones_shared.meta.source.classes import declared_subclasses -from sampletones_shared.meta.source.index import source_index -from sampletones_shared.meta.source.lookups import LookupSite, tree_lookups -from sampletones_shared.meta.source.modules import discover_modules, module_name -from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.meta.source.values import EnumMembers, EnumTable -from sampletones_shared.paths.source import SOURCE_ROOT - -EnumPredicate = Callable[[object], bool] - -APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application") - -RECEIVER_TYPE: Final[str] = "LanguageManager" -ELEMENT_BASE: Final[str] = AbstractElement.__name__ - -LANGUAGE_ENCODING: Final[str] = "utf-8" -ENTRY_SEPARATOR: Final[str] = ":" -COMMENT_PREFIX: Final[str] = "#" -FIRST_LINE: Final[int] = 1 - -BROKEN_LOOKUP: Final[str] = "broken lookup" -UNRESOLVED_PART: Final[str] = "unresolved part" -UNREACHED_ENTRY: Final[str] = "unreached entry" - -DYNAMIC_KEY_RULE: Final[str] = ( - f"a key part arriving in a variable is annotated with a concrete element enum, never " - f"{ELEMENT_BASE}, and a key an f-string builds states no values to check" -) - - -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 is_enum(member: object) -> bool: - """States whether a module member is an enum.""" - return isinstance(member, EnumMeta) - - -def is_element_enum(member: object) -> bool: - """States whether a module member is an enum of the elements a key part names.""" - return isinstance(member, EnumMeta) and issubclass(member, AbstractElement) - - -def enum_members(member_type: Type[Enum]) -> EnumMembers: - """The value each member of an enum spells, keyed by member name.""" - return {member.name: str(member.value) for member in member_type} - - -def declared_enums(module: ModuleType, matches: EnumPredicate) -> Dict[str, EnumMembers]: - """The enums a module declares itself, keyed by enum name. - - An enum is read from the module declaring it, so a name a module merely imports states its - members once, under the module that owns it. - - Args: - module: Imported module to read. - matches: What makes a member one of the enums to read. - - Returns: - Dict[str, EnumMembers]: Enum name to its member names and the values they spell. - """ - return { - name: enum_members(member_type) - for name, member_type in inspect.getmembers(module, matches) - if member_type.__module__ == module.__name__ - } - - -def element_enum_modules() -> List[ModuleType]: - """Every module of the application declaring an element enum, imported for its members. - - A module is found by the classes it declares, so an enum naming a domain's own elements lives - beside that domain and the check reads it there. - - Returns: - List[ModuleType]: The imported modules, in path order. - """ - return [ - importlib.import_module(module_name(module.path, SOURCE_ROOT)) - for module in discover_modules([APPLICATION_PACKAGE]) - if declared_subclasses(module.tree, ELEMENT_BASE) - ] - - -def enum_table() -> EnumTable: - """The members of every enum a key part can name, keyed by enum name. - - The hierarchy states the page, panel, and text type of a key, and an element enum states its - element, so together they cover every part a lookup writes. - - Returns: - EnumTable: Enum name to its member names and the values they spell. - """ - table: Dict[str, EnumMembers] = declared_enums(hierarchy, is_enum) - for module in element_enum_modules(): - table.update(declared_enums(module, is_element_enum)) - - return table - - -def entry_key(line: str) -> Optional[str]: - """The key a line of the language file states, where it states one.""" - key, separator, _ = line.partition(ENTRY_SEPARATOR) - stripped = key.strip() - if not separator or not stripped or stripped.startswith(COMMENT_PREFIX): - return None - - return stripped - - -def entry_lines(path: Path) -> Dict[str, int]: - """Where each key sits in the language file, read line by line.""" - lines: Dict[str, int] = {} - for number, line in enumerate(path.read_text(encoding=LANGUAGE_ENCODING).splitlines(), start=FIRST_LINE): - key = entry_key(line) - if key is not None: - lines.setdefault(key, number) - - return lines - - -def language_entries(path: Path) -> Dict[str, int]: - """Every key the language file holds, paired with the line stating it. - - Args: - path: Language file to read. - - Returns: - Dict[str, int]: Key to its line, in file order. - - Raises: - TypeError: If the file holds anything other than a mapping. - """ - raw = yaml.safe_load(path.read_text(encoding=LANGUAGE_ENCODING)) - if not isinstance(raw, dict): - raise TypeError(f"Language file {path} must hold a mapping, got {type(raw)}") - - lines = entry_lines(path) - return {str(key): lines.get(str(key), FIRST_LINE) for key in raw} - - -def broken_lookups(sites: Sequence[LookupSite], entries: Mapping[str, int]) -> List[Finding]: - """Every literal key a lookup asks for that the language file holds no entry for. - - Args: - sites: Lookups read from the tree. - entries: Keys the language file holds. - - Returns: - List[Finding]: One finding per missing key. - """ - return [ - Finding( - kind=BROKEN_LOOKUP, - location=site.location, - message=f"the language file holds no entry for {key!r}", - ) - for site in sites - if site.exact - for key in site.values - if key not in entries - ] - - -def unresolved_lookups(sites: Sequence[LookupSite]) -> List[Finding]: - """Every lookup holding a part the check reads no values from. - - Args: - sites: Lookups read from the tree. - - Returns: - List[Finding]: One finding per lookup, naming the parts left unresolved. - """ - return [ - Finding( - kind=UNRESOLVED_PART, - location=site.location, - message=f"this lookup states {', '.join(site.unresolved_parts)}, which reaches no key: {DYNAMIC_KEY_RULE}", - ) - for site in sites - if not site.resolved - ] - - -def unreached_entries( - sites: Sequence[LookupSite], - entries: Mapping[str, int], - path: Path, -) -> List[Finding]: - """Every entry of the language file no lookup asks for. - - Args: - sites: Lookups read from the tree. - entries: Keys the language file holds, paired with their lines. - path: Language file the entries live in. - - Returns: - List[Finding]: One finding per unreached entry, in file order. - """ - reached = {key for site in sites for key in site.values} - return [ - Finding( - kind=UNREACHED_ENTRY, - location=f"{path}:{line}", - message=f"no lookup asks for {key!r}", - ) - for key, line in entries.items() - if key not in reached - ] - - -def check_language_keys(source: Path, language_file: Path) -> List[Finding]: - """Reads every lookup under a source root and holds it against a language file. - - Args: - source: Root of the sources to read. - language_file: Language file to check against. - - Returns: - List[Finding]: Everything the check reports, broken lookups first. - """ - modules = discover_modules([source]) - sites = tree_lookups( - modules, - index=source_index(modules), - enums=enum_table(), - receiver_type=RECEIVER_TYPE, - separator=TAG_SEPARATOR, - ) - entries = language_entries(language_file) - return [ - *broken_lookups(sites, entries), - *unresolved_lookups(sites), - *unreached_entries(sites, entries, language_file), - ] - - -def main(argv: Sequence[str]) -> int: - """Report every disagreement between the language file and the lookups reading it.""" - parser = argparse.ArgumentParser( - description="Check language keys against the language file, in both directions.", - ) - parser.add_argument( - "--source", - type=Path, - default=SOURCE_ROOT, - help="root of the sources to read", - ) - parser.add_argument( - "--language-file", - type=Path, - default=LANG_EN, - help="language file to check against", - ) - arguments = parser.parse_args(list(argv)) - - findings = check_language_keys(arguments.source, arguments.language_file) - if not findings: - return 0 - - print("Language key finding(s):", file=sys.stderr) - for kind, location, message in findings: - print(f" [{kind}] {location}: {message}", file=sys.stderr) - - print(f"\nFound {len(findings)} language key finding(s).", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py deleted file mode 100755 index 002c9bf84..000000000 --- a/scripts/checks/palette_colors.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 - -""" -Checks that a colour 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 -written into the shipped configuration as a literal instead of a palette token. - -Usage: - python scripts/checks/palette_colors.py # check the source tree and the config package -""" - -import argparse -import ast -import logging -import re -import sys -from collections.abc import Iterator, Sequence -from itertools import chain -from pathlib import Path -from typing import Final, List, NamedTuple, Tuple, Union - -from sampletones_application.paths import PALETTES_DIRECTORY -from sampletones_shared.logger import logger -from sampletones_shared.meta.source.modules import SourceModule, discover_modules -from sampletones_shared.meta.source.nodes import terminal_name -from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths.resources import CONFIG_DIRECTORY - -APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application") - -HEX_COLOR: Final[re.Pattern[str]] = re.compile(r"[\"']#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?[\"']") - -COLOR_PROPERTY: Final[str] = "rgba" -SELF_NAMES: Final[Tuple[str, ...]] = ("self", "cls") - -THEME_COLOR_CALL: Final[str] = "add_theme_color" -CONFIG_PATTERN: Final[str] = "*.yaml" - - -Assignment = Union[ast.Assign, ast.AnnAssign] - - -class ColorFinding(NamedTuple): - """One place a colour stops following the palette, and what to do about it.""" - - location: str - message: str - - -def _assignments(tree: ast.Module) -> Iterator[Assignment]: - for node in ast.walk(tree): - if isinstance(node, (ast.Assign, ast.AnnAssign)): - yield node - - -def _assigned_targets(statement: Assignment) -> Tuple[ast.expr, ...]: - if isinstance(statement, ast.Assign): - return tuple(statement.targets) - - return (statement.target,) - - -def _is_own_attribute(target: ast.expr) -> bool: - return isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id in SELF_NAMES - - -def _resolves_a_color(value: ast.expr) -> bool: - return isinstance(value, ast.Attribute) and value.attr == COLOR_PROPERTY - - -def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: - """Every attribute a module assigns the resolved value of a palette colour. - - Args: - module: Module to read. - - Yields: - ColorFinding: One per assignment, naming the attribute that keeps the stale shade. - """ - for statement in _assignments(module.tree): - if statement.value is None or not _resolves_a_color(statement.value): - continue - - for target in _assigned_targets(statement): - if _is_own_attribute(target): - yield ColorFinding( - location=module.location(statement), - message=( - f"stores .{COLOR_PROPERTY}; hold the BaseColor and read " - f".{COLOR_PROPERTY} where the colour reaches DearPyGui" - ), - ) - - -def dpg_module_helper() -> Tuple[Path, str]: - """The module allowed to fill a theme colour, and the helper every other module calls. - - Returns: - Tuple[Path, str]: The resolved path of the bindings module, and the helper's name. - """ - import sampletones_application.utils.gui.palette.dpg as bindings - - return Path(bindings.__file__).resolve(), bindings.dpg_add_palette_theme_color.__name__ - - -def unregistered_theme_colors( - module: SourceModule, - *, - bindings_module: Path, - theme_color_helper: str, -) -> Iterator[ColorFinding]: - """Every theme colour a module fills without recording the token behind it. - - Args: - module: Module to read. - bindings_module: Module the call belongs in, which records the token in the same breath. - 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 - built with. - """ - if module.path.resolve() == bindings_module: - return - - for node in ast.walk(module.tree): - 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", - ) - - -def literal_colors(path: Path) -> Iterator[ColorFinding]: - """Every hex colour a shipped configuration file writes out in place of a palette token. - - Args: - path: Configuration file to read. - - Yields: - ColorFinding: One per literal, naming the line that holds it. - """ - for number, line in enumerate( - path.read_text(encoding="utf-8").splitlines(), - start=1, - ): - 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", - ) - - -def find_detached_colors( - package: Path, - *, - bindings_module: Path, - theme_color_helper: str, -) -> List[ColorFinding]: - return [ - finding - for module in discover_modules([package]) - for finding in chain( - stored_colors(module), - unregistered_theme_colors( - module, - bindings_module=bindings_module, - theme_color_helper=theme_color_helper, - ), - ) - ] - - -def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: - """Every hex colour 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. - - Returns: - List[ColorFinding]: One finding per literal, in file order. - - Raises: - FileNotFoundError: If the package holds no configuration file to read. - """ - paths = sorted(package.rglob(CONFIG_PATTERN)) - if not paths: - raise FileNotFoundError(f"The configuration package {package} holds no {CONFIG_PATTERN} file to read") - - return [finding for path in paths if palettes not in path.parents for finding in literal_colors(path)] - - -def main(argv: Sequence[str]) -> int: - """Report every colour 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.", - ) - parser.add_argument( - "--package", - type=Path, - default=APPLICATION_PACKAGE, - help="package whose colour reads to check", - ) - parser.add_argument( - "--config", - type=Path, - default=CONFIG_DIRECTORY, - help="shipped configuration package whose colours must name palette tokens", - ) - parser.add_argument( - "--palettes", - type=Path, - default=PALETTES_DIRECTORY, - help="directory holding the palettes, where colour values belong", - ) - arguments = parser.parse_args(list(argv)) - - findings = find_detached_colors( - arguments.package, - bindings_module=bindings_module, - theme_color_helper=theme_color_helper, - ) - findings.extend( - find_literal_colors( - arguments.config, - arguments.palettes, - ) - ) - - if not findings: - return 0 - - print( - "Colour(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.", - file=sys.stderr, - ) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/checks/tag_names.py b/scripts/checks/tag_names.py deleted file mode 100755 index e2757858d..000000000 --- a/scripts/checks/tag_names.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 - -""" -Checks that every tag constant is named after the tag it composes. - -`TAG_GLOBAL_WINDOW_MAIN = TagName(Page.GLOBAL, Panel.IMPLICIT, Widget.WINDOW, "main")` composes the -tag `global.window.main`, so its name is that tag upper-cased with each separator turned into an -underscore, behind the `TAG_` prefix. Reading the name therefore states the tag, and reading the tag -states where its constant lives. - -Usage: - python scripts/checks/tag_names.py [files...] # check the given modules - python scripts/checks/tag_names.py --all # check every module of the tags package -""" - -import argparse -import ast -import sys -from enum import StrEnum -from pathlib import Path -from typing import Dict, Final, List, NamedTuple, Optional, Sequence, Tuple, Type, TypeVar - -from sampletones_application.categories.hierarchy import Page, Panel, Widget -from sampletones_application.categories.key.tag import TagName -from sampletones_application.tags.compose import TAG_SEPARATOR -from sampletones_shared.meta.source.constants import ModuleConstant, module_constants -from sampletones_shared.meta.source.modules import SourceModule, discover_modules, parse_module -from sampletones_shared.meta.source.nodes import terminal_name -from sampletones_shared.meta.source.packages import package_directory - -TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags") - -TAG_PREFIX: Final[str] = "TAG" -TAG_NAME_CLASS: Final[str] = "TagName" - -PAGE_ARGUMENT: Final[str] = "page" -PANEL_ARGUMENT: Final[str] = "panel" -WIDGET_ARGUMENT: Final[str] = "widget" -ELEMENT_ARGUMENT: Final[str] = "element" -TAG_ARGUMENTS: Final[Tuple[str, ...]] = ( - PAGE_ARGUMENT, - PANEL_ARGUMENT, - WIDGET_ARGUMENT, - ELEMENT_ARGUMENT, -) - -EnumMember = TypeVar("EnumMember", bound=StrEnum) - - -class TagFinding(NamedTuple): - """One tag constant the check reports, and what it reports about it.""" - - location: str - message: str - - -def expected_name(tag: TagName) -> str: - """The constant name a tag calls for. - - Args: - tag: Tag the constant composes. - - Returns: - str: The name, such as `TAG_GLOBAL_WINDOW_MAIN`. - """ - return f"{TAG_PREFIX}_{str(tag).upper().replace(TAG_SEPARATOR, '_')}" - - -def tag_call(constant: ModuleConstant) -> Optional[ast.Call]: - """The `TagName(...)` call a constant is bound to, where it is bound to one.""" - value = constant.value - if isinstance(value, ast.Call) and terminal_name(value.func) == TAG_NAME_CLASS: - return value - - return None - - -def call_arguments(call: ast.Call) -> Optional[Dict[str, ast.expr]]: - """The arguments a tag call passes, keyed by the parameter each one fills. - - Args: - call: Call to read, written with positional arguments, keywords, or both. - - Returns: - Optional[Dict[str, ast.expr]]: One entry per tag argument, or `None` where the call fills - them in a form the check reads no arguments from. - """ - arguments = dict(zip(TAG_ARGUMENTS, call.args, strict=False)) - for keyword in call.keywords: - if keyword.arg is None: - return None - - arguments[keyword.arg] = keyword.value - - if set(arguments) != set(TAG_ARGUMENTS): - return None - - return arguments - - -def hierarchy_member(node: ast.expr, enum: Type[EnumMember]) -> Optional[EnumMember]: - """The hierarchy member an argument names, such as `Page.GLOBAL`. - - Args: - node: Argument to read. - enum: Hierarchy enum the argument names a member of. - - Returns: - Optional[EnumMember]: The member, or `None` where the argument names none. - """ - match node: - case ast.Attribute(value=ast.Name(id=owner), attr=member) if owner == enum.__name__: - return enum.__members__.get(member) - case _: - return None - - -def element_name(node: ast.expr) -> Optional[str]: - """The element an argument spells, where it spells one as a string literal.""" - match node: - case ast.Constant(value=str() as element): - return element - case _: - return None - - -def composed_tag(arguments: Dict[str, ast.expr]) -> Optional[TagName]: - """The tag a set of arguments composes, where each one states its part. - - Args: - arguments: Arguments of a tag call, as `call_arguments` keys them. - - Returns: - Optional[TagName]: The composed tag, or `None` where an argument states something the check - reads no part from. - """ - page = hierarchy_member(arguments[PAGE_ARGUMENT], Page) - panel = hierarchy_member(arguments[PANEL_ARGUMENT], Panel) - widget = hierarchy_member(arguments[WIDGET_ARGUMENT], Widget) - element = element_name(arguments[ELEMENT_ARGUMENT]) - if page is None or panel is None or widget is None or element is None: - return None - - return TagName(page, panel, widget, element) - - -def check_constant(module: SourceModule, constant: ModuleConstant) -> Optional[TagFinding]: - """Checks one constant of a tags module, where it is bound to a tag. - - Args: - module: Module the constant lives in. - constant: Constant to check. - - Returns: - Optional[TagFinding]: What the check reports, or `None` where the constant is well named or - holds no tag. - """ - call = tag_call(constant) - if call is None: - return None - - location = f"{module.path}:{constant.line}" - arguments = call_arguments(call) - if arguments is None: - return TagFinding( - location=location, - message=( - f"{constant.name} calls {TAG_NAME_CLASS} in a form the check reads no arguments from; " - f"a tag states {', '.join(TAG_ARGUMENTS)}" - ), - ) - - tag = composed_tag(arguments) - if tag is None: - return TagFinding( - location=location, - message=( - f"{constant.name} states its parts in a form the check reads no tag from; a tag names a " - f"{Page.__name__}, a {Panel.__name__}, and a {Widget.__name__} member, then spells its " - "element as a string literal" - ), - ) - - expected = expected_name(tag) - if constant.name != expected: - return TagFinding( - location=location, - message=f"{constant.name} composes the tag {str(tag)!r}, which calls for the name {expected}", - ) - - return None - - -def check_module(module: SourceModule) -> List[TagFinding]: - """Checks every tag constant one module declares.""" - findings = (check_constant(module, constant) for constant in module_constants(module.tree)) - return [finding for finding in findings if finding is not None] - - -def check_modules(modules: Sequence[SourceModule]) -> List[TagFinding]: - """Checks every tag constant the given modules declare, in the order they were read.""" - return [finding for module in modules for finding in check_module(module)] - - -def main(argv: Sequence[str]) -> int: - """Report any tag constant whose name departs from the tag it composes.""" - parser = argparse.ArgumentParser( - description="Check that tag constant names state the tags they compose.", - ) - parser.add_argument( - "files", - nargs="*", - type=Path, - help="modules to check", - ) - parser.add_argument( - "--all", - action="store_true", - help=f"check every module under {TAGS_PACKAGE.name}/ instead of named files", - ) - arguments = parser.parse_args(list(argv)) - - files: List[Path] = arguments.files - modules = discover_modules([TAGS_PACKAGE]) if arguments.all else [parse_module(path) for path in files] - findings = check_modules(modules) - if not findings: - return 0 - - print("Tag name(s) departing from the tag they compose:", file=sys.stderr) - for location, message in findings: - print(f" {location}: {message}", file=sys.stderr) - - print(f"\nFound {len(findings)} tag name(s) to fix.", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py deleted file mode 100755 index c2a87b5ce..000000000 --- a/scripts/checks/unused_tags.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 - -""" -Checks that every tag fragment the tags package declares is read somewhere. - -A tag or a suffix nobody reads still reads as part of the interface vocabulary, so it invites a -second constant for the same widget. The check counts reads across the sources, the tests, and the -scripts: a fragment feeding another fragment's value counts as read, while an import alone does not, -which is what makes a re-exported yet unread fragment visible. - -Usage: - python scripts/checks/unused_tags.py -""" - -import argparse -import sys -from collections import Counter -from pathlib import Path -from typing import Dict, Final, List, NamedTuple, Sequence, Tuple - -from sampletones_shared.meta.source.constants import module_constants -from sampletones_shared.meta.source.modules import SourceModule, discover_modules -from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.meta.source.references import count_identifier_loads -from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT - -TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags") -REFERENCE_ROOTS: Final[Tuple[Path, ...]] = ( - SOURCE_ROOT, - REPOSITORY_ROOT / "tests", - REPOSITORY_ROOT / "scripts", -) - -FRAGMENT_PREFIXES: Final[Tuple[str, ...]] = ("TAG_", "SUF_", "PRE_") - - -class Fragment(NamedTuple): - """One tag fragment a module declares, and where it declares it.""" - - name: str - location: str - - -def is_fragment(name: str) -> bool: - """Whether a constant name belongs to the tag vocabulary.""" - return name.startswith(FRAGMENT_PREFIXES) - - -def declared_fragments(modules: Sequence[SourceModule]) -> List[Fragment]: - """Every tag fragment the given modules declare, in the order they were read. - - Args: - modules: Modules of the tags package. - - Returns: - List[Fragment]: The fragments, each paired with its location. - """ - return [ - Fragment( - name=constant.name, - location=f"{module.path}:{constant.line}", - ) - for module in modules - for constant in module_constants(module.tree) - if is_fragment(constant.name) - ] - - -def reference_counts(modules: Sequence[SourceModule]) -> Dict[str, int]: - """How often the given modules read each identifier, summed across them. - - Args: - modules: Modules to read. - - Returns: - Dict[str, int]: Identifier to the number of reads. - """ - counts: Counter[str] = Counter() - for module in modules: - counts.update(count_identifier_loads(module.tree)) - - return dict(counts) - - -def unread_fragments( - fragments: Sequence[Fragment], - counts: Dict[str, int], -) -> List[Fragment]: - """The fragments no module reads. - - Args: - fragments: Fragments the tags package declares. - counts: Reads per identifier, as `reference_counts` states them. - - Returns: - List[Fragment]: The fragments standing at no reads, in the order they were declared. - """ - return [fragment for fragment in fragments if counts.get(fragment.name, 0) == 0] - - -def main(argv: Sequence[str]) -> int: - """Report every tag fragment the repository declares and never reads.""" - parser = argparse.ArgumentParser( - description="Check that every declared tag fragment is read somewhere.", - ) - parser.add_argument( - "--tags", - type=Path, - default=TAGS_PACKAGE, - help="package declaring the tag fragments", - ) - parser.add_argument( - "--reference-root", - type=Path, - action="append", - dest="reference_roots", - help="directory to count reads in, repeatable", - ) - arguments = parser.parse_args(list(argv)) - - tags: Path = arguments.tags - reference_roots: Tuple[Path, ...] = tuple(arguments.reference_roots or REFERENCE_ROOTS) - fragments = declared_fragments(discover_modules([tags])) - counts = reference_counts(discover_modules(reference_roots)) - unread = unread_fragments(fragments, counts) - if not unread: - return 0 - - print("Tag fragment(s) declared and never read:", file=sys.stderr) - for name, location in unread: - print(f" {location}: {name}", file=sys.stderr) - - print(f"\nFound {len(unread)} unread tag fragment(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 deleted file mode 100644 index 08fd64dbb..000000000 --- a/scripts/ci/checks/bundle.py +++ /dev/null @@ -1,83 +0,0 @@ -import argparse -import platform -import subprocess -import sys -from pathlib import Path -from typing import Final, List, Sequence - -WINDOWS: Final[str] = "Windows" -WINDOWS_LAUNCHER: Final[str] = "sampletones.exe" -POSIX_LAUNCHER: Final[str] = "sampletones" -VERSION_FLAG: Final[str] = "--version" - -REQUIRED_NOTICES: Final[Sequence[str]] = ( - "LICENSE", - "THIRD-PARTY-NOTICES.md", - "THIRD-PARTY-LICENSES.txt", -) - -INTERNAL_DIRECTORY: Final[str] = "_internal" -BUILD_TOOLS: Final[Sequence[str]] = ("PIL",) - - -def launcher_path(bundle: Path, *, system: str) -> Path: - """The executable a built bundle offers on the platform it was built for.""" - name = WINDOWS_LAUNCHER if system == WINDOWS else POSIX_LAUNCHER - return bundle / name - - -def missing_notices(bundle: Path) -> List[str]: - """The licence 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()] - - -def carried_build_tools(bundle: Path) -> List[str]: - """The build-time packages found in a bundle, which the notices place on the build machine. - - A bundle carries the application and its runtime dependencies. Tooling that only draws the - assets belongs to the machine that builds it, so finding it here means the notices describe - a different set of components than the bundle ships. - """ - directories = (bundle, bundle / INTERNAL_DIRECTORY) - return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)] - - -def main(argv: Sequence[str]) -> int: - """Confirm a built bundle ships its notices, holds to them, and that its launcher starts.""" - parser = argparse.ArgumentParser( - description="Verify a built bundle before it is archived.", - ) - parser.add_argument( - "bundle", - type=Path, - help="the built bundle directory, such as bin/sampletones", - ) - arguments = parser.parse_args(list(argv)) - - bundle: Path = arguments.bundle - absent = missing_notices(bundle) - if absent: - print(f"::error::Bundle {bundle} is missing {', '.join(absent)}") - return 1 - - carried = carried_build_tools(bundle) - if carried: - print(f"::error::Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}") - return 1 - - launcher = launcher_path(bundle, system=platform.system()) - if not launcher.is_file(): - print(f"::error::Bundle {bundle} offers no launcher at {launcher}") - return 1 - - completed = subprocess.run([str(launcher), VERSION_FLAG], check=False) - if completed.returncode != 0: - print(f"::error::Launcher {launcher} exited with status {completed.returncode}") - return completed.returncode - - print(f"Bundle {bundle} ships its notices and its launcher starts") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/ci/checks/version_tag.py b/scripts/ci/checks/version_tag.py deleted file mode 100644 index 47d4438c7..000000000 --- a/scripts/ci/checks/version_tag.py +++ /dev/null @@ -1,46 +0,0 @@ -import argparse -import sys -from typing import Final, Sequence - -TAG_PREFIX: Final[str] = "v" - - -def version_from_tag(tag: str) -> str: - """The project version a release tag names, read from the tag with its ``v`` prefix dropped.""" - return tag.removeprefix(TAG_PREFIX) - - -def tag_names_version(*, tag: str, project_version: str) -> bool: - """Whether a release tag names the version recorded in the project metadata.""" - return version_from_tag(tag) == project_version - - -def main(argv: Sequence[str]) -> int: - """Confirm a release tag and the project metadata agree on the version being released.""" - parser = argparse.ArgumentParser( - description="Compare a release tag against the project version.", - ) - parser.add_argument( - "--tag", - required=True, - help="the release tag being built, such as v0.3.0", - ) - parser.add_argument( - "--project-version", - required=True, - help="the version recorded in pyproject.toml", - ) - arguments = parser.parse_args(list(argv)) - - tag: str = arguments.tag - project_version: str = arguments.project_version - if not tag_names_version(tag=tag, project_version=project_version): - print(f"::error::Tag {tag} names a version other than the pyproject version {project_version}") - return 1 - - print(f"Version {project_version} matches tag {tag}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/ci/zip_bundle.py b/scripts/ci/zip_bundle.py deleted file mode 100644 index f8a523bef..000000000 --- a/scripts/ci/zip_bundle.py +++ /dev/null @@ -1,56 +0,0 @@ -import argparse -import sys -import zipfile -from pathlib import Path -from typing import Final, List, Sequence - -ARCHIVE_COMPRESSION: Final[int] = zipfile.ZIP_DEFLATED - - -def bundle_entries(source: Path) -> List[Path]: - """Every file and directory inside a built bundle, ordered so repeated runs archive alike.""" - return sorted(source.rglob("*")) - - -def archive_name(path: Path, *, source: Path, root: str) -> str: - """The location a bundle path takes inside the archive, gathered under a single root directory.""" - return f"{root}/{path.relative_to(source).as_posix()}" - - -def write_archive(source: Path, archive: Path, *, root: str) -> List[Path]: - """Archive a built bundle directory, placing every entry under ``root``. - - Each entry is read where it lies, which keeps the archive available while a virus scanner or a - process that ran the executable holds a handle inside the directory, and carries over the - permission bits that let the launcher run once the archive is extracted. - """ - archive.parent.mkdir(parents=True, exist_ok=True) - entries = bundle_entries(source) - with zipfile.ZipFile(archive, "w", ARCHIVE_COMPRESSION) as bundle: - for path in entries: - bundle.write(path, archive_name(path, source=source, root=root)) - - return entries - - -def main(argv: Sequence[str]) -> int: - """Archive a built bundle directory under a versioned root directory.""" - parser = argparse.ArgumentParser(description="Archive a built bundle directory under a versioned root.") - parser.add_argument("source", type=Path, help="the built bundle directory, such as bin/sampletones") - parser.add_argument("archive", type=Path, help="the path of the zip file to write") - parser.add_argument("--root", required=True, help="the directory name every archived entry sits under") - arguments = parser.parse_args(list(argv)) - - source: Path = arguments.source - archive: Path = arguments.archive - if not source.is_dir(): - print(f"::error::Bundle directory {source} is missing") - return 1 - - entries = write_archive(source, archive, root=arguments.root) - print(f"Archived {len(entries)} entries from {source} into {archive}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/clean.py b/scripts/clean.py new file mode 100644 index 000000000..08a690ee6 --- /dev/null +++ b/scripts/clean.py @@ -0,0 +1,56 @@ +import argparse +import sys +from pathlib import Path +from typing import Sequence + +from bootstrap.files import remove_path +from bootstrap.layout import ( + CACHE_DIRECTORIES, + CACHE_DIRECTORY_SUFFIXES, + CACHE_FILE_SUFFIXES, + CLEAN_ARTIFACTS, + CLEAN_PATTERNS, + ENVIRONMENTS, + repository_root, +) + + +def remove_artifacts(root: Path) -> None: + """Removes the build outputs and the coverage reports under ``root``.""" + for name in CLEAN_ARTIFACTS: + remove_path(root / name) + + for pattern in CLEAN_PATTERNS: + for path in root.glob(pattern): + remove_path(path) + + +def remove_caches(root: Path) -> None: + """Removes the bytecode caches and packaging leftovers under ``root``, the environments left alone.""" + for directory, subdirectories, files in root.walk(): + subdirectories[:] = [name for name in subdirectories if name not in ENVIRONMENTS] + for name in list(subdirectories): + if name in CACHE_DIRECTORIES or name.endswith(CACHE_DIRECTORY_SUFFIXES): + remove_path(directory / name) + subdirectories.remove(name) + + for name in files: + if name.endswith(CACHE_FILE_SUFFIXES): + remove_path(directory / name) + + +def main(argv: Sequence[str]) -> int: + """Removes the build artifacts and cache files from the repository.""" + parser = argparse.ArgumentParser(description="Remove build artifacts and cache files.") + parser.parse_args(list(argv)) + + root = repository_root() + print("Removing build artifacts and temporary files...") + remove_artifacts(root) + remove_caches(root) + print("Cleaned build artifacts and temporary files.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/detect_cuda.py b/scripts/detect_cuda.py deleted file mode 100644 index 9aa905228..000000000 --- a/scripts/detect_cuda.py +++ /dev/null @@ -1,235 +0,0 @@ -import argparse -import os -import platform -import re -import shutil -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Final, List, Optional, Sequence, Tuple - -NVIDIA_SMI: Final[str] = "nvidia-smi" - -DARWIN: Final[str] = "Darwin" -WINDOWS: Final[str] = "Windows" - -EXTRA_GPU: Final[str] = "gpu" -EXTRA_GPU_CUDA11: Final[str] = "gpu-cuda11" - -CUDA12_MAJOR: Final[int] = 12 -CUDA11_MAJOR: Final[int] = 11 - -CUDA_VERSION_PATTERN: Final[re.Pattern[str]] = re.compile(r"CUDA Version\s*:?\s*(\d+)\.(\d+)") - -WINDOWS_NVIDIA_SMI_SUBPATHS: Final[Sequence[Tuple[str, str]]] = ( - ("SystemRoot", "System32/nvidia-smi.exe"), - ("ProgramFiles", "NVIDIA Corporation/NVSMI/nvidia-smi.exe"), -) - - -@dataclass(frozen=True, kw_only=True) -class CudaDetection: - """The NVIDIA driver capability observed on the host and the CuPy extra it maps to.""" - - system: str - nvidia_smi: Optional[Path] - cuda_version: Optional[Tuple[int, int]] - extra: Optional[str] - reason: str - - -def _windows_nvidia_smi_candidates() -> List[Path]: - """Return the fixed Windows locations where the driver installs ``nvidia-smi.exe``.""" - candidates: List[Path] = [] - for variable, relative in WINDOWS_NVIDIA_SMI_SUBPATHS: - root = os.environ.get(variable) - if root: - candidates.append(Path(root) / relative) - - return candidates - - -def find_nvidia_smi(*, system: str) -> Optional[Path]: - """Return the path to ``nvidia-smi`` when an NVIDIA driver is installed. - - ``nvidia-smi`` ships with the driver and lands on ``PATH`` on Linux and Windows. On Windows - it may also sit in a fixed system location off ``PATH``, so probe those as a fallback. - """ - located = shutil.which(NVIDIA_SMI) - if located is not None: - return Path(located) - - if system == WINDOWS: - for candidate in _windows_nvidia_smi_candidates(): - if candidate.exists(): - return candidate - return None - - -def _run_nvidia_smi( - nvidia_smi: Path, - arguments: Sequence[str], -) -> Optional[str]: - """Return the combined output of an ``nvidia-smi`` invocation that succeeds.""" - try: - completed = subprocess.run( - [str(nvidia_smi), *arguments], - capture_output=True, - text=True, - check=False, - ) - except (OSError, subprocess.SubprocessError): - return None - - if completed.returncode != 0: - return None - - return completed.stdout + completed.stderr - - -def query_driver_cuda_version(nvidia_smi: Path) -> Optional[Tuple[int, int]]: - """Return the maximum CUDA version the driver supports, as ``(major, minor)``. - - ``nvidia-smi`` reports the driver's maximum supported CUDA version, which governs CuPy wheel - selection: CuPy wheels bind to the driver rather than to a system CUDA Toolkit. The default - table carries the value, and ``-q`` provides a structured fallback for formats that omit it. - """ - for arguments in ((), ("-q",)): - output = _run_nvidia_smi(nvidia_smi, arguments) - if output is None: - continue - match = CUDA_VERSION_PATTERN.search(output) - if match is not None: - return int(match.group(1)), int(match.group(2)) - - return None - - -def select_extra(cuda_version: Optional[Tuple[int, int]]) -> Optional[str]: - """Return the optional-dependency extra matching a driver's CUDA version. - - CUDA 12 and newer drivers map to the ``gpu`` extra (``cupy-cuda12x``): a CUDA 12 wheel runs on - every 12.x driver through Enhanced Compatibility and on newer drivers through backward - compatibility. CUDA 11 drivers map to the legacy ``gpu-cuda11`` extra. Older drivers map to - ``None``, keeping the CPU backend. - """ - if cuda_version is None: - return None - - major = cuda_version[0] - if major >= CUDA12_MAJOR: - return EXTRA_GPU - - if major == CUDA11_MAJOR: - return EXTRA_GPU_CUDA11 - - return None - - -def _describe( - *, - cuda_version: Optional[Tuple[int, int]], - extra: Optional[str], -) -> str: - """Return a one-line summary of a driver-present detection outcome.""" - if cuda_version is None: - return "NVIDIA driver detected, though its CUDA version was unreadable; keeping the CPU (NumPy) backend." - major, minor = cuda_version - if extra is None: - return ( - f"Detected CUDA {major}.{minor}, which predates the supported CuPy builds; " - "keeping the CPU (NumPy) backend." - ) - return f"Detected an NVIDIA driver supporting CUDA {major}.{minor}; selecting the '{extra}' extra." - - -def detect(*, system: str) -> CudaDetection: - """Return the CUDA capability of the host and the CuPy extra that matches it. - - macOS keeps the CPU backend, since NVIDIA CUDA is available only on Linux and Windows. On - those systems the driver's ``nvidia-smi`` supplies the CUDA version that drives selection. - """ - if system == DARWIN: - return CudaDetection( - system=system, - nvidia_smi=None, - cuda_version=None, - extra=None, - reason="NVIDIA CUDA is available on Linux and Windows; on macOS, keeping the CPU (NumPy) backend.", - ) - - nvidia_smi = find_nvidia_smi(system=system) - if nvidia_smi is None: - return CudaDetection( - system=system, - nvidia_smi=None, - cuda_version=None, - extra=None, - reason="No NVIDIA driver detected (nvidia-smi is absent); keeping the CPU (NumPy) backend.", - ) - - cuda_version = query_driver_cuda_version(nvidia_smi) - extra = select_extra(cuda_version) - return CudaDetection( - system=system, - nvidia_smi=nvidia_smi, - cuda_version=cuda_version, - extra=extra, - reason=_describe(cuda_version=cuda_version, extra=extra), - ) - - -def _format_version(cuda_version: Optional[Tuple[int, int]]) -> str: - """Return a human-readable CUDA version, or ``unknown`` when it was unreadable.""" - if cuda_version is None: - return "unknown" - - major, minor = cuda_version - return f"{major}.{minor}" - - -def _print_report(detection: CudaDetection) -> None: - """Print the full detection outcome to stdout for a human reader.""" - nvidia_smi = str(detection.nvidia_smi) if detection.nvidia_smi is not None else "not found" - extra = detection.extra if detection.extra is not None else "none (CPU)" - lines = [ - f"System: {detection.system}", - f"nvidia-smi: {nvidia_smi}", - f"Driver CUDA: {_format_version(detection.cuda_version)}", - f"Selected extra: {extra}", - f"Summary: {detection.reason}", - ] - print("\n".join(lines)) - - -def main(argv: Sequence[str]) -> int: - """Report the detected CUDA capability, or print the matching extra for install scripts. - - Under ``--extra`` the chosen extra name is written to stdout (empty when the CPU backend is - kept) so a Makefile can capture it, and the summary is written to stderr. Without it, a full - report is written to stdout. - """ - parser = argparse.ArgumentParser( - description="Select the CuPy extra matching the local NVIDIA driver.", - ) - parser.add_argument( - "--extra", - action="store_true", - help="print the chosen optional-dependency extra to stdout (empty when the CPU backend is kept)", - ) - arguments = parser.parse_args(list(argv)) - detection = detect(system=platform.system()) - - if arguments.extra: - print(detection.reason, file=sys.stderr) - if detection.extra is not None: - print(detection.extra) - else: - _print_report(detection) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/formatting.py b/scripts/formatting.py new file mode 100644 index 000000000..82407162c --- /dev/null +++ b/scripts/formatting.py @@ -0,0 +1,61 @@ +import argparse +import os +import sys +from pathlib import Path +from typing import Final, Mapping, Sequence, Tuple + +from bootstrap.layout import SOURCE_DIRECTORY, repository_root +from bootstrap.processes import Runner, expect_success, run + +FORMATTED_TREES: Final[Tuple[str, ...]] = (SOURCE_DIRECTORY, "tests", "scripts") +ISORT: Final[Tuple[str, ...]] = ("uv", "run", "python", "-m", "isort") +BLACK: Final[Tuple[str, ...]] = ("uv", "run", "python", "-m", "black") + + +def format_code( + root: Path, + paths: Sequence[str], + *, + runner: Runner, + environment: Mapping[str, str], +) -> None: + """Sorts the imports and formats the code under ``paths``, isort first so black settles the result. + + Args: + root: The repository, which the formatters run in. + paths: The files or directories formatted. + runner: What runs the formatters. + environment: The variables the formatters see. + + Raises: + SystemExit: If a formatter fails, before the next one runs. + """ + print("Formatting imports with isort...") + expect_success(runner, (*ISORT, *paths), cwd=root, environment=environment) + print("Formatting code with black...") + expect_success(runner, (*BLACK, *paths), cwd=root, environment=environment) + + +def formatted_paths(named: Sequence[str]) -> Tuple[str, ...]: + """The paths a run formats: the ones named, or the source, test and script trees.""" + return tuple(named) or FORMATTED_TREES + + +def main(argv: Sequence[str]) -> int: + """Formats the source, test and script trees, or the paths named.""" + parser = argparse.ArgumentParser(description="Format the SampleToNES code with isort and black.") + parser.add_argument("paths", nargs="*", help="files or directories to format in place of the whole trees") + arguments = parser.parse_args(list(argv)) + + format_code( + repository_root(), + formatted_paths(tuple(arguments.paths)), + runner=run, + environment=os.environ, + ) + print("Code formatting complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/hooks.py b/scripts/hooks.py new file mode 100644 index 000000000..96fb13c01 --- /dev/null +++ b/scripts/hooks.py @@ -0,0 +1,48 @@ +import argparse +import os +import sys +from pathlib import Path +from typing import Final, Mapping, Sequence, Tuple + +from bootstrap.layout import repository_root +from bootstrap.processes import Runner, expect_success, run + +INSTALL_HOOKS: Final[Tuple[str, ...]] = ( + "uv", + "run", + "pre-commit", + "install", + "--hook-type", + "pre-commit", + "--hook-type", + "pre-push", +) + + +def install_hooks( + root: Path, + *, + runner: Runner, + environment: Mapping[str, str], +) -> None: + """Installs the git hooks pre-commit runs at commit and at push. + + Raises: + SystemExit: If pre-commit fails to install them. + """ + print("Installing pre-commit hooks...") + expect_success(runner, INSTALL_HOOKS, cwd=root, environment=environment) + print("Pre-commit hooks installed.") + + +def main(argv: Sequence[str]) -> int: + """Installs the git hooks pre-commit runs at commit and at push.""" + parser = argparse.ArgumentParser(description="Install the pre-commit hooks.") + parser.parse_args(list(argv)) + + install_hooks(repository_root(), runner=run, environment=os.environ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100644 index 000000000..55061cffd --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,86 @@ +import argparse +import os +import sys +from pathlib import Path +from typing import Final, Mapping, Sequence, Set, Tuple + +from bootstrap.layout import SOURCE_DIRECTORY, repository_root +from bootstrap.passes import Pass, run_passes +from bootstrap.processes import Runner, run + +MYPY: Final[str] = "mypy" +PYLINT: Final[str] = "pylint" +LINTED_TREES: Final[Tuple[str, ...]] = (SOURCE_DIRECTORY, "scripts") + + +def linters(paths: Sequence[str]) -> Tuple[Pass, ...]: + """Mypy and pylint, in that order, over ``paths``. + + Without paths, mypy reads the files ``pyproject.toml`` configures and pylint sweeps the + source and script trees. + """ + return ( + Pass( + MYPY, + "Running type checking with mypy...", + ("uv", "run", "python", "-m", MYPY, *paths), + ), + Pass( + PYLINT, + "Running linting with pylint...", + ("uv", "run", "python", "-m", PYLINT, *(paths or LINTED_TREES)), + ), + ) + + +def lint_code( + root: Path, + passes: Sequence[Pass], + *, + runner: Runner, + environment: Mapping[str, str], +) -> int: + """Runs every linter asked for and names the ones that failed. + + Args: + root: The repository, which the linters run in. + passes: The linters, in order. + runner: What runs them. + environment: The variables they see. + + Returns: + int: 0 where every linter passed, 1 otherwise. + """ + failed = run_passes(passes, root=root, runner=runner, environment=environment) + if failed: + print(f"Linting failed: {', '.join(failed)}.") + return 1 + + print("All linting checks passed.") + return 0 + + +def chosen_linters(paths: Sequence[str], *, mypy: bool, pylint: bool) -> Tuple[Pass, ...]: + """The linters a run asks for: the ones flagged, or both where none is.""" + chosen: Set[str] = {name for name, wanted in ((MYPY, mypy), (PYLINT, pylint)) if wanted} + return tuple(linter for linter in linters(paths) if not chosen or linter.name in chosen) + + +def main(argv: Sequence[str]) -> int: + """Type checks and lints the code, and reports which linter failed.""" + parser = argparse.ArgumentParser(description="Type check and lint the SampleToNES code.") + parser.add_argument("--mypy", action="store_true", help="run mypy alone") + parser.add_argument("--pylint", action="store_true", help="run pylint alone") + parser.add_argument("paths", nargs="*", help="files or directories to lint in place of the whole trees") + arguments = parser.parse_args(list(argv)) + + return lint_code( + repository_root(), + chosen_linters(tuple(arguments.paths), mypy=arguments.mypy, pylint=arguments.pylint), + runner=run, + environment=os.environ, + ) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh deleted file mode 100755 index 3fd6327e8..000000000 --- a/scripts/linux/build/build.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash - -set -e - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$SCRIPT_DIR/../lib/root.sh" - -PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) -VENV_DIR="$PROJECT_DIR/.venv-build" -VENV_PY="$VENV_DIR/bin/python" - -RELEASE=0 -BUNDLE_ARGS=(--onefile) -RELEASE_HOOK_ARGS=() -for arg in "$@"; do - if [[ "$arg" == "--release" ]]; then - echo "Release build: onedir bundle, injecting release deployment configuration" - RELEASE=1 - BUNDLE_ARGS=(--onedir) - RELEASE_HOOK_ARGS=(--runtime-hook scripts/release_env_hook.py) - fi -done - -if [[ "${RELEASE}" == "1" ]]; then - EXECUTABLE="bin/sampletones/sampletones" -else - EXECUTABLE="bin/sampletones" -fi - -bash "$SCRIPT_DIR/preflight.sh" "$@" -bash "$SCRIPT_DIR/icons.sh" - -if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then - echo "Removing the previous artifact: ./bin/sampletones" - rm -rf "${PROJECT_DIR}/bin/sampletones" -fi - -echo "Building executable..." -"$VENV_PY" -m PyInstaller --name sampletones \ - "${BUNDLE_ARGS[@]}" \ - --noconfirm \ - --distpath ./bin \ - --icon "src/sampletones_assets/icons/sampletones.png" \ - --add-data "src/sampletones_assets/icons:assets/icons" \ - --add-data "src/sampletones_assets/fonts:assets/fonts" \ - --add-data "src/sampletones_config:config" \ - --copy-metadata sampletones \ - --exclude-module PIL \ - "${RELEASE_HOOK_ARGS[@]}" \ - "src/sampletones/__main__.py" - -if [[ ! -x "${EXECUTABLE}" ]]; then - echo "Build failed: PyInstaller produced no executable at ./${EXECUTABLE}." >&2 - exit 1 -fi - -echo "Verifying the bundle..." -if ! "${PROJECT_DIR}/${EXECUTABLE}" --self-check; then - echo "Build failed: ./${EXECUTABLE} did not pass its self-check." >&2 - exit 1 -fi - -if [[ "${RELEASE}" == "1" ]]; then - for notice in LICENSE THIRD-PARTY-NOTICES.md THIRD-PARTY-LICENSES.txt; do - cp "${notice}" "bin/sampletones/${notice}" - done - echo "Bundled notices: LICENSE, THIRD-PARTY-NOTICES.md, THIRD-PARTY-LICENSES.txt" -fi - -echo "Build complete: ./${EXECUTABLE}" diff --git a/scripts/linux/build/clean.sh b/scripts/linux/build/clean.sh deleted file mode 100755 index 932a5fe58..000000000 --- a/scripts/linux/build/clean.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -source "$(dirname "${BASH_SOURCE[0]}")/../lib/root.sh" - -echo "Removing build artifacts and temporary files..." -rm -rf bin/ build/ dist/ *.spec htmlcov/ .coverage -find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true -find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true -find . -type f -name "*.pyc" -delete -echo "Cleaned build artifacts and temporary files." diff --git a/scripts/linux/build/dependencies.sh b/scripts/linux/build/dependencies.sh deleted file mode 100755 index 7b1758276..000000000 --- a/scripts/linux/build/dependencies.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash - -set -e - -PACKAGES=( - # PortAudio: audio playback through pyaudio - libportaudio2 - libasound-dev - libpulse-dev - portaudio19-dev - # Tk: file dialogs where kdialog and zenity are absent - python3-tk - tk-dev - tcl-dev - # OpenGL and X11: the window DearPyGui opens through GLFW - libgl1 - libegl1 - libx11-6 - libx11-xcb1 - libxcursor1 - libxi6 - libxinerama1 - libxrandr2 - libxrender1 - libxxf86vm1 -) - -echo "Installing system dependencies (requires sudo)" -sudo apt-get update -sudo apt-get install -y "${PACKAGES[@]}" -echo "System dependencies installed." diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh deleted file mode 100644 index df7bf35b8..000000000 --- a/scripts/linux/build/icons.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -e - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$SCRIPT_DIR/../lib/root.sh" - -PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) -VENV_PY="$PROJECT_DIR/.venv-build/bin/python" - -echo "Generating the icon suite..." -"$VENV_PY" scripts/assets/icons.py diff --git a/scripts/linux/build/preflight.sh b/scripts/linux/build/preflight.sh deleted file mode 100755 index 5008bde05..000000000 --- a/scripts/linux/build/preflight.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash - -set -e - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$SCRIPT_DIR/../lib/root.sh" - -PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) -VENV_DIR="$PROJECT_DIR/.venv-build" -VENV_PY="$VENV_DIR/bin/python" - -RELEASE=0 -for arg in "$@"; do - if [[ "$arg" == "--release" ]]; then - RELEASE=1 - fi -done - -can_import() { - "$VENV_PY" -c "import $1" >/dev/null 2>&1 -} - -echo "Checking the build environment..." - -if [[ ! -x "$VENV_PY" ]]; then - echo "ERROR: build interpreter not found at ${VENV_PY}." >&2 - echo "Run './install.sh' from the project root to create the build environment." >&2 - exit 1 -fi - -if ! can_import pyaudio; then - echo "ERROR: the build interpreter cannot import pyaudio, so the bundle would carry no audio playback." >&2 - echo "Run 'make system-deps' to install the PortAudio packages, then './install.sh' to reinstall dependencies." >&2 - exit 1 -fi - -echo "pyaudio: available" - -if can_import tkinter; then - echo "tkinter: available" - exit 0 -fi - -if [[ "${RELEASE}" == "1" ]]; then - echo "ERROR: the build interpreter cannot import tkinter, so a release bundle would depend on the" >&2 - echo "target machine providing zenity or kdialog for file dialogs." >&2 - echo "Run 'make system-deps' to install python3-tk, then build again." >&2 - exit 1 -fi - -echo "WARNING: the build interpreter cannot import tkinter." -echo "This bundle opens file dialogs through zenity or kdialog, which the machine running it has to provide." -echo "Run 'make system-deps' to install python3-tk and carry Tk as a self-contained fallback." diff --git a/scripts/linux/build/python.sh b/scripts/linux/build/python.sh deleted file mode 100755 index fec272e0a..000000000 --- a/scripts/linux/build/python.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -e - -PYVER=$(python3 --version 2>&1 | awk '{print $2}') -echo "Detected Python version: $PYVER" -MAJOR=$(echo $PYVER | cut -d. -f1) -MINOR=$(echo $PYVER | cut -d. -f2) -if [ "$MAJOR" -lt 3 ] || { [ "$MAJOR" -eq 3 ] && [ "$MINOR" -lt 12 ]; }; then - echo - echo "ERROR: Python 3.12 or newer is required." - echo "Please install Python 3.12+ from https://www.python.org/downloads/" - exit 1 -fi diff --git a/scripts/linux/build/sampletones.sh b/scripts/linux/build/sampletones.sh deleted file mode 100755 index d5accc984..000000000 --- a/scripts/linux/build/sampletones.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -set -e - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -. "$SCRIPT_DIR/../lib/root.sh" - -PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) -VENV_DIR="$PROJECT_DIR/.venv-build" -VENV_PY="$VENV_DIR/bin/python" - -EXTRAS=("build") -for arg in "$@"; do - case $arg in - --gpu) - EXTRAS+=("gpu") - ;; - esac -done - -echo "Installing dependencies..." -"$VENV_PY" -m pip install --upgrade pip - -EXTRAS_STR=$(IFS=,; echo "${EXTRAS[*]}") -echo "Installing with extras: $EXTRAS_STR" -"$VENV_PY" -m pip install ".[$EXTRAS_STR]" --group assets - -echo "sampletones Python package installed successfully." diff --git a/scripts/linux/build/venv.sh b/scripts/linux/build/venv.sh deleted file mode 100755 index 29b7ad326..000000000 --- a/scripts/linux/build/venv.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -e - -source "$(dirname "${BASH_SOURCE[0]}")/../lib/root.sh" - -if [[ -d ".venv-build" ]]; then - echo "Virtual environment already exists." -else - echo "Creating virtual environment..." - python3 -m venv .venv-build - echo "Virtual environment created." -fi - -return 0 diff --git a/scripts/linux/dev/format.sh b/scripts/linux/dev/format.sh deleted file mode 100755 index 16bcc7d03..000000000 --- a/scripts/linux/dev/format.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -echo "Formatting imports with isort..." -uv run python -m isort src/ tests/ - -echo "Formatting code with black..." -uv run python -m black src/ tests/ - -echo "Code formatting complete." diff --git a/scripts/linux/dev/lint.sh b/scripts/linux/dev/lint.sh deleted file mode 100755 index 6ac482b7a..000000000 --- a/scripts/linux/dev/lint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set +e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -bash "${SCRIPT_DIR}/mypy.sh" -MYPY_EXIT=$? - -bash "${SCRIPT_DIR}/pylint.sh" -PYLINT_EXIT=$? - -if [[ $MYPY_EXIT -ne 0 ]] || [[ $PYLINT_EXIT -ne 0 ]]; then - echo "Linting failed." - exit 1 -fi - -echo "All linting checks passed." -exit 0 diff --git a/scripts/linux/dev/mypy.sh b/scripts/linux/dev/mypy.sh deleted file mode 100755 index 409357be7..000000000 --- a/scripts/linux/dev/mypy.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set +e - -echo "Running type checking with mypy..." -if [ $# -eq 0 ]; then - uv run python -m mypy -else - uv run python -m mypy "$@" -fi -exit $? diff --git a/scripts/linux/dev/pre_commit.sh b/scripts/linux/dev/pre_commit.sh deleted file mode 100755 index bb30b8619..000000000 --- a/scripts/linux/dev/pre_commit.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -echo "Installing pre-commit hooks..." -uv run pre-commit install -uv run pre-commit install --hook-type pre-commit --hook-type pre-push -echo "Pre-commit hooks installed successfully." diff --git a/scripts/linux/dev/pylint.sh b/scripts/linux/dev/pylint.sh deleted file mode 100755 index 6a4ccad62..000000000 --- a/scripts/linux/dev/pylint.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set +e - -echo "Running linting with pylint..." -if [ $# -eq 0 ]; then - uv run python -m pylint src/sampletones -else - uv run python -m pylint "$@" -fi -exit $? diff --git a/scripts/linux/dev/tests.sh b/scripts/linux/dev/tests.sh deleted file mode 100755 index bbd392299..000000000 --- a/scripts/linux/dev/tests.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -set +e - -echo "Running doctests..." -uv run python -m pytest src/ --doctest-modules --no-cov -DOCTEST_EXIT=$? - -echo "Running pytest with coverage..." -uv run python -m pytest -n 6 --cov -PYTEST_EXIT=$? - -if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]]; then - echo "Tests failed." - exit 1 -fi - -echo "All tests passed." -exit 0 diff --git a/scripts/linux/lib/root.sh b/scripts/linux/lib/root.sh deleted file mode 100755 index a5859c2eb..000000000 --- a/scripts/linux/lib/root.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -_PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" - -if [[ ! -d "${_PROJECT_ROOT}/src/sampletones" ]]; then - echo "ERROR: SampleToNES project root not found (expected ${_PROJECT_ROOT}/src/sampletones)." >&2 - exit 1 -fi - -cd "${_PROJECT_ROOT}" diff --git a/scripts/macos/build/build_env.sh b/scripts/macos/build/build_env.sh deleted file mode 100755 index f6d195827..000000000 --- a/scripts/macos/build/build_env.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if ! command -v brew >/dev/null 2>&1; then - echo "ERROR: Homebrew is required to locate the PortAudio headers and library." >&2 - echo "Run scripts/macos/build/dependencies.sh first." >&2 - exit 1 -fi - -PORTAUDIO_PREFIX=$(brew --prefix portaudio) - -echo "CFLAGS=-I${PORTAUDIO_PREFIX}/include" -echo "LDFLAGS=-L${PORTAUDIO_PREFIX}/lib" -echo "ARCHFLAGS=-arch $(uname -m)" diff --git a/scripts/macos/build/dependencies.sh b/scripts/macos/build/dependencies.sh deleted file mode 100755 index ca5049082..000000000 --- a/scripts/macos/build/dependencies.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -e - -PACKAGES=( - portaudio -) - -if ! command -v brew >/dev/null 2>&1; then - echo "ERROR: Homebrew is required to install the macOS system dependencies." >&2 - echo "Install it from https://brew.sh, then run this script again." >&2 - exit 1 -fi - -echo "Installing system dependencies through Homebrew" -brew install "${PACKAGES[@]}" -echo "System dependencies installed." diff --git a/scripts/macos/build/no_bundle.sh b/scripts/macos/build/no_bundle.sh deleted file mode 100755 index b6cfb4ec7..000000000 --- a/scripts/macos/build/no_bundle.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -e - -OPERATION="${1:-this command}" - -echo "ERROR: ${OPERATION} supports Linux and Windows." >&2 -echo "On macOS, SampleToNES runs from source:" >&2 -echo >&2 -echo " make system-deps" >&2 -echo " make setup" >&2 -echo " make run" >&2 -echo >&2 -echo "See docs/guide/installation.md for the full steps." >&2 -exit 1 diff --git a/scripts/run_tests.py b/scripts/run_tests.py new file mode 100644 index 000000000..ad3f4b576 --- /dev/null +++ b/scripts/run_tests.py @@ -0,0 +1,119 @@ +import argparse +import os +import sys +from typing import Dict, Final, Optional, Sequence, Tuple + +from bootstrap.layout import BENCHMARKS_DIRECTORY, SCREENS_DIRECTORY, SOURCE_DIRECTORY, repository_root +from bootstrap.passes import Pass, run_pass +from bootstrap.processes import run + +SUITE: Final[str] = "suite" +DOCTESTS: Final[str] = "doctests" +BENCHMARKS: Final[str] = "benchmarks" +SCREENS: Final[str] = "screens" +DEFAULT_WORKERS: Final[str] = "6" +SCREEN_WORKERS: Final[str] = "4" +PYTEST: Final[Tuple[str, ...]] = ("uv", "run", "python", "-m", "pytest") +SHARD_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_SHARD" + + +def planned_passes(workers: str, screen_workers: str) -> Dict[str, Pass]: + """The passes a test run is made of, by name: the covered suite, the doctests, the benchmarks + and the screen scenarios. + + Each pass is a target and a hook of its own, so a failure names the pass it belongs to. The + covered suite runs across ``workers`` pytest workers. The benchmarks run serial, uncovered + and with their output shown, so a measured duration is the code's own cost and its reading + reaches the terminal. The screen scenarios drive the running application on a display of + each worker's own, each scenario in a process of its own, so they run uncovered across + ``screen_workers`` workers, each drawing its frames on the processor. + + Args: + workers: The worker count for the covered suite, or ``auto`` for one per processor. + screen_workers: The worker count for the screen scenarios, each worker drawing one + application at a time. + + Returns: + Dict[str, Pass]: Every pass, under its name. + """ + passes = ( + Pass( + SUITE, + "Running pytest with coverage...", + ( + *PYTEST, + "-n", + workers, + "--cov", + f"--ignore={BENCHMARKS_DIRECTORY}", + f"--ignore={SCREENS_DIRECTORY}", + ), + ), + Pass( + DOCTESTS, + "Running doctests...", + (*PYTEST, SOURCE_DIRECTORY, "--doctest-modules", "--no-cov"), + ), + Pass( + BENCHMARKS, + "Running benchmarks...", + (*PYTEST, BENCHMARKS_DIRECTORY, "--no-cov", "-s"), + ), + Pass( + SCREENS, + "Running screen scenarios...", + (*PYTEST, SCREENS_DIRECTORY, "-n", screen_workers, "--no-cov"), + ), + ) + return {current.name: current for current in passes} + + +def run_environment(base: Dict[str, str], *, shard: Optional[str]) -> Dict[str, str]: + """The variables a pass runs under: ``base``, naming the part of the screen scenarios where ``shard`` gives one. + + Args: + base: The variables the script itself runs under. + shard: The part as ``INDEX/COUNT``, which the screen scenarios' plugin reads. + """ + environment = dict(base) + if shard is not None: + environment[SHARD_VARIABLE] = shard + + return environment + + +def main(argv: Sequence[str]) -> int: + """Runs one pass of the tests and exits with the status pytest gave it.""" + parser = argparse.ArgumentParser(description="Run one pass of the SampleToNES tests.") + parser.add_argument( + "name", + choices=tuple(planned_passes(DEFAULT_WORKERS, SCREEN_WORKERS)), + help="the pass to run", + ) + parser.add_argument( + "--workers", + default=DEFAULT_WORKERS, + help="pytest workers for the covered suite: a count, or auto for one per processor", + ) + parser.add_argument( + "--screen-workers", + default=SCREEN_WORKERS, + help="pytest workers for the screen scenarios, each drawing one application at a time", + ) + parser.add_argument( + "--shard", + default=None, + help="the part of the screen scenarios this run takes, as INDEX/COUNT, when a run spreads them over machines", + ) + arguments = parser.parse_args(list(argv)) + + return run_pass( + planned_passes(arguments.workers, arguments.screen_workers)[arguments.name], + root=repository_root(), + runner=run, + environment=run_environment(dict(os.environ), shard=arguments.shard), + ) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/release_env_hook.py b/scripts/runtime_hooks/release_environment.py similarity index 100% rename from scripts/release_env_hook.py rename to scripts/runtime_hooks/release_environment.py diff --git a/scripts/setup_environment.py b/scripts/setup_environment.py new file mode 100644 index 000000000..e1358e9ba --- /dev/null +++ b/scripts/setup_environment.py @@ -0,0 +1,126 @@ +import argparse +import os +import platform as running +import sys +from pathlib import Path +from typing import Final, List, Mapping, Optional, Sequence, Tuple + +from bootstrap.cuda import detect +from bootstrap.layout import repository_root +from bootstrap.platforms.factory import current_platform +from bootstrap.platforms.protocol import Platform +from bootstrap.processes import Runner, expect_success, run +from bootstrap.project import DEVELOPMENT_GROUP, GPU_CUDA11_EXTRA, GPU_EXTRA, read_project + +GPU_AUTO: Final[str] = "auto" +GPU_OFF: Final[str] = "0" +GPU_CHOICES: Final[Tuple[str, ...]] = ( + GPU_AUTO, + GPU_OFF, + GPU_EXTRA, + GPU_CUDA11_EXTRA, +) +DEFAULT_GPU: Final[str] = GPU_AUTO + + +def gpu_extra( + choice: str, + platform: Platform, + environment: Mapping[str, str], +) -> Optional[str]: + """The optional-dependency extra a GPU choice selects. + + Args: + choice: ``auto`` to read the NVIDIA driver, ``0`` for the CPU backend, or an extra's name. + platform: The system the setup runs on. + environment: The variables the driver's locations are read from. + + Returns: + Optional[str]: The extra, or ``None`` for the CPU backend. + """ + if choice == GPU_OFF: + return None + + if choice == GPU_AUTO: + detection = detect(platform, environment) + print(detection.reason, file=sys.stderr) + return detection.extra + + return choice + + +def setup_commands(extra: Optional[str]) -> List[List[str]]: + """The commands that create the development environment and install the global command. + + Args: + extra: The GPU extra installed with the package, or ``None`` for the CPU backend. + + Returns: + List[List[str]]: The commands, in order. + """ + synchronize = ["uv", "sync", "--group", DEVELOPMENT_GROUP] + package = "." + if extra is not None: + synchronize.extend(("--extra", extra)) + package = f".[{extra}]" + + return [ + synchronize, + ["uv", "tool", "install", "--force", package], + ] + + +def set_up_environment( + root: Path, + platform: Platform, + extra: Optional[str], + *, + machine: str, + runner: Runner, + environment: Mapping[str, str], +) -> None: + """Synchronizes the development environment and installs the global ``sampletones`` command. + + Args: + root: The repository. + platform: The system the setup runs on, which adds the variables the build needs. + extra: The GPU extra installed with the package, or ``None`` for the CPU backend. + machine: The processor architecture ``platform.machine()`` reports. + runner: What runs the commands. + environment: The caller's variables. + + Raises: + SystemExit: If a command fails. + """ + variables = platform.setup_variables(environment, machine=machine) + for command in setup_commands(extra): + expect_success(runner, command, cwd=root, environment=variables) + + +def main(argv: Sequence[str]) -> int: + """Creates the development environment and installs the global ``sampletones`` command.""" + parser = argparse.ArgumentParser(description="Set up the SampleToNES development environment.") + parser.add_argument( + "--gpu", + default=DEFAULT_GPU, + choices=GPU_CHOICES, + help="auto to match the NVIDIA driver, 0 for the CPU backend, or the GPU extra to install", + ) + arguments = parser.parse_args(list(argv)) + + root = repository_root() + read_project(root) + platform = current_platform() + set_up_environment( + root, + platform, + gpu_extra(arguments.gpu, platform, os.environ), + machine=running.machine(), + runner=run, + environment=os.environ, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/system_dependencies.py b/scripts/system_dependencies.py new file mode 100644 index 000000000..a8071a5a8 --- /dev/null +++ b/scripts/system_dependencies.py @@ -0,0 +1,66 @@ +import argparse +import os +import sys +from pathlib import Path +from typing import Mapping, Sequence + +from bootstrap.layout import repository_root +from bootstrap.platforms.factory import current_platform +from bootstrap.platforms.protocol import Platform +from bootstrap.processes import Runner, expect_success, run + + +def install_system_packages( + root: Path, + platform: Platform, + *, + runner: Runner, + environment: Mapping[str, str], +) -> int: + """Installs the system packages building and running the application needs. + + Args: + root: The repository, which the commands run in. + platform: The system, which names its package manager and packages. + runner: What runs the commands. + environment: The variables the commands see. + + Returns: + int: The exit status: 1 where the package manager is missing, 0 otherwise. + + Raises: + SystemExit: If an install command fails. + """ + missing = platform.missing_package_manager() + if missing is not None: + print(missing, file=sys.stderr) + return 1 + + commands = platform.system_packages() + if not commands: + print(f"Nothing to install on {platform.name}: the Python installer carries what the application needs.") + return 0 + + print("Installing system dependencies...") + for command in commands: + expect_success(runner, command, cwd=root, environment=environment) + + print("System dependencies installed.") + return 0 + + +def main(argv: Sequence[str]) -> int: + """Installs the system packages building and running the application needs on this machine.""" + parser = argparse.ArgumentParser(description="Install the system packages SampleToNES needs.") + parser.parse_args(list(argv)) + + return install_system_packages( + repository_root(), + current_platform(), + runner=run, + environment=os.environ, + ) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/verify_bundle.py b/scripts/verify_bundle.py new file mode 100644 index 000000000..daeb751d9 --- /dev/null +++ b/scripts/verify_bundle.py @@ -0,0 +1,97 @@ +import argparse +import os +import sys +from pathlib import Path +from typing import Final, List, Mapping, Sequence + +from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES, repository_root +from bootstrap.platforms.factory import current_platform +from bootstrap.platforms.protocol import Platform +from bootstrap.processes import Runner, run +from bootstrap.project import read_project + +VERSION_FLAG: Final[str] = "--version" +INTERNAL_DIRECTORY: Final[str] = "_internal" + + +def missing_notices(bundle: Path) -> List[str]: + """The license and notice files a release bundle ships that are absent from it.""" + return [name for name in NOTICES if not (bundle / name).is_file()] + + +def carried_build_tools(bundle: Path) -> List[str]: + """The build-time packages found in a bundle, which the notices place on the build machine. + + A bundle carries the application and its runtime dependencies. Tooling that draws the assets + belongs to the machine that builds it, so finding it here means the notices describe a + different set of components than the bundle ships. + """ + directories = (bundle, bundle / INTERNAL_DIRECTORY) + return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)] + + +def bundle_failures( + root: Path, + platform: Platform, + *, + runner: Runner, + environment: Mapping[str, str], +) -> List[str]: + """What keeps the release bundle under ``root`` from shipping, each as a line of its own. + + The bundle ships its notices, carries the application and its runtime dependencies alone, and + offers a launcher that starts. + + Args: + root: The repository the bundle was built in. + platform: The system the bundle was built for. + runner: What runs the launcher. + environment: The variables the launcher sees. + + Returns: + List[str]: The failures, empty for a bundle ready to archive. + + Raises: + SystemExit: If the system builds no bundle. + """ + name = read_project(root).name + launcher = platform.bundling().launcher(root / DISTRIBUTION, name=name, release=True) + bundle = launcher.parent + failures: List[str] = [] + absent = missing_notices(bundle) + if absent: + failures.append(f"Bundle {bundle} is missing {', '.join(absent)}") + + carried = carried_build_tools(bundle) + if carried: + failures.append(f"Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}") + + if not launcher.is_file(): + failures.append(f"Bundle {bundle} offers no launcher at {launcher}") + return failures + + status = runner((str(launcher), VERSION_FLAG), cwd=root, environment=environment, quiet=False) + if status != 0: + failures.append(f"Launcher {launcher} exited with status {status}") + + return failures + + +def main(argv: Sequence[str]) -> int: + """Confirms the release bundle ships its notices, holds to them, and that its launcher starts.""" + parser = argparse.ArgumentParser(description="Verify the release bundle before it is archived.") + parser.parse_args(list(argv)) + + failures = bundle_failures(repository_root(), current_platform(), runner=run, environment=os.environ) + for failure in failures: + print(f"::error::{failure}") + + if failures: + return 1 + + print("The bundle ships its notices and its launcher starts") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/verify_version_tag.py b/scripts/verify_version_tag.py new file mode 100644 index 000000000..ac1599f25 --- /dev/null +++ b/scripts/verify_version_tag.py @@ -0,0 +1,48 @@ +import argparse +import sys +from typing import Optional, Sequence + +from bootstrap.layout import repository_root +from bootstrap.project import TAG_PREFIX, Project, read_project + + +def version_from_tag(tag: str) -> str: + """The project version a release tag names, read from the tag with its ``v`` prefix dropped.""" + return tag.removeprefix(TAG_PREFIX) + + +def tag_failure(tag: str, project: Project) -> Optional[str]: + """What keeps a release tag from publishing the project, or ``None`` for a tag naming its version. + + Args: + tag: The release tag being built, such as ``v0.3.0``. + project: The project the tag publishes. + + Returns: + Optional[str]: The failure as one line, or ``None``. + """ + if version_from_tag(tag) == project.version: + return None + + return f"Tag {tag} names a version other than the project version {project.version}" + + +def main(argv: Sequence[str]) -> int: + """Confirms a release tag names the version ``pyproject.toml`` records.""" + parser = argparse.ArgumentParser(description="Compare a release tag against the project version.") + parser.add_argument("--tag", required=True, help="the release tag being built, such as v0.3.0") + arguments = parser.parse_args(list(argv)) + + tag: str = arguments.tag + project = read_project(repository_root()) + failure = tag_failure(tag, project) + if failure is not None: + print(f"::error::{failure}") + return 1 + + print(f"Version {project.version} matches tag {tag}") + 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 deleted file mode 100644 index 915dc4713..000000000 --- a/scripts/windows/build/build.bat +++ /dev/null @@ -1,80 +0,0 @@ -@echo off -setlocal EnableExtensions - -set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1 - -set "PROJECT_DIR=%SCRIPT_DIR%..\..\.." -set "VENV_DIR=%PROJECT_DIR%\.venv-build" -set "VENV_PY=%VENV_DIR%\Scripts\python.exe" - -set RELEASE=0 -set BUNDLE_MODE=--onefile -set RELEASE_HOOK= -:parse_args -if "%~1"=="" goto build -if "%~1"=="--release" ( - echo Release build: onedir bundle, injecting release deployment configuration - set RELEASE=1 - set BUNDLE_MODE=--onedir - set RELEASE_HOOK=--runtime-hook scripts\release_env_hook.py -) -shift -goto parse_args - -:build -if "%RELEASE%"=="1" ( - set EXECUTABLE=bin\sampletones\sampletones.exe -) else ( - set EXECUTABLE=bin\sampletones.exe -) - -call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1 -call "%SCRIPT_DIR%icons.bat" || exit /b 1 - -if exist "bin\sampletones.exe" ( - echo Removing the previous artifact: bin\sampletones.exe - del /Q "bin\sampletones.exe" || exit /b 1 -) - -if exist "bin\sampletones\" ( - echo Removing the previous artifact: bin\sampletones - rmdir /S /Q "bin\sampletones" || exit /b 1 -) - -echo Building executable... -"%VENV_PY%" -m PyInstaller --name sampletones ^ - %BUNDLE_MODE% ^ - --noconfirm ^ - --distpath bin ^ - --icon "src\sampletones_assets\icons\sampletones.ico" ^ - --add-data "src\sampletones_assets\icons;assets\icons" ^ - --add-data "src\sampletones_assets\fonts;assets\fonts" ^ - --add-data "src\sampletones_config;config" ^ - --copy-metadata sampletones ^ - --exclude-module PIL ^ - %RELEASE_HOOK% ^ - "src\sampletones\__main__.py" || exit /b - -if not exist "%EXECUTABLE%" ( - echo Build failed: PyInstaller produced no executable at %EXECUTABLE%.>&2 - exit /b 1 -) - -echo Verifying the bundle... -"%EXECUTABLE%" --self-check -if errorlevel 1 ( - echo Build failed: %EXECUTABLE% did not pass its self-check.>&2 - exit /b 1 -) - -if "%RELEASE%"=="1" ( - copy /Y LICENSE bin\sampletones\LICENSE >nul || exit /b 1 - copy /Y THIRD-PARTY-NOTICES.md bin\sampletones\THIRD-PARTY-NOTICES.md >nul || exit /b 1 - copy /Y THIRD-PARTY-LICENSES.txt bin\sampletones\THIRD-PARTY-LICENSES.txt >nul || exit /b 1 - echo Bundled notices: LICENSE, THIRD-PARTY-NOTICES.md, THIRD-PARTY-LICENSES.txt -) - -echo Build complete: .\%EXECUTABLE% - -exit /b 0 diff --git a/scripts/windows/build/clean.bat b/scripts/windows/build/clean.bat deleted file mode 100644 index a41b8bf8d..000000000 --- a/scripts/windows/build/clean.bat +++ /dev/null @@ -1,18 +0,0 @@ -@echo off -setlocal - -call "%~dp0..\lib\root.bat" || exit /b 1 - -echo Cleaning build artifacts... -if exist bin rmdir /s /q bin 2>nul -if exist build rmdir /s /q build 2>nul -if exist dist rmdir /s /q dist 2>nul -if exist htmlcov rmdir /s /q htmlcov 2>nul -if exist .coverage del /q .coverage 2>nul -if exist *.spec del /q *.spec 2>nul -for /d /r . %%d in (__pycache__) do @if exist "%%d" rmdir /s /q "%%d" 2>nul -for /d /r . %%d in (*.egg-info) do @if exist "%%d" rmdir /s /q "%%d" 2>nul -del /s /q *.pyc 2>nul - -echo Clean complete. -exit /b 0 diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat deleted file mode 100644 index d4ee7f14a..000000000 --- a/scripts/windows/build/icons.bat +++ /dev/null @@ -1,14 +0,0 @@ -@echo off -setlocal EnableExtensions - -set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1 - -set "PROJECT_DIR=%SCRIPT_DIR%..\..\.." -set "VENV_DIR=%PROJECT_DIR%\.venv-build" -set "VENV_PY=%VENV_DIR%\Scripts\python.exe" - -echo Generating the icon suite... -"%VENV_PY%" scripts\assets\icons.py || exit /b 1 - -exit /b 0 diff --git a/scripts/windows/build/preflight.bat b/scripts/windows/build/preflight.bat deleted file mode 100644 index 6cb6a7467..000000000 --- a/scripts/windows/build/preflight.bat +++ /dev/null @@ -1,51 +0,0 @@ -@echo off -setlocal EnableExtensions - -set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1 - -set "PROJECT_DIR=%SCRIPT_DIR%..\..\.." -set "VENV_DIR=%PROJECT_DIR%\.venv-build" -set "VENV_PY=%VENV_DIR%\Scripts\python.exe" - -set RELEASE=0 -:parse_args -if "%~1"=="" goto check -if "%~1"=="--release" set RELEASE=1 -shift -goto parse_args - -:check -echo Checking the build environment... - -if not exist "%VENV_PY%" ( - echo ERROR: build interpreter not found at %VENV_PY%.>&2 - echo Run install.bat from the project root to create the build environment.>&2 - exit /b 1 -) - -"%VENV_PY%" -c "import pyaudio" >nul 2>&1 -if errorlevel 1 ( - echo ERROR: the build interpreter cannot import pyaudio, so the bundle would carry no audio playback.>&2 - echo Run install.bat from the project root to reinstall the dependencies.>&2 - exit /b 1 -) - -echo pyaudio: available - -"%VENV_PY%" -c "import tkinter" >nul 2>&1 -if errorlevel 1 goto missing_tkinter - -echo tkinter: available -exit /b 0 - -:missing_tkinter -if "%RELEASE%"=="1" ( - echo ERROR: the build interpreter cannot import tkinter, so a release bundle would open no file dialogs.>&2 - echo Install Python from python.org or the Microsoft Store, which both include Tk, then build again.>&2 - exit /b 1 -) - -echo WARNING: the build interpreter cannot import tkinter. -echo This bundle opens no file dialogs. Install Python from python.org or the Microsoft Store to include Tk. -exit /b 0 diff --git a/scripts/windows/build/python.bat b/scripts/windows/build/python.bat deleted file mode 100644 index 381804ed4..000000000 --- a/scripts/windows/build/python.bat +++ /dev/null @@ -1,22 +0,0 @@ -@echo off -setlocal - -for /f "tokens=2 delims= " %%v in ('python --version 2^>^&1') do set PYTHON_VERSION=%%v -echo Detected Python version: %PYTHON_VERSION% - -for /f "tokens=1,2 delims=." %%a in ("%PYTHON_VERSION%") do set PY_MAJOR=%%a& set PY_MINOR=%%b - -set /a VERSION_OK=0 -if %PY_MAJOR% GEQ 3 ( - if %PY_MINOR% GEQ 12 set VERSION_OK=1 -) - -if not %VERSION_OK%==1 ( - echo. - echo ERROR: Python 3.12 or newer is required. - echo Please install Python 3.12+ from https://www.python.org/downloads/ - pause - exit /b 1 -) - -exit /b 0 diff --git a/scripts/windows/build/sampletones.bat b/scripts/windows/build/sampletones.bat deleted file mode 100644 index 902ea3726..000000000 --- a/scripts/windows/build/sampletones.bat +++ /dev/null @@ -1,28 +0,0 @@ -@echo off -setlocal enabledelayedexpansion -setlocal EnableExtensions - -set "SCRIPT_DIR=%~dp0" -call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1 - -set "PROJECT_DIR=%SCRIPT_DIR%..\..\.." -set "VENV_DIR=%PROJECT_DIR%\.venv-build" -set "VENV_PY=%VENV_DIR%\Scripts\python.exe" - -set "EXTRAS=build" - -:parse_args -if "%~1"=="" goto install -if "%~1"=="--gpu" set "EXTRAS=!EXTRAS!,gpu" -shift -goto parse_args - -:install -echo Installing dependencies... -"%VENV_PY%" -m pip install --upgrade pip - -echo Installing with extras: !EXTRAS! -"%VENV_PY%" -m pip install ".[!EXTRAS!]" --group assets || exit /b 1 - -echo sampletones Python package installed successfully. -exit /b 0 diff --git a/scripts/windows/build/venv.bat b/scripts/windows/build/venv.bat deleted file mode 100644 index 85f4d6307..000000000 --- a/scripts/windows/build/venv.bat +++ /dev/null @@ -1,14 +0,0 @@ -@echo off -setlocal - -call "%~dp0..\lib\root.bat" || exit /b 1 - -if exist ".venv-build" ( - echo Virtual environment already exists. -) else ( - echo Creating virtual environment... - python -m venv .venv-build || exit /b - echo Virtual environment created. -) - -exit /b 0 diff --git a/scripts/windows/dev/format.bat b/scripts/windows/dev/format.bat deleted file mode 100644 index cd1717a4c..000000000 --- a/scripts/windows/dev/format.bat +++ /dev/null @@ -1,11 +0,0 @@ -@echo off -setlocal - -echo Formatting imports with isort... -uv run python -m isort src/ tests/ || exit /b - -echo Formatting code with black... -uv run python -m black src/ tests/ || exit /b - -echo Code formatting complete. -exit /b 0 diff --git a/scripts/windows/dev/lint.bat b/scripts/windows/dev/lint.bat deleted file mode 100644 index fba418f24..000000000 --- a/scripts/windows/dev/lint.bat +++ /dev/null @@ -1,23 +0,0 @@ -@echo off -setlocal - -set SCRIPT_DIR=%~dp0 - -call "%SCRIPT_DIR%mypy.bat" -set MYPY_EXIT=%ERRORLEVEL% - -call "%SCRIPT_DIR%pylint.bat" -set PYLINT_EXIT=%ERRORLEVEL% - -if not %MYPY_EXIT%==0 ( - echo Linting failed. - exit /b 1 -) - -if not %PYLINT_EXIT%==0 ( - echo Linting failed. - exit /b 1 -) - -echo All linting checks passed. -exit /b 0 diff --git a/scripts/windows/dev/mypy.bat b/scripts/windows/dev/mypy.bat deleted file mode 100644 index 686c7716f..000000000 --- a/scripts/windows/dev/mypy.bat +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -setlocal - -echo Running type checking with mypy... -if "%~1"=="" ( - uv run python -m mypy -) else ( - uv run python -m mypy %* -) -exit /b %ERRORLEVEL% diff --git a/scripts/windows/dev/pre_commit.bat b/scripts/windows/dev/pre_commit.bat deleted file mode 100644 index deebc171d..000000000 --- a/scripts/windows/dev/pre_commit.bat +++ /dev/null @@ -1,9 +0,0 @@ -@echo off -setlocal - -echo Installing pre-commit hooks... -uv run pre-commit install || exit /b -uv run pre-commit install --hook-type pre-commit --hook-type pre-push || exit /b -echo Pre-commit hooks installed successfully. - -exit /b 0 diff --git a/scripts/windows/dev/pylint.bat b/scripts/windows/dev/pylint.bat deleted file mode 100644 index 5e6aa0fa0..000000000 --- a/scripts/windows/dev/pylint.bat +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -setlocal - -echo Running linting with pylint... -if "%~1"=="" ( - uv run python -m pylint src/sampletones -) else ( - uv run python -m pylint %* -) -exit /b %ERRORLEVEL% diff --git a/scripts/windows/dev/tests.bat b/scripts/windows/dev/tests.bat deleted file mode 100644 index 1a9fa0c73..000000000 --- a/scripts/windows/dev/tests.bat +++ /dev/null @@ -1,23 +0,0 @@ -@echo off -setlocal - -echo Running doctests... -uv run python -m pytest src/ --doctest-modules --no-cov -set DOCTEST_EXIT=%ERRORLEVEL% - -echo Running pytest with coverage... -uv run python -m pytest -n 6 --cov -set PYTEST_EXIT=%ERRORLEVEL% - -if not %DOCTEST_EXIT%==0 ( - echo Tests failed. - exit /b 1 -) - -if not %PYTEST_EXIT%==0 ( - echo Tests failed. - exit /b 1 -) - -echo All tests passed. -exit /b 0 diff --git a/scripts/windows/lib/root.bat b/scripts/windows/lib/root.bat deleted file mode 100644 index f9952477a..000000000 --- a/scripts/windows/lib/root.bat +++ /dev/null @@ -1,7 +0,0 @@ -if not exist "%~dp0..\..\..\src\sampletones\" ( - echo ERROR: SampleToNES project root not found.>&2 - exit /b 1 -) - -cd /d "%~dp0..\..\.." || exit /b 1 -exit /b 0 diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py index 936911a5e..a059dd93b 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 @@ -74,9 +74,9 @@ def __getattr__(name: str) -> Any: __all__ = [ + "ChannelName", "Config", "Generator", - "GeneratorName", "Instruction", "InstructionLibrary", "NoiseGenerator", diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py index b7625bb23..c17e0f4ba 100644 --- a/src/sampletones/__main__.py +++ b/src/sampletones/__main__.py @@ -1,189 +1,15 @@ -import argparse import multiprocessing -from argparse import RawTextHelpFormatter -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Optional +import sys -from sampletones_shared.paths.extensions import EXT_FILES_AUDIO +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch -if TYPE_CHECKING: - from sampletones_core.configs import Config -HELP_PATH = """Path to either: - * audio file path/directory to reconstruct - * reconstruction .stn file to load a reconstruction - * instructions library .ins file to load a library""" - -HELP_OUTPUT = """Output path for reconstruction.""" - -HELP_CONFIG = """Path to a configuration .json file - (if not provided, default configuration will be used)""" - -HELP_GENERATE = """Generate library data for given configuration - (using default one if not provided)""" - -HELP_HELP = """Show this help message and exit""" - -HELP_VERSION = "Show application version information" - -HELP_SELF_CHECK = """Verify that the imports, bundled resources - and configuration files this build ships are all usable""" - - -@dataclass(frozen=True) -class ProgramArguments: - path: Optional[Path] = None - output: Optional[Path] = None - config: Optional[Path] = None - - help: bool = False - version: bool = False - generate: bool = False - self_check: bool = False - - -def _load_config(config_path: Optional[Path]) -> "Config": - from sampletones_core.configs import Config - - return Config.load(config_path) if config_path else Config.default() - - -def main() -> None: - parser = argparse.ArgumentParser( - prog="SampleToNES", - add_help=False, - formatter_class=RawTextHelpFormatter, - ) - parser.add_argument( - "path", - nargs="?", - default=None, - help=HELP_PATH, - ) - parser.add_argument( - "--output", - "-o", - type=Path, - default=None, - help=HELP_OUTPUT, - ) - parser.add_argument( - "--config", - "-c", - type=Path, - default=None, - help=HELP_CONFIG, - ) - parser.add_argument( - "--generate", - "-g", - action="store_true", - help=HELP_GENERATE, - ) - parser.add_argument( - "--help", - "-h", - action="store_true", - help=HELP_HELP, - ) - parser.add_argument( - "--version", - "-v", - action="store_true", - help=HELP_VERSION, - ) - parser.add_argument( - "--self-check", - action="store_true", - help=HELP_SELF_CHECK, - ) - args: ProgramArguments = ProgramArguments(**vars(parser.parse_args())) - - if args.help: - parser.print_help() - return None - - if args.version: - from sampletones_shared.application import ( - SAMPLETONES_NAME_VERSION, - ) - - return print(SAMPLETONES_NAME_VERSION) - - if args.self_check: - from sampletones.self_check import run_self_check - - raise SystemExit(run_self_check()) - - config_path = Path(args.config) if args.config else None - output_path = Path(args.output) if args.output else None - - from sampletones_shared.paths.extensions import ( - EXT_FILE_LIBRARY, - EXT_FILE_PROJECT, - EXT_FILE_RECONSTRUCTION, - ) - - if args.generate: - from sampletones_core.scripts.library import generate_library - - config = _load_config(config_path) - return generate_library(config) - - project_path: Optional[Path] = None - library_path: Optional[Path] = None - reconstruction_path: Optional[Path] = None - - if args.path: - path = Path(args.path) - if path.is_file(): - suffix = path.suffix.lower() - if suffix == EXT_FILE_PROJECT: - project_path = path - - elif suffix == EXT_FILE_RECONSTRUCTION: - reconstruction_path = path - - elif suffix == EXT_FILE_LIBRARY: - library_path = path - - elif suffix in EXT_FILES_AUDIO: - from sampletones_core.scripts.reconstruction import ( - reconstruct_file, - ) - - config = _load_config(config_path) - return reconstruct_file(path, config, output_path) - - else: - raise RuntimeError( - f"Unsupported file extension, only audio ({', '.join(EXT_FILES_AUDIO)})," - f"{EXT_FILE_RECONSTRUCTION} reconstruction, " - f"and {EXT_FILE_LIBRARY} library files are supported." - ) - - elif path.is_dir(): - from sampletones_core.scripts.reconstruction import ( - reconstruct_directory, - ) - - config = _load_config(config_path) - return reconstruct_directory(path, config) - - else: - raise RuntimeError("Unsupported path type or file extension.") - - from sampletones.run import run_application - - return run_application( - config_path, - library_path=library_path, - reconstruction_path=reconstruction_path, - project_path=project_path, - ) +def main() -> int: + """Runs the command named on the command line, which is what the ``sampletones`` entry does.""" + return dispatch(COMMANDS, sys.argv[1:]) if __name__ == "__main__": multiprocessing.freeze_support() - main() + raise SystemExit(main()) diff --git a/tests/unit/sampletones_synthesis/voice/__init__.py b/src/sampletones/commands/__init__.py similarity index 100% rename from tests/unit/sampletones_synthesis/voice/__init__.py rename to src/sampletones/commands/__init__.py diff --git a/src/sampletones/commands/convert.py b/src/sampletones/commands/convert.py new file mode 100644 index 000000000..59d909229 --- /dev/null +++ b/src/sampletones/commands/convert.py @@ -0,0 +1,88 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Tuple + +from sampletones_shared.command import Command +from sampletones_shared.options import add_config_option + +NAME: Final[str] = "convert" +HELP: Final[str] = "reconstruct recordings into a .stn file" +SOURCES_HELP: Final[str] = "recordings mixed into one reconstruction, or one directory converted file by file" +OUTPUT_HELP: Final[str] = ( + "where the reconstruction is written; without it, the configuration's reconstructions directory" +) +CHANNELS_HELP: Final[str] = ( + "channels the reconstruction may use, comma separated: pulse1, pulse2, triangle, noise; " + "without it pulse1, triangle and noise" +) +STEMS_HELP: Final[str] = "a JSON file holding a stems setup; its entries pair with the sources in order" + + +@dataclass(frozen=True) +class ConvertArguments: + """What a conversion is given: the sources, where the result goes, and how the channels are handed out.""" + + sources: Tuple[Path, ...] + output: Optional[Path] + config: Optional[Path] + channels: Optional[str] + stems: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("sources", nargs="+", type=Path, help=SOURCES_HELP) + parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP) + add_config_option(parser) + setup = parser.add_mutually_exclusive_group() + setup.add_argument("--channels", type=str, default=None, help=CHANNELS_HELP) + setup.add_argument("--stems", type=Path, default=None, help=STEMS_HELP) + + +def run(arguments: Namespace) -> int: + """Reconstructs the sources under the stems setup the options describe. + + Raises: + SystemExit: If a source is missing or no recording, a channel is unknown, the stems file + is missing or no setup, or the sources and the setup pair up wrong. + """ + given = ConvertArguments( + sources=tuple(arguments.sources), + output=arguments.output, + config=arguments.config, + channels=arguments.channels, + stems=arguments.stems, + ) + + from sampletones_core.headless.config import load_config + from sampletones_core.headless.conversion.pairing import pairing_lines + from sampletones_core.headless.conversion.request import ( + ConversionRequest, + channels_named, + classic_setup, + load_stems, + ) + from sampletones_core.headless.conversion.runners import reconstruct + from sampletones_shared.array import report_array_backend + from sampletones_shared.utils.validation import describe_failure + + try: + stems = load_stems(given.stems) if given.stems is not None else classic_setup(channels_named(given.channels)) + request = ConversionRequest(sources=given.sources, stems=stems, output_path=given.output) + except ValueError as error: + raise SystemExit(describe_failure(error)) from error + + for line in pairing_lines(request): + print(line) + + report_array_backend() + reconstruct(request, load_config(given.config)) + return 0 + + +CONVERT: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones/commands/library.py b/src/sampletones/commands/library.py new file mode 100644 index 000000000..09308690f --- /dev/null +++ b/src/sampletones/commands/library.py @@ -0,0 +1,42 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.command import Command +from sampletones_shared.options import add_config_option + +NAME: Final[str] = "library" +HELP: Final[str] = "generate the instruction library for a configuration" + + +@dataclass(frozen=True) +class LibraryArguments: + """What a library generation is given: the configuration the library is built for, if any.""" + + config: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + add_config_option(parser) + + +def run(arguments: Namespace) -> int: + """Generates the instruction library for the configuration.""" + given = LibraryArguments(config=arguments.config) + + from sampletones_core.headless.config import load_config + from sampletones_core.headless.library import generate_library + from sampletones_shared.array import report_array_backend + + report_array_backend() + generate_library(load_config(given.config)) + return 0 + + +LIBRARY: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones/commands/open.py b/src/sampletones/commands/open.py new file mode 100644 index 000000000..d65cb121a --- /dev/null +++ b/src/sampletones/commands/open.py @@ -0,0 +1,77 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.command import Command +from sampletones_shared.options import add_config_option + +NAME: Final[str] = "open" +HELP: Final[str] = "start the application with a project, reconstruction or library loaded" +PATH_HELP: Final[str] = "a .stp project, a .stn reconstruction or an .ins library" + + +@dataclass(frozen=True) +class OpenArguments: + """What an opening run is given: the file to load and the configuration to start with.""" + + path: Path + config: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("path", type=Path, help=PATH_HELP) + add_config_option(parser) + + +def run(arguments: Namespace) -> int: + """Starts the application with the file loaded. + + The file is handed on by its absolute path, so the session that remembers it reopens it from + any folder a later run starts in. + + Raises: + SystemExit: If the path names no file, a recording, or a file of another kind. + """ + given = OpenArguments(path=arguments.path, config=arguments.config) + + from sampletones_shared.paths.extensions import ( + EXT_FILE_LIBRARY, + EXT_FILE_PROJECT, + EXT_FILE_RECONSTRUCTION, + is_audio_file, + ) + + if not given.path.is_file(): + raise SystemExit(f"No file at {given.path}.") + + if is_audio_file(given.path): + raise SystemExit(f"{given.path} is a recording; run: sampletones convert {given.path}") + + suffix = given.path.suffix.lower() + if suffix not in (EXT_FILE_PROJECT, EXT_FILE_RECONSTRUCTION, EXT_FILE_LIBRARY): + raise SystemExit( + f"{given.path} is neither a {EXT_FILE_PROJECT} project, a {EXT_FILE_RECONSTRUCTION} " + f"reconstruction nor an {EXT_FILE_LIBRARY} library." + ) + + from sampletones.run import run_application + from sampletones_shared.array import report_array_backend + + report_array_backend() + opened = given.path.resolve() + run_application( + given.config, + project_path=opened if suffix == EXT_FILE_PROJECT else None, + reconstruction_path=opened if suffix == EXT_FILE_RECONSTRUCTION else None, + library_path=opened if suffix == EXT_FILE_LIBRARY else None, + ) + return 0 + + +OPEN: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones/commands/registry.py b/src/sampletones/commands/registry.py new file mode 100644 index 000000000..540865cce --- /dev/null +++ b/src/sampletones/commands/registry.py @@ -0,0 +1,12 @@ +from typing import Final, Tuple + +from sampletones.commands.convert import CONVERT +from sampletones.commands.library import LIBRARY +from sampletones.commands.open import OPEN +from sampletones.commands.run import RUN +from sampletones.commands.self_check import SELF_CHECK +from sampletones_shared.command import Command +from sampletones_tools.registry import DEVELOPER_COMMANDS + +USER_COMMANDS: Final[Tuple[Command, ...]] = (RUN, OPEN, CONVERT, LIBRARY, SELF_CHECK) +COMMANDS: Final[Tuple[Command, ...]] = (*USER_COMMANDS, *DEVELOPER_COMMANDS) diff --git a/src/sampletones/commands/run.py b/src/sampletones/commands/run.py new file mode 100644 index 000000000..9f5a3e1d1 --- /dev/null +++ b/src/sampletones/commands/run.py @@ -0,0 +1,41 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.command import Command +from sampletones_shared.options import add_config_option + +NAME: Final[str] = "run" +HELP: Final[str] = "start the application" + + +@dataclass(frozen=True) +class RunArguments: + """What a run of the application is given: the configuration it starts with, if any.""" + + config: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + add_config_option(parser) + + +def run(arguments: Namespace) -> int: + """Starts the application.""" + given = RunArguments(config=arguments.config) + + from sampletones.run import run_application + from sampletones_shared.array import report_array_backend + + report_array_backend() + run_application(given.config) + return 0 + + +RUN: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones/commands/self_check.py b/src/sampletones/commands/self_check.py new file mode 100644 index 000000000..a9957caa0 --- /dev/null +++ b/src/sampletones/commands/self_check.py @@ -0,0 +1,28 @@ +from argparse import ArgumentParser, Namespace +from typing import Final + +from sampletones_shared.command import Command + +NAME: Final[str] = "self-check" +HELP: Final[str] = "verify that this build's imports, bundled resources and configuration files are usable" + + +def configure(parser: ArgumentParser) -> None: + del parser + + +def run(arguments: Namespace) -> int: + """Runs every startup check and answers with the status a packaged build is held to.""" + del arguments + + from sampletones.self_check import run_self_check + + return run_self_check() + + +SELF_CHECK: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones/dispatcher.py b/src/sampletones/dispatcher.py new file mode 100644 index 000000000..b0d85872a --- /dev/null +++ b/src/sampletones/dispatcher.py @@ -0,0 +1,73 @@ +from argparse import ArgumentParser +from typing import Final, Sequence + +from sampletones_shared.application import SAMPLETONES_NAME_VERSION +from sampletones_shared.command import Command + +PROGRAM: Final[str] = "sampletones" +DESCRIPTION: Final[str] = "SampleToNES turns recordings into NES instruments and plays them back." +DEFAULT_COMMAND: Final[str] = "run" +COMMAND_FIELD: Final[str] = "command" +COMMAND_METAVAR: Final[str] = "" +EPILOG: Final[str] = ( + f"Run '{PROGRAM} {COMMAND_METAVAR} --help' for a command's options. The commands for developing " + "SampleToNES follow the ones for using it; one that needs the repository says so and names the " + f"'uv run {PROGRAM} {COMMAND_METAVAR}' line to run from a checkout." +) + + +def build_parser(commands: Sequence[Command]) -> ArgumentParser: + """The parser over ``commands``: one subcommand each, with the version and the help as flags. + + Args: + commands: The commands on offer, listed in the help in this order. + + Returns: + ArgumentParser: The parser the entry runs. + + Raises: + ValueError: If two commands share a name. + """ + names = [command.name for command in commands] + repeated = sorted({name for name in names if names.count(name) > 1}) + if repeated: + raise ValueError(f"the commands share a name: {', '.join(repeated)}") + + parser = ArgumentParser( + prog=PROGRAM, + description=DESCRIPTION, + epilog=EPILOG, + ) + parser.add_argument( + "--version", + "-v", + action="version", + version=SAMPLETONES_NAME_VERSION, + ) + subparsers = parser.add_subparsers( + dest=COMMAND_FIELD, + metavar=COMMAND_METAVAR, + required=True, + ) + + for command in commands: + subparser = subparsers.add_parser(command.name, help=command.help, description=command.help) + command.configure(subparser) + + return parser + + +def dispatch(commands: Sequence[Command], argv: Sequence[str]) -> int: + """Runs the command ``argv`` names, and the default command when it names none. + + Args: + commands: The commands on offer. + argv: The arguments after the program name. + + Returns: + int: The exit status the command answers with. + """ + parser = build_parser(commands) + arguments = parser.parse_args(list(argv) or [DEFAULT_COMMAND]) + command = next(command for command in commands if command.name == arguments.command) + return command.run(arguments) diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py index ff5bb192d..613c6ef53 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 @@ -115,7 +115,10 @@ def _check_language() -> str: def _check_resources() -> str: from sampletones_application.ui.resources.items import FontResource, IconResource - from sampletones_application.ui.resources.resources import get_font_path, get_icon_path + from sampletones_application.ui.resources.resources import ( + get_font_path, + get_icon_path, + ) for font in FontResource: get_font_path(font) @@ -126,8 +129,21 @@ 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 ExportBackends + + return ", ".join(sorted(ExportBackends.build().by_format)) + + def _check_file_dialog_backend() -> str: - from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend + from sampletones_application.utils.file_dialogs.selection import ( + select_file_dialog_backend, + ) return type(select_file_dialog_backend()).__name__ @@ -141,6 +157,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), ) @@ -156,7 +173,10 @@ def run_self_check() -> int: try: detail = check.run() except CHECK_FAILURES as exception: - print(f"{FAILURE_PREFIX} {check.name}: {type(exception).__name__}: {exception}", file=sys.stderr) + print( + f"{FAILURE_PREFIX} {check.name}: {type(exception).__name__}: {exception}", + file=sys.stderr, + ) return FAILURE_STATUS print(f"{SUCCESS_PREFIX} {check.name}: {detail}") diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ecb423e3c..8ddcb2ca0 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,9 +1,12 @@ +from contextlib import ExitStack, contextmanager +from functools import partial from pathlib import Path -from typing import Any, Dict, Final, Optional +from typing import Any, Callable, Dict, Final, Iterator, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError +from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.hierarchy import Tab from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.deployment.deployment import ( @@ -16,8 +19,16 @@ 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.export.nsf import NSFExportCoordinator +from sampletones_application.coordinators.export.setup import ExportSetup +from sampletones_application.coordinators.failures import UnhandledFailurePresenter from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.coordinators.playback.router import PlaybackRouter from sampletones_application.coordinators.project import ProjectCoordinator @@ -25,6 +36,7 @@ ReconstructionCoordinator, ) from sampletones_application.coordinators.render import SongRenderCoordinator +from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.coordinators.tabs.instructions import ( InstructionsTabCoordinator, ) @@ -32,8 +44,12 @@ 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 ExportBackends from sampletones_application.layout import LayoutConfig, load_layout_config +from sampletones_application.logic.export import SongExportLogic +from sampletones_application.logic.export.instrument.logic import InstrumentExportLogic +from sampletones_application.logic.export.nsf.logic import NSFExportLogic from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.instruction.library_manager import ( @@ -47,8 +63,19 @@ document_title, ) from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.logic.reconstruction.edit import ( + ChannelEdit, + ReconstructionEdit, + Retune, + StemRemoval, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ( + ReconstructionRewrites, +) +from sampletones_application.logic.reconstruction.rewrites.steps import RateChange from sampletones_application.logic.render import SongRenderLogic +from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.parameters import ( InstructionsTabParameters, MainTabParameters, @@ -66,21 +93,21 @@ ) from sampletones_application.services import ( ConversionService, + ExportResult, ExportService, - RegeneratedInstrument, RegenerationService, RetunedSample, RetuneResult, SampleRetuneService, - ServiceCancelled, + ServiceCanceled, ServiceError, + ServiceProgress, ServiceSuccess, SongRenderService, ) from sampletones_application.shell import ApplicationShell, ShortcutBindings from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_ABOUT, - TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_TEXTURE_LOGO, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_MENU_FPS, @@ -101,25 +128,39 @@ 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.nsf import GUINSFExportWindow 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 -from sampletones_application.utils.file_dialogs.api import ( - open_file_dialog, - select_directory_dialog, +from sampletones_application.utils.callbacks.failures import UnhandledFailures +from sampletones_application.utils.callbacks.gates import ( + FirstRequestFlight, + Gate, + GestureParameters, + GestureResult, + LatestRequestFlight, + gated, + waiting, + waiting_for_the_screen, ) -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.fps import FPSTimer from sampletones_application.utils.frame_limiter import FrameLimiter +from sampletones_application.utils.gui.callbacks import run_held_callbacks from sampletones_application.utils.gui.dialogs import DialogsRenderer, get_dialog_tag from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.palette.palette import PaletteBindings +from sampletones_application.utils.gui.render_thread import ( + claim_render_thread, + on_render_thread, + release_render_thread, +) from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme @@ -133,22 +174,23 @@ 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, ) +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel 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.exporters import Features -from sampletones_core.project.instruments.sample import Sample -from sampletones_core.reconstructions import Reconstruction +from sampletones_core.constants.enums import ChannelName, FeatureKey +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.sample import Sample +from sampletones_core.project.voices.voice import samples 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_player.export.backend import NSFBackend from sampletones_shared.application import ( SAMPLETONES_AUTHOR, SAMPLETONES_GROUP, @@ -156,8 +198,8 @@ ) from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger -from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}" SEQUENCER_SAMPLE_ORDINAL_FORMAT: Final[str] = "02X" @@ -216,6 +258,15 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self._playback_failures: PlaybackFailurePresenter = PlaybackFailurePresenter( + dialogs=self.dialogs, + language_manager=self.language_manager, + ) + self._unhandled_failures: UnhandledFailurePresenter = UnhandledFailurePresenter( + dialogs=self.dialogs, + language_manager=self.language_manager, + ) + UnhandledFailures.attach(self._unhandled_failures.present, post=CallbackQueue.add) self.audio_device_manager: AudioDeviceManager = AudioDeviceManager() self.config_manager = ConfigManager(config_path) @@ -227,19 +278,28 @@ def __init__( self.config_manager, language_manager=self.language_manager, ) + self.renders: RenderCache = RenderCache(budget_bytes=self.layout.behavior.rendering.cache_bytes) self.reconstruction_manager = ReconstructionManager( scheduling=self.layout.behavior.scheduling, + renders=self.renders, ) _priority = self.layout.behavior.scheduling.priorities.schedule self.conversion_service: ConversionService = ConversionService(priority=_priority) self.regeneration_service: RegenerationService = RegenerationService(priority=_priority) + self.reconstruction_rewrites: ReconstructionRewrites = ReconstructionRewrites( + self.reconstruction_manager, + self.regeneration_service, + ) self.export_service: ExportService = ExportService(priority=_priority) + self.export_service.subscribe(self._on_export_activity) self.render_service: SongRenderService = SongRenderService(priority=_priority) self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) - self.tracker_backends: Dict[TrackerFormat, TrackerBackend] = build_tracker_backends() + backends = ExportBackends.build() + self.nsf_backend: NSFBackend = backends.nsf + self.export_backends: Dict[ExportFormat, ExportBackend] = backends.by_format self.project_manager: ProjectManager = ProjectManager() self.project_controller: ProjectController = ProjectController(self.project_manager) @@ -250,6 +310,7 @@ def __init__( ) self.project_controller.on_mutation = self.history.handle_mutation self.project_controller.on_saved = self.history.mark_saved + self.project_controller.on_project_replaced = self._on_project_replaced self.history.on_history_changed = self._on_history_changed self.fps_timer: FPSTimer = FPSTimer(interval=self.layout.behavior.ui.fps_update_interval) @@ -287,6 +348,21 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self.stem_selection_window: GUIStemSelectionWindow = GUIStemSelectionWindow( + layout=self.layout.tabs.main.converter, + stems_layout=self.layout.general.stems, + glyphs=self.layout.glyphs.common, + stem_colors=self.layout.general.colors.stems, + language_manager=self.language_manager, + status_bar=self.status_bar, + 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 +371,22 @@ 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.nsf_export_window: GUINSFExportWindow = GUINSFExportWindow( + layout=self.layout.settings, + path_colors=self.layout.general.colors.paths, + text_colors=self.layout.general.colors.text, + language_manager=self.language_manager, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + status_bar=self.status_bar, + ) self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow( layout=self.layout.project_properties, language_manager=self.language_manager, @@ -315,7 +407,9 @@ def __init__( player_glyphs=self.layout.glyphs.player, player_layout=self.layout.player, language_manager=self.language_manager, + frame_rate_shown=self.session_manager.show_frame_rate, 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, @@ -335,6 +429,7 @@ def __init__( self.frame_limiter, self._palette_source, self._palette_catalog, + frame_rate_reading=self._menu_bar, window=self.display_settings_window, countdown=self.display_countdown_window, behavior=self.layout.behavior.display, @@ -352,12 +447,29 @@ def __init__( language_manager=self.language_manager, ) + self._nsf_exports = NSFExportCoordinator( + NSFExportLogic( + self.project_controller, + self.session_manager, + self.export_service, + self.nsf_backend, + is_operation_active=self._is_operation_active, + ), + window=self.nsf_export_window, + language_manager=self.language_manager, + on_activity_changed=self._on_dialog_activity_changed, + ) + self._format_setups: Dict[ExportFormat, ExportSetup] = { + ExportFormat.NSF: self._nsf_exports, + } + self._project_coordinator = ProjectCoordinator( self.project_controller, self.project_manager, self.session_manager, self.export_service, - tracker_backends=self.tracker_backends, + export_backends=self.export_backends, + format_setups=self._format_setups, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, @@ -367,37 +479,61 @@ def __init__( self._reconstruction_coordinator = ReconstructionCoordinator( self.reconstruction_manager, self.session_manager, - self.regeneration_service, + self.reconstruction_rewrites, self.audio_device_manager, + self.project_controller, + self.history, dialogs=self.dialogs, language_manager=self.language_manager, on_tab_switch=self._set_current_tab, on_session_state_changed=self._on_reconstruction_state_changed, on_reconstruction_updated=self._on_reconstruction_updated, - is_reconstruction_embedded=self._editing_project_sample, ) + self._reconstruction_opening: LatestRequestFlight[[Optional[Path]]] = self._reconstruction_opening_flight() self._original_audio_locator = OriginalAudioLocator( dialogs=self.dialogs, 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, + dialogs=self.dialogs, + messages=ExportMessages.build(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, - on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, + export_backends=self.export_backends, + format_setups=self._format_setups, + on_load_reconstruction_with_confirmation=self._reconstruction_opening, on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, - on_reconstruction_instrument_updated=self._regenerate_instrument, + on_rewrite_requested=self._reconstruction_coordinator.request_rewrite, + pending_changes=self.reconstruction_rewrites, + after_edits=self._reconstruction_coordinator.after_edits, original_audio_locator=self._original_audio_locator, + instrument_exports=self._instrument_exports, + history=self.history, + 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, + playback_failures=self._playback_failures, status_bar=self.status_bar, ) @@ -415,6 +551,7 @@ def __init__( layout=InstructionsTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, + playback_failures=self._playback_failures, status_bar=self.status_bar, ) @@ -424,21 +561,27 @@ def __init__( audio_device_manager=self.audio_device_manager, library_manager=self.library_manager, conversion_service=self.conversion_service, - on_reconstruct_file=self._reconstruct_file, - on_reconstruct_directory=self._reconstruct_directory, - on_load_reconstruction=self._reconstruction_coordinator.load_with_confirmation, - on_load_library=self._load_library, - is_operation_active=self._is_operation_active, - on_busy_state_changed=self._refresh_busy_state, + hooks=MainTabHooks( + is_operation_active=self._is_operation_active, + on_busy_state_changed=self._refresh_busy_state, + on_reconstruct_listed=self._show_reconstruct_listing, + on_load_reconstruction=self._reconstruction_opening, + on_load_library=self._load_library, + on_load_file=self._converted_loading_flight(), + on_load_directory=self._navigate_to_reconstructions, + on_canceled=self._refresh_browsers, + on_refresh_trees=self._refresh_browsers, + on_prepare_library=self._instructions_tab.prepare_library, + ), layout=MainTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, + playback_failures=self._playback_failures, 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_refresh_trees=self._refresh_reconstruction_trees, - on_generate_library=self._instructions_tab.ensure_library_loaded, + stem_selection_window=self.stem_selection_window, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + tab_active=self._is_main_tab_current, ) self._sequencer_tab = SequencerTabCoordinator( @@ -450,18 +593,23 @@ def __init__( browser_manager=self.browser_manager, project_controller=self.project_controller, history=self.history, + renders=self.renders, 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, + playback_failures=self._playback_failures, status_bar=self.status_bar, - on_edit_sample_requested=self._edit_project_sample, + on_edit_voice_requested=self._voice_editing_flight(), on_favorite_changed=self._repaint_reconstruction_favorites, - on_sample_reconstruction_replaced=self._rebind_replaced_sample, + on_sample_reconstruction_replaced=self._reconstruction_coordinator.replace_sample, on_tab_switch=self._set_current_tab, on_nes_frequency_changed=self._retune_samples_for_rate, on_channels_changed=self._update_menu, + on_audio_state_changed=self._update_menu, + after_edits=self._reconstruction_coordinator.after_edits, ) self._edit_router = EditRouter(surfaces=self._sequencer_tab.edit_surfaces) @@ -498,7 +646,23 @@ def __init__( window=self.render_window, dialogs=self.dialogs, language_manager=self.language_manager, - on_activity_changed=self._on_render_activity_changed, + on_activity_changed=self._on_dialog_activity_changed, + ) + + self._export_logic = SongExportLogic( + 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"], + canceling_label=self.language_manager["settings.export.message.status_canceling"], + ) + + self._export_coordinator = SongExportCoordinator( + self._export_logic, + window=self.export_window, ) self._shell = ApplicationShell( @@ -517,6 +681,9 @@ def __init__( sequencer_tab=self._sequencer_tab, instructions_tab=self._instructions_tab, ) + self.browser_manager.on_recordings_read = self._show_reconstruction_recordings + self._exiting: LatestRequestFlight[[]] = self._exit_flight() + self._setup_gui() self._restore_current_items( library_path=library_path, @@ -543,7 +710,7 @@ def _try_load_project(self, path: Path) -> None: self._project_coordinator.load_project_safely(path) def _try_load_library(self, path: Path) -> None: - self._instructions_tab.load_library_safely(path) + self._instructions_tab.load_library_file(path) def _load_layout_config(self) -> LayoutConfig: try: @@ -582,29 +749,53 @@ def _setup_gui(self) -> None: self._update_menu() def _create_shortcut_bindings(self) -> ShortcutBindings: + """The call behind every action the menus and the keys reach. + + A gesture that reads or puts away a whole document, the project or the open reconstruction, + waits for the edits of the open reconstruction made before it, so it acts on what the + reader has drawn. A gesture that replaces or closes a document, or leaves, holds one + conversation at a time, so asking for it again before its question is answered asks once, + and the answer goes on with the request made last. + """ + after_edits = self._reconstruction_coordinator.after_edits return ShortcutBindings( - new_project=self._project_coordinator.new_project_with_confirmation, - open_project=self._project_coordinator.open_with_confirmation, - save_project=self._project_coordinator.save, - save_project_as=self._project_coordinator.save_as_dialog, + new_project=self._document_flight( + self._project_coordinator.guard_new, + self._project_coordinator.new_project, + ), + open_project=self._document_flight( + self._project_coordinator.guard_open, + self._project_coordinator.open_project, + ), + save_project=gated(after_edits, self._project_coordinator.save), + save_project_as=gated(after_edits, self._project_coordinator.save_as_dialog), project_properties=self._open_project_properties, - export_project=self._project_coordinator.export_project_dialog, - render_song=self._render_coordinator.open, - close_project=self._project_coordinator.close_with_confirmation, - exit=self._on_close, + export_project=gated(after_edits, self._project_coordinator.export_project_dialog), + render_song=gated(after_edits, self._render_coordinator.open), + close_project=self._document_flight( + self._project_coordinator.guard_close, + self._project_coordinator.close_project, + ), + exit=self._exiting, undo=self._sequencer_tab.undo, redo=self._sequencer_tab.redo, - reconstruct_file=self._reconstruct_file_dialog, - reconstruct_directory=self._reconstruct_directory_dialog, + reconstruct_file=self._main_tab.reconstruct_file_dialog, + reconstruct_directory=self._main_tab.reconstruct_directory_dialog, load_generation_settings=self._config_coordinator.load_dialog, save_generation_settings=self._config_coordinator.save_dialog, - open_reconstruction=self._reconstruction_coordinator.load_with_confirmation, - save_reconstruction=self._reconstruction_coordinator.save, - save_reconstruction_as=self._reconstruction_coordinator.save_as_dialog, - close_reconstruction=self._reconstruction_coordinator.close_with_confirmation, - export_wav=self._export_reconstruction_wav_dialog, - export_instruments=self._export_reconstruction_instruments_dialog, - add_reconstruction_to_sequencer=self._add_current_reconstruction_to_sequencer, + open_reconstruction=self._reconstruction_opening, + save_reconstruction=gated(after_edits, self._reconstruction_coordinator.save), + save_reconstruction_as=gated(after_edits, self._reconstruction_coordinator.save_as_dialog), + close_reconstruction=self._document_flight( + self._reconstruction_coordinator.guard_close, + self._reconstruction_coordinator.close, + ), + export_wav=gated(after_edits, self._export_reconstruction_wav_dialog), + export_instruments=gated(after_edits, self._export_reconstruction_instruments_dialog), + add_reconstruction_to_sequencer=gated(after_edits, self._add_current_reconstruction_to_sequencer), + new_instrument=self._add_instrument, + add_sample_from_file=self._add_sample_from_file, + import_instrument=self._import_instrument, open_reconstruction_in_explorer=self._open_reconstruction_in_explorer, locate_original_audio=self._locate_original_audio, play=self._play, @@ -629,10 +820,61 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: select_tab=self._set_current_tab, ) + def _document_flight( + self, + guard: Gate, + arrive: Callable[GestureParameters, GestureResult], + ) -> LatestRequestFlight[GestureParameters]: + """A gesture on a whole document as one conversation: the edits on their way land, then ``guard`` asks. + + ``guard`` asks the same question whatever the gesture carries, so its answer lets through the + request made last. + """ + return LatestRequestFlight( + ( + waiting(self._reconstruction_coordinator.after_edits), + guard, + ), + arrive, + ) + + def _converted_loading_flight(self) -> FirstRequestFlight[[Path]]: + """Loading what a run wrote as one conversation, whose question speaks of the file it loads. + + The question reads whether the open document is backed by that very file, so its answer holds + for that file alone, and the request that raised it is the one the answer loads. + """ + coordinator = self._reconstruction_coordinator + + def conversation(filepath: Path) -> Sequence[Gate]: + return ( + waiting(coordinator.after_edits), + partial(coordinator.guard_load_converted, filepath), + ) + + return FirstRequestFlight(conversation, coordinator.load) + + def _reconstruction_opening_flight(self) -> LatestRequestFlight[[Optional[Path]]]: + """Opening a reconstruction as one conversation, whichever door asks: the menu or a browser. + + The answer opens what was asked for last, a browser's file or the menu's file dialog. + """ + return self._document_flight( + self._reconstruction_coordinator.guard_load, + self._reconstruction_coordinator.open, + ) + + def _voice_editing_flight(self) -> LatestRequestFlight[[str]]: + """Editing a voice of the project as one conversation, whose answer opens the voice asked for last.""" + return self._document_flight( + self._reconstruction_coordinator.guard_edit_voice, + self._reconstruction_coordinator.open_project_voice, + ) + def _setup_shell(self, bindings: ShortcutBindings) -> None: self._shell.setup( bindings, - on_close=self._on_close, + on_close=self._exiting, on_tab_changed=self._on_tab_changed, initial_menu_state=self._build_initial_menu_state(), ) @@ -675,11 +917,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 +943,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 +964,22 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) + def _is_main_tab_current(self) -> bool: + """Whether the Main tab is in front, which is what puts the converter's list on the keyboard. + + The list keeps the row a reader picked out while another tab is worked on, so this is what + tells a press meant for that row from one meant for whatever is now in front. + """ + return self._shell.get_current_tab() == Tab.MAIN + + 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 +1000,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, @@ -763,6 +1021,17 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) + def _on_project_replaced(self) -> None: + """Fans one project replacement out to the two tabs that show the project. + + The controller exposes a single ``on_project_replaced`` slot, fired by a new, opened or + closed project and by every undo, redo, history jump and rollback of a failed gesture; the + composition root owns it. The sequencer realigns its views first, then the Reconstructions + tab follows the voice it shows into the project now in place. + """ + self._sequencer_tab.realign_with_project() + self._reconstruction_coordinator.follow_replaced_project() + def _on_history_changed(self) -> None: """Fans one history change out to every consumer. @@ -825,40 +1094,6 @@ def _toggle_auto_expand_favorite_directories(self) -> None: ) self._update_menu() - def _reconstruct_file_dialog(self) -> None: - if self._is_operation_active(): - logger.warning("A conversion or library generation is already in progress; cannot start a new one") - return - - self._instructions_tab.ensure_library_loaded() - - filepath = open_file_dialog( - title=self.language_manager["global.dialog.title.reconstruct_file"], - initial_directory=self.session_manager.get_audio_input_path(), - filters=( - FileFilter.for_extensions( - self.language_manager["global.dialog.filter.audio"], - EXT_FILES_AUDIO, - ), - ), - ) - - self._handle_reconstruct_file(filepath) - - def _reconstruct_directory_dialog(self) -> None: - if self._is_operation_active(): - logger.warning("A conversion or library generation is already in progress; cannot start a new one") - return - - self._instructions_tab.ensure_library_loaded() - - directory = select_directory_dialog( - title=self.language_manager["global.dialog.title.reconstruct_directory"], - initial_directory=self.session_manager.get_audio_input_path(), - ) - - self._handle_reconstruct_directory(directory) - def _is_converter_panel_visible(self) -> bool: if self._main_tab is None: return False @@ -870,26 +1105,42 @@ def _is_operation_active(self) -> bool: self._main_tab.is_converter_active() or self._instructions_tab.is_library_generating() or self._render_coordinator.is_active + or self._nsf_exports.is_active + or self.export_service.is_running() ) + 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.""" - self._instructions_tab.refresh_generate_button() + grays an entry offering another such operation is one already running.""" + self._instructions_tab.follow_busy_state() self._update_menu() - def _on_render_activity_changed(self) -> None: - """Follows a render claiming the application and handing it back. + def _on_dialog_activity_changed(self) -> None: + """Follows a render or an NSF export setup claiming the application and handing it back. - What a render occupies is the same ground a conversion or a library generation occupies, - so its edges reach the same busy state — the action buttons of each tab, the converter's - own view, and the menu entries that would start another exclusive operation. + What either dialog occupies is the same ground a conversion or a library generation + occupies, so its edges reach the same busy state — the action buttons of each tab, the + converter's own view, and the menu entries that would start another exclusive operation. """ self._refresh_busy_state() - self._main_tab.refresh_converter_view() + self._main_tab.follow_busy_state() def _on_library_operation_changed(self) -> None: """Responds to a library generation starting or finishing: refreshes the cross-tab action @@ -897,19 +1148,18 @@ def _on_library_operation_changed(self) -> None: operation. The converter's own view changes refresh only the action buttons, so this extra converter refresh fires solely on library edges and stays clear of a refresh loop.""" self._refresh_busy_state() - self._main_tab.refresh_converter_view() + self._main_tab.follow_busy_state() 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) - self.session_manager.set_audio_input_path(filepath.parent) + def _show_reconstruct_listing(self) -> None: + """Brings the Main tab forward on what a Reconstruct listed, with the menu following it.""" self._set_current_tab(Tab.MAIN) self._update_menu() @@ -919,34 +1169,34 @@ def _load_library(self, filepath: Path) -> None: self._set_current_tab(Tab.INSTRUCTIONS) self._update_menu() - @ignore_none_path - def _handle_reconstruct_file(self, filepath: Path) -> None: - self._reconstruct_file(filepath) - - def _reconstruct_directory(self, directory_path: Path) -> None: - self._main_tab.set_input_path(directory_path, convert=True) - self.session_manager.set_audio_input_path(directory_path) - self._set_current_tab(Tab.MAIN) - self._update_menu() - - @ignore_none_path - def _handle_reconstruct_directory(self, directory_path: Path) -> None: - self._reconstruct_directory(directory_path) - def _on_playback_error(self, exception: Exception) -> None: + """Reports a playback the device refused, on the render thread whichever thread heard of it. + + The device manager reports a stream that failed to open from the thread playing the audio, and + the report raises a dialog, so it crosses to the render thread the way every worker's result does. + """ logger.error_with_traceback(exception, "Playback error occurred") - self.dialogs.show_error( - exception, - self.language_manager["global.dialog.message.audio_playback_error"], - ) + on_render_thread(self._playback_failures.present_playing_failure, exception) - def _on_converted_reconstruction_loaded(self, filepath: Path) -> None: - self._reconstruction_coordinator.load_with_confirmation(filepath) + def _refresh_browsers(self) -> None: + """Reads the disk afresh in every browser, so a reconstruction just written stands in each. - def _refresh_reconstruction_trees(self) -> None: + The Main tab browses the whole filesystem and offers to load a reconstruction from it, so + it reads a finished run as much as the two tabs the run was started from. + """ + self._main_tab.refresh_browser() self._reconstructions_tab.refresh_browser() self._sequencer_tab.refresh_browser() + def _show_reconstruction_recordings(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + """Hands a document's recordings to both browsers, whichever of them asked for the reading. + + The two browsers render one tree and read one set of documents, so a reading answers for + each of them and the row it belongs to is the one that shows it. + """ + self._reconstructions_tab.show_browser_recordings(path, recordings) + self._sequencer_tab.show_browser_recordings(path, recordings) + def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: """Repaints the toggled path in both browsers, whichever tab the star was clicked in. @@ -960,111 +1210,80 @@ 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 - - self.reconstruction_manager.load_reconstruction_object( - sample.reconstruction, - name=sample.name, - ) - - def _rebind_replaced_sample( - self, - sample_id: str, - reconstruction: Reconstruction, - ) -> None: - """Points the open Reconstructions-tab document at the reconstruction replacing the one it edits. - - The editor and its owning sample share one reconstruction object, so a sample whose audio is - substituted takes its editor along. This runs while the sample still holds the outgoing - reconstruction, which is what identifies the open document as belonging to it. - - Args: - sample_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: - return - - self.reconstruction_manager.apply_regenerated(reconstruction) - self._reconstructions_tab.update_reconstruction() - - def _regenerate_instrument( - self, - generator_name: GeneratorName, - features: Features, - feature_key: FeatureKey, - feature_value: FeatureValue, - ) -> None: - self._reconstruction_coordinator.regenerate_instrument( - generator_name, - features, - feature_key, - feature_value, - ) - 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: return 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,), + edit.history_action, + 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, the recording it took out, or the rate it set.""" + 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, + ) + case Retune(): + return self._sequencer_tab.nes_frequency_detail(edit.nes_frequency) + def _retune_samples_for_rate(self, nes_frequency: int) -> None: """Refreshes the stored reconstructions of samples left out of sync by a rate change. Song playback already follows the new rate; this re-synthesizes only the persistent rendered waveforms the Reconstructions tab edits, and only for the samples still off the - target rate. The batch runs in the background so the rate change stays responsive. + target rate. The batch runs in the background so the rate change stays responsive. The + sample open on the Reconstructions tab takes the rate as a step of its own, after the edits + the reader made before it, so it is left out of the batch. """ + open_voice_id = self.reconstruction_manager.voice_id + if self._owning_project_sample() is not None: + self._reconstruction_coordinator.request_rewrite(RateChange(nes_frequency=nes_frequency)) + targets = [ (sample.id, sample.reconstruction) - for sample in self.project_manager.current.samples - if sample.reconstruction.config.nes_frequency != nes_frequency + for sample in samples(self.project_manager.current.voices) + if sample.reconstruction.config.nes_frequency != nes_frequency and sample.id != open_voice_id ] if not targets: return - if not self.retune_service.start(targets, nes_frequency): - return - - self.status_bar.set(self.language_manager["global.status.message.retuning_samples"]) - if self._editing_retuned_sample(nes_frequency): - self._reconstructions_tab.set_reconstruction_dimmed(True) - - def _editing_retuned_sample(self, nes_frequency: int) -> bool: - """Whether the open Reconstructions-tab document is a project sample this batch will retune.""" - sample = self._owning_project_sample() - return sample is not None and sample.reconstruction.config.nes_frequency != nes_frequency + if self.retune_service.start(targets, nes_frequency): + self.status_bar.set(self.language_manager["global.status.message.retuning_samples"]) def _on_retune_result(self, result: RetuneResult) -> None: match result: @@ -1072,47 +1291,54 @@ 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(): self.status_bar.set("") - self._reconstructions_tab.set_reconstruction_dimmed(False) def _apply_retuned_sample(self, retuned: RetunedSample) -> None: """Swaps a retuned reconstruction into its sample, folding it into the rate-change undo entry. A batch superseded by a newer rate change is discarded by the rate guard, so a stale result neither overwrites the current reconstruction nor appends a stray history entry. The - rate-keyed coalesce target rewrites the single ``SET_NES_FREQUENCY`` entry, and a sample - open in the Reconstructions tab rebinds so its editor and the project sample stay one object. + rate-keyed coalesce target rewrites the single ``SET_NES_FREQUENCY`` entry. + + A sample the reader opened on the Reconstructions tab since the batch started takes the + rate as a step of the open document, after the edits made there. A sample edited since the + batch started is retuned again from what it now holds, so the edit stands. """ project = self.project_manager.current - sample = project.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 if nes_frequency != project.settings.nes_frequency: return - is_open = sample.reconstruction is self.reconstruction_manager.reconstruction + if self.reconstruction_manager.voice_id == sample.id: + self._reconstruction_coordinator.request_rewrite(RateChange(nes_frequency=nes_frequency)) + return + + reconstruction = ( + retuned.reconstruction + if sample.reconstruction is retuned.source + else sample.reconstruction.with_nes_frequency(nes_frequency) + ) + if reconstruction is sample.reconstruction: + return + with self.history.transaction( HistoryAction.SET_NES_FREQUENCY, detail=self._sequencer_tab.nes_frequency_detail(nes_frequency), coalesce=(nes_frequency,), ): self.project_controller.replace_sample_reconstruction( - retuned.sample_id, - retuned.reconstruction, + retuned.voice_id, + reconstruction, ) - if is_open: - self.reconstruction_manager.apply_regenerated( - retuned.reconstruction, - ) - self._reconstructions_tab.update_reconstruction() - def _open_project_properties(self) -> None: """Opens the properties dialog seeded with the current project's info. @@ -1200,8 +1426,7 @@ def content(parent: str) -> None: get_dialog_tag(TAG_GLOBAL_DIALOG_ABOUT), self.language_manager["global.dialog.title.about"], content, - width=about.width, - height=about.height, + geometry=about.window, ) def _refresh_audio_devices(self) -> None: @@ -1229,29 +1454,32 @@ def _apply_audio_settings( sample_rate: SampleRate, buffer_size: BufferSize, ) -> None: - """Applies the dialog's committed device, sample rate, and buffer size. + """Applies the dialog's committed device, sample rate, and buffer size, and remembers them. Switching devices needs the output free; a source that keeps hold of it leaves the - settings as they stand and reports the failure. + settings as they stand and reports the failure. The session keeps what the user chose, + so a remembered device that is unplugged for one run is looked for again in the next. """ try: - self.audio_device_manager.configure_device(device_index, sample_rate) + current_device = self.audio_device_manager.configure_device(device_index, sample_rate) except PlaybackError as exception: self._on_playback_error(exception) return self.audio_device_manager.set_buffer_size(buffer_size) + self.session_manager.set_audio_settings(current_device, buffer_size) def _owning_project_sample(self) -> Optional[Sample]: - reconstruction = self.reconstruction_manager.reconstruction - if reconstruction is None: + """The project sample the open document is, found by the voice id the document remembers.""" + voice_id = self.reconstruction_manager.voice_id + if voice_id is None: return None - for sample in self.project_manager.current.samples: - if sample.reconstruction is reconstruction: + match self.project_manager.current.voice(voice_id): + case Sample() as sample: return sample - - return None + case _: + return None def _editing_project_sample(self) -> bool: return self._owning_project_sample() is not None @@ -1280,7 +1508,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, @@ -1313,26 +1541,8 @@ def _update_title(self) -> None: ) ) - def _sync_reconstruction_ownership(self) -> None: - """Reflects sequencer ownership in the open reconstruction view. - - When the reconstruction on screen becomes a project sample — added to the sequencer — its - source audio and file location are detached. The open document follows so both locations read - as not applicable, matching an owned sample. The guard lets this run only when a file-backed - reconstruction is added while it is the one on screen. - """ - reconstruction_data = self.reconstruction_manager.current_reconstruction - if reconstruction_data is None or reconstruction_data.filepath is None: - return - - if self._owning_project_sample() is None: - return - - self.reconstruction_manager.detach_current_reconstruction() - self._reconstructions_tab.display_reconstruction() - def _on_project_state_changed(self) -> None: - self._sync_reconstruction_ownership() + self._reconstruction_coordinator.follow_project() self._update_title() self._update_menu() @@ -1359,7 +1569,6 @@ def _get_active_source(self) -> Optional[AudioPlayerProtocol]: return self._shell.get_active_source() def _persist_application_state(self) -> None: - self.session_manager.set_current_audio_device(self.audio_device_manager) self._viewport_manager.save_window_state() self._save_browser_shapes() current_tab = self._shell.get_current_tab() @@ -1369,7 +1578,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,13 +1590,27 @@ 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() def _play(self) -> None: self._playback_router.play() - self._update_menu() def _play_from_frame(self) -> None: """Plays from the current order frame; available only in the Sequencer tab.""" @@ -1395,82 +1618,70 @@ def _play_from_frame(self) -> None: return self._sequencer_tab.play_from_current_frame() - self._update_menu() 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 - reconstruction is built from on the Main tab, the slices the waveform draws and plays on + A channel is switched by a control of its own on three tabs: the channels the row picked + out of the converter may take on the Main tab, the slices the waveform draws and plays on the Reconstructions tab, and the sequencer's mix elsewhere. One key reaches whichever of - them is on screen, so a reader silences what they are listening to without leaving it. + them is on screen, so a reader stays where they are while they move it. """ 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) - def _show_confirmation_dialog( - self, - message: str, - ok_label: str, - ) -> None: - self.dialogs.show_confirmation( - tag=TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, - title=self.language_manager["global.dialog.title.exit_confirmation"], - message=message, - on_confirm=self._exit_application, - ok_label=ok_label, - ) + def _exit_flight(self) -> LatestRequestFlight[[]]: + """The exit as one conversation, in which each owner of something unfinished asks in turn. - def _on_close(self) -> None: - if self.project_manager.is_dirty: - self._project_coordinator.show_exit_save_confirmation(on_confirm=self._exit_application) - - elif self._reconstruction_coordinator.is_unsaved() and not self._editing_project_sample(): - self._reconstruction_coordinator.show_exit_save_confirmation(on_confirm=self._exit_application) - - elif self._is_converter_active(): - self._show_confirmation_dialog( - self.language_manager["global.dialog.message.exit_conversion_in_progress"], - ok_label=self.language_manager["global.dialog.label.exit"], - ) - - elif self._is_library_generating(): - self._show_confirmation_dialog( - self.language_manager["global.dialog.message.exit_library_generation_in_progress"], - ok_label=self.language_manager["global.dialog.label.exit"], - ) - - else: - self._exit_application() - - def _is_converter_active(self) -> bool: - return self._main_tab.is_converter_active() + The edits of the open reconstruction land first, so each question asks about what the + reader has drawn. While any owner has something to ask, the questions wait for the screen, + and every owner reads what it holds there, so a dialog standing as the window was closed has + settled what it changes. A close asked for again while the questions stand is absorbed, and + Cancel on any of them ends the conversation. + """ + return LatestRequestFlight( + ( + waiting(self._reconstruction_coordinator.after_edits), + waiting_for_the_screen(self._has_anything_to_ask_before_exit, self.dialogs.when_free), + self._project_coordinator.guard_exit, + self._reconstruction_coordinator.guard_exit, + self._main_tab.guard_exit, + self._instructions_tab.guard_exit, + ), + self._exit_application, + ) - def _is_library_generating(self) -> bool: - return self._instructions_tab.is_library_generating() + def _has_anything_to_ask_before_exit(self) -> bool: + """Whether any owner the exit asks holds something unfinished: unsaved work, a conversion or a library build.""" + return ( + self._project_coordinator.is_unsaved + or self._reconstruction_coordinator.is_unsaved_standalone() + or self._main_tab.is_converter_active() + or self._instructions_tab.is_library_generating() + ) def _is_project_open(self) -> bool: return self.project_controller.is_open def _exit_application(self) -> None: - self._render_coordinator.cleanup() - stop_background_workers() - self._playback_router.shutdown() - self._main_tab.cleanup() + """Stops the frames and the work waiting on the render thread, in the frame the exit is decided. + The run lets go of what it holds once its loop has ended, so the session it writes is the one + the reader left. + """ + CallbackQueue.stop() dpg.stop_dearpygui() def _update_status(self) -> None: @@ -1498,6 +1709,13 @@ def frame(self) -> None: dpg.render_dearpygui_frame() def _post_frame(self) -> None: + """Answer the gestures the frame gathered, then the work waiting on the render thread. + + DearPyGui holds a widget's callback rather than running it where the gesture landed, so + the gestures run here — on the thread that drew the items they reach — and whatever they + ask of the queue is due from the same thread on the next pass. + """ + run_held_callbacks() CaretOverlay.redraw() CallbackQueue.notify_frame() CallbackQueue.add( @@ -1506,32 +1724,64 @@ def _post_frame(self) -> None: ) CallbackQueue.process(self.layout.behavior.scheduling.queue_budget_seconds) - def _save_config(self) -> bool: + def _save_config(self) -> None: + """Writes the generation settings, ending the process with a failing status where the write fails. + + Raises: + SystemExit: If the settings could not be written. + """ try: self.config_manager.save_config() - return False except OSError as exception: logger.error_with_traceback(exception, "Failed to save configuration on exit") - return True + raise SystemExit(1) from exception def run(self) -> None: - try: - while dpg.is_dearpygui_running(): - self.frame() - self._post_frame() - self.frame_limiter.tick() - except KeyboardInterrupt: - return - finally: - self._render_coordinator.cleanup() - stop_background_workers() - self._playback_router.shutdown() - self._main_tab.cleanup() - self.library_manager.shutdown() - save_failed = self._save_config() - - self._persist_application_state() - self.audio_device_manager.terminate() - dpg.destroy_context() - if save_failed: - raise SystemExit(1) + claim_render_thread() + with self._teardown(): + try: + while dpg.is_dearpygui_running(): + self.frame() + self._post_frame() + self.frame_limiter.tick() + except KeyboardInterrupt: + return + + @contextmanager + def _teardown(self) -> Iterator[None]: + """Lets go of everything the run holds once the block it wraps has ended, whichever way it ended. + + Every step is taken whatever an earlier one raised, so a failure leaves the background work + stopped, the audio backend closed and the DearPyGui context destroyed, and it is raised once + the last step has run. A step failing after the block raised carries the block's failure as + its context, so the traceback names what ended the run. + """ + with ExitStack() as steps: + for step in reversed(self._teardown_steps()): + steps.callback(step) + + yield + + def _teardown_steps(self) -> Tuple[VoidCallback, ...]: + """The steps of the teardown in the order they are taken. + + Background work stops before anything it reaches is let go of. A failure is logged alone once + the loop has stopped, since nothing drains what a report would post. Display settings put back + what is left unconfirmed before the session records the window, the session is written while + the window it measures still stands, and the DearPyGui context goes last. + """ + return ( + release_render_thread, + UnhandledFailures.detach, + self._render_coordinator.cleanup, + self._export_coordinator.cleanup, + stop_background_workers, + self._playback_router.shutdown, + self._main_tab.cleanup, + self.library_manager.release_creator, + self._display_coordinator.cleanup, + self._save_config, + self._persist_application_state, + self.audio_device_manager.terminate, + dpg.destroy_context, + ) diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 820d60a3a..c6a755aef 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,36 @@ 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 channel_letter( + language_manager: LanguageManager, + channel: ChannelName, +) -> str: + """Resolves the single letter a channel is marked by where a name has no room to stand. + + The ownership ribbon gives each channel a lane one bar high, so the lane is named by the + letter the reader already meets in a converted reconstruction's folder name. + """ + return context_text(language_manager, TextType.LETTER, 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..bb2ed7505 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" @@ -174,14 +117,18 @@ class GlobalMessageElements(AbstractElement): EXIT_CONVERSION_IN_PROGRESS = "exit_conversion_in_progress" EXIT_LIBRARY_GENERATION_IN_PROGRESS = "exit_library_generation_in_progress" EXIT_UNSAVED_RECONSTRUCTION = "exit_unsaved_reconstruction" + EDIT_VOICE_UNSAVED_RECONSTRUCTION = "edit_voice_unsaved_reconstruction" CLOSE_UNSAVED_RECONSTRUCTION = "close_unsaved_reconstruction" LOAD_UNSAVED_RECONSTRUCTION = "load_unsaved_reconstruction" + LOAD_REPLACED_RECONSTRUCTION = "load_replaced_reconstruction" PROJECT_SAVED_SUCCESSFULLY = "project_saved_successfully" PROJECT_SAVE_FAILED = "project_save_failed" PROJECT_EXPORTED_SUCCESSFULLY = "project_exported_successfully" 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 +145,8 @@ 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" + NO_AUDIO_OUTPUT = "no_audio_output" + REMOVE_VOICE = "remove_voice" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" OPERATION_IN_PROGRESS = "operation_in_progress" @@ -218,18 +166,21 @@ class GlobalDialogTitleElements(AbstractElement): EXIT_CONFIRMATION = "exit_confirmation" SAVE_RECONSTRUCTION = "save_reconstruction" RECONSTRUCTION_SAVED = "reconstruction_saved" + EDIT_VOICE_UNSAVED_RECONSTRUCTION = "edit_voice_unsaved_reconstruction" CLOSE_UNSAVED_RECONSTRUCTION = "close_unsaved_reconstruction" LOAD_UNSAVED_RECONSTRUCTION = "load_unsaved_reconstruction" SAVE_PROJECT = "save_project" 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" + NO_AUDIO_OUTPUT = "no_audio_output" + REMOVE_VOICE = "remove_voice" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" ABOUT = "about" @@ -242,24 +193,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..1d2ddf0b8 100644 --- a/src/sampletones_application/categories/elements/instructions.py +++ b/src/sampletones_application/categories/elements/instructions.py @@ -4,11 +4,15 @@ class InstructionsLibraryElements(AbstractElement): GENERATE_LIBRARY_BUTTON = "generate_library_button" REGENERATE_LIBRARY_BUTTON = "regenerate_library_button" + REBUILD_LIBRARY_BUTTON = "rebuild_library_button" REFRESH_LIBRARIES_BUTTON = "refresh_libraries_button" CANCEL_GENERATION_BUTTON = "cancel_generation_button" REGENERATE_CONFIRMATION_DIALOG = "regenerate_confirmation_dialog" REGENERATE_CONFIRMATION_MESSAGE = "regenerate_confirmation_message" REGENERATE_CONFIRMATION_OK = "regenerate_confirmation_ok" + REBUILD_CONFIRMATION_DIALOG = "rebuild_confirmation_dialog" + REBUILD_CONFIRMATION_MESSAGE = "rebuild_confirmation_message" + REBUILD_CONFIRMATION_OK = "rebuild_confirmation_ok" LIBRARIES_TEXT = "libraries_text" AVAILABLE_LIBRARIES_TEXT = "available_libraries_text" LIBRARIES_NODE = "libraries_node" @@ -23,7 +27,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" @@ -39,38 +43,7 @@ class InstructionsLibraryElements(AbstractElement): GENERATION_PROGRESS_TEMPLATE = "generation_progress_template" LIBRARY_NOT_EXISTS_TEMPLATE = "library_not_exists_template" LIBRARY_EXISTS_TEMPLATE = "library_exists_template" + LIBRARY_OUTDATED_TEMPLATE = "library_outdated_template" + LIBRARY_NODE_OUTDATED_TEMPLATE = "library_node_outdated_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..f5cd82d40 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -1,87 +1,20 @@ 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 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" -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 ConverterFolderElements(AbstractElement): + """What a gathered folder offers, as the folder's own menu names it.""" -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" + CONTEXT_OPEN_FOLDER = "context_open_folder" + CONTEXT_CLOSE_FOLDER = "context_close_folder" + CONTEXT_REMOVE_FOLDER = "context_remove_folder" 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..e41be189f 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" @@ -53,6 +37,14 @@ class SequencerTrackerElements(AbstractElement): CONTEXT_UNSOLO = "context_unsolo" CONTEXT_MUTE_ALL = "context_mute_all" CONTEXT_UNMUTE_ALL = "context_unmute_all" + STATUS_VOICE = "status_voice" + STATUS_SAMPLE = "status_sample" + STATUS_PITCH = "status_pitch" + STATUS_VOLUME = "status_volume" + STATUS_NOTE_OFF = "status_note_off" + STATUS_DIFFERENT_VOICES = "status_different_voices" + STATUS_DIFFERENT_PITCHES = "status_different_pitches" + STATUS_DIFFERENT_VOLUMES = "status_different_volumes" class SequencerOrderElements(AbstractElement): @@ -84,11 +76,17 @@ 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" + NEW_INSTRUMENT_NO_PROJECT = "new_instrument_no_project" + 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 +95,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 +109,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..c99ff24dd 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" @@ -89,6 +80,8 @@ class KeybindingActionElements(AbstractElement): SELECT_TAB_SEQUENCER = "select_tab_sequencer" SELECT_TAB_INSTRUCTIONS = "select_tab_instructions" + SOURCES_REMOVE_SOURCE = "sources_remove_source" + ORDER_PREVIOUS_POSITION = "order_previous_position" ORDER_NEXT_POSITION = "order_next_position" ORDER_PREVIOUS_CHANNEL = "order_previous_channel" @@ -155,22 +148,23 @@ 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): """The name a reader finds each editable scope under, one member per :class:`ShortcutCategory`.""" APPLICATION = "application" + SOURCES = "sources" ORDER = "order" TRACKER = "tracker" - SAMPLES = "samples" + VOICES = "voices" class KeybindingsElements(AbstractElement): @@ -189,6 +183,8 @@ class KeybindingsElements(AbstractElement): DISCARD_BUTTON = "discard_button" KEEP_EDITING_BUTTON = "keep_editing_button" REASSIGN_CONFIRMATION = "reassign_confirmation" + REASSIGN_SEVERAL_CONFIRMATION = "reassign_several_confirmation" + ASSIGNMENT = "assignment" RESET_CONFIRMATION = "reset_confirmation" DISCARD_CONFIRMATION = "discard_confirmation" UNREADABLE_COMBINATION = "unreadable_combination" diff --git a/src/sampletones_application/categories/estimate.py b/src/sampletones_application/categories/estimate.py new file mode 100644 index 000000000..7dfed4e5a --- /dev/null +++ b/src/sampletones_application/categories/estimate.py @@ -0,0 +1,26 @@ +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_shared.utils.time import format_span + + +def time_estimation( + language_manager: LanguageManager, + eta_seconds: Optional[float], +) -> str: + """How long a run has left, phrased to sit beside the count it is reported with. + + A run needs two measurements before it has a rate, so the first moments of one read the mark + the language file keeps for an estimate still being taken, and every later moment reads the + span itself. Both keys are read here, at the moment the line is composed, so a language + chosen mid-run reaches the next reading. + + Args: + language_manager: Where the template and the mark are read from. + eta_seconds: The seconds the run has left, or ``None`` while its rate is still being taken. + + Returns: + str: The clause a status line carries, as ``" (ETA: 2m 13s)"``. + """ + span = language_manager["global.dialog.label.unknown_duration"] if eta_seconds is None else format_span(eta_seconds) + return language_manager["global.dialog.template.time_estimation"].format(eta_string=span) diff --git a/src/sampletones_application/categories/export.py b/src/sampletones_application/categories/export.py index 8fff7c3de..4a0cf458d 100644 --- a/src/sampletones_application/categories/export.py +++ b/src/sampletones_application/categories/export.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.truncation import TruncationMessages @dataclass(frozen=True) @@ -17,10 +18,10 @@ class ExportMessages: status_title: Title of the instrument export result dialog. wav_title: Title of the WAV export dialog and its result. instrument_success: Shown when one instrument reaches its file. - instrument_truncated: Template describing what a shortened instrument carries. + instrument_truncated: The words describing what a shortened instrument carries. instrument_failed: Shown when one instrument export fails. instruments_success: Shown when a reconstruction's instruments reach their files. - instruments_truncated: Template describing how many instruments were shortened. + instruments_truncated: The words describing how many instruments were shortened. instruments_failed: Shown when a reconstruction's instrument export fails. wav_success: Shown when the reconstruction reaches a WAV file. wav_failed: Shown when the WAV export fails. @@ -29,10 +30,10 @@ class ExportMessages: status_title: str wav_title: str instrument_success: str - instrument_truncated: str + instrument_truncated: TruncationMessages instrument_failed: str instruments_success: str - instruments_truncated: str + instruments_truncated: TruncationMessages instruments_failed: str wav_success: str wav_failed: str @@ -51,10 +52,10 @@ def build(cls, language_manager: LanguageManager) -> ExportMessages: status_title=language_manager["reconstructions.instruments.title.export_status_dialog"], wav_title=language_manager["reconstructions.instruments.title.export_wav_dialog"], instrument_success=language_manager["reconstructions.instruments.message.export_instrument_success"], - instrument_truncated=language_manager["reconstructions.instruments.message.export_instrument_truncated"], + instrument_truncated=TruncationMessages.for_instrument(language_manager), instrument_failed=language_manager["reconstructions.instruments.message.export_instrument_failed"], instruments_success=language_manager["reconstructions.instruments.message.export_instruments_success"], - instruments_truncated=language_manager["reconstructions.instruments.message.export_instruments_truncated"], + instruments_truncated=TruncationMessages.for_instruments(language_manager), instruments_failed=language_manager["reconstructions.instruments.message.export_instruments_failed"], wav_success=language_manager["reconstructions.reconstruction.message.export_wav_success"], wav_failed=language_manager["reconstructions.reconstruction.message.export_wav_failed"], 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..0c4803807 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -3,6 +3,7 @@ class TextType(StrEnum): LABEL = "label" + LETTER = "letter" TITLE = "title" MESSAGE = "message" TEMPLATE = "template" @@ -62,6 +63,7 @@ class Panel(StrEnum): TRACEBACK = auto() CONTEXT = auto() STATUS = auto() + STEMS = auto() GRAPH = auto() PITCH = auto() @@ -73,7 +75,7 @@ class Panel(StrEnum): # Main tab CONFIG = auto() - RECONSTRUCTOR = auto() + SOURCE = auto() CONVERTER = auto() ADVANCED = auto() @@ -84,6 +86,7 @@ class Panel(StrEnum): TRACKER = auto() ORDER = auto() MODULE = auto() + VOICES = auto() INSTRUMENTS = auto() HISTORY = auto() @@ -97,3 +100,5 @@ class Panel(StrEnum): KEYBINDINGS = auto() PROPERTIES = auto() RENDER = auto() + EXPORT = auto() + NSF = 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/skipped.py b/src/sampletones_application/categories/skipped.py new file mode 100644 index 000000000..2ea26bd23 --- /dev/null +++ b/src/sampletones_application/categories/skipped.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, Optional, Self, Tuple + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow, SkipReason +from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_core.structures import IdentifiedCollection +from sampletones_core.utils.display import display_id, display_voice_label + +MAX_REPORTED_ROWS: Final[int] = 12 +ROW_BULLET: Final[str] = " - " +PARAGRAPH_BREAK: Final[str] = "\n\n" + + +@dataclass(frozen=True) +class SkippedRowMessages: + """The words the rows an export wrote other than the song plays them are reported in. + + A row naming a voice on a channel the voice has no instrument for plays nothing in the song, + so the tracker formats write a note cut there. A transpose row the format has no pitch change + for keeps the note where it was. An instrument taking the channel's pitch in a pattern several + frames play keeps the pitch the first of them reaches it at. The report gives each reason a + paragraph of its own, names each row where the reader finds it in the tracker, and closes a + paragraph on how many more it leaves out. + + Attributes: + headings: The line introducing the rows, per reason. + row: The template one row is printed with. + more: The template naming the rows past the ones listed. + channels: The name each channel is printed under. + """ + + headings: Dict[SkipReason, str] + row: str + more: str + channels: Dict[ChannelName, str] + + @classmethod + def build(cls, language_manager: LanguageManager) -> Self: + """Resolves every word the report prints. + + Args: + language_manager: The catalog the words are read from. + + Returns: + Self: The bundle the export result handler reads. + """ + return cls( + headings={ + SkipReason.NO_INSTRUMENT: language_manager["global.dialog.message.export_skipped_rows"], + SkipReason.UNREACHED_TRANSPOSE: language_manager["global.dialog.message.export_untransposed_rows"], + SkipReason.CARRIED_PITCH: language_manager["global.dialog.message.export_carried_pitch_rows"], + }, + row=language_manager["global.dialog.template.export_skipped_row"], + more=language_manager["global.dialog.template.export_skipped_rows_more"], + channels={channel: channel_label(language_manager, channel) for channel in ChannelName.items()}, + ) + + def notice( + self, + skipped_rows: Tuple[SkippedRow, ...], + voices: IdentifiedCollection[VoiceUnion], + ) -> Optional[str]: + """Phrases the rows an export wrote other than the song plays them, a paragraph per reason. + + Args: + skipped_rows: The rows the export reported, in the order the song plays them. + voices: The project's voices, which the rows name their voice through. + + Returns: + Optional[str]: The paragraphs the export dialog appends, and ``None`` where every row + was written as the song plays it. + """ + paragraphs = [ + self._paragraph(reason, rows, voices) + for reason in SkipReason + if (rows := tuple(skipped for skipped in skipped_rows if skipped.reason == reason)) + ] + if not paragraphs: + return None + + return PARAGRAPH_BREAK.join(paragraphs) + + def _paragraph( + self, + reason: SkipReason, + skipped_rows: Tuple[SkippedRow, ...], + voices: IdentifiedCollection[VoiceUnion], + ) -> str: + """One reason's rows under its heading, cut after the rows a paragraph lists.""" + listed = skipped_rows[:MAX_REPORTED_ROWS] + lines = [self.headings[reason], *(f"{ROW_BULLET}{self._row(skipped, voices)}" for skipped in listed)] + remaining = len(skipped_rows) - len(listed) + if remaining > 0: + lines.append(self.more.format(count=remaining)) + + return "\n".join(lines) + + def _row( + self, + skipped: SkippedRow, + voices: IdentifiedCollection[VoiceUnion], + ) -> str: + return self.row.format( + frame=display_id(skipped.order_position), + channel=self.channels[skipped.channel], + row=display_id(skipped.row_index), + voice=self._voice(skipped.voice_id, voices), + ) + + @staticmethod + def _voice(voice_id: str, voices: IdentifiedCollection[VoiceUnion]) -> str: + """The voice as the voices list prints it, and blank where the project no longer holds it.""" + voice = voices.get(voice_id) + if voice is None: + return display_id(None) + + return display_voice_label(voices.get_index(voice_id), voice.name) 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/categories/truncation.py b/src/sampletones_application/categories/truncation.py new file mode 100644 index 000000000..97f7bbf1e --- /dev/null +++ b/src/sampletones_application/categories/truncation.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from typing import Optional, Self + +from sampletones_application.categories.manager import LanguageManager +from sampletones_core.exporters.truncation import EnvelopeTruncation + + +@dataclass(frozen=True) +class TruncationMessages: + """The words one kind of export reports its shortened envelopes in. + + A tracker stores a bounded number of values per dimension, so an export shortens a longer one + and says so beside the files it wrote. Each kind of export names the shortening its own way: + one instrument by the frames it kept of the frames it had, several by how many were shortened. + + Attributes: + template: The line naming the frames kept, the frames given and the instruments shortened. + """ + + template: str + + @classmethod + def for_instrument(cls, language_manager: LanguageManager) -> Self: + """The words an export of one instrument reports its shortening in. + + Args: + language_manager: The catalog the words are read from. + + Returns: + Self: The bundle the export result handler reads. + """ + return cls(template=language_manager["reconstructions.instruments.message.export_instrument_truncated"]) + + @classmethod + def for_instruments(cls, language_manager: LanguageManager) -> Self: + """The words an export of a reconstruction's instruments reports its shortening in. + + Args: + language_manager: The catalog the words are read from. + + Returns: + Self: The bundle the export result handler reads. + """ + return cls(template=language_manager["reconstructions.instruments.message.export_instruments_truncated"]) + + @classmethod + def for_project(cls, language_manager: LanguageManager) -> Self: + """The words an export of a whole song reports its shortening in. + + Args: + language_manager: The catalog the words are read from. + + Returns: + Self: The bundle the export result handler reads. + """ + return cls(template=language_manager["global.dialog.template.export_truncated"]) + + def notice(self, truncation: Optional[EnvelopeTruncation]) -> Optional[str]: + """Phrases what an export left out of the envelopes it wrote. + + Args: + truncation: The shortening the export underwent, or ``None`` where it fit whole. + + Returns: + Optional[str]: The line the export dialog appends, and ``None`` where every envelope + was written whole. + """ + if truncation is None: + return None + + return self.template.format( + frames=truncation.frames, + source_frames=truncation.source_frames, + instruments=truncation.instruments, + ) diff --git a/src/sampletones_application/config/deployment/deployment.py b/src/sampletones_application/config/deployment/deployment.py index c86e23821..d537bc7b5 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/runtime_hooks/release_environment.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..61f174579 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -2,10 +2,13 @@ from typing import Dict, Optional, Set from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.constants.output import OutputKind from sampletones_application.constants.playback import FollowMode -from sampletones_core.audio import AudioDeviceManager, CurrentDevice +from sampletones_core.audio import CurrentDevice from sampletones_core.constants.audio import BufferSize +from sampletones_core.constants.enums import HierarchyMode from sampletones_core.data.metadata import Metadata +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.logger import logger from sampletones_shared.utils.serialization import load_yaml, save_yaml_atomic from sampletones_shared.utils.system.paths import to_path @@ -57,8 +60,12 @@ def save(self) -> None: def toggle_favorite(self, path: Path) -> None: self.config.favorites.toggle_favorite(path) - def set_current_audio_device(self, audio_device_manager: AudioDeviceManager) -> None: - self.config.audio.set_audio_settings(audio_device_manager) + def set_audio_settings( + self, + current_device: CurrentDevice, + buffer_size: BufferSize, + ) -> None: + self.config.audio.set_audio_settings(current_device, buffer_size) @property def current_audio_device(self) -> CurrentDevice: @@ -103,6 +110,13 @@ def borderless(self) -> bool: def set_borderless(self, borderless: bool) -> None: self.config.display.borderless = borderless + @property + def show_frame_rate(self) -> bool: + return self.config.display.show_frame_rate + + def set_show_frame_rate(self, show_frame_rate: bool) -> None: + self.config.display.show_frame_rate = show_frame_rate + @property def shortcut_scheme_name(self) -> str: return self.config.shortcuts.scheme @@ -153,6 +167,37 @@ def follow_mode(self) -> FollowMode: def set_follow_mode(self, value: FollowMode) -> None: self.config.playback.follow_mode = value + @property + def converter_settings(self) -> StemSettings: + """The settings a recording is given when it joins the converter's list.""" + return self.config.converter.settings + + def set_converter_settings(self, settings: StemSettings) -> None: + self.config.converter.settings = settings + + @property + def converter_output(self) -> OutputKind: + """What a run writes, as the reader last left the output switch.""" + return self.config.converter.output + + def set_converter_output(self, output: OutputKind) -> None: + self.config.converter.output = output + + @property + def converter_hierarchy_mode(self) -> HierarchyMode: + """How the levels of a mix take turns, as the reader last set it.""" + return self.config.converter.hierarchy_mode + + def set_converter_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: + self.config.converter.hierarchy_mode = hierarchy_mode + + @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..c5f09f1ac 100644 --- a/src/sampletones_application/config/managers/config.py +++ b/src/sampletones_application/config/managers/config.py @@ -13,10 +13,9 @@ from sampletones_application.view_model.main.updates import ( AdvancedSettingsUpdate, AudioSettingsUpdate, - GenerationSettingsUpdate, LibrarySettingsUpdate, ) -from sampletones_core.configs import Config +from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.data.metadata import Metadata from sampletones_core.fft import Window from sampletones_core.library import InstructionLibraryKey @@ -110,24 +109,6 @@ def apply_library_settings(self, update: LibrarySettingsUpdate) -> None: self.window = Window.from_config(self.config) self.update_gui() - def apply_generation_settings( - self, - update: GenerationSettingsUpdate, - ) -> None: - new_generation = self.config.generation.model_copy( - update={ - "drive": update.drive, - "generators": update.generators, - } - ) - self.config = self.config.model_copy( - update={ - "generation": new_generation, - } - ) - self.window = Window.from_config(self.config) - self.update_gui() - def apply_advanced_settings(self, update: AdvancedSettingsUpdate) -> None: new_general = self.config.general.model_copy( update={ @@ -167,29 +148,51 @@ def update_gui(self) -> None: for callback in self.config_change_callbacks: callback() - def apply_library_config(self, library_key: InstructionLibraryKey) -> None: - sample_rate = library_key.sample_rate - nes_frequency = round(sample_rate / library_key.frame_length) - window_size = library_key.window_size - transformation_gamma = library_key.transformation_gamma - spectrum_method = library_key.spectrum_method + def apply_library_config( + self, + library_key: InstructionLibraryKey, + stored_config: Optional[InstructionsLibraryConfig], + ) -> None: + """Makes the settings the library ``library_key`` names was built for the configuration's. - new_library_config = self.config.library.model_copy( - update={ - "sample_rate": sample_rate, - "nes_frequency": nes_frequency, - "window_size": window_size, - "spectrum_method": spectrum_method, - "transformation_gamma": transformation_gamma, - } - ) + The settings the library states are applied whole where they name the library's own file, + tuning included. Otherwise the settings its filename carries are applied over the current + tuning. + + Args: + library_key: The library whose settings are applied. + stored_config: The settings the library's file states, or ``None`` where it states none + this build reads. + """ + if stored_config is None or not self._names_library(stored_config, library_key): + stored_config = self._filename_library_config(library_key) self.config = self.config.model_copy( - update={"library": new_library_config}, + update={"library": stored_config}, ) self.window = Window.from_config(self.config) self.update_gui() + @staticmethod + def _names_library( + library_config: InstructionsLibraryConfig, + library_key: InstructionLibraryKey, + ) -> bool: + """Whether ``library_config`` names the file of the library ``library_key`` names.""" + named = InstructionLibraryKey.create(library_config, Window.from_config(library_config)) + return named.filename == library_key.filename + + def _filename_library_config(self, library_key: InstructionLibraryKey) -> InstructionsLibraryConfig: + """The current library settings under the ones the filename of ``library_key`` carries.""" + return self.config.library.model_copy( + update={ + "sample_rate": library_key.sample_rate, + "nes_frequency": round(library_key.sample_rate / library_key.frame_length), + "spectrum_method": library_key.spectrum_method, + "transformation_gamma": library_key.transformation_gamma, + } + ) + def load_default_config(self) -> None: self.load_config(Config()) diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index c5ee6fa1b..26943e18d 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -7,9 +7,12 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.constants.output import OutputKind from sampletones_application.constants.playback import FollowMode -from sampletones_core.audio import AudioDeviceManager, CurrentDevice +from sampletones_core.audio import CurrentDevice from sampletones_core.constants.audio import BufferSize +from sampletones_core.constants.enums import HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings class SessionManager: @@ -84,9 +87,36 @@ def set_auto_expand_favorite_directories(self, value: bool) -> None: def set_follow_mode(self, value: FollowMode) -> None: self._config_manager.set_follow_mode(value) + @property + def converter_settings(self) -> StemSettings: + """The settings a recording is given when it joins the converter's list.""" + return self._config_manager.converter_settings + + def set_converter_settings(self, settings: StemSettings) -> None: + self._config_manager.set_converter_settings(settings) + + @property + def converter_output(self) -> OutputKind: + """What a run writes, as the reader last left the output switch.""" + return self._config_manager.converter_output + + def set_converter_output(self, output: OutputKind) -> None: + self._config_manager.set_converter_output(output) + + @property + def converter_hierarchy_mode(self) -> HierarchyMode: + """How the levels of a mix take turns, as the reader last set it.""" + return self._config_manager.converter_hierarchy_mode + + def set_converter_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: + self._config_manager.set_converter_hierarchy_mode(hierarchy_mode) + 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) @@ -99,12 +129,6 @@ def set_config_path(self, path: Path) -> None: def get_config_path(self) -> Path: return self._state_manager.get_config_path() - def set_library_path(self, path: Path) -> None: - self._state_manager.set_library_path(path) - - def get_library_path(self) -> Path: - return self._state_manager.get_library_path() - def set_instrument_path(self, path: Path) -> None: self._state_manager.set_instrument_path(path) @@ -145,11 +169,13 @@ def set_current_project(self, path: Optional[Path]) -> None: def current_project(self) -> Optional[Path]: return self._state_manager.current_project - def set_current_audio_device( + def set_audio_settings( self, - audio_device_manager: AudioDeviceManager, + current_device: CurrentDevice, + buffer_size: BufferSize, ) -> None: - self._config_manager.set_current_audio_device(audio_device_manager) + """Records the output device, its rate and the buffer size the user committed.""" + self._config_manager.set_audio_settings(current_device, buffer_size) def set_master_gain(self, value: float) -> None: self._config_manager.set_master_gain(value) @@ -166,6 +192,9 @@ def set_max_fps(self, max_fps: int) -> None: def set_borderless(self, borderless: bool) -> None: self._config_manager.set_borderless(borderless) + def set_show_frame_rate(self, show_frame_rate: bool) -> None: + self._config_manager.set_show_frame_rate(show_frame_rate) + def set_shortcut_scheme_name(self, name: str) -> None: self._config_manager.set_shortcut_scheme_name(name) @@ -235,6 +264,10 @@ def max_fps(self) -> int: def borderless(self) -> bool: return self._config_manager.borderless + @property + def show_frame_rate(self) -> bool: + return self._config_manager.show_frame_rate + @property def shortcut_scheme_name(self) -> str: return self._config_manager.shortcut_scheme_name @@ -263,6 +296,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/managers/state.py b/src/sampletones_application/config/managers/state.py index 97936b044..ee6d40f06 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -130,12 +130,6 @@ def set_config_path(self, path: Path) -> None: def get_config_path(self) -> Path: return self.state.last_paths.config - def set_library_path(self, path: Path) -> None: - self.state.last_paths.library = get_directory(path) - - def get_library_path(self) -> Path: - return self.state.last_paths.library - def set_instrument_path(self, path: Path) -> None: self.state.last_paths.instrument = get_directory(path) diff --git a/src/sampletones_application/config/session/application/audio.py b/src/sampletones_application/config/session/application/audio.py index 60bd1cb2a..5995474d2 100644 --- a/src/sampletones_application/config/session/application/audio.py +++ b/src/sampletones_application/config/session/application/audio.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.audio import AudioDeviceManager, CurrentDevice +from sampletones_core.audio import CurrentDevice from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE, BufferSize from sampletones_shared.constants.audio import ( DEFAULT_MASTER_GAIN, @@ -27,6 +27,10 @@ class AudioConfig(BaseModel): description="The linear master playback gain applied to song playback.", ) - def set_audio_settings(self, audio_device_manager: AudioDeviceManager) -> None: - self.buffer_size = audio_device_manager.buffer_size - self.current_device = audio_device_manager.get_current_device() + def set_audio_settings( + self, + current_device: CurrentDevice, + buffer_size: BufferSize, + ) -> None: + self.current_device = current_device + self.buffer_size = buffer_size diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index 0c3fc0c9f..203701669 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -2,11 +2,13 @@ from sampletones_application.config.session.application.audio import AudioConfig from sampletones_application.config.session.application.browser import BrowserConfig +from sampletones_application.config.session.application.converter import ConverterConfig from sampletones_application.config.session.application.display import DisplayConfig from sampletones_application.config.session.application.favorites import Favorites 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 @@ -25,6 +27,10 @@ class ApplicationConfig(BaseModel): default_factory=BrowserConfig, description="How the browsers of reconstructions read what they narrow to.", ) + converter: ConverterConfig = Field( + default_factory=ConverterConfig, + description="What a recording is converted with when it joins the converter's list.", + ) display: DisplayConfig = Field( default_factory=DisplayConfig, description="The palette and frame pacing preferences.", @@ -39,9 +45,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/converter.py b/src/sampletones_application/config/session/application/converter.py new file mode 100644 index 000000000..28a0313ee --- /dev/null +++ b/src/sampletones_application/config/session/application/converter.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel, ConfigDict, Field, field_serializer + +from sampletones_application.constants.output import OutputKind +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import DEFAULT_CHANNELS, HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.types.data import SerializedData + + +def _starting_settings() -> StemSettings: + return StemSettings.covering(list(DEFAULT_CHANNELS)) + + +class ConverterConfig(BaseModel): + """How the converter opens, carried between runs. + + A recording joins the conversion holding ``settings``, and the reader then says otherwise for + it alone. Keeping those as one value is what lets a further per-recording choice reach the + settings file, the list and the reconstruction record in the same step. + + The rest name the shape of the run itself, so a launch opens on the run the reader last set up + rather than on the shipped one. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + settings: StemSettings = Field( + default_factory=_starting_settings, + description="The settings a recording is given when it joins the conversion.", + ) + + output: OutputKind = Field( + default=OutputKind.PER_RECORDING, + description="Whether a run writes one reconstruction per recording or one from them all.", + ) + hierarchy_mode: HierarchyMode = Field( + default=DEFAULT_STEMS_HIERARCHY_MODE, + description="How the levels of a mix take turns choosing.", + ) + + @field_serializer("settings") + def serialize_settings(self, settings: StemSettings) -> SerializedData: + """Writes each channel as the plain word it names, which is what the settings file carries.""" + return settings.model_dump(mode="json") + + @field_serializer("output") + def serialize_output(self, output: OutputKind) -> str: + """Writes the kind as the plain word it names, which is what the settings file carries.""" + return output.value + + @field_serializer("hierarchy_mode") + def serialize_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> str: + """Writes the mode as the plain word it names, which is what the settings file carries.""" + return hierarchy_mode.value diff --git a/src/sampletones_application/config/session/application/display.py b/src/sampletones_application/config/session/application/display.py index d59ba0d2b..7b06f16b0 100644 --- a/src/sampletones_application/config/session/application/display.py +++ b/src/sampletones_application/config/session/application/display.py @@ -8,10 +8,12 @@ DEFAULT_VSYNC: Final[bool] = True DEFAULT_MAX_FPS: Final[int] = 60 DEFAULT_BORDERLESS: Final[bool] = False +DEFAULT_SHOW_FRAME_RATE: Final[bool] = True class DisplayConfig(BaseModel): - """How the application presents itself: the palette it wears and the pacing it renders at. + """How the application presents itself: the palette it wears, the pacing it renders at, and whether it + shows the rate it reaches. The window's own geometry belongs to the session state, which records where the user left the window; these are the preferences a user picks in the display settings and keeps. @@ -34,3 +36,7 @@ class DisplayConfig(BaseModel): default=DEFAULT_BORDERLESS, description="Whether the window is drawn without the system's title bar and frame.", ) + show_frame_rate: bool = Field( + default=DEFAULT_SHOW_FRAME_RATE, + description="Whether the menu bar shows the frame rate the render loop reaches.", + ) diff --git a/src/sampletones_application/config/session/application/shortcuts.py b/src/sampletones_application/config/session/application/shortcuts.py index b70a0391b..5123906d4 100644 --- a/src/sampletones_application/config/session/application/shortcuts.py +++ b/src/sampletones_application/config/session/application/shortcuts.py @@ -9,7 +9,8 @@ class ShortcutsConfig(BaseModel): """The keys the application answers to: the scheme it runs under and the actions rebound on it. A scheme names a whole set of keys the build ships, while an override rebinds one action on top - of it, so a reader who changes a single combination keeps every other key the scheme gives them. + of it, so a reader who changes a single action keeps every other key the scheme gives them. An + override lists every key of its action, so a stored single key reads as an action with one key. Both are stored by name — the same names a keybinding file writes — which lets a preference outlive the build that wrote it, since the names a build carries are what it reads back. @@ -24,7 +25,7 @@ class ShortcutsConfig(BaseModel): overrides: Dict[str, Optional[str]] = Field( default_factory=dict, description=( - "The combination each rebound action answers to, keyed by the action's name, " - "stating null for an action the reader left unbound." + "The keys each rebound action answers to, main key first and joined by commas, " + "keyed by the action's name, and null for an action the reader left unbound." ), ) 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/config/session/state/paths.py b/src/sampletones_application/config/session/state/paths.py index 885909d64..d510b1890 100644 --- a/src/sampletones_application/config/session/state/paths.py +++ b/src/sampletones_application/config/session/state/paths.py @@ -4,17 +4,12 @@ from sampletones_shared.paths.user import ( CONFIG_PATH, - LIBRARY_DIRECTORY, PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY, ) class LastPaths(BaseModel): - library: Path = Field( - default=LIBRARY_DIRECTORY, - description="The last used library directory path.", - ) reconstruction: Path = Field( default=RECONSTRUCTIONS_DIRECTORY, description="The last used directory of saved reconstruction files.", @@ -41,7 +36,6 @@ class LastPaths(BaseModel): ) @field_serializer( - "library", "reconstruction", "audio_input", "config", diff --git a/src/sampletones_application/constants/conversion.py b/src/sampletones_application/constants/conversion.py new file mode 100644 index 000000000..c5a39b325 --- /dev/null +++ b/src/sampletones_application/constants/conversion.py @@ -0,0 +1,3 @@ +from typing import Final + +MAX_STEM_SOURCES: Final[int] = 8 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/output.py b/src/sampletones_application/constants/output.py new file mode 100644 index 000000000..69a157ace --- /dev/null +++ b/src/sampletones_application/constants/output.py @@ -0,0 +1,19 @@ +from enum import StrEnum + + +class OutputKind(StrEnum): + """What a run writes from the recordings a reader gathered. + + The two kinds read the same list and answer differently for it: a per-recording run converts + every recording the list holds, writing each into a tree that mirrors the folder it came from; + a mixed run converts the recordings into one reconstruction, which is why it holds a fixed + number of them and the levels say which picks first. + """ + + PER_RECORDING = "per_recording" + MIXED = "mixed" + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self is OutputKind.MIXED diff --git a/src/sampletones_application/constants/playback.py b/src/sampletones_application/constants/playback.py index 31324fedc..575efcd08 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. @@ -25,17 +20,8 @@ def follows_pattern(self) -> bool: @property def follows_row(self) -> bool: - """Whether the tracker keeps the sounding row within the visible band.""" + """Whether the tracker keeps the sounding row at the center of its visible band.""" return self is FollowMode.ROWS 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/sources.py b/src/sampletones_application/constants/sources.py new file mode 100644 index 000000000..43648d3dd --- /dev/null +++ b/src/sampletones_application/constants/sources.py @@ -0,0 +1,26 @@ +from enum import StrEnum + + +class SourceKind(StrEnum): + """The kinds of row a stems list holds. + + A recording stands for itself and a folder for what was found below it, which is what decides + how a row is drawn, what one gesture on it settles, and what taking it out takes with it. A + list describing a finished conversion holds a third: the frames the reader wrote by hand, + which answer to no recording and are named where they are drawn. + """ + + RECORDING = "recording" + FOLDER = "folder" + EDITS = "edits" + + +class SettingsField(StrEnum): + """The per-recording choices a reader makes, by the name the settings hold each under. + + A further choice is one more member here, one more slot beside it, and one more line in the + card that names them — which is what keeps a new field from reaching every layer by hand. + """ + + CHANNELS = "channels" + BENDS = "bends" 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/display.py b/src/sampletones_application/coordinators/display.py index 1bc022c1f..541977035 100644 --- a/src/sampletones_application/coordinators/display.py +++ b/src/sampletones_application/coordinators/display.py @@ -1,5 +1,6 @@ import math -from typing import Optional +from dataclasses import dataclass +from typing import Optional, Protocol from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.session import SessionManager @@ -19,22 +20,44 @@ DisplaySettingsViewModel, WindowMode, ) -from sampletones_application.viewport import ViewportManager +from sampletones_application.viewport import ViewportManager, WindowPlacement from sampletones_shared.display import Resolution +class FrameRateReading(Protocol): + """The frame-rate reading the window shows, which a display setting puts on or takes off the screen.""" + + def show_frame_rate(self, shown: bool) -> None: + """Shows the reading when ``shown`` is true, and takes it off the screen otherwise.""" + + +@dataclass(frozen=True) +class ReadableWindow: + """A window mode last seen readable, with the place and size the window stood at under it. + + A window sitting at a size of its own reads as the offered size nearest it, so the placement is + what carries the size the window had. + """ + + mode: WindowMode + placement: WindowPlacement + + class DisplayCoordinator: """Owns the display settings: the options offered, the live application of a change, and the countdown that returns a window mode nobody confirmed. A change reaches the screen the moment it is made, so a user judges it by looking at it, while - the session keeps the values the dialog opened with until OK commits them. Cancel re-applies - that snapshot, asking first when there is something to lose. + the session keeps the values the dialog opened with until OK commits them. Cancel puts back the + display the dialog opened with, a windowed window at its own place and size, asking first when + there is something to lose, and a run that ends while the dialog stands puts it back the same + way before the session is written. Changing the window's size, its frame, or fullscreen can leave the window unreadable, so each of those arms a countdown over the dialog: keeping it disarms the clock and leaves the change - pending, and letting the clock run out brings the last confirmed window mode back while every - other pending edit stays. + pending, and letting the clock run out puts the window back as it stood under the last confirmed + mode, at its own place and size, while every other pending edit stays. Cancel and the clock put a + window back the same way. """ def __init__( @@ -45,6 +68,7 @@ def __init__( palette_source: PaletteSource, palette_catalog: PaletteCatalog, *, + frame_rate_reading: FrameRateReading, window: GUIDisplaySettingsWindow, countdown: GUICountdownWindow, behavior: DisplayBehavior, @@ -57,6 +81,7 @@ def __init__( self._frame_limiter = frame_limiter self._palette_source = palette_source self._palette_catalog = palette_catalog + self._frame_rate_reading = frame_rate_reading self._window = window self._countdown = countdown self._behavior = behavior @@ -66,7 +91,8 @@ def __init__( self._settings: Optional[DisplaySettings] = None self._snapshot: Optional[DisplaySettings] = None - self._armed: Optional[WindowMode] = None + self._opening_placement: Optional[WindowPlacement] = None + self._armed: Optional[ReadableWindow] = None self._remaining: float = 0.0 self._window.on_settings_changed = self._change @@ -84,8 +110,20 @@ def open(self) -> None: view_model = self._view_model(self._settings_in_force()) self._snapshot = view_model.settings self._settings = view_model.settings + self._opening_placement = self._viewport_manager.placement self._window.open(view_model) + def cleanup(self) -> None: + """Puts back the display the dialog opened with when the run ends while it stands, as Cancel does. + + The session keeps the opening values until OK, and leaving records the window as it stands, so + the window returns to its opening place and size before that record is taken. + """ + if self._snapshot is None: + return + + self._discard() + def tick(self, delta_time: float) -> None: """Advances an armed countdown, restoring the last confirmed window mode when it runs out.""" if self._armed is None: @@ -99,17 +137,21 @@ def tick(self, delta_time: float) -> None: self._countdown.set_remaining(self._displayed_seconds()) def _change(self, settings: DisplaySettings) -> None: - """Puts an edit on screen, arming the countdown when it changed the window mode.""" + """Puts an edit on screen, arming the countdown when it changed the window mode. + + The window is read before the change reaches it, which is the window the countdown puts back. + """ previous = self._require_settings() + readable = ReadableWindow(mode=previous.window, placement=self._viewport_manager.placement) self._settings = settings self._apply(previous, settings) if settings.window != previous.window: - self._arm(previous.window) + self._arm(readable) self._window.update_view(self._view_model(settings)) - def _arm(self, restorable: WindowMode) -> None: - """Starts the countdown that brings ``restorable`` back unless the change is confirmed. + def _arm(self, restorable: ReadableWindow) -> None: + """Starts the countdown that puts ``restorable`` back unless the change is confirmed. A countdown already running keeps the mode it was going to restore and starts its count again on the prompt already on screen, so a run of unconfirmed changes still returns to @@ -139,19 +181,27 @@ def _keep(self) -> None: self._disarm() def _revert(self) -> None: - """Brings the last confirmed window mode back, leaving every other pending edit in place.""" + """Puts the window back as it stood under the last confirmed mode, leaving every other pending edit.""" restorable = self._armed self._disarm() if restorable is None: return - self._restore(self._require_settings().with_window(restorable)) + self._put_back(self._require_settings().with_window(restorable.mode), restorable.placement) - def _restore(self, settings: DisplaySettings) -> None: - """Puts ``settings`` on screen as the state in force, without arming a countdown.""" + def _put_back(self, settings: DisplaySettings, placement: WindowPlacement) -> None: + """Puts ``settings`` on screen as the state in force, and a windowed window back at ``placement``. + + A place and a size set directly reach DearPyGui's reading of the window at once, while a + fullscreen change reaches it on a drawn frame alone, so a windowed window is placed after its + settings, and a record of the window taken right after names that place and size. + """ previous = self._require_settings() self._settings = settings self._apply(previous, settings) + if not settings.window.fullscreen: + self._viewport_manager.place(placement) + self._window.update_view(self._view_model(settings)) def _commit(self) -> None: @@ -161,6 +211,7 @@ def _commit(self) -> None: self._session_manager.set_palette_name(settings.palette) self._session_manager.set_vsync(settings.vsync) self._session_manager.set_max_fps(settings.frame_rate) + self._session_manager.set_show_frame_rate(settings.show_frame_rate) self._session_manager.set_borderless(settings.window.borderless) self._close() @@ -188,17 +239,20 @@ def _ask_to_discard(self) -> None: ) def _discard(self) -> None: - """Puts back the settings the dialog opened with and closes it.""" + """Puts back the display the dialog opened with, a windowed window at its own place and size, and + closes it.""" self._disarm() snapshot = self._snapshot - if snapshot is not None: - self._apply(self._require_settings(), snapshot) + placement = self._opening_placement + if snapshot is not None and placement is not None: + self._put_back(snapshot, placement) self._close() def _close(self) -> None: self._settings = None self._snapshot = None + self._opening_placement = None self._window.hide() def _apply(self, previous: DisplaySettings, current: DisplaySettings) -> None: @@ -212,6 +266,9 @@ def _apply(self, previous: DisplaySettings, current: DisplaySettings) -> None: if current.frame_rate != previous.frame_rate: self._frame_limiter.set_max_fps(current.frame_rate) + if current.show_frame_rate != previous.show_frame_rate: + self._frame_rate_reading.show_frame_rate(current.show_frame_rate) + self._apply_window(previous.window, current.window) def _apply_window(self, previous: WindowMode, current: WindowMode) -> None: @@ -244,6 +301,7 @@ def _settings_in_force(self) -> DisplaySettings: ), vsync=self._session_manager.vsync, frame_rate=self._session_manager.max_fps, + show_frame_rate=self._session_manager.show_frame_rate, ) def _view_model(self, settings: DisplaySettings) -> DisplaySettingsViewModel: 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..c0efb6c37 --- /dev/null +++ b/src/sampletones_application/coordinators/export/instrument.py @@ -0,0 +1,145 @@ +from pathlib import Path +from typing import Dict, Optional, Tuple + +from sampletones_application.categories.elements.global_ import FileFilterElements +from sampletones_application.categories.export import ExportMessages +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_application.utils.gui.dialogs import DialogsRenderer +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 +from sampletones_shared.exceptions import ProjectTuningError +from sampletones_shared.logger import logger + + +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. + + A project voice is measured against the project before a file is asked for, so a voice the + project cannot state as one instrument is refused with a message ahead of the save dialog. + """ + + def __init__( + self, + export_logic: InstrumentExportLogic, + language_manager: LanguageManager, + *, + dialogs: DialogsRenderer, + messages: ExportMessages, + ) -> None: + self._logic = export_logic + self._dialogs = dialogs + self._messages = messages + 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. A project whose samples were converted at different tunings + states no one tuning for the instrument, so the export stops there with a message. + + 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. + """ + try: + exportable = self._logic.voice_instrument(voice_id, channel_name) + except ProjectTuningError as exception: + logger.warning(f"The instrument of voice {voice_id} was refused: {exception}") + self._dialogs.show_error(exception, self._messages.instrument_failed) + return + + 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/nsf.py b/src/sampletones_application/coordinators/export/nsf.py new file mode 100644 index 000000000..e339ddf03 --- /dev/null +++ b/src/sampletones_application/coordinators/export/nsf.py @@ -0,0 +1,126 @@ +from pathlib import Path +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.export.nsf.logic import NSFExportLogic +from sampletones_application.ui.panels.dialogs.nsf import GUINSFExportWindow +from sampletones_application.utils.file_dialogs.api import save_file_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.exports.request import SampleExport +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from sampletones_shared.types.callback import VoidCallback + + +class NSFExportCoordinator: + """Owns setting an NSF export up from the reader's side: the dialog, the file it asks for, and + the hand-over to the run. + + This is the NSF format's :class:`ExportSetup`, so File > Export and the Reconstructions tab + open it where the save dialog would otherwise ask for the file. The logic holds the setup, so + what is orchestrated here is the screen: the window opens over the choices the logic offers and + follows every view it emits, and the destination is asked for through the OS dialog. + + The setup claims the application from the dialog opening. Every way out passes through the + logic's close and tells the application to read the claim again, and Export closes the dialog + a frame ahead of the run, so the export's own window opens onto a clear screen. + """ + + def __init__( + self, + nsf_logic: NSFExportLogic, + *, + window: GUINSFExportWindow, + language_manager: LanguageManager, + on_activity_changed: VoidCallback, + ) -> None: + self._logic = nsf_logic + self._window = window + self._on_activity_changed = on_activity_changed + self._view_model: Optional[NSFExportViewModel] = None + self._window_open = False + + self._title_destination = language_manager["global.dialog.title.export_nsf_project"] + self._filter_name = language_manager["global.dialog.filter.nsf"] + + self._logic.on_view_changed = self._on_view_changed + self._logic.on_choose_destination = self._choose_destination + + self._window.on_choices_changed = self._logic.apply + self._window.on_browse = self._logic.request_destination + self._window.on_export = self._export + self._window.on_close = self._close + + @property + def is_active(self) -> bool: + """An NSF export occupies the application from the dialog opening until the run takes over.""" + return self._logic.is_active + + def open_project(self) -> None: + """Offers the open project's song as a program.""" + self._open(self._logic.open_project()) + + def open_sample(self, request: SampleExport) -> None: + """Offers a reconstruction's slices as a program.""" + self._open(self._logic.open_sample(request)) + + def _open(self, opened: bool) -> None: + if not opened: + return + + self._window_open = True + self._window.open(self._require_view_model()) + self._on_activity_changed() + + def _on_view_changed(self, view_model: NSFExportViewModel) -> None: + """Keeps the open window standing at the choices the export stands at.""" + self._view_model = view_model + if self._window_open: + self._window.update_view(view_model) + + def _choose_destination(self, destination: Path) -> None: + """Asks for the file the export writes, starting from the one the dialog stands at.""" + filepath = save_file_dialog( + title=self._title_destination, + initial_directory=destination.parent, + default_filename=destination.name, + filters=(FileFilter.for_extensions(self._filter_name, [EXT_FILE_NSF]),), + ) + + self._set_destination(filepath) + + @ignore_none_path + def _set_destination(self, filepath: Path) -> None: + self._logic.set_destination(filepath) + + def _export(self) -> None: + """Takes the dialog off screen and starts the export, which the running service holds from there.""" + self._take_off_screen() + self._logic.start() + self._release() + + def _close(self) -> None: + """Takes the dialog off screen and hands the application back.""" + self._take_off_screen() + self._release() + + def _take_off_screen(self) -> None: + self._window_open = False + self._view_model = None + self._window.hide() + + def _release(self) -> None: + self._logic.close() + self._on_activity_changed() + + def _require_view_model(self) -> NSFExportViewModel: + """The export the dialog opens on. + + Raises: + SystemError: when the window is raised before the logic offers a view. + """ + if self._view_model is None: + raise SystemError("The NSF export window is opened over the view the logic emits") + + return self._view_model diff --git a/src/sampletones_application/coordinators/export/setup.py b/src/sampletones_application/coordinators/export/setup.py new file mode 100644 index 000000000..28cbd5512 --- /dev/null +++ b/src/sampletones_application/coordinators/export/setup.py @@ -0,0 +1,16 @@ +from typing import Protocol + +from sampletones_core.exports.request import SampleExport + + +class ExportSetup(Protocol): + """A format's own setup dialog, opened where a save dialog would otherwise ask for the file. + + A format with choices of its own asks for them together with the destination, so the + surfaces offering an export consult the formats' setups first and reach the save dialog for + the formats that ask for a file alone. + """ + + def open_project(self) -> None: ... + + def open_sample(self, request: SampleExport) -> None: ... diff --git a/src/sampletones_application/coordinators/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/failures.py b/src/sampletones_application/coordinators/failures.py new file mode 100644 index 000000000..e61d412d8 --- /dev/null +++ b/src/sampletones_application/coordinators/failures.py @@ -0,0 +1,27 @@ +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.utils.gui.dialogs import DialogsRenderer + + +class UnhandledFailurePresenter: + """Tells the reader that an action stopped on a failure nothing recovered from. + + ``UnhandledFailures`` hands every such failure here on the render thread. Nothing more precise + can be said about a failure no coordinator named, so the report opens with one plain line and + carries the error and its traceback. + """ + + def __init__( + self, + *, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + ) -> None: + self._dialogs = dialogs + self._language_manager = language_manager + + def present(self, exception: Exception) -> None: + """Shows the reader ``exception`` under the line saying the last action did not finish.""" + self._dialogs.show_failure_report( + exception, + self._language_manager["global.dialog.message.unexpected_failure"], + ) diff --git a/src/sampletones_application/coordinators/keybindings.py b/src/sampletones_application/coordinators/keybindings.py index 41894d9d9..9f99a36dd 100644 --- a/src/sampletones_application/coordinators/keybindings.py +++ b/src/sampletones_application/coordinators/keybindings.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Dict, Final, List, Optional, Tuple from sampletones_application.categories.elements.settings import ( KeybindingActionElements, @@ -15,7 +15,11 @@ ) from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, + combination_parts, + display_combinations, +) from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog from sampletones_application.utils.gui.shortcuts.draft import ShortcutDraft from sampletones_application.utils.gui.shortcuts.ids import ( @@ -30,9 +34,11 @@ KeybindingRow, KeybindingsViewModel, ) +from sampletones_shared.types.callback import VoidCallback -NO_COMBINATION: str = "" -NO_MESSAGE: str = "" +NO_KEYS: Final[str] = "" +NO_MESSAGE: Final[str] = "" +ASSIGNMENT_SEPARATOR: Final[str] = "\n" class KeybindingsCoordinator: @@ -41,10 +47,15 @@ class KeybindingsCoordinator: The dialog edits a draft while the application keeps running on the keys it started with, so Escape, Tab and Enter answer the same way throughout a session of rebinding them. Confirming hands the draft's scheme to the source every action resolves against and writes the scheme name - and the rebound actions to the session; cancelling drops the draft and leaves the keys alone. - - An assignment onto keys another action of the same scope holds is offered after a prompt naming - that action, which is then left unbound — one combination reaches one action within a scope. + and the rebound actions to the session; canceling drops the draft and leaves the keys alone. + + An action answers a list of keys, main key first. A pressed key becomes the action's main key + and the keys it had follow it, while a written list becomes exactly its keys. An assignment + onto keys other actions of the same scope hold is offered after one prompt naming each holder, + which then gives up those keys and keeps the rest, since one combination reaches one action + within a scope. Declining the prompt brings the dialog back where the reader gave the keys: a + written list returns to the entry box, and a pressed key returns to its row listening for the + next press. """ def __init__( @@ -106,59 +117,132 @@ def _select_action(self, name: str) -> None: self._window.update_view(self._view_model()) def _type_combination(self, text: str) -> None: - """Gives the selected action the keys a reader wrote out, reporting what reads as no key.""" + """Gives the selected action the list of keys a reader wrote out, reporting a part that reads + as no key. + """ shortcut_id = self._require_selected() - try: - combination = KeyCombination.parse(text) - except KeyError: - self._message = self._template(KeybindingsElements.UNREADABLE_COMBINATION).format(combination=text) - self._window.update_view(self._view_model()) + combinations = self._read_typed(text) + if combinations is None: return - self._assign(shortcut_id, combination) + self._assign( + shortcut_id, + combinations, + on_declined=self._window.resume, + ) + + def _read_typed(self, text: str) -> Optional[Tuple[KeyCombination, ...]]: + """The keys a written list names, or ``None`` once a part naming no key is reported.""" + combinations: List[KeyCombination] = [] + for part in combination_parts(text): + try: + combination = KeyCombination.parse(part) + except KeyError: + self._message = self._template(KeybindingsElements.UNREADABLE_COMBINATION).format(combination=part) + self._window.update_view(self._view_model()) + return None + + combinations.append(combination) + + return tuple(combinations) def _capture_combination(self, combination: KeyCombination) -> None: - """Gives the selected action the keys a reader pressed.""" - self._assign(self._require_selected(), combination) + """Makes the key a reader pressed the selected action's main key, its other keys following.""" + shortcut_id = self._require_selected() + self._assign( + shortcut_id, + self._require_draft().keys_led_by(shortcut_id, combination), + on_declined=self._window.listen_again, + ) - def _assign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None: - """Assigns the combination, asking first where another action of the scope holds it.""" + def _assign( + self, + shortcut_id: ShortcutId, + combinations: Tuple[KeyCombination, ...], + *, + on_declined: VoidCallback, + ) -> None: + """Assigns the keys, asking first where other actions of the scope hold any of them. + + Args: + shortcut_id: The action the keys go to. + combinations: Its keys, main key first. + on_declined: What brings the dialog back once the reader declines to take the keys. + """ draft = self._require_draft() self._message = NO_MESSAGE - claimant = draft.claimant(shortcut_id, combination) - if claimant is None: - self._apply(draft.assign(shortcut_id, combination)) + holders = draft.holders(shortcut_id, combinations) + if not holders: + self._apply(draft.assign(shortcut_id, combinations)) return - self._window.yield_to(lambda: self._ask_to_reassign(shortcut_id, combination, claimant)) + self._window.yield_to( + lambda: self._ask_to_reassign( + shortcut_id, + combinations, + holders, + on_declined=on_declined, + ), + ) def _ask_to_reassign( self, shortcut_id: ShortcutId, - combination: KeyCombination, - claimant: ShortcutId, + combinations: Tuple[KeyCombination, ...], + holders: Dict[KeyCombination, ShortcutId], + *, + on_declined: VoidCallback, ) -> None: - message = self._template(KeybindingsElements.REASSIGN_CONFIRMATION).format( - combination=combination.display(), - holder=self._action_label(claimant), - action=self._action_label(shortcut_id), - ) self._dialogs.show_confirmation( tag=TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, title=self._title(KeybindingsElements.REASSIGN_CONFIRMATION), - message=message, - on_confirm=lambda: self._reassign(shortcut_id, combination), - on_cancel=self._window.resume, + message=self._reassign_question(shortcut_id, holders), + on_confirm=lambda: self._reassign(shortcut_id, combinations), + on_cancel=on_declined, ok_label=self._label(KeybindingsElements.REASSIGN_BUTTON), ) - def _reassign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None: - """Takes the keys for the action the reader named, leaving the action that held them free.""" - self._apply(self._require_draft().assign(shortcut_id, combination)) + def _reassign_question( + self, + shortcut_id: ShortcutId, + holders: Dict[KeyCombination, ShortcutId], + ) -> str: + """The question naming each action that holds keys the reader is giving away. + + One key taken reads as one sentence; several list each key beside the action holding it. + """ + action = self._action_label(shortcut_id) + if len(holders) == 1: + ((combination, holder),) = holders.items() + return self._template(KeybindingsElements.REASSIGN_CONFIRMATION).format( + combination=combination.display(), + holder=self._action_label(holder), + action=action, + ) + + assignments = ASSIGNMENT_SEPARATOR.join( + self._template(KeybindingsElements.ASSIGNMENT).format( + combination=combination.display(), + holder=self._action_label(holder), + ) + for combination, holder in holders.items() + ) + return self._template(KeybindingsElements.REASSIGN_SEVERAL_CONFIRMATION).format( + assignments=assignments, + action=action, + ) + + def _reassign( + self, + shortcut_id: ShortcutId, + combinations: Tuple[KeyCombination, ...], + ) -> None: + """Gives the action the keys the reader named, each holder keeping the keys it has left.""" + self._apply(self._require_draft().assign(shortcut_id, combinations)) self._window.resume() def _clear(self) -> None: - """Leaves the selected action unbound, its keys free for another action to take.""" + """Leaves the selected action unbound, every key it held free for another action to take.""" self._message = NO_MESSAGE self._apply(self._require_draft().clear(self._require_selected())) @@ -225,7 +309,7 @@ def _view_model(self) -> KeybindingsViewModel: schemes=self._shortcut_catalog.names, scheme=self._scheme_name, selected=None if self._selected is None else self._selected.value, - combination=self._selected_combination(draft), + keys=self._selected_keys(draft), message=self._message, ) @@ -245,22 +329,18 @@ def _rows( KeybindingRow( action=shortcut_id.value, label=self._action_label(shortcut_id), - combination=self._displayed(draft.combination(shortcut_id)), + keys=display_combinations(draft.keys(shortcut_id)), ) for shortcut_id in ShortcutId if shortcut_id.category is category ) - def _selected_combination(self, draft: ShortcutDraft) -> str: - """The keys the entry box shows, empty while no action is selected.""" + def _selected_keys(self, draft: ShortcutDraft) -> str: + """The keys the entry box shows, joined by commas, and empty while no action is selected.""" if self._selected is None: - return NO_COMBINATION - - return self._displayed(draft.combination(self._selected)) + return NO_KEYS - @staticmethod - def _displayed(combination: Optional[KeyCombination]) -> str: - return NO_COMBINATION if combination is None else combination.display() + return display_combinations(draft.keys(self._selected)) def _action_label(self, shortcut_id: ShortcutId) -> str: """The name a reader finds an action under, which its element mirrors member for member.""" 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/playback/failures.py b/src/sampletones_application/coordinators/playback/failures.py new file mode 100644 index 000000000..e559f2bc4 --- /dev/null +++ b/src/sampletones_application/coordinators/playback/failures.py @@ -0,0 +1,57 @@ +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_shared.exceptions import NoOutputDeviceError + + +class PlaybackFailurePresenter: + """Tells the reader why a sound they asked for stays silent, whichever gesture asked for it. + + Every recovery boundary around a playback start hands its failure here, so what the reader sees + is decided in one place. A machine offering no audio output is a fact about the machine, which a + plain notice states in the reader's words. Any other failure is reported as an error with its + details. + """ + + def __init__( + self, + *, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + ) -> None: + self._dialogs = dialogs + self._language_manager = language_manager + + def present( + self, + exception: Exception, + *, + message: Optional[str], + ) -> None: + """Shows the reader what stopped a playback. + + Args: + exception: The failure the playback met. + message: The line an error report opens with, or ``None`` for a report of the error + alone. + """ + match exception: + case NoOutputDeviceError(): + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, + self._language_manager["global.dialog.message.no_audio_output"], + self._language_manager["global.dialog.title.no_audio_output"], + modal=True, + ) + case _: + self._dialogs.show_error(exception, message) + + def present_playing_failure(self, exception: Exception) -> None: + """Shows the reader what stopped a playback they asked for, such as a stream the device refused. + + Every source reports such a failure with the same opening line, whether its start failed at + once or on the thread playing it, so one refusal reads alike on every tab. + """ + self.present(exception, message=self._language_manager["global.dialog.message.audio_playback_error"]) diff --git a/src/sampletones_application/coordinators/playback/guard.py b/src/sampletones_application/coordinators/playback/guard.py index 47653b9fd..9f86d8a48 100644 --- a/src/sampletones_application/coordinators/playback/guard.py +++ b/src/sampletones_application/coordinators/playback/guard.py @@ -1,39 +1,33 @@ +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol -from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.types.callback import VoidCallback class GuardedPlayer: """Drives an ``AudioPlayerProtocol`` player on behalf of panels and the - ``PlaybackRouter``, presenting playback failures as dialogs. + ``PlaybackRouter``, handing playback failures to the presenter. Panels only fire intent hooks, so this wrapper is the coordinator-layer recovery boundary for the transport commands that can raise - ``PlaybackError``; queries pass straight through. + ``PlaybackError``; queries pass straight through. A start that fails at once + reads the way a failure on the playing thread does. """ def __init__( self, player: AudioPlayerProtocol, *, - dialogs: DialogsRenderer, - error_message: str, + failures: PlaybackFailurePresenter, ) -> None: self._player = player - self._dialogs = dialogs - self._error_message = error_message + self._failures = failures def play(self) -> None: - try: - self._player.play() - except PlaybackError as exception: - self._dialogs.show_error(exception, self._error_message) + self.run_guarded(self._player.play) def pause_or_resume(self) -> None: - try: - self._player.pause_or_resume() - except PlaybackError as exception: - self._dialogs.show_error(exception, self._error_message) + self.run_guarded(self._player.pause_or_resume) def stop(self) -> None: self._player.stop() @@ -49,3 +43,14 @@ def is_engaged(self) -> bool: def is_loaded(self) -> bool: return self._player.is_loaded() + + def run_guarded(self, command: VoidCallback) -> None: + """Runs a playback command, showing the reader a failure to start the audio. + + A command beyond the transport — sounding a sample from a point the reader clicked — goes + through the same boundary the transport commands do. + """ + try: + command() + except PlaybackError as exception: + self._failures.present_playing_failure(exception) diff --git a/src/sampletones_application/coordinators/playback/router.py b/src/sampletones_application/coordinators/playback/router.py index 194460f3d..ab1206d27 100644 --- a/src/sampletones_application/coordinators/playback/router.py +++ b/src/sampletones_application/coordinators/playback/router.py @@ -13,7 +13,7 @@ class PlaybackRouter: Every command acts on one *target*: the active tab's own source when that tab has something to play, and otherwise the intentional source currently engaged (owning the device). Preview sounds belong to no source and answer only to Stop. The full contract is documented in - ``docs/development/playback.md``. + ``docs/development/application/playback.md``. It is stateless: the target is recomputed from the live sources on each call, so the transport stays in step with tab changes and playback transitions automatically. diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 63f755e37..db303bd1d 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Dict, List, Mapping, Optional, Tuple from sampletones_application.categories.elements.global_ import ( DialogElements, @@ -7,10 +7,13 @@ 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.categories.skipped import SkippedRowMessages +from sampletones_application.categories.truncation import TruncationMessages from sampletones_application.config.managers.session import SessionManager +from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.services.export.error import ExportError @@ -24,6 +27,7 @@ TAG_GLOBAL_DIALOG_PROJECT_SAVED, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, ) +from sampletones_application.utils.callbacks.gates import Gate, asking from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, save_file_dialog, @@ -31,9 +35,12 @@ 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.dialogs.outcome import SaveOutcome +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.backend import ExportBackend +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 +74,8 @@ def __init__( session_manager: SessionManager, export_service: ExportService, *, - tracker_backends: Dict[TrackerFormat, TrackerBackend], + export_backends: Dict[ExportFormat, ExportBackend], + format_setups: Mapping[ExportFormat, ExportSetup], dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, @@ -77,11 +85,15 @@ 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._format_setups = format_setups self._dialogs = dialogs self._language_manager = language_manager + self._skipped_row_messages = SkippedRowMessages.build(language_manager) + self._truncation_messages = TruncationMessages.for_project(language_manager) self._on_tab_switch = on_tab_switch self._project_manager.session.on_state_changed = on_session_state_changed + self._project_manager.on_path_changed = session_manager.set_current_project export_service.subscribe(self._on_export_result) @@ -94,35 +106,49 @@ def project_name(self) -> Optional[str]: def is_unsaved(self) -> bool: return self._project_controller.is_dirty - def new_project_with_confirmation(self) -> None: + def guard_new(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets a new project take the open one's place, asking first what becomes of the open one. + + The signature is a :data:`Gate`, so the question leads the new project's conversation. + """ self._guard_open( title=GlobalDialogTitleElements.NEW_UNSAVED_PROJECT, message=GlobalMessageElements.NEW_UNSAVED_PROJECT, open_message=GlobalMessageElements.NEW_OPEN_PROJECT, - on_confirm=self._new, + proceed=proceed, + decline=decline, ) - def open_with_confirmation(self, filepath: Optional[Path] = None) -> None: - def open_project() -> None: - if filepath is None: - self._open_dialog() - else: - self._load(filepath) + def guard_open(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets another project take the open one's place, asking first what becomes of the open one. + The signature is a :data:`Gate`, so the question leads the opening's conversation. + """ self._guard_open( title=GlobalDialogTitleElements.OPEN_UNSAVED_PROJECT, message=GlobalMessageElements.OPEN_UNSAVED_PROJECT, open_message=GlobalMessageElements.OPEN_OPEN_PROJECT, - on_confirm=open_project, + proceed=proceed, + decline=decline, ) + def new_project(self) -> None: + """Puts a new project in place and brings the Sequencer forward.""" + self._project_controller.new() + self._on_tab_switch(Tab.SEQUENCER) + + def open_project(self) -> None: + """Opens the project file the reader picks.""" + self._open_dialog() + def load_project_safely(self, path: Path) -> None: - """Loads the persisted project when the application starts. + """Loads the project a run starts on, the one the session remembers or the one it was given. Startup restore happens automatically, so a failed load is recovered silently: the stale session pointer is cleared so a missing, moved, or corrupt file leaves the next launch starting from a clean slate. Only known domain and I/O failures - are absorbed; unexpected errors propagate. + are absorbed; unexpected errors propagate. A project that loads stands for its file, + so a later save writes there. """ try: self._project_controller.load(path) @@ -130,46 +156,87 @@ def load_project_safely(self, path: Path) -> None: logger.warning(f"Could not restore project from {logger.format_path(path)}: {exception}") self._session_manager.set_current_project(None) - def close_with_confirmation(self) -> None: + def guard_close(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the open project close, asking first to save unsaved changes. + + With no project open there is nothing to close, so the request is turned away. The question + reads the project once the screen is free for it. The signature is a :data:`Gate`, so the + question leads the closing's conversation. + """ if not self._project_controller.is_open: + decline() return - if self.is_unsaved: + self._asking_to_save( + title=GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT, + message=GlobalMessageElements.CLOSE_UNSAVED_PROJECT, + ok_label=DialogElements.DISCARD, + )(proceed, decline) + + def close_project(self) -> None: + self._project_controller.close() + + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the exit go on, asking first to save a project with unsaved changes. + + The question reads the project once the screen is free for it, so a project another + conversation saved or closed meanwhile is asked about no more. Save and Exit both go on, so + what the exit asks about next is asked in turn, and Cancel keeps the application open and + turns the exit away. + """ + self._asking_to_save( + title=GlobalDialogTitleElements.EXIT_CONFIRMATION, + message=GlobalMessageElements.EXIT_UNSAVED_PROJECT, + ok_label=DialogElements.EXIT, + )(proceed, decline) + + def _asking_to_save( + self, + *, + title: GlobalDialogTitleElements, + message: GlobalMessageElements, + ok_label: DialogElements, + ) -> Gate: + """The gate that offers to save the project's unsaved changes once the screen is free for the question.""" + + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, - title=self._title(GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT), - message=self._message(GlobalMessageElements.CLOSE_UNSAVED_PROJECT), - on_save=self.save, - on_confirm=self._close, - ok_label=self._label(DialogElements.DISCARD), + title=self._title(title), + message=self._message(message), + on_save=self._write_project, + on_confirm=proceed, + on_cancel=decline, + ok_label=self._label(ok_label), ) - else: - self._close() - - def show_exit_save_confirmation(self, on_confirm: VoidCallback) -> None: - self._dialogs.show_save_confirmation( - tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, - title=self._title(GlobalDialogTitleElements.EXIT_CONFIRMATION), - message=self._message(GlobalMessageElements.EXIT_UNSAVED_PROJECT), - on_save=self.save, - on_confirm=on_confirm, - ok_label=self._label(DialogElements.EXIT), - ) - def save(self) -> bool: - """Saves the project to its current file, prompting for one when it has none. + return asking(lambda: self.is_unsaved, ask, self._dialogs.when_free) + + def save(self) -> SaveOutcome: + """Saves the project to its current file, prompting for one when it has none, and says so. + + Reports what the save came to, the way :meth:`_write_project` does. + """ + return self._announced(self._write_project()) + + def save_as_dialog(self) -> SaveOutcome: + """Prompts for a destination and saves the project there, saying so once it is written.""" + return self._announced(self._write_to_chosen_file()) - Reports whether the project was written, so a caller waiting on the save (the exit and - close prompts) proceeds only once it lands on disk and holds when the user cancels. + def _write_project(self) -> SaveOutcome: + """Writes the project to its current file, prompting for one when it has none. + + A save prompt waits on this, going on once the project lands on disk and asking again when + the reader closes the file dialog. The reader asked to go on, so the save goes on without a + word of its own, and whatever the prompt guards opens alone. """ - filepath = self._session_manager.current_project + filepath = self._project_manager.path if filepath is None: - return self.save_as_dialog() + return self._write_to_chosen_file() - return self._save(filepath) + return self._write(filepath) - def save_as_dialog(self) -> bool: - """Prompts for a destination and saves the project there, reporting whether it was written.""" + def _write_to_chosen_file(self) -> SaveOutcome: path = self._session_manager.get_project_path() filename = path.name if path.is_file() else DEFAULT_PROJECT_FILENAME directory = get_directory(path) @@ -182,6 +249,17 @@ def save_as_dialog(self) -> bool: return self._handle_save_as(filepath) + def _announced(self, outcome: SaveOutcome) -> SaveOutcome: + """Tells the reader a save they asked for by itself has landed.""" + if outcome is SaveOutcome.WRITTEN: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_PROJECT_SAVED, + self._message(GlobalMessageElements.PROJECT_SAVED_SUCCESSFULLY), + self._title(GlobalDialogTitleElements.PROJECT_SAVED), + ) + + return outcome + def _project_filters(self) -> Tuple[FileFilter, ...]: """The single type a project of this application's own is written as and read from.""" return ( @@ -195,17 +273,25 @@ 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: + """Writes the open project in ``export_format``, asking first for what the format leaves open. + + A format with a setup of its own opens it, and that setup asks for the destination with + the rest of its choices. Every other format asks for the destination through the save + dialog. Args: - 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] + if export_format in self._format_setups: + self._format_setups[export_format].open_project() + return + + backend = self._export_backends[export_format] + elements = EXPORT_PROJECT_ELEMENTS[export_format] extension = backend.extension(ExportScope.PROJECT) path = self._session_manager.get_project_path() filepath = save_file_dialog( @@ -220,7 +306,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( @@ -236,28 +322,19 @@ def _handle_open(self, filepath: Path) -> None: self._session_manager.set_project_path(filepath.parent) self._load(filepath) - @ignore_none_path(default=False) - def _handle_save_as(self, filepath: Path) -> bool: + @ignore_none_path(default=SaveOutcome.CALLED_OFF) + def _handle_save_as(self, filepath: Path) -> SaveOutcome: self._session_manager.set_project_path(filepath.parent) - return self._save(filepath) + return self._write(filepath) @ignore_none_path - def _handle_export_project(self, filepath: Path, 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, ) - def _new(self) -> None: - self._project_controller.new() - self._session_manager.set_current_project(None) - self._on_tab_switch(Tab.SEQUENCER) - - def _close(self) -> None: - self._project_controller.close() - self._session_manager.set_current_project(None) - def _load(self, filepath: Path) -> None: try: self._project_controller.load(filepath) @@ -269,10 +346,9 @@ def _load(self, filepath: Path) -> None: self._dialogs.show_error(exception) return - self._session_manager.set_current_project(filepath) self._on_tab_switch(Tab.SEQUENCER) - def _save(self, filepath: Path) -> bool: + def _write(self, filepath: Path) -> SaveOutcome: try: self._project_controller.save(filepath) except (SerializationError, OSError) as exception: @@ -284,57 +360,100 @@ def _save(self, filepath: Path) -> bool: exception, self._message(GlobalMessageElements.PROJECT_SAVE_FAILED), ) - return False + return SaveOutcome.FAILED - self._session_manager.set_current_project(filepath) - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_PROJECT_SAVED, - self._message(GlobalMessageElements.PROJECT_SAVED_SUCCESSFULLY), - self._title(GlobalDialogTitleElements.PROJECT_SAVED), - ) - return True + return SaveOutcome.WRITTEN def _on_export_result(self, result: ExportResult) -> None: """Reports a finished project export in the words of the format it was written in.""" match result: case ExportSuccess( kind=ExportKind.PROJECT, - tracker_format=TrackerFormat() as tracker_format, + export_format=ExportFormat() as export_format, + skipped_rows=skipped_rows, + truncation=truncation, ): self._dialogs.show_info( TAG_GLOBAL_DIALOG_MODULE_EXPORTED, - self._message(TRACKER_PROJECT_ELEMENTS[tracker_format].exported_message), + self._exported_message(export_format, skipped_rows, truncation), 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._message(EXPORT_PROJECT_ELEMENTS[export_format].export_failed_message), ) + def _exported_message( + self, + export_format: ExportFormat, + skipped_rows: Tuple[SkippedRow, ...], + truncation: Optional[EnvelopeTruncation], + ) -> str: + """The report of a written project, followed by what the format left out of it. + + The rows the format wrote as a note cut come first, then the instruments whose envelopes it + shortened. + """ + paragraphs: List[str] = [self._message(EXPORT_PROJECT_ELEMENTS[export_format].exported_message)] + for notice in ( + self._skipped_row_messages.notice(skipped_rows, self._project_manager.current.voices), + self._truncation_messages.notice(truncation), + ): + if notice is not None: + paragraphs.append(notice) + + return "\n\n".join(paragraphs) + def _guard_open( self, *, title: GlobalDialogTitleElements, message: GlobalMessageElements, open_message: GlobalMessageElements, - on_confirm: Callback, + proceed: VoidCallback, + decline: VoidCallback, ) -> None: - if not self._project_controller.is_open: - on_confirm() - return + """Lets another project take the open one's place, asking first while one is open. + + Unsaved changes are offered a save, and an open project holding none is still asked about, + so a project is replaced only by an answer. The question reads the project once the screen + is free for it. Cancel turns the request away. + """ + + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + self._ask_before_replacing( + title=title, + message=message, + open_message=open_message, + proceed=proceed, + decline=decline, + ) + asking(lambda: self._project_controller.is_open, ask, self._dialogs.when_free)(proceed, decline) + + def _ask_before_replacing( + self, + *, + title: GlobalDialogTitleElements, + message: GlobalMessageElements, + open_message: GlobalMessageElements, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Asks what becomes of the open project: a save of its unsaved changes, or its place alone.""" if self.is_unsaved: self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, title=self._title(title), message=self._message(message), - on_save=self.save, - on_confirm=on_confirm, + on_save=self._write_project, + on_confirm=proceed, + on_cancel=decline, ok_label=self._label(DialogElements.DISCARD), ) else: @@ -342,7 +461,8 @@ def _guard_open( tag=TAG_GLOBAL_DIALOG_PROJECT_OPEN, title=self._title(title), message=self._message(open_message), - on_confirm=on_confirm, + on_confirm=proceed, + on_cancel=decline, ok_label=self._label(DialogElements.DISCARD), ) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index d2e5222ad..081560cb1 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, Optional, Tuple @@ -7,19 +8,28 @@ from sampletones_application.coordinators.tabs.reconstruction import ( ReconstructionTabCoordinator, ) +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.reconstruction.edit import ( + ChannelEdit, + ReconstructionEdit, + Retune, + StemRemoval, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.services import ( - RegeneratedInstrument, - RegenerationResult, - RegenerationService, - ServiceCancelled, - ServiceError, - ServiceSuccess, +from sampletones_application.logic.reconstruction.rewrites.queue import ( + ReconstructionRewrites, +) +from sampletones_application.logic.reconstruction.rewrites.steps import ( + AfterEdits, + Rewrite, ) from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, ) +from sampletones_application.utils.callbacks.gates import asking from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, save_file_dialog, @@ -27,15 +37,16 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.types.feature import FeatureValue +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION 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: @@ -46,8 +57,13 @@ class ReconstructionCoordinator: - It can be opened from multiple entry points. - It must be saved before replacement. - Its dirty/saved state drives the window title. - - Menu bar instrument regeneration flows through it so that all - reconstruction mutations remain centralised. + - Every edit of the open document flows through it, one step at a time in the + reader's order (see :class:`ReconstructionRewrites`), so all reconstruction + mutations remain centralized. + - Every change from outside the tab that replaces the open document passes + through it: opening a project voice, a replaced sample, and a project change + that restores, removes or lets go of the voice the tab shows. Each puts away + the edits meant for the document it replaces. The reconstructions tab is wired in after construction through ``set_reconstructions_tab``; ``_tab`` asserts it is present before first use. @@ -57,30 +73,35 @@ def __init__( self, reconstruction_manager: ReconstructionManager, session_manager: SessionManager, - regeneration_service: RegenerationService, + rewrites: ReconstructionRewrites, audio_device_manager: AudioDeviceManager, + project_controller: ProjectController, + history: HistoryManager, *, dialogs: DialogsRenderer, language_manager: LanguageManager, on_tab_switch: Callback, on_session_state_changed: VoidCallback, - on_reconstruction_updated: Callable[[RegeneratedInstrument], None], - is_reconstruction_embedded: Callable[[], bool], + on_reconstruction_updated: Callable[[ReconstructionEdit], None], ) -> None: self._reconstruction_manager = reconstruction_manager self._session_manager = session_manager - self._regeneration_service = regeneration_service + self._rewrites = rewrites self._audio_device_manager = audio_device_manager + self._project_controller = project_controller + self._history = history self._reconstructions_tab: Optional[ReconstructionTabCoordinator] = None self._dialogs = dialogs self._language_manager = language_manager self._on_tab_switch = on_tab_switch self._on_session_state_changed_callback = on_session_state_changed self._on_reconstruction_updated_callback = on_reconstruction_updated - self._is_reconstruction_embedded = is_reconstruction_embedded self._reconstruction_manager.session.on_state_changed = self._on_state_changed - self._regeneration_service.subscribe(self._on_regeneration_result) + self._rewrites.on_edit = self.apply_edit + self._rewrites.on_dropped = self._redraw_open_document + self._rewrites.on_failed = self._report_rebuild_failure + self._rewrites.on_busy_changed = self._set_reconstruction_dimmed self._reconstruction_manager.set_callbacks( on_reconstruction_loaded=self.on_reconstruction_loaded, on_reconstruction_closed=self._on_closed, @@ -109,14 +130,15 @@ def is_unsaved(self) -> bool: def is_saveable(self) -> bool: return self._reconstruction_manager.is_file_backed - def _requires_save_confirmation(self) -> bool: - """Reports pending edits that a save prompt can resolve. + def is_unsaved_standalone(self) -> bool: + """Whether the open document has unsaved changes that a save prompt can resolve. A prompt is warranted only for a standalone reconstruction with unsaved changes. A - project-embedded reconstruction has no file of its own, and its edits belong to the - project — closing or replacing it loses nothing, so it needs no prompt. + project sample has no file of its own, and its edits belong to the project — closing or + replacing it loses nothing, so it needs no prompt. This is what every save prompt of the + document reads, the exit's included. """ - return self.is_unsaved() and not self._is_reconstruction_embedded() + return self.is_unsaved() and not self._reconstruction_manager.is_project_sample def check_loaded(self) -> bool: if not self.is_loaded(): @@ -126,10 +148,27 @@ def check_loaded(self) -> bool: return True - def save_as_dialog(self) -> None: + def save_as_dialog(self) -> SaveOutcome: + """Saves the open reconstruction to a file the reader picks, and says so once it is written.""" + outcome = self._save_to_chosen_file() + if outcome is SaveOutcome.WRITTEN: + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, + self._language_manager["global.dialog.message.reconstruction_saved_successfully"], + self._language_manager["global.dialog.title.reconstruction_saved"], + ) + + return outcome + + def _save_to_chosen_file(self) -> SaveOutcome: + """Asks where the open reconstruction goes, writes it there and adopts that file as its own. + + A document that came from a file offers that file's place, and one without a file offers + the reconstructions folder under its own name. + """ reconstruction_data = self._reconstruction_manager.current_reconstruction if reconstruction_data is None: - return + return SaveOutcome.CALLED_OFF filepath = reconstruction_data.filepath if filepath is not None: @@ -139,14 +178,25 @@ def save_as_dialog(self) -> None: default_filename = get_filename(reconstruction_data.name, EXT_FILE_RECONSTRUCTION) default_path = str(self._session_manager.get_reconstruction_path()) - filepath = save_file_dialog( + chosen = save_file_dialog( title=self._language_manager["global.dialog.title.save_reconstruction"], initial_directory=default_path, default_filename=default_filename, filters=self._reconstruction_filters(), ) + if chosen is None: + return SaveOutcome.CALLED_OFF - self._handle_save_as(filepath) + try: + self._reconstruction_manager.save_reconstruction_as(chosen) + except (OSError, SampleToNESError) as exception: + self._report_save_failure(exception, chosen) + return SaveOutcome.FAILED + + self._session_manager.set_reconstruction_path(chosen.parent) + self._session_manager.set_current_reconstruction(chosen) + self._tab.display_reconstruction() + return SaveOutcome.WRITTEN def _reconstruction_filters(self) -> Tuple[FileFilter, ...]: """The single type a reconstruction is written as and read from.""" @@ -157,31 +207,6 @@ def _reconstruction_filters(self) -> Tuple[FileFilter, ...]: ), ) - @ignore_none_path - def _handle_save_as(self, filepath: Path) -> None: - try: - self._reconstruction_manager.save_reconstruction_as(filepath) - except (OSError, SampleToNESError) as exception: - logger.error_with_traceback( - exception, - f"Failed to save reconstruction to {filepath}", - ) - self._dialogs.show_error( - exception, - self._language_manager["global.dialog.message.reconstruction_save_failed"], - ) - - return - - self._session_manager.set_reconstruction_path(filepath.parent) - self._session_manager.set_current_reconstruction(filepath) - self._tab.display_reconstruction() - self._dialogs.show_info( - TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, - self._language_manager["global.dialog.message.reconstruction_saved_successfully"], - self._language_manager["global.dialog.title.reconstruction_saved"], - ) - def _load_dialog(self) -> None: filepath = open_file_dialog( title=self._language_manager["reconstructions.browser.title.load_reconstruction_dialog"], @@ -195,23 +220,72 @@ def _load_dialog(self) -> None: def load(self, filepath: Path) -> None: return self._tab.load_reconstruction(filepath) - def load_with_confirmation(self, filepath: Optional[Path] = None) -> None: - def load_reconstruction() -> None: - if filepath is None: - self._load_dialog() - else: - self.load(filepath) + def open(self, filepath: Optional[Path] = None) -> None: + """Loads ``filepath``, or the reconstruction file the reader picks where none is named.""" + if filepath is None: + self._load_dialog() + else: + self.load(filepath) + + def guard_load(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets another document take the open one's place, offering first to save unsaved changes. - if self._requires_save_confirmation(): - self._show_save_confirmation( + The signature is a :data:`Gate`, so the question leads the loading's conversation. + """ + self._save_first( + title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.discard"], + proceed=proceed, + decline=decline, + ) + + def guard_load_converted( + self, + filepath: Path, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Lets the reconstruction a conversion wrote at ``filepath`` take the open one's place. + + A conversion can write over the very file the open document came from, and a save would + then write the old document over the new one. The question in that case is whether to + discard the changes and load, and Cancel keeps them for a save to another file. Every other + document is asked about the way one opened by hand is. The question reads the open document + once the screen is free for it. With ``filepath`` bound, the signature is a :data:`Gate`, so + the question leads the loading's conversation. + """ + asking( + self.is_unsaved_standalone, + partial(self._ask_before_loading_converted, filepath), + self._dialogs.when_free, + )(proceed, decline) + + def _ask_before_loading_converted( + self, + filepath: Path, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Asks what becomes of the unsaved changes before the file a conversion wrote loads.""" + if self._reconstruction_manager.is_backed_by(filepath): + self._dialogs.show_confirmation( + tag=TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, + message=self._language_manager["global.dialog.message.load_replaced_reconstruction"], title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], - message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], - on_save=self.save, - on_confirm=load_reconstruction, + on_confirm=proceed, ok_label=self._language_manager["global.dialog.label.discard"], + on_cancel=decline, ) - else: - load_reconstruction() + return + + self._offer_save( + title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.discard"], + proceed=proceed, + decline=decline, + ) def load_reconstruction_safely(self, path: Path) -> None: """Loads the persisted reconstruction when the application starts. @@ -227,13 +301,18 @@ def load_reconstruction_safely(self, path: Path) -> None: logger.warning(f"Could not restore reconstruction from {logger.format_path(path)}: {exception}") self._session_manager.set_current_reconstruction(None) - def show_exit_save_confirmation(self, on_confirm: Callback) -> None: - self._show_save_confirmation( + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the exit go on, asking first to save a standalone reconstruction with unsaved changes. + + A project sample's changes belong to the project, which the exit asks about on its own. + Cancel keeps the application open and turns the exit away. + """ + self._save_first( title=self._language_manager["global.dialog.title.exit_confirmation"], message=self._language_manager["global.dialog.message.exit_unsaved_reconstruction"], - on_save=self.save, - on_confirm=on_confirm, ok_label=self._language_manager["global.dialog.label.exit"], + proceed=proceed, + decline=decline, ) @ignore_none_path @@ -241,60 +320,82 @@ def _handle_load(self, filepath: Path) -> None: self._session_manager.set_reconstruction_path(filepath.parent) self._tab.load_reconstruction(filepath) - def save(self, filepath: Optional[Path] = None) -> bool: - """Saves the open reconstruction, reporting whether it was written. + def save(self, filepath: Optional[Path] = None) -> SaveOutcome: + """Saves the open reconstruction, reporting what the save came to. - The exit and close prompts wait on this, so they proceed only once it lands on disk and - hold otherwise. + The save prompts wait on this: a document written to disk lets them go on, a save the + reader calls off asks again, and a write that fails shows its error. A standalone document + whose file was taken away asks where to go, the way Save As does, so its save prompt has a + way forward. """ - return self._reconstruction_manager.save_reconstruction(filepath) - - def close_with_confirmation(self) -> None: - if self._requires_save_confirmation(): - self._show_save_confirmation( - title=self._language_manager["global.dialog.title.close_unsaved_reconstruction"], - message=self._language_manager["global.dialog.message.close_unsaved_reconstruction"], - on_confirm=self._close, - on_save=self.save, - ok_label=self._language_manager["global.dialog.label.close"], - ) - else: - self._close() + manager = self._reconstruction_manager + if filepath is None and not manager.is_file_backed and not manager.is_project_sample: + return self._save_to_chosen_file() - def _close(self) -> None: - self._reconstruction_manager.close_reconstruction() + try: + written = self._reconstruction_manager.save_reconstruction(filepath) + except (OSError, SampleToNESError) as exception: + self._report_save_failure(exception, filepath or self._reconstruction_manager.filepath) + return SaveOutcome.FAILED - def regenerate_instrument( - self, - generator_name: GeneratorName, - features: Features, - feature_key: FeatureKey, - data: FeatureValue, - ) -> None: - reconstruction_data = self._reconstruction_manager.current_reconstruction - if reconstruction_data is None: - return + return SaveOutcome.WRITTEN if written else SaveOutcome.CALLED_OFF - accepted = self._regeneration_service.start( - reconstruction_data.reconstruction, - generator_name, - features, - feature_key, - data, + def _report_save_failure(self, exception: Exception, filepath: Optional[Path]) -> None: + logger.error_with_traceback( + exception, + f"Failed to save reconstruction to {filepath}", + ) + self._dialogs.show_error( + exception, + self._language_manager["global.dialog.message.reconstruction_save_failed"], ) - if accepted: - self._set_reconstruction_dimmed(True) + + def guard_close(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the open document close, offering first to save unsaved changes. + + The signature is a :data:`Gate`, so the question leads the closing's conversation. + """ + self._save_first( + title=self._language_manager["global.dialog.title.close_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.close_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.close"], + proceed=proceed, + decline=decline, + ) + + def close(self) -> None: + """Puts the open document away, with the edits still on their way to it.""" + self._drop_pending() + self._reconstruction_manager.close_reconstruction() + + def request_rewrite(self, rewrite: Rewrite) -> None: + """Asks for a change of the open document, which it takes after the changes asked for before it.""" + self._rewrites.request(rewrite) + + def after_edits(self, gesture: VoidCallback) -> None: + """Runs a gesture that reads or puts away the whole document once the edits before it have landed. + + Undo, a save, a load or an export acts on the document the reader has drawn, so it waits + for the edits still on their way. With nothing on its way, the gesture runs at once. The + signature is a :data:`Wait`, which :func:`waiting` turns into the gate that leads a chain. + """ + self._rewrites.request(AfterEdits(gesture)) + + def _drop_pending(self) -> None: + """Puts away the edits meant for the open document, which an outside change is about to replace.""" + self._rewrites.drop() def on_reconstruction_loaded(self) -> None: reconstruction_data = self._reconstruction_manager.current_reconstruction if reconstruction_data is None: raise RuntimeError("No reconstruction is loaded after loading process") + self._drop_pending() self._audio_device_manager.stop() - 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,38 +408,167 @@ 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 lands here as the rewrites take it, so one path answers a + regenerated instrument, a removed recording and a retune alike, in the reader's order. The + owning-sample hook runs first and records the edit against the project history as the + ``edit`` describes itself, so the history holds it by the time the tab shows it. The open + document then rebinds to the new reconstruction, the same object the sample now holds, and + the tab shows it. """ - self._on_reconstruction_updated_callback(outcome) - self._reconstruction_manager.apply_regenerated(outcome.reconstruction) - self._tab.update_reconstruction() + self._on_reconstruction_updated_callback(edit) + self._reconstruction_manager.apply_edited(edit.reconstruction) + self._show_edit(edit) self._reconstruction_manager.mark_updated() - def _on_regeneration_result(self, result: RegenerationResult) -> None: - match result: - case ServiceSuccess(value=outcome): - self._on_updated(outcome) - case ServiceError(exception=exception): - logger.error_with_traceback(exception, "Regeneration failed") - self._dialogs.show_error(exception) - case ServiceCancelled(): - logger.info("Regeneration cancelled") + def _show_edit(self, edit: ReconstructionEdit) -> None: + """Shows the edited document, redrawing the instruments panel where the edit came from elsewhere. - self._set_reconstruction_dimmed(self._regeneration_service.is_running()) + A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel + keeps drawing them and a field the reader is typing in keeps its text. A retune carries + every envelope over too, and its audio spans another length, so the waveform re-fits. A + removed recording releases frames the panel drew as sounding, so the panel draws every + channel as the document now holds it. + """ + match edit: + case ChannelEdit(): + self._tab.update_reconstruction() + case Retune(): + self._tab.update_reconstruction(refit_waveform=True) + case StemRemoval(): + self._tab.redraw_reconstruction(refit_waveform=False) + + def guard_edit_voice(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets a voice of the project take the open document's place, offering first to save unsaved changes. + + Either kind of voice takes the place of the open document, so a standalone document with + unsaved changes is offered a save first, the way loading a file offers it. The signature is a + :data:`Gate`, so the question leads the editing's conversation. + """ + self._save_first( + title=self._language_manager["global.dialog.title.edit_voice_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.edit_voice_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.discard"], + proceed=proceed, + decline=decline, + ) + + def open_project_voice(self, voice_id: str) -> None: + """Puts a voice of the project in front of the tab, in the terms of its kind. + + A sample opens as the reconstruction behind it, waveform and stems and all, and the + document remembers the voice it is. An instrument stands on no recording, so the tab + shows its envelopes alone. Either kind brings that tab to the front, so the voice a + reader asked to edit is the one in view. + """ + match self._project_controller.project.voice(voice_id): + case Sample() as sample: + self._tab.release_instrument() + self._reconstruction_manager.load_reconstruction_object( + sample.reconstruction, + name=sample.name, + voice_id=sample.id, + ) + case Instrument(): + self._drop_pending() + self._tab.edit_instrument(voice_id) + self._on_tab_switch(Tab.RECONSTRUCTIONS) + case _: + logger.warning(f"Cannot edit unknown project voice: {voice_id}") + + def replace_sample(self, voice_id: str, reconstruction: Reconstruction) -> None: + """Shows the reconstruction a sample now holds, where the tab has that sample open. + + A sample whose audio is substituted keeps its id, so the open document takes the incoming + reconstruction along. It brings envelopes of its own, so the tab redraws the instruments + panel from it. + + Args: + voice_id: The sample that received a new reconstruction. + reconstruction: The reconstruction the sample now holds. + """ + if self._reconstruction_manager.voice_id == voice_id: + self._rebind(reconstruction) + + def follow_project(self) -> None: + """Closes the voice the tab shows once the project stops holding it. + + Every change to the project reaches here, the tab's own edits included. An edit writes + the project before the open document shows it, so a voice the project still holds is + left as it stands and the panel keeps what the reader is drawing. The tab follows a + gesture once it has landed, so a gesture rolled back leaves the tab showing what it showed. + """ + self._history.after_landing(partial(self._follow, restored=False)) + + def follow_replaced_project(self) -> None: + """Follows the voice the tab shows into a project put in place of the one it belonged to. + + An undo, a redo, a history jump or the rollback of a failed gesture installs a snapshot + that keeps every voice's id, so a voice it keeps is shown as restored and one it took out + closes. Any other replacement (a new, opened or closed project) lets the voice go, since a + reopened file brings back the same ids. + """ + if self._history.is_restoring: + self._follow(restored=True) + else: + self._let_go_of_project_voice() + + def _follow(self, *, restored: bool) -> None: + """Brings the voice in front of the tab in line with the project. + + A sample the project no longer holds closes. A restore hands a kept sample the + reconstruction it held then, which the tab rebinds to. The instrument the tab holds + follows by the same rule. + """ + voice_id = self._reconstruction_manager.voice_id + if voice_id is not None: + match self._project_controller.project.voice(voice_id): + case Sample() as sample: + if restored: + self._rebind(sample.reconstruction) + case _: + self.close() + + self._tab.follow_instrument(restored=restored) + + def _let_go_of_project_voice(self) -> None: + """Closes a project voice the tab shows, leaving a standalone document open.""" + if self._reconstruction_manager.is_project_sample: + self.close() + + self._tab.close_instrument() + + def _rebind(self, reconstruction: Reconstruction) -> None: + """Points the open document at the reconstruction its sample now holds, and redraws it. + + A reconstruction timed at another NES frequency spans another length, so the waveform + re-fits to it. The document the sample already holds needs no redraw. + """ + open_reconstruction = self._reconstruction_manager.reconstruction + if open_reconstruction is None or reconstruction is open_reconstruction: + return + + retimed = reconstruction.config.nes_frequency != open_reconstruction.config.nes_frequency + self._drop_pending() + self._reconstruction_manager.apply_edited(reconstruction) + self._tab.redraw_reconstruction(refit_waveform=retimed) + + def _redraw_open_document(self) -> None: + """Draws the open document as it stands, where the panel drew a change that will never land.""" + if self.is_loaded(): + self._tab.redraw_reconstruction(refit_waveform=False) + + def _report_rebuild_failure(self, exception: Exception) -> None: + logger.error_with_traceback(exception, "Regeneration failed") + self._dialogs.show_error(exception) def _set_reconstruction_dimmed(self, dimmed: bool) -> None: - """Fades the reconstruction waveform while the regeneration worker is busy. + """Fades the reconstruction waveform while the open document is being rewritten. - The dim tracks the service's own ``is_running`` span, so a continuous edit stream keeps - the waveform faded until the worker settles. Each finished result re-reads the live span: - while more work is queued it stays faded, and it restores once the worker is idle. + The dim follows the rewrites' busy span, so a continuous edit stream keeps the waveform + faded until the last step lands, and it restores once nothing runs or waits. """ if self._reconstructions_tab is None: return @@ -348,19 +578,55 @@ def _set_reconstruction_dimmed(self, dimmed: bool) -> None: def _on_state_changed(self) -> None: self._on_session_state_changed_callback() - def _show_save_confirmation( + def _save_first( + self, + *, + title: str, + message: str, + ok_label: str, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Goes on with ``proceed``, offering first to save a standalone document with unsaved changes. + + The offer reads the document once the screen is free for it, so a document another + conversation saved or closed meanwhile goes on with no question. + + Args: + title: The prompt's title. + message: What the prompt says would be lost. + ok_label: The label of the answer that goes on without saving. + proceed: What runs once the document is saved, or once the reader lets the changes go. + decline: What runs once the reader keeps the changes, or once the save fails. + """ + + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + self._offer_save( + title=title, + message=message, + ok_label=ok_label, + proceed=proceed, + decline=decline, + ) + + asking(self.is_unsaved_standalone, ask, self._dialogs.when_free)(proceed, decline) + + def _offer_save( self, + *, title: str, message: str, - on_save: Callable[[], bool], - on_confirm: Callback, ok_label: str, + proceed: VoidCallback, + decline: VoidCallback, ) -> None: + """Puts the save prompt for the open document to the reader.""" self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, title=title, message=message, - on_save=on_save, - on_confirm=on_confirm, + on_save=self.save, + on_confirm=proceed, + on_cancel=decline, ok_label=ok_label, ) diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py index b2ed163a5..11f53f31f 100644 --- a/src/sampletones_application/coordinators/render.py +++ b/src/sampletones_application/coordinators/render.py @@ -1,4 +1,3 @@ -from functools import partial from pathlib import Path from typing import Dict, Optional, Tuple @@ -9,7 +8,6 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.view_model.shared.render import SongRenderViewModel from sampletones_core.audio.writers import AudioFormat, capability_of from sampletones_shared.types.callback import VoidCallback @@ -57,7 +55,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 @@ -124,21 +122,18 @@ def _destination_filters(self, audio_format: AudioFormat) -> Tuple[FileFilter, . def _on_success(self, destination: Path) -> None: """Reports the file a finished render wrote, as a path that opens in the file manager.""" self._close() - self._present( - partial( - self._dialogs.show_message_with_path, - self._title_rendered, - self._msg_rendered, - destination, - ) + self._dialogs.show_message_with_path( + self._title_rendered, + self._msg_rendered, + destination, ) def _on_error(self, exception: Exception) -> None: """Reports what a render failed on, leaving the destination as it was.""" self._close() - self._present(partial(self._dialogs.show_error, exception, self._msg_failed)) + self._dialogs.show_error(exception, self._msg_failed) - def _on_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() @@ -150,14 +145,6 @@ def _close(self) -> None: self._logic.close() self._on_activity_changed() - def _present(self, raise_dialog: VoidCallback) -> None: - """Raises ``raise_dialog`` once the frame the window left the screen in has finished. - - The render window is modal and DearPyGui carries one modal at a time, so a report waits - for the frame that draws the screen without it and opens onto a clear screen. - """ - FrameCallbackManager.set_frame_callback(raise_dialog) - def _require_view_model(self) -> SongRenderViewModel: """The render the dialog opens on. diff --git a/src/sampletones_application/coordinators/tabs/hooks.py b/src/sampletones_application/coordinators/tabs/hooks.py new file mode 100644 index 000000000..9fd912d43 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/hooks.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from sampletones_shared.types.callback import PathCallback, VoidCallback + + +@dataclass(frozen=True) +class MainTabHooks: + """What the Main tab reports to the application around it, and what it asks of it. + + The tab answers for its own panels and logic; everything that reaches past them — loading a + reconstruction, refreshing the browsers of other tabs, asking whether another exclusive + operation is running — travels here, so the tab's own wiring reads as one collaborator rather + than as a dozen loose arguments. + """ + + is_operation_active: Callable[[], bool] + on_busy_state_changed: VoidCallback + on_reconstruct_listed: VoidCallback + on_load_reconstruction: Callable[[Optional[Path]], None] + on_load_library: PathCallback + on_load_file: PathCallback + on_load_directory: VoidCallback + on_canceled: VoidCallback + on_refresh_trees: VoidCallback + on_prepare_library: VoidCallback diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index fb6d0b4fa..3b6c36e76 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, List, Optional, Protocol @@ -6,6 +7,7 @@ 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.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.logic.instruction.details import ( @@ -15,6 +17,7 @@ from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.instructions import InstructionsTabParameters @@ -23,6 +26,7 @@ SUF_PANEL_CENTER, SUF_PANEL_LEFT, SUF_PANEL_RIGHT, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_TAB_INSTRUCTIONS, TAG_GLOBAL_TABS, TAG_GLOBAL_THEME_PANEL_GROUND, @@ -33,6 +37,7 @@ TAG_INSTRUCTIONS_DETAILS_WINDOW_PARAMETERS_CARD, TAG_INSTRUCTIONS_INSTRUCTION_PANEL_SPECTRUM, TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM, + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION, TAG_INSTRUCTIONS_LIBRARY_DIALOG_REMOVE_LIBRARY_CONFIRMATION, TAG_INSTRUCTIONS_LIBRARY_PANEL, @@ -58,6 +63,7 @@ from sampletones_application.ui.panels.instruction.waveform import ( GUIInstructionWaveformPanel, ) +from sampletones_application.utils.callbacks.gates import asking 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 @@ -67,8 +73,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.library import InstructionLibraryKey +from sampletones_core.library import InstructionLibraryKey, LibraryState from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError from sampletones_shared.logger import logger @@ -80,7 +85,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: ... @@ -100,13 +105,13 @@ def __init__( layout: InstructionsTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, ) -> None: self._language_manager = language_manager self._config_manager = config_manager self._session_manager = session_manager self._audio_device_manager = audio_device_manager - self._library_manager = library_manager self._on_audio_state_changed = on_audio_state_changed self._is_converter_visible = is_converter_visible self._dialogs = dialogs @@ -126,9 +131,10 @@ def __init__( language_manager=language_manager, is_operation_active=is_operation_active, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) self._library_tree_logic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._library_panel = GUIInstructionsLibraryPanel( @@ -143,7 +149,7 @@ def __init__( is_operation_active=is_operation_active, ) self._library_panel.set_collapse_handler(self._on_library_collapse_changed) - self._library_tree_logic.on_lock_state_changed = self._library_panel.set_tree_enabled + self._library_tree_logic.on_lock_state_changed = self._on_library_tree_lock_changed self._library_tree_logic.on_favorite_changed = self._repaint_library_favorites self._library_tree_logic.on_search_update_needed = self._library_panel.update_tree_visibility @@ -156,15 +162,16 @@ 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 + self._library_logic.on_library_outdated = self._on_library_outdated self._library_panel.on_refresh_requested = self._library_logic.refresh_libraries self._library_panel.on_generate_requested = self._request_generate_library self._library_panel.on_cancel_generation = self._library_logic.cancel_generation self._library_panel.on_library_selected = self._library_logic.load_library_and_set_current - self._library_panel.on_generator_selected = self._on_generator_selected + self._library_panel.on_generator_selected = self._library_logic.load_library_generator self._library_panel.on_library_remove_requested = self._request_remove_library self._instruction_player_logic = PlayerLogic( audio_device_manager, @@ -172,18 +179,19 @@ def __init__( ) self._guarded_player = GuardedPlayer( self._instruction_player_logic, - dialogs=dialogs, - error_message=language_manager["global.player.message.audio_playback_error"], + failures=playback_failures, ) 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, ) @@ -191,6 +199,7 @@ def __init__( self._spectrum_panel.set_collapse_handler(self._on_card_collapse_changed) self._graph_panels: List[_StackedGraphPanel] = [self._waveform_panel, self._spectrum_panel] self._instruction_player_logic.on_position_changed = self._waveform_panel.set_position + self._waveform_panel.on_position_clicked = self._play_from self._instruction_details_logic = InstructionDetailsPanelLogic( library_manager, layout=layout.instructions, @@ -212,7 +221,7 @@ def __init__( self._instruction_choice_panel.set_collapse_handler(self._on_card_collapse_changed) self._instruction_parameters_panel.set_collapse_handler(self._on_card_collapse_changed) - config_manager.add_config_change_callback(self._library_logic.update_status) + config_manager.add_config_change_callback(self._library_logic.follow_config) self._library_logic.set_callbacks( on_apply_library_config=config_manager.apply_library_config, @@ -226,7 +235,7 @@ def __init__( ) def _request_generate_library(self) -> None: - if self._library_logic.library_available_for_config(): + if self._library_logic.config_library_state() is LibraryState.CURRENT: self._dialogs.show_confirmation( TAG_INSTRUCTIONS_LIBRARY_DIALOG_REGENERATE_CONFIRMATION, self._language_manager["instructions.library.message.regenerate_confirmation_message"], @@ -238,14 +247,6 @@ def _request_generate_library(self) -> None: self._library_logic.request_generation() - def _on_generator_selected( - self, - library_key: InstructionLibraryKey, - generator_name: LibraryGeneratorName, - ) -> None: - self._library_logic.load_library_and_set_current(library_key) - self._library_logic.load_generator(generator_name) - def _request_remove_library(self, library_key: InstructionLibraryKey) -> None: library_path = self._library_logic.get_path(library_key) self._dialogs.show_confirmation( @@ -288,10 +289,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, ) @@ -302,6 +303,21 @@ def _on_library_file_not_found(self, path: Path, message: str) -> None: def _on_library_load_error(self, exception: Exception, message: str) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception, message)) + def _on_library_outdated(self, library_key: InstructionLibraryKey) -> None: + """Asks whether to rebuild a library another version built, a frame later, which lets an + opening at startup ask once the window stands.""" + FrameCallbackManager.set_frame_callback(lambda: self._confirm_rebuild(library_key)) + + def _confirm_rebuild(self, library_key: InstructionLibraryKey) -> None: + self._dialogs.show_confirmation( + TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION, + self._language_manager["instructions.library.message.rebuild_confirmation_message"], + self._language_manager["instructions.library.title.rebuild_confirmation_dialog"], + lambda: self._library_logic.rebuild_library(library_key), + ok_label=self._language_manager["instructions.library.label.rebuild_confirmation_ok"], + path=self._library_logic.get_path(library_key), + ) + def _display_instruction(self, instruction_data: Optional[InstructionPanelData]) -> None: """Renders the instruction and reloads the player with its audio. @@ -350,12 +366,23 @@ def _close_instruction(self) -> None: self._instruction_details_logic.clear_display() self._instruction_player_logic.clear_audio() + def _play_from(self, position: int) -> None: + """Sounds the audio from the sample a click on the waveform pointed at.""" + self._guarded_player.run_guarded(partial(self._instruction_player_logic.play_from, position)) + 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 _on_library_tree_lock_changed(self, is_unlocked: bool) -> None: + """Enables the catalog's tree along with its lock, and loads a folder's remembered library once + the lock is let go.""" + self._library_panel.set_tree_enabled(is_unlocked) + if is_unlocked: + self._library_logic.reload_remembered_library() + 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 +477,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) @@ -465,29 +492,16 @@ def initialize(self) -> None: """Populates the library tree once the tab's widgets exist.""" self._library_logic.refresh_libraries(load_if_needed=False) - def ensure_library_loaded(self) -> None: - """Make sure a library matching the current configuration exists before reconstructing. - - The reconstruction pipeline loads the library from disk by the configuration's key, so the - only requirement here is that the corresponding file is present; it is generated when missing. - The library's stored parameters are deliberately not applied back to the configuration, which - would overwrite the user's current settings. - """ - if not self._library_manager.is_library_available_for_config(): - self._library_logic.generate_library() + def prepare_library(self) -> None: + """Prepares the library a conversion under the current configuration reads from disk.""" + self._library_logic.prepare_library() def load_library_file(self, filepath: Path) -> None: self._close_instruction() self._library_logic.load_library_file(filepath) - def load_library_safely(self, filepath: Path) -> None: - try: - self.load_library_file(filepath) - except (SampleToNESError, OSError) as exception: - 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, @@ -496,7 +510,25 @@ def save_browser_shape(self) -> None: def is_library_generating(self) -> bool: return self._library_logic.is_library_generating() - def refresh_generate_button(self) -> None: + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the exit go on, asking first while a library is being built, which exiting stops. + + The question reads the library once the screen is free for it, so a build that ended + meanwhile is asked about no more. Cancel keeps the library building and turns the exit away. + """ + asking(self.is_library_generating, self._ask_before_exit, self._dialogs.when_free)(proceed, decline) + + def _ask_before_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + self._dialogs.show_confirmation( + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + self._language_manager["global.dialog.message.exit_library_generation_in_progress"], + self._language_manager["global.dialog.title.exit_confirmation"], + proceed, + ok_label=self._language_manager["global.dialog.label.exit"], + on_cancel=decline, + ) + + def follow_busy_state(self) -> None: self._library_panel.refresh_action_buttons() @property diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 3cc17adab..6a8945ab9 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -1,26 +1,31 @@ from pathlib import Path -from typing import Callable, Optional +from typing import List, Optional, Tuple import dearpygui.dearpygui as dpg 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.output import OutputKind +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter +from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) -from sampletones_application.logic.main.converter import ( - ConversionSuccess, - ConverterLogic, -) -from sampletones_application.logic.main.explorer import ExplorerLogic +from sampletones_application.logic.main.converter.logic import ConverterLogic +from sampletones_application.logic.main.converter.run import ConversionSuccess +from sampletones_application.logic.main.explorer_manager import ExplorerManager +from sampletones_application.logic.main.sources.scan import FolderScan +from sampletones_application.logic.shared.file_playback import FilePlayback 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.services.folder_scan.service import FolderScanService from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_PANEL_CENTER, SUF_PANEL_LEFT, + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, TAG_GLOBAL_TAB_MAIN, TAG_GLOBAL_TABS, TAG_GLOBAL_THEME_PANEL_GROUND, @@ -28,43 +33,52 @@ ) from sampletones_application.tags.main import ( TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, TAG_MAIN_CONFIG_PANEL, TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, + 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_DIALOG_NOTHING_BELOW, TAG_MAIN_EXPLORER_PANEL, - TAG_MAIN_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL, + TAG_MAIN_SOURCE_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.dialogs.scanning import GUIScanWindow +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 +from sampletones_application.ui.panels.main.converter.panel import GUIConverterPanel from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel -from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel -from sampletones_application.utils.file_dialogs.api import select_directory_dialog +from sampletones_application.ui.panels.main.source.panel import GUISourceSettingsPanel +from sampletones_application.utils.callbacks.gates import asking +from sampletones_application.utils.file_dialogs.api import open_file_dialog, select_directory_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.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.render_thread import on_render_thread +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.view_model.main.advanced import ( AdvancedSettingsPanelViewModel, ) from sampletones_application.view_model.main.config import ConfigPanelViewModel from sampletones_application.view_model.main.converter import ConverterViewModel -from sampletones_application.view_model.main.reconstructor import ( - ReconstructorPanelViewModel, -) from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName +from sampletones_core.library import library_state from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger -from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO +from sampletones_shared.types.callback import VoidCallback _LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_MAIN, SUF_PANEL_LEFT) _CENTER_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_MAIN, SUF_PANEL_CENTER) @@ -90,51 +104,83 @@ def __init__( audio_device_manager: AudioDeviceManager, library_manager: InstructionsLibraryManager, conversion_service: ConversionService, - on_reconstruct_file: PathCallback, - on_reconstruct_directory: PathCallback, - on_load_reconstruction: Callable[[Optional[Path]], None], - on_load_library: PathCallback, - is_operation_active: Callable[[], bool], - on_busy_state_changed: VoidCallback, + hooks: MainTabHooks, *, layout: MainTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, - on_load_file: PathCallback, - on_load_directory: VoidCallback, - on_cancelled: VoidCallback, - on_refresh_trees: VoidCallback, - on_generate_library: VoidCallback, + stem_selection_window: GUIStemSelectionWindow, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, ) -> None: self._language_manager = language_manager self._config_manager = config_manager self._session_manager = session_manager self._library_manager = library_manager - self._on_reconstruct_file = on_reconstruct_file - self._on_reconstruct_directory = on_reconstruct_directory - self._on_load_reconstruction = on_load_reconstruction - self._is_operation_active = is_operation_active - self._on_busy_state_changed = on_busy_state_changed - self._on_refresh_trees = on_refresh_trees + self._hooks = hooks self._dialogs = dialogs + self._playback_failures = playback_failures + 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"] self._ttl_progress = language_manager["main.converter.title.progress_dialog"] + self._repaint_priority = layout.scheduling.priorities.gui_action - self._explorer_logic: ExplorerLogic = ExplorerLogic( + self._build_explorer( + config_manager, + session_manager, + audio_device_manager, + layout=layout, + language_manager=language_manager, + status_bar=status_bar, + ) + self._build_cards( + config_manager, + session_manager, + conversion_service, + layout=layout, + language_manager=language_manager, + status_bar=status_bar, + key_router=key_router, + shortcut_source=shortcut_source, + tab_active=tab_active, + ) + self._wire_settings(config_manager) + self._wire_explorer() + self._wire_converter( + config_manager, + library_manager, + conversion_service, + dialogs, + language_manager, + ) + + def _build_explorer( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + audio_device_manager: AudioDeviceManager, + *, + layout: MainTabParameters, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + """The file browser: what reads the disk, what plays from it, and what draws both.""" + self._explorer_logic: ExplorerManager = ExplorerManager( config_manager, language_manager=language_manager, open_directories=session_manager.expanded_directories, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._folder_scan: FolderScan = FolderScan(FolderScanService(priority=self._repaint_priority)) self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._explorer_panel: GUIExplorerPanel = GUIExplorerPanel( @@ -149,8 +195,22 @@ def __init__( self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled self._explorer_tree_logic.on_favorite_changed = self._repaint_explorer_favorites self._explorer_tree_logic.on_search_update_needed = self._explorer_panel.update_tree_visibility - self._explorer_tree_logic.on_autoplay_error = self._on_explorer_autoplay_error + self._file_playback.on_error = self._on_explorer_autoplay_error + def _build_cards( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + conversion_service: ConversionService, + *, + layout: MainTabParameters, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, + ) -> None: + """The tab's cards and the converter behind them, each opening on what it last stood at.""" _config = config_manager.config self._config_panel: GUIConfigPanel = GUIConfigPanel( ConfigPanelViewModel( @@ -165,14 +225,19 @@ def __init__( language_manager=language_manager, status_bar=status_bar, ) - self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( - ReconstructorPanelViewModel( - generators=frozenset(_config.generation.generators), - drive=_config.generation.drive, - ), - layout=layout.main.reconstructor, + self._converter_logic: ConverterLogic = ConverterLogic( + config_manager, + session_manager, + conversion_service, + scheduling=layout.scheduling, + language_manager=language_manager, + is_operation_active=self._hooks.is_operation_active, + ) + self._source_panel: GUISourceSettingsPanel = GUISourceSettingsPanel( + self._converter_logic.source_settings_view, + layout=layout.main.source, inputs=layout.inputs, - initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_RECONSTRUCTOR_PANEL), + initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_SOURCE_PANEL), language_manager=language_manager, status_bar=status_bar, ) @@ -191,45 +256,70 @@ def __init__( status_bar=status_bar, path_colors=layout.path_colors, ) - self._converter_logic: ConverterLogic = ConverterLogic( - config_manager, - conversion_service, - scheduling=layout.scheduling, + self._scan_window: GUIScanWindow = GUIScanWindow( + layout=layout.main.converter, language_manager=language_manager, - is_operation_active=is_operation_active, ) + self._scan_window.on_stop = self._folder_scan.stop + self._folder_scan.on_started = self._scan_window.open + self._folder_scan.on_progress = self._scan_window.report + self._folder_scan.on_stopped = self._scan_window.close + self._folder_scan.on_failed = self._on_scan_failed self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, + stems_layout=layout.stems, + inputs=layout.inputs, path_colors=layout.path_colors, + stem_colors=layout.stem_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_CONVERTER_PANEL), language_manager=language_manager, status_bar=status_bar, + key_router=key_router, + shortcut_source=shortcut_source, + tab_active=tab_active, ) + def _wire_settings(self, config_manager: ConfigManager) -> None: + """What the settings cards report, and what redraws them when the configuration moves.""" config_manager.add_config_change_callback(self._update_config_panel_view) - config_manager.add_config_change_callback(self._update_reconstructor_panel_view) config_manager.add_config_change_callback(self._update_advanced_settings_panel_view) self._config_panel.on_audio_settings_changed = config_manager.apply_audio_settings self._config_panel.on_library_settings_changed = config_manager.apply_library_settings - self._reconstructor_panel.on_generation_settings_changed = config_manager.apply_generation_settings + self._source_panel.on_slot_toggled = self._converter_logic.toggle_slot + self._source_panel.on_drive_changed = self._converter_logic.set_drive + self._source_panel.on_channel_cap_changed = self._converter_logic.set_channel_cap self._advanced_settings_panel.on_advanced_settings_changed = config_manager.apply_advanced_settings self._advanced_settings_panel.on_select_library_directory = self._select_library_directory self._advanced_settings_panel.on_select_output_directory = self._select_output_directory self._wire_collapse_handlers() + def _wire_explorer(self) -> None: + """What a gesture in the browser reaches: the converter, the tab's own guards, the app.""" self._explorer_panel.set_callbacks( - on_wave_file_clicked=self._on_wave_file_clicked, - on_directory_clicked=self._on_directory_clicked, - on_reconstruct_file=self._request_reconstruct_file, - on_reconstruct_directory=self._request_reconstruct_directory, - on_load_reconstruction=on_load_reconstruction, - on_load_library=on_load_library, - on_set_as_library_directory=self._advanced_settings_panel.change_library_directory, + on_directory_add_requested=self._on_directory_add_requested, + on_file_add_requested=self._on_file_add_requested, + on_reconstruct_file=self.request_reconstruct_file, + on_reconstruct_directory=self.request_reconstruct_directory, + on_load_reconstruction=self._hooks.on_load_reconstruction, + on_load_library=self._hooks.on_load_library, + on_set_as_library_directory=self._handle_select_library_directory, on_set_as_reconstructions_directory=self._advanced_settings_panel.change_reconstructions_directory, ) + def _wire_converter( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + conversion_service: ConversionService, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + ) -> None: + """What the converter reports and what answers it: the panel, the dialogs, the library.""" + _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_channels"] self._converter_logic.on_view_changed = self._on_converter_view_changed self._converter_logic.on_success = self._on_conversion_success self._converter_logic.on_error = lambda error: dialogs.show_error(error, _msg_converter_error) @@ -243,67 +333,193 @@ def __init__( _msg_no_generators, self._ttl_progress, ) - self._converter_logic.is_library_available = library_manager.is_library_available_for_config + self._converter_logic.on_target_exists = self._confirm_overwriting_target + self._converter_logic.library_readiness = library_manager.library_readiness + self._converter_logic.library_state = library_state 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.generate_library = on_generate_library + self._converter_logic.on_load_file = self._hooks.on_load_file + self._converter_logic.on_load_directory = self._hooks.on_load_directory + self._converter_logic.on_canceled = self._hooks.on_canceled + self._converter_logic.prepare_library = self._hooks.on_prepare_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_output_changed = self._request_output + 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._converter_panel.on_folder_removed = self._converter_logic.remove_folder + self._converter_panel.on_folder_channel_toggled = self._converter_logic.toggle_folder_channel + self._converter_panel.on_row_selected = self._converter_logic.select_row + self._converter_panel.on_selection_cleared = self._converter_logic.clear_selection + self._converter_panel.on_source_played = self._file_playback.play + self._stem_selection_window.on_source_played = self._file_playback.play 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.""" self._explorer_panel.update_favorite_indicators((node,)) def _on_explorer_autoplay_error(self, exception: Exception) -> None: - FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) + FrameCallbackManager.set_frame_callback(lambda: self._playback_failures.present(exception, message=None)) def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: + """The converter's own view, and the settings card that follows what it has picked out. + + A gesture on the list rebuilds the list, and DearPyGui calls a widget's callback on a + thread of its own, so the redraw crosses to the render thread rather than tearing widgets + down underneath the frame being walked. + """ + on_render_thread(self._repaint_converter, view_model, priority=self._repaint_priority) + + def _repaint_converter(self, view_model: ConverterViewModel) -> None: self._converter_panel.update_view(view_model) - self._on_busy_state_changed() + self._update_source_panel_view() + self._hooks.on_busy_state_changed() + + def reconstruct_file_dialog(self) -> None: + """Asks for a recording to list for a Reconstruct, once the list takes changes.""" + self._changing_the_list(self._choose_recording_to_reconstruct) + + def reconstruct_directory_dialog(self) -> None: + """Asks for a folder to list for a Reconstruct, once the list takes changes.""" + self._changing_the_list(self._choose_folder_to_reconstruct) + + def request_reconstruct_file(self, filepath: Path) -> None: + """Lists the recording a Reconstruct named, asking first where it would replace a mix.""" + self._changing_the_list(lambda: self._reconstruct_file(filepath)) + + def request_reconstruct_directory(self, directory_path: Path) -> None: + """Lists the folder a Reconstruct named, asking first where it would replace a mix.""" + self._changing_the_list(lambda: self._reconstruct_directory(directory_path)) + + def _changing_the_list(self, gesture: VoidCallback) -> None: + """Runs a gesture that lists recordings, or tells the reader the conversion holds the list. + + Every door to the list comes here: Reconstruct in the menu and in the browser, Add as stem, + Add folder, Ctrl-click and a double-click. The list refuses changes only while the converter's + own run holds it. A library generation, a render or an export leaves it open, since listing + starts nothing, and the busy authority keeps Convert greyed until they end. A gesture that + lands later, once a folder is read or a question is answered, comes here again, since a run + may have started in between. + """ + if self._converter_logic.live: + gesture() + return - 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._dialogs.show_info( + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, + self._language_manager["main.converter.message.conversion_running"], + self._language_manager["main.converter.title.conversion_running_dialog"], + ) - def _on_directory_clicked(self, directory_path: Path) -> None: - if not self._is_operation_active(): - self._converter_logic.set_input_path(directory_path, convert=False) + def _choose_recording_to_reconstruct(self) -> None: + filepath = open_file_dialog( + title=self._language_manager["global.dialog.title.reconstruct_file"], + initial_directory=self._session_manager.get_audio_input_path(), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.audio"], + EXT_FILES_AUDIO, + ), + ), + ) + self._reconstruct_file(filepath) - def _request_reconstruct_file(self, filepath: Path) -> None: - if self._notify_converter_running(): - return + def _choose_folder_to_reconstruct(self) -> None: + directory_path = select_directory_dialog( + title=self._language_manager["global.dialog.title.reconstruct_directory"], + initial_directory=self._session_manager.get_audio_input_path(), + ) + self._reconstruct_directory(directory_path) + + @ignore_none_path + def _reconstruct_file(self, filepath: Path) -> None: + self._giving_way_to_one_apiece( + lambda: self._list_for_reconstruct( + filepath, + input_folder=filepath.parent, + ) + ) - self._on_reconstruct_file(filepath) + @ignore_none_path + def _reconstruct_directory(self, directory_path: Path) -> None: + self._giving_way_to_one_apiece( + lambda: self._list_for_reconstruct( + directory_path, + input_folder=directory_path, + ) + ) - def _request_reconstruct_directory(self, directory_path: Path) -> None: - if self._notify_converter_running(): + def _list_for_reconstruct(self, path: Path, *, input_folder: Path) -> None: + """Lists what a Reconstruct named, and keeps ``input_folder`` as the folder the next + Reconstruct dialog opens at.""" + self._take_up_path(path) + self._session_manager.set_audio_input_path(input_folder) + self._hooks.on_reconstruct_listed() + + def _giving_way_to_one_apiece(self, take_up: VoidCallback) -> None: + """Takes up what a Reconstruct named, asking first about a mix it would replace. + + A Reconstruct lists recordings to convert one apiece, so a list of that kind takes them + beside what it holds and an empty mix gives way at once. A mix holding recordings is the + reader's work, so it gives way once they confirm; declining leaves it as it stands. + """ + if not (self._converter_logic.mixes and self._converter_logic.gathered_paths): + take_up() return - self._on_reconstruct_directory(directory_path) - - def _notify_converter_running(self) -> bool: - if not self._is_operation_active(): - return False + self._confirm_discarding_stems(lambda: self._changing_the_list(take_up)) - logger.warning("Conversion is already running. Wait or cancel the current operation.") - self._dialogs.show_info( - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, - self._language_manager["main.explorer.message.converter_running_msg"], - self._language_manager["main.explorer.title.converter_running_dialog"], + 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, ) - return True + def _confirm_overwriting_target(self, targets: Tuple[Path, ...]) -> None: + """Asks before a conversion writes over the reconstructions already standing at its targets. + + Confirming writes over every one of them, so the prompt speaks for all of them: one is + named by the path it stands at, and several are counted. + """ + one = len(targets) == 1 + message = ( + self._language_manager["main.converter.message.overwrite_target_prompt"] + if one + else self._language_manager["main.converter.message.overwrite_targets_prompt"] + ) + title = ( + self._language_manager["main.converter.title.overwrite_target_dialog"] + if one + else self._language_manager["main.converter.title.overwrite_targets_dialog"] + ) + self._dialogs.show_confirmation( + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, + message if one else message.format(count=len(targets)), + title, + lambda: self._converter_logic.start_conversion(confirmed=True), + ok_label=self._language_manager["main.converter.label.overwrite_target_button"], + path=targets[0] if one else None, + ) def _on_conversion_success(self, success: ConversionSuccess) -> None: - self._on_refresh_trees() - if success.is_file: + self._hooks.on_refresh_trees() + 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 +536,106 @@ def _on_conversion_success(self, success: ConversionSuccess) -> None: on_cancel=self._converter_logic.close, ) + def _request_output(self, output: OutputKind) -> None: + """Answers the output switch, asking which recordings to mix where the list overflows one. + + A mix reaches a fixed number of recordings, so a longer list is put to the reader in the + window that shows what fits already ticked. The switch reads the output the setup still + holds while the question stands, since the run is what the reader is being asked about. + Every other switch takes effect straight away. + """ + if not output.mixes or self._converter_logic.list_fits_a_mix: + self._converter_logic.set_output(output) + return + + self._converter_logic.refresh_view() + self._stem_selection_window.open( + self._converter_logic.gathered_rows, + self._converter_logic.mix_ceiling, + self._answer_mix, + ) + + def _answer_mix(self, paths: List[Path]) -> None: + """Takes the recordings the reader picked to mix, once the list takes changes.""" + self._changing_the_list(lambda: self._converter_logic.mix_only(paths)) + + def _on_file_add_requested(self, filepath: Path) -> None: + """Gathers one recording into the list, opening a stems conversion where none is being built.""" + self._changing_the_list(lambda: self._converter_logic.gather_recordings([filepath])) + + def _on_directory_add_requested(self, directory_path: Path) -> None: + """Reads what a folder holds, and gathers it once the reading is done. + + A tree is read one entry at a time and a large one takes seconds, so the reading runs + beside the interface and says how far it has got. + """ + self._changing_the_list(lambda: self._folder_scan.start(directory_path, self._gather_read)) + + def _on_scan_failed(self, exception: Exception) -> None: + """Takes the wait away and shows the failure that ended the reading, which added nothing.""" + self._scan_window.close() + self._dialogs.show_error(exception, self._language_manager["main.converter.message.scan_failed"]) + + def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Gathers what the walk found, saying so where the folder holds no recordings.""" + self._scan_window.close() + if not found: + self._nothing_below(directory_path) + return + + self._changing_the_list(lambda: self._gather_found(directory_path, found)) + + def _gather_found(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Gathers the recordings a folder holds, asking first which to mix where a mix overflows.""" + if self._mixing_beyond_room(found): + return + + self._converter_logic.gather_folder(directory_path, found) + + def _nothing_below(self, directory_path: Path) -> None: + """Says that a folder holds no recordings, which is the answer the reading came back with. + + The reading is what knows, so the menu offers every folder and the answer arrives once, + rather than every folder being walked to decide whether the item may be clicked. + """ + logger.info(f"No recordings below {directory_path}.") + self._dialogs.show_info( + TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, + self._language_manager["main.converter.message.scan_nothing_below"], + self._language_manager["main.converter.title.scan_dialog"], + ) + + def _take_up_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Lists what the walk found for a Reconstruct, saying so where the folder holds no recordings.""" + self._scan_window.close() + if not found: + self._nothing_below(directory_path) + return + + self._changing_the_list(lambda: self._converter_logic.take_up_folder(directory_path, found)) + + def _mixing_beyond_room(self, found: Tuple[Path, ...]) -> bool: + """Whether what was read brings in more than the mix has room for, which is a question. + + A mix already standing on recordings leaves the reader a choice between those and the ones + the folder offers, so the question stands both together and opens with the mix as it is. + The answer names the whole mix, which is how letting one go makes room for another; the + setup stands as it was until the reader gives one. + """ + if not self._converter_logic.mixes: + return False + + offered = self._converter_logic.rows_offered(found) + if sum(len(row.recordings) for row in offered) <= self._converter_logic.room_for_sources: + return False + + self._stem_selection_window.open( + self._converter_logic.gathered_rows + offered, + self._converter_logic.mix_ceiling, + self._answer_mix, + ) + return True + def _request_cancel_confirmation(self) -> None: self._dialogs.show_confirmation( TAG_MAIN_CONVERTER_DIALOG_CANCEL, @@ -341,14 +657,9 @@ def _update_config_panel_view(self) -> None: ) ) - def _update_reconstructor_panel_view(self) -> None: - config = self._config_manager.config - self._reconstructor_panel.update_view( - ReconstructorPanelViewModel( - generators=frozenset(config.generation.generators), - drive=config.generation.drive, - ) - ) + def _update_source_panel_view(self) -> None: + """The settings card reads what the converter's picked row, or its joining settings, hold.""" + self._source_panel.update_view(self._converter_logic.source_settings_view) def _update_advanced_settings_panel_view(self) -> None: self._advanced_settings_panel.update_view( @@ -371,7 +682,6 @@ def _select_library_directory(self) -> None: @ignore_none_path def _handle_select_library_directory(self, directory: Path) -> None: self._advanced_settings_panel.change_library_directory(directory) - self._session_manager.set_library_path(directory) def _select_output_directory(self) -> None: directory = select_directory_dialog( @@ -412,35 +722,45 @@ def create_tab(self) -> None: self._sync_explorer_width() + @property + def _config_columns(self) -> Tuple[ColumnSpec, ...]: + """The settings cards the tab lays side by side, in the order they read.""" + return ( + ColumnSpec( + tag=TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, + build=self._config_panel.create_panel, + ), + ColumnSpec( + tag=TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, + build=self._advanced_settings_panel.create_panel, + ), + ) + def _build_center(self, parent: str) -> None: - """Stacks the config and reconstructor cards side by side, then the advanced and converter cards below.""" + """Stacks the settings cards side by side, then the converter and the card reading its list. + + The reconstruction card names whichever row the converter's list stands on, so it follows + that list and the tab reads in one direction: what a run is set up with, what it gathers, + and what the gathered row takes. + """ TabColumns.row( panel_gap=self._geometry.panel_gap, height=self._config_height, tag=TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, - columns=[ - ColumnSpec( - tag=TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, - build=self._config_panel.create_panel, - ), - ColumnSpec( - tag=TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL, - build=self._reconstructor_panel.create_panel, - ), - ], + columns=self._config_columns, ) - self._sync_config_row_height() - dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) - self._advanced_settings_panel.create_panel(parent) + self._sync_advanced_settings() dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._converter_panel.create_panel(parent) + dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) + self._source_panel.create_panel(parent) def _wire_collapse_handlers(self) -> None: """Routes each Main card's collapse toggle to the handler that persists it and reflows the shared config row.""" self._explorer_panel.set_collapse_handler(self._on_explorer_collapse_changed) self._config_panel.set_collapse_handler(self._on_config_row_collapse_changed) - self._reconstructor_panel.set_collapse_handler(self._on_config_row_collapse_changed) - self._advanced_settings_panel.set_collapse_handler(self._on_card_collapse_changed) + self._advanced_settings_panel.set_collapse_handler(self._on_config_row_collapse_changed) + self._source_panel.set_collapse_handler(self._on_card_collapse_changed) self._converter_panel.set_collapse_handler(self._on_card_collapse_changed) def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: @@ -472,27 +792,59 @@ def _sync_explorer_width(self) -> None: dpg_configure_item(_LEFT_COLUMN_TAG, width=width) def _on_config_row_collapse_changed(self, card_tag: str, collapsed: bool) -> None: - """Persists the config or reconstructor collapse, then reflows the row both cards share.""" + """Persists a settings card's collapse, then reflows the row both of them share.""" self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_config_row_height() def _sync_config_row_height(self) -> None: - """Lets the shared config row size to its collapsed cards once both are collapsed, else keeps it full height.""" - both_collapsed = self._config_panel.collapsed and self._reconstructor_panel.collapsed - height = 0 if both_collapsed else self._config_height + """Lets the settings row size to its collapsed bars once every card standing in it is collapsed.""" + advanced_stands = self._session_manager.advanced_settings and not self._advanced_settings_panel.collapsed + expanded = not self._config_panel.collapsed or advanced_stands + height = self._config_height if expanded else 0 dpg_configure_item(TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, height=height) def is_converter_active(self) -> bool: return self._converter_logic.is_active + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the exit go on, asking first while a conversion runs, which exiting stops. + + The question reads the converter once the screen is free for it, so a run that ended + meanwhile is asked about no more. Cancel keeps the conversion running and turns the exit away. + """ + asking(self.is_converter_active, self._ask_before_exit, self._dialogs.when_free)(proceed, decline) + + def _ask_before_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + self._dialogs.show_confirmation( + TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + self._language_manager["global.dialog.message.exit_conversion_in_progress"], + self._language_manager["global.dialog.title.exit_confirmation"], + proceed, + ok_label=self._language_manager["global.dialog.label.exit"], + on_cancel=decline, + ) + def is_converter_panel_visible(self) -> bool: return self._converter_panel.is_visible() - def refresh_converter_view(self) -> None: + def follow_busy_state(self) -> None: + """Re-applies the converter's view to the busy state after another operation starts or ends. + + The converter's own view changes already reach the busy state through + ``on_busy_state_changed``, so this answers the edges of other operations alone. + """ self._converter_logic.refresh_view() - def set_input_path(self, path: Path, convert: bool) -> None: - self._converter_logic.set_input_path(path, convert=convert) + def _take_up_path(self, path: Path) -> None: + """Lists what a Reconstruct named, a recording or a folder, for the reader to convert one apiece. + + A folder is read before it is listed, and the reader watches the reading count what it finds. + """ + if not path.is_dir(): + self._converter_logic.take_up_recording(path) + return + + self._folder_scan.start(path, self._take_up_read) def save_browser_shape(self) -> None: """Writes down the folders the explorer stands open, so a later run reads down to them.""" @@ -501,16 +853,40 @@ 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: + """Settles one channel on the recording or folder the reader has picked out of the list. + + The key reaches the pick on the same terms as the list's own keys, so it rests while the + card stands collapsed. + """ + if not self._converter_panel.keys_active: + return + + self._converter_logic.toggle_channel(channel) def toggle_advanced_settings(self) -> None: - advanced_settings = self._session_manager.toggle_show_advanced_settings() - self._advanced_settings_panel.set_visibility(advanced_settings) + """Puts the advanced card away or stands it back beside the general one.""" + self._session_manager.toggle_show_advanced_settings() + self._sync_advanced_settings() def sync_advanced_settings_visibility(self) -> None: - self._advanced_settings_panel.set_visibility(self._session_manager.advanced_settings) + """Stands the settings row as the session left it, which is what a launch opens on.""" + self._sync_advanced_settings() + + def _sync_advanced_settings(self) -> None: + """Stands the advanced card where the reader asked for it, the general one taking the rest. + + The row divides itself among the cards standing in it, so a card put away leaves the whole + width to the one beside it and the general settings reach as far as the cards below them. + """ + standing = self._session_manager.advanced_settings + cells = {TAG_MAIN_CONFIG_PANEL_CONFIG_CELL} + if standing: + cells.add(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + + self._advanced_settings_panel.set_visibility(standing) + TabColumns.stand_columns(self._config_columns, cells) + self._sync_config_row_height() def emit_initial_view(self) -> None: self._converter_logic.emit_initial_view() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 53a77dbca..b427fadee 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,30 +1,49 @@ +from functools import partial from pathlib import Path -from typing import Callable, Dict, Optional, Sequence, Tuple +from typing import Callable, Dict, Mapping, Optional, Sequence, Tuple 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.categories.truncation import TruncationMessages 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.export.setup import ExportSetup from sampletones_application.coordinators.original_audio import OriginalAudioLocator +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter 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.editor import ( + InstrumentEditDetail, + InstrumentEditor, +) from sampletones_application.logic.reconstruction.instruments import ( - OnReconstructionInstrumentUpdatedCallback, + PendingChangesProtocol, ReconstructionInstrumentsLogic, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.reconstruction.reconstruction import ( ReconstructionPanelLogic, ) +from sampletones_application.logic.reconstruction.rewrites.steps import ( + RateChange, + Rewrite, + StemRemovalRequest, +) +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.reconstruction import ( @@ -50,8 +69,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 +89,29 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) +from sampletones_application.ui.panels.reconstruction.stems import ( + GUIReconstructionStemsPanel, +) +from sampletones_application.utils.callbacks.gates import Wait, gated from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path 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_application.view_model.shared.recording import NamedRecordingViewModel 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.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,26 +136,46 @@ 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], + format_setups: Mapping[ExportFormat, ExportSetup], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], - on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, + on_rewrite_requested: Callable[[Rewrite], None], + pending_changes: PendingChangesProtocol, + after_edits: Wait, 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, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, ) -> 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._format_setups = format_setups + self._instrument_exports = instrument_exports self._dialogs = dialogs + self._playback_failures = playback_failures self._original_audio_locator = original_audio_locator + self._on_rewrite_requested = on_rewrite_requested self._geometry = layout.geometry self._side_panel_count: int @@ -138,23 +185,24 @@ 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( config_manager, browser_manager, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) self._browser_tree_logic: TreeLogic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._browser_panel: GUIReconstructionsBrowserPanel = GUIReconstructionsBrowserPanel( @@ -164,14 +212,16 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, + stem_colors=layout.stem_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL), initial_expanded_rows=session_manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) + self._browser_panel.on_recordings_requested = self._on_browser_recordings_requested 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._file_playback.on_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( @@ -180,8 +230,7 @@ def __init__( ) self._guarded_player = GuardedPlayer( self._reconstruction_player_logic, - dialogs=dialogs, - error_message=language_manager["global.player.message.audio_playback_error"], + failures=playback_failures, ) self._reconstruction_audio_panel: GUIReconstructionAudioPanel = GUIReconstructionAudioPanel( path_colors=layout.path_colors, @@ -192,32 +241,52 @@ def __init__( ) self._reconstruction_plot_panel: GUIReconstructionPlotPanel = GUIReconstructionPlotPanel( layout_graphs=layout.graphs, + channel_colors=layout.channel_colors, + stem_colors=layout.stem_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, + stem_colors=layout.stem_colors, + 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_plot_panel.on_position_clicked = self._play_from 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, copy_width=layout.copy_width, feature_colors=layout.feature_colors, + stem_colors=layout.stem_colors, 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, - scheduling=layout.scheduling, + self._instrument_editor, + pending_changes, + ) + 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 @@ -226,10 +295,17 @@ 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_audio_panel.on_nes_frequency_changed = self._request_rate_change + self._reconstruction_plot_panel.on_channels_changed = self._reconstruction_panel_logic.set_selected_channels + self._reconstruction_stems_panel.on_stem_channels_changed = self._reconstruction_panel_logic.set_stem_channels + self._reconstruction_stems_panel.on_stem_solo_requested = self._reconstruction_panel_logic.solo_stem + 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_ownership_changed = self._reconstruction_plot_panel.update_ownership + self._reconstruction_panel_logic.on_heard_changed = self._reconstruction_instruments_logic.update_display 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 +315,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( @@ -253,27 +328,29 @@ def __init__( self._reconstruction_instruments_logic.on_feature_data_changed = ( self._reconstruction_instruments_panel.update_feature_data ) - self._reconstruction_instruments_logic.on_reconstruction_instrument_updated = ( - on_reconstruction_instrument_updated - ) + self._reconstruction_instruments_logic.on_channel_changed = on_rewrite_requested - self._reconstruction_instruments_panel.on_instrument_export = ( - self._reconstruction_panel_logic.request_export_instrument_dialog - ) + self._reconstruction_instruments_panel.on_instrument_export = gated(after_edits, self._export_instrument) self._reconstruction_instruments_panel.on_reconstruction_instrument_hovered = ( self._reconstruction_plot_panel.set_overlay ) 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 artifact it produced.""" messages = self._export_messages match result: case ExportSuccess(kind=ExportKind.WAV, filepath=fp): @@ -316,28 +393,24 @@ def _on_export_result(self, result: ExportResult) -> None: def _export_message( self, success: str, - shortened: str, + shortened: TruncationMessages, truncation: Optional[EnvelopeTruncation], ) -> str: - """Follows the success line with the frames the FamiTracker sequence limit left out. + """Follows the success line with the frames the format's value limit left out. Args: success: The message shown for a complete export. - shortened: The template describing what a shortened export carries. + shortened: The words describing what a shortened export carries. truncation: The shortening the export underwent, or ``None`` when it fit whole. Returns: str: The message for the export result dialog. """ - if truncation is None: + notice = shortened.notice(truncation) + if notice is None: return success - note = shortened.format( - frames=truncation.frames, - source_frames=truncation.source_frames, - instruments=truncation.instruments, - ) - return f"{success}\n\n{note}" + return f"{success}\n\n{notice}" def _update_reconstruction_view( self, @@ -347,52 +420,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. - - 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 - ) + instrument = self._instrument_editor.instrument + if instrument is not None: + self._instrument_exports.request_voice(instrument.id, None) + return - @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 +453,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 +540,16 @@ 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 _play_from(self, position: int) -> None: + """Sounds the audio from the sample a click on the waveform pointed at.""" + self._guarded_player.run_guarded(partial(self._reconstruction_player_logic.play_from, position)) def _on_card_collapse_changed( self, @@ -505,6 +576,16 @@ def _on_browser_favorites_filter_changed( """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 _on_browser_recordings_requested(self, path: Path) -> None: + """Hands the browser what a document names, where it has already been read.""" + recordings = self._browser_logic.recordings(path) + if recordings is not None: + self._browser_panel.update_recordings(path, recordings) + + def show_browser_recordings(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + """Hands the browser a reading that landed after the row asked for it.""" + self._browser_panel.update_recordings(path, recordings) + def _on_instruments_collapse_changed( self, card_tag: str, @@ -565,13 +646,58 @@ 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 follow_instrument(self, *, restored: bool) -> None: + """Brings the instrument the tab holds in line with the project it belongs to. + + An instrument the project no longer holds leaves the tab empty. A restore hands a kept + instrument the envelopes it had then, so the panel draws them. Any other change to the + project leaves the panel as drawn, so a keystroke in the tracker keeps what the reader + is writing here. + """ + if not self._instrument_editor.holds_instrument: + return + + if self._instrument_editor.instrument is None: + self.close_instrument() + elif restored: + self._reconstruction_instruments_logic.update_display() + + def close_instrument(self) -> None: + """Lets go of the instrument the tab holds and empties the tab, as a closed reconstruction leaves it. + + The waveform card draws the instrument's own audio while it is open, so it empties together + with the instruments panel. + """ + if not self._instrument_editor.holds_instrument: + return + + self._instrument_editor.release_instrument() + self.close_reconstruction() + def close_reconstruction(self) -> None: self._reconstruction_panel_logic.close_reconstruction() self._reconstruction_instruments_logic.update_display() @@ -586,6 +712,25 @@ 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._on_rewrite_requested(StemRemovalRequest(stem_id=stem_id, stem_name=row.name)), + ok_label=self._lbl_remove, + path=row.path, + ) + + def _request_rate_change(self, nes_frequency: int) -> None: + """Asks for the open document to be re-timed to the rate the reader typed.""" + self._on_rewrite_requested(RateChange(nes_frequency=nes_frequency)) + def _request_remove_directory(self, directory: Path) -> None: self._dialogs.show_confirmation( tag=TAG_RECONSTRUCTIONS_BROWSER_DIALOG_REMOVE_DIRECTORY_CONFIRMATION, @@ -597,10 +742,8 @@ def _request_remove_directory(self, directory: Path) -> None: ) def _remove_reconstruction(self, filepath: Path) -> None: - if self._reconstruction_manager.filepath == filepath: - self._reconstruction_manager.detach_current_reconstruction() - self._reconstruction_manager.mark_updated() - + """Deletes a reconstruction file, keeping the open document once the file it stood on is gone.""" + stood_on_it = self._reconstruction_manager.is_backed_by(filepath) try: self._browser_logic.remove_path(filepath) except OSError as exception: @@ -608,14 +751,15 @@ def _remove_reconstruction(self, filepath: Path) -> None: self._dialogs.show_error(exception, self._msg_load_error) return + if stood_on_it: + self._let_go_of_removed_file() + self._browser_panel.refresh() def _remove_directory(self, directory: Path) -> None: + """Deletes a folder, keeping the open document once the file it stood on inside it is gone.""" current_filepath = self._reconstruction_manager.filepath - if current_filepath is not None and current_filepath.is_relative_to(directory): - self._reconstruction_manager.detach_current_reconstruction() - self._reconstruction_manager.mark_updated() - + stood_in_it = current_filepath is not None and current_filepath.is_relative_to(directory) try: self._browser_logic.remove_path(directory) except OSError as exception: @@ -626,18 +770,48 @@ def _remove_directory(self, directory: Path) -> None: self._dialogs.show_error(exception, self._msg_load_error) return + if stood_in_it: + self._let_go_of_removed_file() + self._browser_panel.refresh() - def update_reconstruction(self) -> None: - self._reconstruction_panel_logic.update_reconstruction() + def _let_go_of_removed_file(self) -> None: + """Holds the open document as unsaved changes with no file of its own, and shows it that way. + + The reader still has the document in front of them, so its changes stay open for a save to + another file, and the Source card stops naming the file that is gone. + """ + self._reconstruction_manager.detach_current_reconstruction() + self._reconstruction_manager.mark_updated() + self.update_reconstruction() + + def update_reconstruction(self, *, refit_waveform: bool = False) -> None: + """Re-answers every reading of an edited document whose envelopes the instruments panel already draws. + + A regenerated instrument carries the envelopes the panel's own edit wrote, and a retune + carries every envelope over, so the panel keeps what it draws and a field the reader is + typing in keeps its text. + """ + self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) self._reconstruction_instruments_logic.refresh_view() + def redraw_reconstruction(self, *, refit_waveform: bool) -> None: + """Re-answers every reading of a document rewritten outside the instruments panel, envelopes included. + + A removed recording releases the frames it held, and a replaced sample or an undo brings + envelopes of its own, so the panel draws the document as it now stands and the next edit + starts from it. A document timed at another NES frequency spans another length, so the + caller that knows this asks the waveform to re-fit. + """ + self._reconstruction_panel_logic.update_reconstruction(refit_waveform=refit_waveform) + self._reconstruction_instruments_logic.update_display() + def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) - 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,11 +820,26 @@ 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: + """Writes the loaded reconstruction's slices in ``export_format``, asking first for what the + format leaves open: its own setup where it has one, and the destination alone otherwise.""" + if export_format in self._format_setups: + self._format_setups[export_format].open_sample(self._reconstruction_panel_logic.sample_request()) + return + + self._reconstruction_panel_logic.request_export_instruments_dialog(export_format) - def _on_browser_autoplay_error(self, exception: Exception) -> None: - FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) + 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._playback_failures.present(exception, message=None)) def _on_audio_data_changed(self, audio_data: Optional[AudioData]) -> None: if audio_data is 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_synthesis/py.typed b/src/sampletones_application/coordinators/tabs/sequencer/__init__.py similarity index 100% rename from src/sampletones_synthesis/py.typed 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..b87c63aeb --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py @@ -0,0 +1,214 @@ +from functools import partial +from typing import Callable, Optional, ParamSpec + +from sampletones_application.logic.history.manager import HistoryManager +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.protocol import TextClipboard +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) +from sampletones_shared.types.callback import VoidCallback + +GestureParams = ParamSpec("GestureParams") + + +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 asks the system clipboard first + and takes its text while that text is a block, which is what lets one instance hand a block to + the next. The clipboard answers in its own time, so the blocks keep its last answer, and every + question about the block in hand reads that answer. + + 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. A cut + fills the clipboards once its gesture lands, so a cut that fails leaves them as they stood. + """ + + def __init__( + self, + tracker_logic: SequencerTrackerLogic, + order_logic: SequencerOrderLogic, + project_controller: ProjectController, + history: HistoryManager, + *, + text_clipboard: TextClipboard, + ) -> None: + self._history: HistoryManager = history + self._clipboard: SequencerClipboard = SequencerClipboard() + self._text_clipboard: TextClipboard = text_clipboard + self._clipboard_text: str = "" + 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 read_clipboard(self, then: VoidCallback) -> None: + """Asks the system clipboard for its text, running ``then`` once the read is over. + + The answer is what the pastes and the menus offering them read from then on, so a gesture + that asked first acts on the text standing on the clipboard as it answered. A read the + clipboard leaves unanswered keeps the answer before it, and ``then`` runs on that one. + """ + self._text_clipboard.read(partial(self._take_clipboard_text, then)) + + def after_reading_clipboard( + self, + paste: Callable[GestureParams, None], + ) -> Callable[GestureParams, None]: + """Holds a paste back until the system clipboard has answered, so it writes the block in hand then. + + A clipboard that answers nothing leaves the text it holds unknown, and the paste it was + asked for is let go of rather than written from a block the answer would have stood ahead of. + """ + + def wrapped(*args: GestureParams.args, **kwargs: GestureParams.kwargs) -> None: + self._text_clipboard.read(partial(self._paste_on_answer, partial(paste, *args, **kwargs))) + + return wrapped + + 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._clipboard_text) + 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._clipboard_text) + 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._hold_tracker(block, self._tracker_text.state(block, region)) + + def cut_tracker(self, region: TrackerRegion) -> None: + """Empties what a region covers, and takes the block it held onto the clipboard once the cut lands. + + The block and its text are read before the region empties, so the clipboards take what the + cut covered. + """ + block = self._tracker_reader.read(region) + self._history.after_landing( + partial(self._hold_tracker, block, self._tracker_text.state(block, 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._hold_order(block, self._order_text.state(block, region)) + + def cut_order(self, region: OrderRegion) -> None: + """Silences what a region covers, and takes the block it held onto the clipboard once the cut lands. + + The block and its text are read before the region falls silent, so the clipboards take what + the cut covered. + """ + block = self._order_reader.read(region) + self._history.after_landing( + partial(self._hold_order, block, self._order_text.state(block, 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) + + def _hold_tracker(self, block: TrackerBlock, text: str) -> None: + """Puts a tracker block in this tab's slot, and the text stating it on the system clipboard.""" + self._clipboard.store_tracker_block(block) + self._text_clipboard.write(text) + + def _hold_order(self, block: OrderBlock, text: str) -> None: + """Puts an order block in this tab's slot, and the text stating it on the system clipboard.""" + self._clipboard.store_order_block(block) + self._text_clipboard.write(text) + + def _take_clipboard_text(self, then: VoidCallback, text: Optional[str]) -> None: + if text is not None: + self._clipboard_text = text + + then() + + def _paste_on_answer(self, paste: VoidCallback, text: Optional[str]) -> None: + if text is None: + return + + self._clipboard_text = text + paste() 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..58921e891 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -0,0 +1,984 @@ +from functools import partial +from pathlib import Path +from typing import Callable, Optional, 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.failures import ( + PlaybackFailurePresenter, +) +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.file_playback import FilePlayback +from sampletones_application.logic.shared.renders import RenderCache +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.callbacks.gates import Wait, gated +from sampletones_application.utils.gui.clipboard.selection import select_text_clipboard +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.frame import FrameCallbackManager +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, + VoiceSelection, +) +from sampletones_application.view_model.shared.history import ( + HistoryDetail, +) +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel +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.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, + renders: RenderCache, + original_audio_locator: OriginalAudioLocator, + instrument_exports: InstrumentExportCoordinator, + *, + tab_active: ActivePredicate, + layout: SequencerTabParameters, + language_manager: LanguageManager, + dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, + 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, + on_audio_state_changed: VoidCallback, + after_edits: Wait, + ) -> 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._on_audio_state_changed = on_audio_state_changed + self._after_edits = after_edits + self._language_manager = language_manager + self._dialogs = dialogs + self._playback_failures = playback_failures + + self._nes_frequency_change_acknowledged: bool = False + + self._sequencer_browser_logic: SequencerBrowserLogic = SequencerBrowserLogic( + config_manager, + browser_manager, + project_controller, + ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._sequencer_tree_logic: TreeLogic = TreeLogic( + session_manager, + self._file_playback, + 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, + stem_colors=layout.stem_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, + history, + text_clipboard=select_text_clipboard(), + ) + 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, + renders=renders, + ) + 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, + failures=playback_failures, + ) + 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, + status_bar=status_bar, + 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 = gated(self._after_edits, self._request_nes_frequency_change) + self._sequencer_module_panel.on_rows_per_pattern = self._recorder.undoable( + HistoryAction.SET_ROWS_PER_PATTERN, + self._sequencer_tracker_logic.set_rows_per_pattern, + 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.marked_voice = self._marked_voice + 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_panel.on_reach_changed = self._sequencer_tracker_logic.set_reach + 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._play_from_frame + 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. The + three gestures that write go through :meth:`SequencerHistoryRecorder.undoable` as whole + ones, each recording the single entry that takes the grid back to where it stood, and a cut + fills the clipboard once that entry stands. + + A paste asks the system clipboard first and records its entry once the answer has landed. + Each grid also asks whether a block stands ready to paste, which a menu offering Paste + consults as it opens, and asks the clipboard again so the item follows the fresh answer. + """ + self._sequencer_tracker_panel.can_paste_block = self._blocks.can_paste_tracker + self._sequencer_tracker_panel.refresh_paste_block = self._blocks.read_clipboard + self._sequencer_order_panel.can_paste_block = self._blocks.can_paste_order + self._sequencer_order_panel.refresh_paste_block = self._blocks.read_clipboard + 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._blocks.after_reading_clipboard( + 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._blocks.after_reading_clipboard( + 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 = gated(self._after_edits, self._voices.remove) + self._sequencer_voices_panel.on_play_requested = self._sequencer_voices_logic.play_voice + self._sequencer_voices_panel.on_move_requested = self._recorder.undoable( + HistoryAction.MOVE_VOICE, + 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 = gated( + self._after_edits, + self._recorder.undoable( + HistoryAction.DUPLICATE_VOICE, + self._sequencer_voices_logic.duplicate_voice, + detail=self._history_detail.duplicate_voice, + ), + ) + self._sequencer_voices_panel.on_new_instrument_requested = self.add_instrument + self._sequencer_voices_panel.on_add_sample_requested = self.add_sample_from_file + self._sequencer_voices_panel.on_import_instrument_requested = self.import_instrument + self._sequencer_voices_panel.voice_instruments = self._instrument_exports.voice_instruments + self._sequencer_voices_panel.on_export_instrument_requested = gated( + self._after_edits, + self._instrument_exports.request_voice, + ) + self._sequencer_voices_panel.instrument_channels = self._sequencer_voices_logic.instrument_channels + self._sequencer_voices_panel.on_instrument_from_channel_requested = gated( + self._after_edits, + self.add_instrument_from_channel, + ) + + def _wire_browser_callbacks(self) -> None: + self._sequencer_browser_panel.set_collapse_handler(self._layout.on_browser_collapse_changed) + 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_browser_panel.on_recordings_requested = self._on_browser_recordings_requested + 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._file_playback.on_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 + self._song_player_logic.on_change_audio_state = self._on_audio_state_changed + + 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 + + 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 realign_with_project(self) -> None: + """Realigns the tab with a replaced project, keeping the mute set across history navigation. + + The application's fan-out of the controller's single ``on_project_replaced`` signal calls + this. Undo, redo, history jumps and the rollback of a failed gesture 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._play_from_frame(self._sequencer_tracker_logic.frame_index) + + def _play_from_frame(self, position: int) -> None: + """Plays from a frame inside the player's recovery boundary, the one Play goes through.""" + self._guarded_player.run_guarded(partial(self._frames.play_from, position)) + + def add_instrument(self) -> None: + """Appends an instrument 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: + """Steps the project back, once the edits of the open document the reader made before it have landed.""" + self._after_edits(self._history.undo) + + def redo(self) -> None: + self._after_edits(self._history.redo) + + def jump_to_history(self, index: int) -> None: + self._after_edits(partial(self._history.jump_to, index)) + + def refresh_history(self) -> None: + """Re-renders the history panel from the manager's current stack. + + 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 an instrument'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) + self._sequencer_voices_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: + """Shows the reader what stopped the song on the thread playing it, the way every tab shows it.""" + self._playback_failures.present_playing_failure(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._playback_failures.present(exception, message=None)) + + def _on_browser_recordings_requested(self, path: Path) -> None: + """Hands the browser what a document names, where it has already been read.""" + recordings = self._sequencer_browser_logic.recordings(path) + if recordings is not None: + self._sequencer_browser_panel.update_recordings(path, recordings) + + def show_browser_recordings(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + """Hands the browser a reading that landed after the row asked for it.""" + self._sequencer_browser_panel.update_recordings(path, recordings) + + 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. + + The sample may be the one open on the Reconstructions tab, so the substitution waits for + the edits made there before it. + """ + self._after_edits(partial(self._reconstructions.replace_from_file, filepath)) + + def _dispatch_edit_voice(self, voice_id: str) -> None: + self._on_edit_voice_requested(voice_id) + + 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._guarded_player.run_guarded( + partial( + 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) + + 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. Canceling 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 takes the keyboard from the voices when the tracker takes focus. + + The tracker, order, and voices panels each register a key-router scope active only while + it holds the keyboard; keeping a single holder across the three lets only the focused + panel consume keystrokes. The voices panel keeps its mark, which the pitches typed into + the grid carry. + """ + self._sequencer_order_panel.deselect_cell() + self._sequencer_voices_panel.blur() + + def _on_order_cell_focused(self) -> None: + """Drops the tracker cursor and takes the keyboard from the voices when the order takes focus.""" + self._sequencer_tracker_panel.deselect_cell() + self._sequencer_voices_panel.blur() + + def _marked_voice(self) -> Optional[VoiceSelection]: + """The voice the voices list marks, which a pitch typed into the grid places beside itself.""" + return self._sequencer_voices_panel.selection + + 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..e0dacc335 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/history.py @@ -0,0 +1,166 @@ +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 +from sampletones_core.project.patterns.pitch import RowPitch + +_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 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], + pitch: Optional[RowPitch], + 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, + pitch 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..4cdef33d4 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/reconstructions.py @@ -0,0 +1,266 @@ +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_application.view_model.sequencer.voices import VoiceKind, VoiceSelection +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 project stores the detached document, so the open document keeps its own source-audio + location and file backing while the sample stays self-contained. A reconstruction never + changes once made, so the two share every part the detachment leaves alone. + """ + if not self._open_project.met(): + return + + self._add_with_frequency_check(reconstruction, 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 the sample the voices panel has picked as the gesture starts, + which is also what named the menu item the user clicked. + """ + selection = self._picked_sample() + 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 picked.""" + selection = self._picked_sample() + if selection is None: + return None + + return selection.label + + def _picked_sample(self) -> Optional[VoiceSelection]: + """The voice the panel has picked while it is a sample, the one kind a reconstruction replaces.""" + match self._voices_panel.selection: + case VoiceSelection(kind=VoiceKind.SAMPLE) as selection: + return selection + case _: + return None + + 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 once the + sample holds the incoming reconstruction, in the form the project keeps, so an editor holding + the sample open by its id shows what the project now plays. The frequency adoption, the + rename, and the substitution share a single history entry, so one undo restores the previous + rate, name, and audio together. + """ + detail = self._history_detail.replace_sample(voice_id, name) + with self._history.transaction( + 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) + held = self._browser_logic.replace_reconstruction(voice_id, reconstruction) + self._on_sample_reconstruction_replaced(voice_id, held) 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..3392deac5 --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/sequencer/voices.py @@ -0,0 +1,248 @@ +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. + + A voice enters the pool while a project is open. Each way in asks for the open project as it + starts, so the rule holds wherever the gesture came from: a button, a menu, or a key bound to it. + """ + + 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 an instrument, 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. + """ + if not self._open_project.met(): + return + + 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. 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.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..6a79c7951 --- /dev/null +++ b/src/sampletones_application/exports.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict + +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends +from sampletones_player.export.backend import NSFBackend + + +@dataclass(frozen=True) +class ExportBackends: + """Every backend the application exports through. + + The reconstruction engine stands below the console player and owns the tracker formats + alone, so the backend writing a program the console runs joins them here, where both + packages are in reach. Everything the application offers to export in is keyed by its + format in :attr:`by_format`, so a menu entry, a file type and a shortcut all reach one + backend, and the console player's backend is named as well, since the NSF export setup binds + that same backend to the program it chooses. + + Attributes: + nsf: The backend writing console programs. + trackers: The backends writing the tracker formats, keyed by the format each writes. + """ + + nsf: NSFBackend + trackers: Dict[ExportFormat, ExportBackend] + + @classmethod + def build(cls) -> ExportBackends: + """Builds every backend, reading the resources each writes with. + + Raises: + OSError: If the console player's packaged driver is absent. + """ + return cls( + nsf=NSFBackend.stated(), + trackers=build_tracker_backends(), + ) + + @property + def by_format(self) -> Dict[ExportFormat, ExportBackend]: + """Every backend, keyed by the format it writes.""" + return { + **self.trackers, + ExportFormat.NSF: self.nsf, + } diff --git a/src/sampletones_application/layout/behavior/behavior.py b/src/sampletones_application/layout/behavior/behavior.py index c374d2d8d..6a3988bc7 100644 --- a/src/sampletones_application/layout/behavior/behavior.py +++ b/src/sampletones_application/layout/behavior/behavior.py @@ -1,6 +1,7 @@ from pydantic import BaseModel from sampletones_application.layout.behavior.display import DisplayBehavior +from sampletones_application.layout.behavior.rendering import RenderingBehavior from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.layout.behavior.ui import UIBehavior @@ -9,3 +10,4 @@ class BehaviorConfig(BaseModel, extra="forbid", frozen=True): scheduling: SchedulingBehavior ui: UIBehavior display: DisplayBehavior + rendering: RenderingBehavior diff --git a/src/sampletones_application/layout/behavior/rendering.py b/src/sampletones_application/layout/behavior/rendering.py new file mode 100644 index 000000000..8e26826fe --- /dev/null +++ b/src/sampletones_application/layout/behavior/rendering.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, Field + +from sampletones_shared.constants.general import BYTES_PER_MEGABYTE + + +class RenderingBehavior(BaseModel, extra="forbid", frozen=True): + cache_megabytes: int = Field(..., ge=1) + + @property + def cache_bytes(self) -> int: + """The bytes the render cache keeps audio within before it lets the renders read longest ago go.""" + return self.cache_megabytes * BYTES_PER_MEGABYTE diff --git a/src/sampletones_application/layout/behavior/scheduling/delays.py b/src/sampletones_application/layout/behavior/scheduling/delays.py index 7a25eb70e..fd54eb759 100644 --- a/src/sampletones_application/layout/behavior/scheduling/delays.py +++ b/src/sampletones_application/layout/behavior/scheduling/delays.py @@ -3,5 +3,4 @@ class SchedulingDelays(BaseModel, extra="forbid", frozen=True): schedule: int - reconstruction_update: int cancel: int diff --git a/src/sampletones_application/layout/fonts.py b/src/sampletones_application/layout/fonts.py index 7c5f44108..2b81c7fda 100644 --- a/src/sampletones_application/layout/fonts.py +++ b/src/sampletones_application/layout/fonts.py @@ -10,6 +10,7 @@ class Typeface(Enum): class Step(Enum): + TINY = "tiny" SMALL = "small" MEDIUM = "medium" LARGE = "large" @@ -17,6 +18,7 @@ class Step(Enum): class FontScale(BaseModel, extra="forbid", frozen=True): + tiny: int small: int medium: int large: int @@ -24,6 +26,7 @@ class FontScale(BaseModel, extra="forbid", frozen=True): def step(self, step: Step) -> int: return { + Step.TINY: self.tiny, Step.SMALL: self.small, Step.MEDIUM: self.medium, Step.LARGE: self.large, @@ -34,7 +37,7 @@ def step(self, step: Step) -> int: class FontsLayout(BaseModel, extra="forbid", frozen=True): """Per-typeface pixel-size scales for every rendered font. - Each typeface carries its own ``small``/``medium``/``large``/``title`` scale, so Sans + Each typeface carries its own ``tiny``/``small``/``medium``/``large``/``title`` scale, so Sans and Mono are tuned to the same apparent size independently, and a rung is drawn at where a font asks for it. ``scale`` is the DearPyGui global font multiplier applied on top. 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..96565f644 100644 --- a/src/sampletones_application/layout/general/colors/colors.py +++ b/src/sampletones_application/layout/general/colors/colors.py @@ -1,9 +1,11 @@ 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 from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.layout.general.colors.table import TableColors from sampletones_application.layout.general.colors.text import TextColors @@ -15,3 +17,5 @@ class GeneralColors(BaseModel, extra="forbid", frozen=True): paths: PathColors headers: HeaderColors features: FeatureColors + channels: ChannelColors + stems: StemColors 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/colors/stem.py b/src/sampletones_application/layout/general/colors/stem.py new file mode 100644 index 000000000..290dabb40 --- /dev/null +++ b/src/sampletones_application/layout/general/colors/stem.py @@ -0,0 +1,57 @@ +from typing import Tuple + +from pydantic import BaseModel + +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.faded import FadedColor +from sampletones_application.utils.palette.colors.written import WrittenColor +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID + + +class StemColors(BaseModel, extra="forbid", frozen=True): + """The colors the recordings behind a reconstruction are told apart by. + + A recording takes its color from the id it was converted as, which it keeps for as long as it + stays on the record, so the ribbon under the waveform, the ribbon under an instrument's bars + and the swatch beside a name all paint one recording alike, and a recording leaving the record + leaves the others' colors as they were. The frames a reader wrote answer to no recording and + take a color of their own, and a resting frame shows the ground the ribbon is laid on. + + A recording the reader left out keeps that color and carries it faded, so a stretch names its + owner whether or not the reader is listening to it and the reading sits on top of the record. + A rest answers to no recording, so every reader hears it and the ground paints solid. + + A conversion may hold more recordings than there are colors, in which case the list starts + over, so two recordings far apart on the record can share one color while the neighbors a + reader compares stay distinct. + """ + + recordings: Tuple[WrittenColor, ...] + authored: WrittenColor + rest: WrittenColor + left_out_fraction: float + + def for_recording(self, stem_id: int) -> BaseColor: + """The color the recording converted as ``stem_id`` is known by.""" + return self.recordings[stem_id % len(self.recordings)] + + def for_stem( + self, + stem_id: int, + *, + heard: bool, + ) -> BaseColor: + """The color one frame's owner is painted in. + + Args: + stem_id: The stem holding the frame. + heard: Whether the reader hears that stem here. + + Returns: + BaseColor: The color the ribbon paints that frame with. + """ + if stem_id == RESTING_STEM_ID: + return self.rest + + solid = self.authored if stem_id == AUTHORED_STEM_ID else self.for_recording(stem_id) + return solid if heard else FadedColor(color=solid, fraction=self.left_out_fraction) 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/dialogs/about.py b/src/sampletones_application/layout/general/dialogs/about.py index c7cf36dbd..40aaa6dba 100644 --- a/src/sampletones_application/layout/general/dialogs/about.py +++ b/src/sampletones_application/layout/general/dialogs/about.py @@ -1,15 +1,16 @@ from pydantic import BaseModel +from sampletones_application.layout.primitives import DialogGeometry + class AboutDialogLayout(BaseModel, extra="forbid", frozen=True): """The About dialog's size, the size its mark is drawn at, and the room left around the mark.""" - width: int - height: int + window: DialogGeometry logo: int padding: int @property def text_wrap(self) -> int: """Width the text standing beside the mark wraps at.""" - return self.width - self.logo - self.padding + return self.window.width - self.logo - self.padding diff --git a/src/sampletones_application/layout/general/dialogs/dialogs.py b/src/sampletones_application/layout/general/dialogs/dialogs.py index 3d1d8ebf8..945e9fb87 100644 --- a/src/sampletones_application/layout/general/dialogs/dialogs.py +++ b/src/sampletones_application/layout/general/dialogs/dialogs.py @@ -1,15 +1,19 @@ from pydantic import BaseModel from sampletones_application.layout.general.dialogs.about import AboutDialogLayout -from sampletones_application.layout.general.dialogs.height import DialogSizeNoWidth -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry class DialogsLayout(BaseModel, extra="forbid", frozen=True): - default: Dimensions - error: Dimensions - recovery: Dimensions - confirmation: DialogSizeNoWidth - text_input: DialogSizeNoWidth - traceback: Dimensions + """The dialogs a reader is answered by, each stating the size it opens at. + + ``traceback_height`` is the height the traceback's text box takes once a reader unfolds it, + which the dialog holding it grows to make room for. + """ + + default: DialogGeometry + error: DialogGeometry + recovery: DialogGeometry + confirmation: DialogGeometry + traceback_height: int about: AboutDialogLayout diff --git a/src/sampletones_application/layout/general/dialogs/height.py b/src/sampletones_application/layout/general/dialogs/height.py deleted file mode 100644 index 2a4bf449b..000000000 --- a/src/sampletones_application/layout/general/dialogs/height.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - - -class DialogSizeNoWidth(BaseModel, extra="forbid", frozen=True): - height: int diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py new file mode 100644 index 000000000..8e3b7cdc0 --- /dev/null +++ b/src/sampletones_application/layout/general/stems.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel + + +class StemsListLayout(BaseModel, extra="forbid", frozen=True): + master_column_width: int + channel_column_width: int + channel_box_width: int + remove_button_width: int + swatch_size: int + level_strip_height: int + well_padding: int + well_margin: int + well_ceiling: int + twisty_width: int + folder_ceiling: int + folder_indent: int + window_overscan: int + scrollbar_width: int + cell_padding: int + name_height: int + + @property + def folder_reserve(self) -> int: + """The room a folder's region spends at the right of its body. + + A region insets its body by its padding and keeps a scrollbar's width clear beside it, so + the grid outside a folder holds that same strip and the two grids stand as one. + """ + return self.well_padding + self.scrollbar_width diff --git a/src/sampletones_application/layout/glyphs/common.py b/src/sampletones_application/layout/glyphs/common.py index 10fddf7b9..65db42ebf 100644 --- a/src/sampletones_application/layout/glyphs/common.py +++ b/src/sampletones_application/layout/glyphs/common.py @@ -3,6 +3,7 @@ class CommonGlyphs(BaseModel, extra="forbid", frozen=True): tick: str + swatch: str favorite: str expanded: str collapsed: str 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..5346cdf40 100644 --- a/src/sampletones_application/layout/glyphs/header.py +++ b/src/sampletones_application/layout/glyphs/header.py @@ -13,8 +13,9 @@ class HeaderGlyphs(BaseModel, extra="forbid", frozen=True): details: str parameters: str source: str + stems: 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/__init__.py b/src/sampletones_application/layout/graphs/__init__.py index ed140a4e5..3a1dffec4 100644 --- a/src/sampletones_application/layout/graphs/__init__.py +++ b/src/sampletones_application/layout/graphs/__init__.py @@ -1,9 +1,11 @@ from pydantic import BaseModel from sampletones_application.layout.graphs.bar_plot import BarPlotLayout +from sampletones_application.layout.graphs.clock import ClockLayout from sampletones_application.layout.graphs.colors import GraphColors from sampletones_application.layout.graphs.dimensions import GraphDimensions from sampletones_application.layout.graphs.graph import GraphRange +from sampletones_application.layout.graphs.ribbon import RibbonLayout from sampletones_application.layout.graphs.spectrum import SpectrumLayout from sampletones_application.layout.graphs.waveform import WaveformLayout @@ -14,4 +16,6 @@ class GraphsLayout(BaseModel, extra="forbid", frozen=True): spectrum: SpectrumLayout graph: GraphRange bar_plot: BarPlotLayout + ribbon: RibbonLayout + clock: ClockLayout colors: GraphColors diff --git a/src/sampletones_application/layout/graphs/bar_plot.py b/src/sampletones_application/layout/graphs/bar_plot.py index 373b57061..8e568272d 100644 --- a/src/sampletones_application/layout/graphs/bar_plot.py +++ b/src/sampletones_application/layout/graphs/bar_plot.py @@ -2,8 +2,23 @@ class BarPlotLayout(BaseModel, extra="forbid", frozen=True): + """How a dimension's bars are drawn. + + Attributes: + min_x: Where the first bar's slot begins. + min_y: The floor of the value range. + max_y: The ceiling of the value range. + bar_weight: The share of its slot a bar fills. + hover_alpha: How solid the bar under the cursor is drawn. + minimum_span: The slots the axis holds room for, so a short dimension keeps a grid. + ownership_band: The share of the value range kept beneath it for the stretches naming + the recording behind each frame. + """ + min_x: float min_y: float max_y: float bar_weight: float hover_alpha: int + minimum_span: float + ownership_band: float diff --git a/src/sampletones_application/layout/graphs/clock.py b/src/sampletones_application/layout/graphs/clock.py new file mode 100644 index 000000000..6b8b27d65 --- /dev/null +++ b/src/sampletones_application/layout/graphs/clock.py @@ -0,0 +1,15 @@ +from typing import Tuple + +from pydantic import BaseModel + + +class ClockLayout(BaseModel, extra="forbid", frozen=True): + """How a time axis names the moments along it. + + Attributes: + steps_seconds: The distances between marks a clock divides evenly into, in order. + tick_count: The marks a stretch is read with, which picks the step from the list. + """ + + steps_seconds: Tuple[float, ...] + tick_count: int diff --git a/src/sampletones_application/layout/graphs/dimensions.py b/src/sampletones_application/layout/graphs/dimensions.py index ec1bd4137..7e9ad0c92 100644 --- a/src/sampletones_application/layout/graphs/dimensions.py +++ b/src/sampletones_application/layout/graphs/dimensions.py @@ -2,6 +2,14 @@ class GraphDimensions(BaseModel, extra="forbid", frozen=True): + """The sizes the plots are drawn at. + + Attributes: + width: The width a plot fills, negative to take the room it is offered. + height: The height of a waveform plot. + bar_plot_height: The height of one dimension's bar plot. + """ + width: int height: int bar_plot_height: int diff --git a/src/sampletones_application/layout/graphs/ribbon.py b/src/sampletones_application/layout/graphs/ribbon.py new file mode 100644 index 000000000..1cb71de9b --- /dev/null +++ b/src/sampletones_application/layout/graphs/ribbon.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class RibbonLayout(BaseModel, extra="forbid", frozen=True): + """How the ownership ribbon under the waveform is drawn. + + Attributes: + lane_height: The height one channel's lane takes, which holds the letter marking it. + lane_gap: The share of a lane left as air around its bars, so the lanes read apart. + """ + + lane_height: int = Field(gt=0) + lane_gap: float = Field(ge=0.0, lt=1.0) 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/graphs/waveform.py b/src/sampletones_application/layout/graphs/waveform.py index 1d9517d9a..427823d08 100644 --- a/src/sampletones_application/layout/graphs/waveform.py +++ b/src/sampletones_application/layout/graphs/waveform.py @@ -4,4 +4,6 @@ class WaveformLayout(BaseModel, extra="forbid", frozen=True): reconstruction_dim_opacity: float zoom_factor: float + pan_factor: float max_display_points: int + click_travel: float diff --git a/src/sampletones_application/layout/primitives.py b/src/sampletones_application/layout/primitives.py index 389f5db69..53f653b49 100644 --- a/src/sampletones_application/layout/primitives.py +++ b/src/sampletones_application/layout/primitives.py @@ -1,4 +1,8 @@ -from pydantic import BaseModel +from typing import Final, Optional, Tuple + +from pydantic import BaseModel, Field + +DEARPYGUI_MAXIMUM_WINDOW_SIZE: Final[int] = 30000 class Dimensions(BaseModel, extra="forbid", frozen=True): @@ -6,3 +10,36 @@ class Dimensions(BaseModel, extra="forbid", frozen=True): width: int height: int + + +class DialogGeometry(BaseModel, extra="forbid", frozen=True): + """How large a dialog opens, which is what the place it opens at follows from. + + The width is the dialog's, held both ways: it is the smallest the window may take and the + largest, so every dialog reads at one width whatever it holds and a field, a combo or a + button stretching across the window measures against a width that stands. A window free to + widen to its content and content asking for the window's width feed each other a little + more every frame, which is what holding the width both ways settles. + + The height is the smallest the dialog opens at, and a dialog holding more than that grows + to hold it, so what a reader is shown is always the whole of what the dialog says. A dialog + stating a height opens centered on the frame it is first drawn in, since both numbers stand + before anything is drawn; one stating none is centered against the size it settles at. + """ + + width: int = Field(..., gt=0) + height: Optional[int] = Field(default=None, gt=0) + + @property + def minimum_size(self) -> Tuple[int, int]: + """The size the dialog opens at, which DearPyGui reads as the smallest it may take.""" + return self.width, self.height if self.height is not None else 0 + + @property + def maximum_size(self) -> Tuple[int, int]: + """The size the dialog stops at: the width it states, and as tall as what it holds asks for. + + DearPyGui reads a window's bounds as one pair, so a height left to the content is stated + as the bound DearPyGui carries of its own accord, which is the one no window reaches. + """ + return self.width, DEARPYGUI_MAXIMUM_WINDOW_SIZE diff --git a/src/sampletones_application/layout/project_properties/__init__.py b/src/sampletones_application/layout/project_properties/__init__.py index bbb24c4d6..52e70343e 100644 --- a/src/sampletones_application/layout/project_properties/__init__.py +++ b/src/sampletones_application/layout/project_properties/__init__.py @@ -1,10 +1,10 @@ from pydantic import BaseModel -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry class ProjectPropertiesLayout(BaseModel, extra="forbid", frozen=True): - window: Dimensions + window: DialogGeometry label_width: int input_width: int comment_height: int diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index 99c11b68b..8e4aae008 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -2,7 +2,9 @@ from sampletones_application.layout.settings.audio import AudioSettingsLayout from sampletones_application.layout.settings.display import DisplaySettingsLayout +from sampletones_application.layout.settings.export.export import ExportSettingsLayout from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout +from sampletones_application.layout.settings.nsf import NSFSettingsLayout from sampletones_application.layout.settings.render import RenderSettingsLayout @@ -19,3 +21,5 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True): display: DisplaySettingsLayout keybindings: KeybindingsSettingsLayout render: RenderSettingsLayout + export: ExportSettingsLayout + nsf: NSFSettingsLayout diff --git a/src/sampletones_application/layout/settings/audio.py b/src/sampletones_application/layout/settings/audio.py index ca8eaad9d..7e3152bda 100644 --- a/src/sampletones_application/layout/settings/audio.py +++ b/src/sampletones_application/layout/settings/audio.py @@ -1,9 +1,9 @@ from pydantic import BaseModel -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry from sampletones_application.layout.settings.master_gain import MasterGainLayout class AudioSettingsLayout(BaseModel, extra="forbid", frozen=True): - window: Dimensions + window: DialogGeometry master_gain: MasterGainLayout diff --git a/src/sampletones_application/layout/settings/display.py b/src/sampletones_application/layout/settings/display.py index c5ef12341..d47ea8aef 100644 --- a/src/sampletones_application/layout/settings/display.py +++ b/src/sampletones_application/layout/settings/display.py @@ -1,8 +1,8 @@ from pydantic import BaseModel -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry class DisplaySettingsLayout(BaseModel, extra="forbid", frozen=True): - window: Dimensions - countdown: Dimensions + window: DialogGeometry + countdown: DialogGeometry diff --git a/src/sampletones_application/layout/settings/export/__init__.py b/src/sampletones_application/layout/settings/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/layout/settings/export/export.py b/src/sampletones_application/layout/settings/export/export.py new file mode 100644 index 000000000..fbe7ccb6f --- /dev/null +++ b/src/sampletones_application/layout/settings/export/export.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from sampletones_application.layout.primitives import DialogGeometry +from sampletones_application.layout.settings.export.indicator import LoadingIndicatorLayout + + +class ExportSettingsLayout(BaseModel, extra="forbid", frozen=True): + window: DialogGeometry + indicator: LoadingIndicatorLayout diff --git a/src/sampletones_application/layout/settings/export/indicator.py b/src/sampletones_application/layout/settings/export/indicator.py new file mode 100644 index 000000000..f4099966a --- /dev/null +++ b/src/sampletones_application/layout/settings/export/indicator.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class LoadingIndicatorLayout(BaseModel, extra="forbid", frozen=True): + """The turning ring's size, stated as factors of the font it is drawn in. + + DearPyGui draws the ring ``radius × font size × (1 − thickness / 4)`` pixels across and pads its + box by the frame padding above and below, the same padding it sets text beside the ring down by. + The ring sits level with a line of that font when ``radius × (1 − thickness / 4)`` equals one. + """ + + radius: float = Field(gt=0.0) + thickness: float = Field(gt=0.0, lt=4.0) diff --git a/src/sampletones_application/layout/settings/keybindings.py b/src/sampletones_application/layout/settings/keybindings.py index 863f0adfc..0f4a905bb 100644 --- a/src/sampletones_application/layout/settings/keybindings.py +++ b/src/sampletones_application/layout/settings/keybindings.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry class KeybindingsSettingsLayout(BaseModel, extra="forbid", frozen=True): @@ -10,6 +10,6 @@ class KeybindingsSettingsLayout(BaseModel, extra="forbid", frozen=True): showing, and the action column takes a stated width so every combination reads down one edge. """ - window: Dimensions + window: DialogGeometry list_height: int action_width: int diff --git a/src/sampletones_application/layout/settings/nsf.py b/src/sampletones_application/layout/settings/nsf.py new file mode 100644 index 000000000..d918f85fa --- /dev/null +++ b/src/sampletones_application/layout/settings/nsf.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + +from sampletones_application.layout.primitives import DialogGeometry + + +class NSFSettingsLayout(BaseModel, extra="forbid", frozen=True): + """The NSF export dialog's geometry. + + Attributes: + window: The dialog's size, its height a floor the content grows past. + text_width: The width of a header text field, negative to leave its byte count room at the + row's end. + frame_width: The width of the field a repeat's order frame is typed into. + """ + + window: DialogGeometry + text_width: int + frame_width: int diff --git a/src/sampletones_application/layout/settings/render.py b/src/sampletones_application/layout/settings/render.py index 58dc9e84a..aa1fd7114 100644 --- a/src/sampletones_application/layout/settings/render.py +++ b/src/sampletones_application/layout/settings/render.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry class RenderSettingsLayout(BaseModel, extra="forbid", frozen=True): - window: Dimensions + window: DialogGeometry diff --git a/src/sampletones_application/layout/tabs/main/__init__.py b/src/sampletones_application/layout/tabs/main/__init__.py index b06ef45d5..b5a3de0d6 100644 --- a/src/sampletones_application/layout/tabs/main/__init__.py +++ b/src/sampletones_application/layout/tabs/main/__init__.py @@ -3,11 +3,11 @@ from sampletones_application.layout.tabs.main.advanced import AdvancedLayout from sampletones_application.layout.tabs.main.config import ConfigLayout from sampletones_application.layout.tabs.main.converter import ConverterLayout -from sampletones_application.layout.tabs.main.reconstructor import ReconstructorLayout +from sampletones_application.layout.tabs.main.source import SourceSettingsLayout class MainLayout(BaseModel, extra="forbid", frozen=True): config: ConfigLayout converter: ConverterLayout - reconstructor: ReconstructorLayout + source: SourceSettingsLayout advanced: AdvancedLayout diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index 64dc2a5ba..ae3d978f9 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -1,7 +1,12 @@ from pydantic import BaseModel +from sampletones_application.layout.primitives import DialogGeometry + class ConverterLayout(BaseModel, extra="forbid", frozen=True): width: int - height: int button_height: int + stem_selection: DialogGeometry + stem_selection_footer: int + stem_selection_list: int + scan: DialogGeometry diff --git a/src/sampletones_application/layout/tabs/main/reconstructor.py b/src/sampletones_application/layout/tabs/main/reconstructor.py deleted file mode 100644 index 93b4b2a27..000000000 --- a/src/sampletones_application/layout/tabs/main/reconstructor.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class ReconstructorLayout(BaseModel, extra="forbid", frozen=True): - drive_format: str - height: int diff --git a/src/sampletones_application/layout/tabs/main/source.py b/src/sampletones_application/layout/tabs/main/source.py new file mode 100644 index 000000000..e81e69f78 --- /dev/null +++ b/src/sampletones_application/layout/tabs/main/source.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class SourceSettingsLayout(BaseModel, extra="forbid", frozen=True): + drive_decimals: int + name_column_width: int + choice_column_width: int + step_button_width: int + + @property + def drive_format(self) -> str: + """The format a drive slider prints its value in, to the decimals a drive is set at.""" + return f"%.{self.drive_decimals}f" 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_application/logic/export/instrument/__init__.py b/src/sampletones_application/logic/export/instrument/__init__.py new file mode 100644 index 000000000..e69de29bb 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..8c4dadc4a --- /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: + ProjectTuningError: 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..bbafdb234 --- /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: + ProjectTuningError: 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: + ProjectTuningError: 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: + ProjectTuningError: 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..5f8cfcb0f --- /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 canceling is answered at the next point the + format looks up. + """ + + def __init__( + self, + export_service: ExportProgressServiceProtocol, + *, + stage_labels: Dict[ExportStage, str], + size_template: str, + canceling_label: str, + ) -> None: + self._service = export_service + self._stage_labels = stage_labels + self._size_template = size_template + self._canceling_label = canceling_label + + self._phase: ExportPhase = ExportPhase.IDLE + self._stages: List[ExportStage] = [] + self._figure: str = NO_FIGURE + self._progress: float = NO_PROGRESS + self._traveling: 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.CANCELING + self._figure = self._canceling_label + self._traveling = 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._traveling = 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.CANCELING: + return + + stage = progress.current_item + if stage is None: + return + + self._reach(stage) + self._traveling = 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._traveling 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._traveling, + ) diff --git a/src/sampletones_application/logic/export/nsf/__init__.py b/src/sampletones_application/logic/export/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/export/nsf/draft.py b/src/sampletones_application/logic/export/nsf/draft.py new file mode 100644 index 000000000..15924217e --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/draft.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Optional + +from sampletones_application.logic.export.nsf.source.protocol import NSFExportSource +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_player.builder import SONG_START +from sampletones_player.export.program import NSFProgram + + +@dataclass(frozen=True) +class NSFExportDraft: + """An NSF export as it stands while its dialog is open. + + Attributes: + source: What the export writes. + offer: What the source leaves to choose, read once the dialog opens. + choices: The choices the dialog stands at. + destination: The file the export writes. + """ + + source: NSFExportSource + offer: NSFExportOffer + choices: NSFExportChoices + destination: Path + + def with_choices(self, choices: NSFExportChoices) -> NSFExportDraft: + return replace(self, choices=choices) + + def with_destination(self, destination: Path) -> NSFExportDraft: + return replace(self, destination=destination) + + def program(self) -> NSFProgram: + """The program the standing choices write, its repeat placed on the source's own ticks. + + Raises: + ValidationError: If no channel is ticked. + """ + return NSFProgram( + information=self.choices.information, + channels=self.choices.channels, + loop_tick=self._loop_tick(), + scheme=self.choices.scheme, + ) + + def _loop_tick(self) -> Optional[int]: + """The tick the song returns to: the first one, the one the chosen frame starts on, or none.""" + match self.choices.repeat: + case NSFRepeat.ONCE: + return None + case NSFRepeat.FROM_START: + return SONG_START + case NSFRepeat.FROM_FRAME: + return self.source.frame_tick(self.choices.loop_frame) + + def view(self) -> NSFExportViewModel: + return NSFExportViewModel( + offer=self.offer, + choices=self.choices, + destination=self.destination, + ticks=self.source.ticks(self.choices.channels), + nes_frequency=self.source.nes_frequency, + ) diff --git a/src/sampletones_application/logic/export/nsf/logic.py b/src/sampletones_application/logic/export/nsf/logic.py new file mode 100644 index 000000000..4a55f9dd1 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/logic.py @@ -0,0 +1,147 @@ +from pathlib import Path +from typing import Callable, Optional + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.draft import NSFExportDraft +from sampletones_application.logic.export.nsf.protocol import ( + NSFExportServiceProtocol, + NSFProgramBackend, +) +from sampletones_application.logic.export.nsf.source.project import ProjectSource +from sampletones_application.logic.export.nsf.source.protocol import NSFExportSource +from sampletones_application.logic.export.nsf.source.sample import SampleSource +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.exports.request import SampleExport +from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import PathCallback +from sampletones_shared.utils.callbacks import CallbackMixin +from sampletones_shared.utils.system.paths import get_filename + + +class NSFExportLogic(CallbackMixin): + """Owns setting an NSF export up: what it writes, the program it writes it as, and the file. + + The dialog opens on the program the export writes as its source states it, so a reader + changing nothing writes what the save dialog alone would have written. Every choice is the + player's own, held in the form the file states it, and the source places the choices on its + own ticks once the export is handed over. + + Setting an export up is an exclusive operation, claimed the moment the dialog opens. Handing + the export over closes the setup in the same call the service starts running in, so the + application stays busy from the dialog opening until the run reports its outcome. + """ + + def __init__( + self, + project_controller: ProjectController, + session_manager: SessionManager, + export_service: NSFExportServiceProtocol, + backend: NSFProgramBackend, + *, + is_operation_active: Callable[[], bool], + ) -> None: + self._project_controller = project_controller + self._session_manager = session_manager + self._service = export_service + self._backend = backend + self._is_operation_active = is_operation_active + self._draft: Optional[NSFExportDraft] = None + + self.on_view_changed: Optional[Callable[[NSFExportViewModel], None]] = None + self.on_choose_destination: Optional[PathCallback] = None + + @property + def is_active(self) -> bool: + """An NSF export occupies the application while its setup is open.""" + return self._draft is not None + + def open_project(self) -> bool: + """Offers the open project's song as a program, reporting whether the setup took over.""" + return self._open( + ProjectSource( + request=self._project_controller.export_request, + name=self._project_controller.name or DEFAULT_EXPORT_NAME, + ) + ) + + def open_sample(self, request: SampleExport) -> bool: + """Offers a reconstruction's slices as a program, reporting whether the setup took over.""" + return self._open(SampleSource(request=request)) + + def apply(self, choices: NSFExportChoices) -> None: + """Takes the reconciled choices the dialog reports.""" + if self._draft is None: + return + + self._draft = self._draft.with_choices(choices) + self._emit_view() + + def request_destination(self) -> None: + """Asks for the file the export writes, starting from the one standing.""" + self.call(self.on_choose_destination, self._require_draft().destination) + + def set_destination(self, destination: Path) -> None: + """Writes the export to ``destination``.""" + self._draft = self._require_draft().with_destination(destination) + self._emit_view() + + def start(self) -> bool: + """Hands the export to the service under the standing choices, and closes the setup. + + Returns: + bool: Whether the export was handed over, which a ticked channel is what allows. + """ + draft = self._draft + if draft is None or not draft.choices.writable: + return False + + draft.source.remember_directory(self._session_manager, draft.destination.parent) + draft.source.submit( + self._service, + draft.destination, + self._backend.choosing(draft.program()), + ) + self.close() + return True + + def close(self) -> None: + """Leaves the setup, releasing the application.""" + self._draft = None + + def _open(self, source: NSFExportSource) -> bool: + """Sets an export of ``source`` up, where no other exclusive operation holds the application. + + The destination is proposed afresh each time, carrying the source's name into the folder + that kind of export opens on. + """ + if self._is_operation_active(): + logger.warning("An exclusive operation is already in progress; the NSF export was not offered") + return False + + offer = source.offer() + self._draft = NSFExportDraft( + source=source, + offer=offer, + choices=NSFExportChoices.initial(source.program(), offer), + destination=source.proposed_directory(self._session_manager) + / get_filename(source.name, self._backend.extension(source.scope)), + ) + self._emit_view() + return True + + def _require_draft(self) -> NSFExportDraft: + """The export the open dialog sets up. + + Raises: + SystemError: when the setup is driven while its dialog is closed. + """ + if self._draft is None: + raise SystemError("An NSF export is set up only while its dialog is open") + + return self._draft + + def _emit_view(self) -> None: + self.call(self.on_view_changed, self._require_draft().view()) diff --git a/src/sampletones_application/logic/export/nsf/protocol.py b/src/sampletones_application/logic/export/nsf/protocol.py new file mode 100644 index 000000000..53fab62f7 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/protocol.py @@ -0,0 +1,37 @@ +from pathlib import Path +from typing import Protocol + +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import ProjectExport, SampleExport +from sampletones_core.exports.scope import ExportScope +from sampletones_player.export.program import NSFProgram + + +class NSFExportServiceProtocol(Protocol): + """The slice of the export service an NSF export hands its run to. + + Typing the collaborator structurally keeps the logic layer independent of the service + implementation; the composition root supplies the real service. + """ + + def export_sample( + self, + destination: Path, + backend: ExportBackend, + request: SampleExport, + ) -> None: ... + + def export_project( + self, + destination: Path, + backend: ExportBackend, + request: ProjectExport, + ) -> None: ... + + +class NSFProgramBackend(Protocol): + """The backend writing console programs, which binds a run to the program a setup chose.""" + + def choosing(self, program: NSFProgram) -> ExportBackend: ... + + def extension(self, scope: ExportScope) -> str: ... diff --git a/src/sampletones_application/logic/export/nsf/source/__init__.py b/src/sampletones_application/logic/export/nsf/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/export/nsf/source/project.py b/src/sampletones_application/logic/export/nsf/source/project.py new file mode 100644 index 000000000..42636febc --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/source/project.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import AbstractSet + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.protocol import NSFExportServiceProtocol +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import ProjectExport +from sampletones_core.exports.scope import ExportScope +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.compression.scheme import offered_schemes +from sampletones_player.export.program import NSFProgram +from sampletones_shared.utils.system.paths import get_directory + + +@dataclass(frozen=True) +class ProjectSource: + """The open project, written as a program playing its whole song. + + Attributes: + request: The project the export writes. + name: The name the project is known by, which the proposed file carries. + """ + + request: ProjectExport + name: str + + @property + def scope(self) -> ExportScope: + return ExportScope.PROJECT + + @property + def nes_frequency(self) -> int: + return self.request.project.settings.nes_frequency + + def offer(self) -> NSFExportOffer: + """Every channel, the frames of the order, and every scheme, the samples seeding the dictionary.""" + return NSFExportOffer( + channels=tuple(ChannelName.items()), + frame_count=self.request.project.song.order_length(), + schemes=offered_schemes(seeded=True), + ) + + def program(self) -> NSFProgram: + return NSFProgram.for_project(self.request.project) + + def ticks(self, channels: AbstractSet[ChannelName]) -> int: # pylint: disable=unused-argument + """The ticks the whole order plays for, which every channel shares.""" + project = self.request.project + return SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) + + def frame_tick(self, frame: int) -> int: + """The tick order frame ``frame`` starts on, under the timing the song plays at.""" + return SongTiming.from_project(self.request.project, bounds=SONG_TICK_BOUNDS).frame_tick(frame) + + def proposed_directory(self, session_manager: SessionManager) -> Path: + """The folder the project was last opened or saved in.""" + return get_directory(session_manager.get_project_path()) + + def remember_directory( # pylint: disable=unused-argument + self, + session_manager: SessionManager, + directory: Path, + ) -> None: + """Keeps the project's folder where opening and saving the project set it.""" + + def submit( + self, + service: NSFExportServiceProtocol, + destination: Path, + backend: ExportBackend, + ) -> None: + service.export_project(destination, backend, self.request) diff --git a/src/sampletones_application/logic/export/nsf/source/protocol.py b/src/sampletones_application/logic/export/nsf/source/protocol.py new file mode 100644 index 000000000..13c5bbfb2 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/source/protocol.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import AbstractSet, Protocol + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.protocol import NSFExportServiceProtocol +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.scope import ExportScope +from sampletones_player.export.program import NSFProgram + + +class NSFExportSource(Protocol): + """What an NSF export writes, answering every question the setup asks of it. + + A whole project and a reconstruction's slices reach a program through the same dialog, and + each states for itself what that dialog offers, what the export writes on its own, how long + the song runs, the tick each order frame starts on and which run of the service writes it. + """ + + @property + def scope(self) -> ExportScope: ... + + @property + def name(self) -> str: ... + + @property + def nes_frequency(self) -> int: ... + + def offer(self) -> NSFExportOffer: ... + + def program(self) -> NSFProgram: ... + + def ticks(self, channels: AbstractSet[ChannelName]) -> int: ... + + def frame_tick(self, frame: int) -> int: ... + + def proposed_directory(self, session_manager: SessionManager) -> Path: ... + + def remember_directory(self, session_manager: SessionManager, directory: Path) -> None: ... + + def submit( + self, + service: NSFExportServiceProtocol, + destination: Path, + backend: ExportBackend, + ) -> None: ... diff --git a/src/sampletones_application/logic/export/nsf/source/sample.py b/src/sampletones_application/logic/export/nsf/source/sample.py new file mode 100644 index 000000000..cd0d51a46 --- /dev/null +++ b/src/sampletones_application/logic/export/nsf/source/sample.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import AbstractSet, Final + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.export.nsf.protocol import NSFExportServiceProtocol +from sampletones_application.view_model.shared.nsf.offer import NO_FRAMES, NSFExportOffer +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import SampleExport +from sampletones_core.exports.scope import ExportScope +from sampletones_player.compression.scheme import offered_schemes +from sampletones_player.export.program import NSFProgram + +NO_TICKS: Final[int] = 0 + + +@dataclass(frozen=True) +class SampleSource: + """A reconstruction's slices, written as a program sounding them together once through. + + Attributes: + request: The slices the export writes. + """ + + request: SampleExport + + @property + def scope(self) -> ExportScope: + return ExportScope.SAMPLE + + @property + def name(self) -> str: + return self.request.name + + @property + def nes_frequency(self) -> int: + return self.request.nes_frequency + + def offer(self) -> NSFExportOffer: + """The channels the slices sound on, and the schemes that write slices seeding no dictionary.""" + sounded = {instrument.channel for instrument in self.request.instruments} + return NSFExportOffer( + channels=tuple(channel for channel in ChannelName.items() if channel in sounded), + frame_count=NO_FRAMES, + schemes=offered_schemes(seeded=False), + ) + + def program(self) -> NSFProgram: + return NSFProgram.for_sample(self.request) + + def ticks(self, channels: AbstractSet[ChannelName]) -> int: + """The ticks the longest slice on ``channels`` runs for, which is how long the song lasts.""" + return max( + ( + instrument.features.frame_count + for instrument in self.request.instruments + if instrument.channel in channels + ), + default=NO_TICKS, + ) + + def frame_tick(self, frame: int) -> int: + """The tick order frame ``frame`` starts on. + + Raises: + ValueError: Always, since a reconstruction plays its slices through once, laid out in + no order frames. + """ + raise ValueError(f"A reconstruction is laid out in no order frames, and frame {frame} was asked for") + + def proposed_directory(self, session_manager: SessionManager) -> Path: + """The folder the last instrument was exported to.""" + return session_manager.get_instrument_path() + + def remember_directory(self, session_manager: SessionManager, directory: Path) -> None: + """Opens the next instrument export where this one was written.""" + session_manager.set_instrument_path(directory) + + def submit( + self, + service: NSFExportServiceProtocol, + destination: Path, + backend: ExportBackend, + ) -> None: + service.export_sample(destination, backend, self.request) diff --git a/src/sampletones_application/logic/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..55bba7f44 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -1,12 +1,25 @@ -import hashlib -from typing import Callable, Dict, Iterable, List, Tuple +from typing import Callable, Dict, Final, Iterable, List, Tuple + +from pydantic import BaseModel 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 +from sampletones_shared.utils.hashing import identity_digest +from sampletones_shared.utils.serialization import dump + +FINGERPRINT_LENGTH: Final[int] = 64 ReconstructionHash = Callable[[Reconstruction], str] +def _canonical(model: BaseModel) -> str: + """The model spelled as JSON with its keys sorted, one spelling for one state.""" + return dump(model.model_dump(mode="json")) + + def fingerprint_project( project: Project, *, @@ -14,25 +27,29 @@ def fingerprint_project( ) -> str: """Returns a content hash used to verify that a restore reproduces a snapshot. - The hash covers the full project state; each sample's reconstruction content - enters through ``reconstruction_hash``, so the caller decides between a - memoized digest (capture, where copy-on-write keeps it valid) and a fresh one + The hash covers the full project state, spelled out part by part so that two projects + differing anywhere in it differ here. Each part is hashed with its keys sorted, so a mapping + holding the same entries in another order reads as the same state. Each sample's + reconstruction content enters through ``reconstruction_hash``, so the caller decides between + a memoized digest (capture, where copy-on-write keeps it valid) and a fresh one (verification, where recomputing from scratch catches any divergence). """ parts: List[str] = [ - project.metadata.model_dump_json(), - project.info.model_dump_json(), - project.settings.model_dump_json(), - project.song.model_dump_json(), + _canonical(project.metadata), + _canonical(project.info), + _canonical(project.settings), + _canonical(project.song), ] - 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(_canonical(voice)) - combined = "|".join(parts) - return hashlib.sha256(combined.encode("utf-8")).hexdigest() + return identity_digest(*parts, length=FINGERPRINT_LENGTH) class ReconstructionHashCache: @@ -62,5 +79,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..64dec819c 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -3,11 +3,10 @@ from typing import Iterator, List, Optional, Tuple from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.view_model.shared.history import HistoryDetail 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.hashing import hash_model from .action import HistoryAction from .errors import HistoryIntegrityError, UntrackedMutationError @@ -72,13 +71,14 @@ def can_redo(self) -> bool: @property def is_restoring(self) -> bool: - """Whether an undo, redo, or jump is reinstalling a snapshot right now. + """Whether an undo, redo, jump or rollback is reinstalling a snapshot right now. Every project transition reaches its handlers through the controller's single - ``on_project_replaced`` signal, so a handler that keeps transient session state — a - listening mute set, an acknowledged prompt — reads this to recognise history - navigation and carry that state across it, while a new, opened, or closed document - starts it fresh. + ``on_project_replaced`` signal, which the composition root fans out to each tab. A + handler that keeps session state across history navigation reads this to recognize + it: the sequencer carries the listening mute set across it, and the Reconstructions + tab keeps the voice it shows and redraws it as restored, while a new, opened, or + closed document starts both fresh. """ return self._restoring @@ -128,22 +128,45 @@ def transaction( """Groups every mutation of one user gesture into a single history entry. Nested scopes coalesce into the outermost transaction, and only a gesture - that changes the project records an entry. The commit runs on scope exit even when - the gesture raises: the mutations that already landed are part of the live - project, so recording them preserves the invariant that the live project - equals a restoration of ``entries[cursor]``. + that changes the project records an entry. A gesture lands whole or not at + all: one whose outermost scope ends by an exception reinstalls + ``entries[cursor]``, the state it started from, records nothing, and lets the + exception go on. The live project therefore equals a restoration of + ``entries[cursor]`` whichever way the gesture ends. ``coalesce`` names the gesture's target. Consecutive commits that share the same action and target replace the previous entry, so a continuous interaction — a graph drag, repeated edits of one cell — records a single entry whose undo restores the state before - the first gesture of the run. Any undo, redo, or jump breaks the run. + the first gesture of the run. Any undo, redo, or jump breaks the run, and a + rollback leaves it going, since the entry it continues stays as it was. + + What the gesture does outside the project waits on its fate through + :meth:`after_landing`. """ self._begin(action, detail, coalesce) + completed = False try: yield + completed = True finally: - self._end() + self._end(completed=completed) + + def after_landing(self, effect: VoidCallback) -> None: + """Runs ``effect`` once the gesture in progress has landed, and at once between gestures. + + A gesture reaches beyond the project as well: a cut fills the clipboard, and a removal + closes the voice the Reconstructions tab shows. A rollback reinstalls the project alone, so + an effect outside it waits for the outermost scope. A gesture that completes runs its + effects once its entry is recorded, in the order they were handed in, and one that ends by + an exception drops them. An undo, a redo, a jump and the reinstall of a rollback run between + gestures, so an effect they give rise to runs at once. + """ + if self._pending is None: + effect() + return + + self._pending.effects.append(effect) def handle_mutation(self) -> None: if self._restoring: @@ -198,7 +221,12 @@ def _begin( self._pending.depth += 1 - def _end(self) -> None: + def _end(self, *, completed: bool) -> None: + """Closes one scope; the outermost lands a completed gesture and rolls back a failed one. + + A failed gesture's effects go with it. A scope a reset ended midway has nothing left to + close: the reset seeded the stack from the project the gesture left and let its effects go. + """ if self._pending is None: return @@ -208,6 +236,13 @@ def _end(self) -> None: pending = self._pending self._pending = None + if completed: + self._land(pending) + elif pending.mutations > 0: + self._roll_back() + + def _land(self, pending: PendingTransaction) -> None: + """Records what a completed gesture changed, then runs what it does outside the project.""" if pending.mutations > 0: self._commit( pending.action, @@ -215,6 +250,9 @@ def _end(self) -> None: coalesce=pending.coalesce, ) + for effect in pending.effects: + effect() + def _commit( self, action: HistoryAction, @@ -282,7 +320,7 @@ def _capture( else None ) return HistoryEntry( - project=snapshot_project(project), + project=project.snapshot(), action=action, created=datetime.now(UTC), detail=detail, @@ -299,12 +337,29 @@ def _enforce_budget(self) -> None: self._saved_cursor = shifted if shifted >= 0 else None def _restore(self) -> None: - entry = self._entries[self._cursor] self._last_commit_key = None + self._reinstall(self._entries[self._cursor]) + + def _roll_back(self) -> None: + """Reinstalls the state a failed gesture started from, which the cursor still stands at. + + A closed project holds no state to return to. + """ + if self._cursor < 0: + return + + self._reinstall(self._entries[self._cursor]) + + def _reinstall(self, entry: HistoryEntry) -> None: + """Installs a fresh copy of ``entry`` as the live project and checks it against its fingerprint. + + Every handler of the replacement reads :attr:`is_restoring` while it runs, so a tab keeps + what a history step keeps. + """ self._restoring = True try: self._controller.replace_project( - snapshot_project(entry.project), + entry.project.snapshot(), clean=self._cursor == self._saved_cursor, ) finally: diff --git a/src/sampletones_application/logic/history/transaction.py b/src/sampletones_application/logic/history/transaction.py index 5400fd892..5d0e839bd 100644 --- a/src/sampletones_application/logic/history/transaction.py +++ b/src/sampletones_application/logic/history/transaction.py @@ -1,7 +1,8 @@ -from dataclasses import dataclass -from typing import Optional, Tuple, Union +from dataclasses import dataclass, field +from typing import List, Optional, Tuple, Union from sampletones_application.view_model.shared.history import HistoryDetail +from sampletones_shared.types.callback import VoidCallback from .action import HistoryAction @@ -14,9 +15,10 @@ class PendingTransaction: Bundles the gesture being recorded (``action`` and its ``detail`` segments), the ``coalesce`` key naming the gesture's target, the nesting ``depth`` of - coalesced ``transaction()`` scopes, and the count of fine-grained - ``mutations`` absorbed so far. Keeping these together holds the label, - detail, target, and mutation tally of one gesture in lockstep. The presence + coalesced ``transaction()`` scopes, the count of fine-grained ``mutations`` + absorbed so far, and the ``effects`` outside the project waiting for the + gesture to land. Keeping these together holds the label, detail, target, + mutation tally and effects of one gesture in lockstep. The presence of a ``PendingTransaction`` instance is itself the signal that a transaction is open, and nesting a coalesced scope increments its ``depth``. """ @@ -26,3 +28,4 @@ class PendingTransaction: coalesce: Optional[CoalesceKey] depth: int = 1 mutations: int = 0 + effects: List[VoidCallback] = field(default_factory=list) diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 23439f211..e6192536c 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -1,24 +1,29 @@ -import threading +from functools import partial from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Optional, Tuple +from sampletones_application.categories.estimate import time_estimation from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.instruction.library import ( LibraryPanelViewModel, ) -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.configs import InstructionsLibraryConfig +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 ( + InstructionLibraryData, InstructionLibraryKey, + LibraryState, get_display_name_from_key, ) from sampletones_core.library.filename.utils import create_key_from_filename @@ -43,10 +48,16 @@ from sampletones_shared.utils.system.filesystem import remove_path OnLoadInstructionCallback = Callable[[InstructionUnion], None] -OnApplyLibraryConfigCallback = Callable[[InstructionLibraryKey], None] +OnApplyLibraryConfigCallback = Callable[[InstructionLibraryKey, Optional[InstructionsLibraryConfig]], None] class LibraryLogic(CallbackMixin): + """The Instructions tab's library catalog, and the generation that writes a library into it. + + A generation reports from the creator's own threads, so each report is queued for the render + loop and taken there, where the catalog and the tree lock stand. + """ + def __init__( self, config_manager: ConfigManager, @@ -60,7 +71,7 @@ def __init__( self._library_manager = library_manager self._is_operation_active = is_operation_active self._eta_estimator: Optional[ETAEstimator] = None - self._status_lock = threading.Lock() + self._replaced_library_path: Optional[Path] = None self._lock_function: Optional[VoidCallback] = None self._unlock_function: Optional[VoidCallback] = None @@ -73,19 +84,20 @@ 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_library_outdated: Optional[Callable[[InstructionLibraryKey], None]] = None self.on_load_error: Optional[Callable[[Exception, str], None]] = None self._msg_window_not_available = language_manager["instructions.library.message.status_window_not_available"] self._msg_load_error = language_manager["instructions.library.message.status_load_error"] self._library_manager.set_callbacks( - on_generation_start=self._on_generation_start, - 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_start=partial(CallbackQueue.add, self._on_generation_start), + on_generation_progress=partial(CallbackQueue.add, self._on_generation_progress), + on_generation_completed=partial(CallbackQueue.add, self._on_generation_completed), + on_generation_error=partial(CallbackQueue.add, self._on_generation_error), + on_generation_canceled=partial(CallbackQueue.add, self._on_generation_canceled), ) def configure_lock( @@ -117,10 +129,6 @@ def _is_locked(self) -> bool: def tree(self) -> Tree: return self._library_manager.tree - @property - def config_key(self) -> InstructionLibraryKey: - return self._config_manager.key - @property def current_library_key(self) -> Optional[InstructionLibraryKey]: return self._library_manager.current_library_key @@ -128,14 +136,9 @@ def current_library_key(self) -> Optional[InstructionLibraryKey]: def is_library_generating(self) -> bool: return self._library_manager.is_generating() - def library_available_for_config(self) -> bool: - return self._library_manager.is_library_available_for_config() - - def is_library_loaded(self, key: InstructionLibraryKey) -> bool: - return self._library_manager.is_library_loaded(key) - - def library_exists_for_key(self, key: InstructionLibraryKey) -> bool: - return self._library_manager.library_exists_for_key(key) + def config_library_state(self) -> LibraryState: + """Where the library the configuration names stands for this build.""" + return self._library_manager.library_state(self._config_manager.key) def get_path(self, key: InstructionLibraryKey) -> Path: return self._library_manager.get_path(key) @@ -144,11 +147,55 @@ def rebuild_tree(self) -> None: self._library_manager.rebuild_tree() def refresh_libraries(self, load_if_needed: bool = True) -> None: + """Reads the catalog of the configuration's library directory and asks for its tree. + + A directory the reader comes back to loads again the library it had loaded when the reader + left it, through :meth:`reload_remembered_library`. + """ self._library_manager.set_library_directory(self._config_manager.get_library_directory()) self._library_manager.gather_available_libraries() self._sync_with_config_key(load_if_needed=load_if_needed) + self.reload_remembered_library() self.call(self.on_rebuild_tree_needed) + def follow_config(self) -> None: + """Follows a configuration change, reading the catalog afresh where the change names another + library directory and repainting the status otherwise. + + Another spelling of the folder the catalog stands at, a link to it included, names the same + folder, so the catalog keeps what it holds and takes that spelling for the paths it shows. A + generation writes into the catalog it was started in, so the catalog follows a change at + once, whatever is running; the tree it lists is drawn once the generation lets its lock go. + """ + if self._library_manager.set_library_directory(self._config_manager.get_library_directory()): + self.refresh_libraries(load_if_needed=False) + return + + self.update_status() + + def reload_remembered_library(self) -> None: + """Loads again the library a directory had loaded when the reader left it, once the reader is + back and the tree stands free. + + The library opens the way a Load opens it, so a file removed meanwhile is reported missing, a + file another version wrote asks for a rebuild, and a file that fails to load is reported. A + library that stays unloaded stops being the directory's choice. The settings stay as the + reader left them. The library loads only while it is still the directory's choice. A + generation or a rebuild holding the tree's lock keeps the load waiting, and the coordinator + calls this again once the lock is let go. + """ + if self._is_locked: + return + + key = self._library_manager.take_released_library() + if key is None or self.current_library_key != key or self._library_manager.is_library_loaded(key): + return + + if self._load_readable_library(key) is None: + self._library_manager.clear_current_library() + + self.update_status() + def remove_library(self, library_key: InstructionLibraryKey) -> Path: filepath = self._library_manager.get_path(library_key) remove_path(filepath) @@ -183,13 +230,12 @@ def load_library_file(self, filepath: Path) -> None: return 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) @@ -218,6 +264,35 @@ def request_generation(self) -> None: self.generate_library() + def prepare_library(self) -> None: + """Generates the library a conversion under the configuration searches, unless the one + standing there is a library this build reads. + + A missing library is generated, and one built by another version is rebuilt in its place. + The configuration stays as the reader set it. + """ + if self.config_library_state() is not LibraryState.CURRENT: + self.generate_library() + + def rebuild_library(self, library_key: InstructionLibraryKey) -> None: + """Rebuilds the library ``library_key`` names, which another version built, for the settings + it was built for, the exclusive-operation gate permitting. + + Those settings become the configuration's before the generation starts, which writes the + library in the place of the one it replaces. Where this build names that library's file + differently, the replaced file is removed once the new one is written. + """ + if self._is_operation_active(): + logger.warning("A conversion or library generation is already in progress") + return + + self.call(self.on_apply_library_config, library_key, self._library_manager.stored_config(library_key)) + replaced = self._library_manager.get_path(library_key) + rebuilt = self._library_manager.get_path(self._config_manager.key) + self.generate_library() + if self._library_manager.is_generating() and replaced != rebuilt: + self._replaced_library_path = replaced + def generate_library(self) -> None: if self._library_manager.is_generating(): return @@ -231,6 +306,7 @@ def generate_library(self) -> None: self.call(self.on_load_error, exception, self._msg_window_not_available) return + self._do_lock() self._library_manager.generate_library(config, window) self._emit_view(self._language_manager["instructions.library.message.status_generating"]) @@ -238,44 +314,90 @@ def cancel_generation(self) -> None: self._library_manager.cancel_generation() def _sync_with_config_key(self, load_if_needed: bool = True) -> None: + """Takes up the library the configuration names where this build reads it, loading it + where ``load_if_needed`` asks, and repaints the status either way.""" config_key = self._config_manager.key matching_key = self._library_manager.sync_with_config_key(config_key) - if matching_key: - self._set_current_library( - matching_key, - load_if_needed=load_if_needed, - apply_config=False, - ) + if matching_key is not None and load_if_needed and not self._library_manager.is_library_loaded(matching_key): + self._load_library(matching_key) - def _set_current_library( - self, - library_key: InstructionLibraryKey, - load_if_needed: bool = True, - apply_config: bool = False, - ) -> None: - if load_if_needed and not self._library_manager.is_library_loaded(library_key): - self._load_library(library_key) + self.update_status() - if apply_config: - self.call(self.on_apply_library_config, library_key) + def load_library_and_set_current(self, library_key: InstructionLibraryKey) -> None: + """Opens the library ``library_key`` names and makes the settings it was built for the + configuration's. + + A library this build reads is loaded, and its settings are applied once it is. A library + another version built is put to the reader through ``on_library_outdated``, and a missing + one is reported. + """ + self._open_library(library_key) + + def load_library_generator(self, library_key: InstructionLibraryKey, generator_name: GeneratorName) -> None: + """Opens the library ``library_key`` names and shows the default instruction of + ``generator_name`` from it, once the library is loaded.""" + if self._open_library(library_key): + self.load_generator(generator_name) + + def _open_library(self, library_key: InstructionLibraryKey) -> bool: + """Opens the library ``library_key`` names, as :meth:`load_library_and_set_current` states. + + Returns: + bool: Whether the library is loaded. + """ + library_data = self._load_readable_library(library_key) + if library_data is not None: + self.call(self.on_apply_library_config, library_key, library_data.config) self.update_status() + return library_data is not None - def load_library_and_set_current(self, library_key: InstructionLibraryKey) -> None: - self._set_current_library( - library_key, - load_if_needed=True, - apply_config=True, + def _load_readable_library(self, library_key: InstructionLibraryKey) -> Optional[InstructionLibraryData]: + """Loads the library ``library_key`` names where this build reads it. + + A library another version built is put to the reader through ``on_library_outdated``, and a + missing one is reported. + + Returns: + Optional[InstructionLibraryData]: The library loaded, or ``None`` where the tree is locked + or the library stays unloaded. + """ + if self._is_locked: + return None + + match self._library_manager.library_state(library_key): + case LibraryState.OUTDATED: + self.call(self.on_library_outdated, library_key) + return None + case LibraryState.MISSING: + self._report_missing(library_key) + return None + + return self._load_library(library_key) + + def _report_missing(self, library_key: InstructionLibraryKey) -> None: + logger.warning(f"Library file not found for key {library_key}") + self.call( + self.on_load_file_not_found, + self._library_manager.get_path(library_key), + self._language_manager["instructions.library.message.status_file_not_found"], ) - def _load_library(self, library_key: InstructionLibraryKey) -> None: + def _load_library(self, library_key: InstructionLibraryKey) -> Optional[InstructionLibraryData]: + """Loads the library ``library_key`` names into the catalog as its current one. + + Returns: + Optional[InstructionLibraryData]: The library loaded, or ``None`` where the tree is + locked or the load failed, which is reported. + """ if self._is_locked: - return + return None self._do_lock() try: - self._library_manager.load_library(library_key) + library_data = self._library_manager.load_library(library_key) logger.info(f"Library loaded: {library_key}") + return library_data except FileNotFoundError as exception: logger.error_with_traceback( exception, @@ -356,8 +478,9 @@ def _load_library(self, library_key: InstructionLibraryKey) -> None: finally: self._do_unlock() + return None + def _on_generation_start(self) -> None: - self._do_lock() assert self._library_manager.creator is not None, "Library manager creator is not initialized" self._eta_estimator = ETAEstimator(self._library_manager.creator.total_instructions) self.call(self.on_generation_state_changed) @@ -367,16 +490,16 @@ def _on_generation_progress( task_status: TaskStatus, task_progress: TaskProgress, ) -> None: - with self._status_lock: - match task_status: - case TaskStatus.COMPLETED: - 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.RUNNING: - self._update_progress_state(task_progress) + """Paints a report of the generation under way.""" + match task_status: + case TaskStatus.COMPLETED: + 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.CANCELED: + self._emit_view(self._language_manager["instructions.library.message.status_generation_canceled"]) + case TaskStatus.RUNNING: + self._update_progress_state(task_progress) def _update_progress_state(self, task_progress: TaskProgress) -> None: creator = self._library_manager.creator @@ -384,51 +507,47 @@ def _update_progress_state(self, task_progress: TaskProgress) -> None: assert self._eta_estimator is not None, "ETA Estimator is not initialized" eta_seconds = self._eta_estimator.update(creator.completed_instructions) - eta_string = ETAEstimator.format_duration(eta_seconds) - status_text = self._language_manager["instructions.library.template.generation_progress_template"].format( creator.completed_instructions, creator.total_instructions, ) - if eta_string: - status_text += self._language_manager["global.dialog.template.time_estimation"].format( - eta_string=eta_string - ) - - self._emit_view(status_text, progress=task_progress.get_progress()) + self._emit_view( + status_text + time_estimation(self._language_manager, eta_seconds), + progress=task_progress.fraction, + ) def _on_generation_completed(self) -> None: + """Closes the generation, removes the file a rebuild replaced under another name, and reads + the catalog again, which lists the library it wrote.""" self.call(self.on_generation_completed) - self._finalize_generation() + self._close_generation() + self._remove_replaced_library() + self.refresh_libraries(load_if_needed=False) 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) - self._finalize_generation() + self._close_generation() + self._replaced_library_path = None + self.update_status() - def _finalize_generation(self) -> None: - try: - self._library_manager.cleanup_creator() - self._set_current_library( - self._config_manager.key, - load_if_needed=True, - apply_config=False, - ) - self.refresh_libraries() - finally: - self._do_unlock() - self.call(self.on_generation_state_changed) + def _on_generation_canceled(self) -> None: + self.call(self.on_generation_canceled) + self._close_generation() + self._replaced_library_path = None + self.update_status() - def _finalize_generation_error(self) -> None: - try: - self._library_manager.cleanup_creator() - self.update_status() - finally: - self._do_unlock() - self.call(self.on_generation_state_changed) + def _remove_replaced_library(self) -> None: + """Removes the file a rebuild replaced under another name, where it still stands.""" + replaced, self._replaced_library_path = self._replaced_library_path, None + if replaced is not None and replaced.is_file(): + remove_path(replaced) + + def _close_generation(self) -> None: + """Lets the creator go along with the tree lock the generation took when it was asked for, + which loading a library and rebuilding the tree both yield to.""" + self._library_manager.release_creator() + self._do_unlock() + self.call(self.on_generation_state_changed) def _emit_view( self, @@ -438,31 +557,17 @@ def _emit_view( ) -> None: """Builds and emits the panel view model from freshly computed values. - ``status_text`` of ``None`` renders the idle status derived from the manager state; - generation emits pass their status and progress explicitly. + ``status_text`` of ``None`` renders the idle status and the Generate label read from the + catalog. A generation's emits pass their status and progress explicitly, and its controls + replace the idle ones, Generate included. """ key = self._config_manager.key is_generating = self._library_manager.is_generating() + generate_button_label = self._language_manager["instructions.library.label.generate_library_button"] if status_text is None: - library_name = get_display_name_from_key(key) - if self._library_manager.is_library_loaded(key): - status_text = self._language_manager["instructions.library.template.library_loaded_template"].format( - library_name - ) - elif self._library_manager.library_exists_for_key(key): - status_text = self._language_manager["instructions.library.template.library_exists_template"].format( - library_name - ) - else: - status_text = self._language_manager[ - "instructions.library.template.library_not_exists_template" - ].format(library_name) - - if self._library_manager.is_library_loaded(key): - generate_button_label = self._language_manager["instructions.library.label.regenerate_library_button"] - else: - generate_button_label = self._language_manager["instructions.library.label.generate_library_button"] + status_template, generate_button_label = self._idle_texts(key) + status_text = status_template.format(get_display_name_from_key(key)) view_model = LibraryPanelViewModel( status_text=status_text, @@ -471,3 +576,29 @@ def _emit_view( progress_value=progress, ) self.call(self.on_view_changed, view_model) + + def _idle_texts(self, key: InstructionLibraryKey) -> Tuple[str, str]: + """The status template and the Generate label for the library ``key`` names while no + generation runs.""" + if self._library_manager.is_library_loaded(key): + return ( + self._language_manager["instructions.library.template.library_loaded_template"], + self._language_manager["instructions.library.label.regenerate_library_button"], + ) + + match self._library_manager.library_state(key): + case LibraryState.CURRENT: + return ( + self._language_manager["instructions.library.template.library_exists_template"], + self._language_manager["instructions.library.label.generate_library_button"], + ) + case LibraryState.OUTDATED: + return ( + self._language_manager["instructions.library.template.library_outdated_template"], + self._language_manager["instructions.library.label.rebuild_library_button"], + ) + case LibraryState.MISSING: + return ( + self._language_manager["instructions.library.template.library_not_exists_template"], + self._language_manager["instructions.library.label.generate_library_button"], + ) diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index def2e3973..00e849b47 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -1,19 +1,26 @@ +import threading +from dataclasses import dataclass +from functools import partial from pathlib import Path from typing import Callable, Dict, Optional, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.instruction.readiness import LibraryReadiness 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.configs import Config, InstructionsLibraryConfig +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 ( InstructionLibrary, InstructionLibraryData, InstructionLibraryKey, + LibraryHeader, + LibraryState, create_key_from_filename, get_display_name_from_key, + library_state, ) from sampletones_core.library.creator import InstructionsLibraryCreator from sampletones_core.library.filename.fields import InstructionsFilenameFields @@ -25,7 +32,6 @@ Tree, TreeNode, ) -from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -35,6 +41,41 @@ OnGenerationErrorCallback = Callable[[Exception], None] +@dataclass +class _Catalog: + """The libraries one directory holds in memory, and the one taken up as current there. + + Only the directory the catalog stands at holds libraries in memory. Every directory keeps its + choice of current library. A directory left keeps, as ``released_key``, the current library it + had loaded, which the reader coming back gets loaded again. + """ + + library: InstructionLibrary + current_key: Optional[InstructionLibraryKey] + released_key: Optional[InstructionLibraryKey] = None + + def name_by(self, directory: Path) -> None: + """Names the folder by ``directory``, the spelling the reader configured last, keeping what the + folder holds. + + Every spelling names one folder, so a generation writing through the spelling it read first + lands in the same place. + """ + spelling = str(directory) + if self.library.directory != spelling: + self.library = self.library.model_copy(update={"directory": spelling}) + + def release(self) -> None: + """Lets go of every library loaded here, remembering the current one where it was loaded. + + A release still waiting to be taken stays until a loaded current library replaces it. + """ + if self.current_key is not None and self.current_key in self.library.data: + self.released_key = self.current_key + + self.library.data.clear() + + class InstructionsLibraryManager(CallbackMixin): def __init__( self, @@ -44,10 +85,10 @@ def __init__( ) -> None: self._language_manager = language_manager self._config_manager = config_manager - library_directory = config_manager.get_library_directory() - self._library = InstructionLibrary(directory=str(library_directory)) - self._library_files: Dict[InstructionLibraryKey, str] = {} - self._current_library_key: Optional[InstructionLibraryKey] = None + self._catalogs: Dict[Path, _Catalog] = {} + self._catalog_lock = threading.Lock() + self._catalog = self._catalog_at(config_manager.get_library_directory()) + self._listed_libraries: Dict[InstructionLibraryKey, bool] = {} self._tree = Tree() self._creator: Optional[InstructionsLibraryCreator] = None @@ -57,98 +98,113 @@ 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 - - def set_library_directory(self, directory: Path) -> None: - self._library = InstructionLibrary(directory=str(directory)) - self.gather_available_libraries() - - def gather_available_libraries(self) -> Dict[InstructionLibraryKey, str]: - library_directory = to_path(self._library.directory) - if not library_directory.exists(): - self._library_files.clear() - self.rebuild_tree() - return {} - - new_library_files = {} - for filepath in library_directory.iterdir(): - if filepath.is_file() and filepath.suffix == EXT_FILE_LIBRARY and self._is_library_file(filepath.stem): - library_key = create_key_from_filename(filepath) - new_library_files[library_key] = filepath.stem - - removed_libraries = set(self._library_files.keys()) - set(new_library_files.keys()) - for removed_key in removed_libraries: - if removed_key in self._library.data: - del self._library.data[removed_key] - - self._library_files = new_library_files - return self._library_files - - def get_library_key( - self, - library_key: Optional[InstructionLibraryKey] = None, - ) -> Optional[InstructionLibraryKey]: - if library_key is None: - if self._current_library_key is None: - return None + self.on_generation_canceled: Optional[VoidCallback] = None - library_key = self._current_library_key + @property + def library_directory(self) -> Path: + return to_path(self._catalog.library.directory) - return library_key + def set_library_directory(self, directory: Path) -> bool: + """Roots the catalog at ``directory``, and answers whether that moved it to another folder. - def is_library_loaded( - self, - library_key: Optional[InstructionLibraryKey] = None, - ) -> bool: - library_key = self.get_library_key(library_key) - if not self.does_library_exist(library_key): - return False + The folder left lets go of the libraries it loaded and keeps the one it had taken up as + current. Where that library was loaded, the folder remembers it, and a reader coming back + gets it from :meth:`take_released_library` to load again. Every spelling of one folder, a + link to it included, names the same catalog, and the catalog goes by the spelling given last. + """ + catalog = self._catalog_at(directory) + with self._catalog_lock: + catalog.name_by(to_path(directory)) + if catalog is self._catalog: + return False - return library_key in self._library.data + self._catalog.release() + self._catalog = catalog - def does_library_exist( - self, - library_key: Optional[InstructionLibraryKey] = None, - ) -> bool: - library_key = self.get_library_key(library_key) - if library_key is None: - return False + return True - filepath = self.get_path(library_key) - return filepath.exists() + def take_released_library(self) -> Optional[InstructionLibraryKey]: + """The library the folder the catalog stands at had loaded when the reader last left it, handed + over once.""" + with self._catalog_lock: + key = self._catalog.released_key + self._catalog.released_key = None + + return key + + def _catalog_at(self, directory: Path) -> _Catalog: + """The catalog of the folder ``directory`` names, started empty under that spelling the first time + the folder is read.""" + spelling = to_path(directory) + root = spelling.resolve() + catalog = self._catalogs.get(root) + if catalog is None: + catalog = _Catalog( + library=InstructionLibrary(directory=str(spelling)), + current_key=None, + ) + self._catalogs[root] = catalog - def load_library(self, library_key: InstructionLibraryKey) -> bool: - if self.is_library_loaded(library_key): - self._current_library_key = library_key - return True + return catalog - if library_key not in self._library_files: - return False + def gather_available_libraries(self) -> None: + """Lists the library files standing in the catalog's directory, marking each one another + version built, and lets go of the libraries whose files are gone.""" + library_directory = self.library_directory + listed: Dict[InstructionLibraryKey, bool] = {} + if library_directory.exists(): + for filepath in library_directory.iterdir(): + if filepath.is_file() and filepath.suffix == EXT_FILE_LIBRARY and self._is_library_file(filepath.stem): + listed[create_key_from_filename(filepath)] = library_state(filepath) is LibraryState.OUTDATED - self._library.load_data(library_key) - self._current_library_key = library_key - return True + loaded = self._catalog.library.data + for removed_key in set(loaded) - set(listed): + del loaded[removed_key] - def load_library_file(self, path: Path) -> InstructionLibraryKey: - logger.info(f"Loading library data: {logger.format_path(path)}") - library_key = create_key_from_filename(path) - self._library.load_data(library_key) - self._current_library_key = library_key - logger.info(f"Library data: {logger.format_path(path)} loaded successfully") - return library_key + self._listed_libraries = listed + + def is_library_loaded(self, library_key: InstructionLibraryKey) -> bool: + """Whether the catalog holds the library ``library_key`` names in memory. + + A load takes only a library this build reads, so a library held here is one to use as it + stands. + """ + return library_key in self._catalog.library.data + + def library_state(self, library_key: InstructionLibraryKey) -> LibraryState: + """Where the library ``library_key`` names stands in the catalog for this build.""" + return self._catalog.library.state(library_key) + + def stored_config(self, library_key: InstructionLibraryKey) -> Optional[InstructionsLibraryConfig]: + """The settings the library ``library_key`` names states it was built for, where its file + states them in a form this build reads.""" + try: + return LibraryHeader.read(self.get_path(library_key)).config + except FileNotFoundError: + return None + + def load_library(self, library_key: InstructionLibraryKey) -> InstructionLibraryData: + """Takes up the library ``library_key`` names as the current one, reading it from its file + where the catalog holds it in no memory yet.""" + if not self.is_library_loaded(library_key): + self._catalog.library.load_data(library_key) + + self._catalog.current_key = library_key + return self._catalog.library.data[library_key] def load_instruction( self, instruction: InstructionUnion, ) -> Optional[InstructionPanelData]: - if not self._current_library_key or not self.is_library_loaded(self._current_library_key): + current_key = self._catalog.current_key + if not current_key or not self.is_library_loaded(current_key): return None - data = self._library.data[self._current_library_key] + data = self._catalog.library.data[current_key] fragment = data[instruction] library_config = data.config instruction_data = InstructionPanelData( - library_key=self._current_library_key, + library_key=current_key, instruction=instruction, config=library_config, fragment=fragment, @@ -157,25 +213,43 @@ def load_instruction( return instruction_data def get_path(self, library_key: InstructionLibraryKey) -> Path: - return self._library.get_path(library_key) + return self._catalog.library.get_path(library_key) def sync_with_config_key( self, config_key: InstructionLibraryKey, ) -> Optional[InstructionLibraryKey]: - if self.library_exists_for_key(config_key): - self._current_library_key = config_key + if self.library_state(config_key) is LibraryState.CURRENT: + self._catalog.current_key = config_key return config_key return None - def library_exists_for_key(self, key: InstructionLibraryKey) -> bool: - return self._library.exists(key) + def library_readiness( + self, + directory: Path, + key: InstructionLibraryKey, + ) -> LibraryReadiness: + """Where the library ``key`` names under ``directory`` stands for a conversion waiting on it. + + A generation writes its file while it runs, so the file counts only once the generation has + been released. A library this build reads is then ready, and any other is one the conversion + will not get. + """ + if self.is_generating(): + return LibraryReadiness.PREPARING + + if library_state(directory / key.filename) is LibraryState.CURRENT: + return LibraryReadiness.READY - def is_library_available_for_config(self) -> bool: - return self.library_exists_for_key(self._config_manager.key) + return LibraryReadiness.MISSING def generate_library(self, config: Config, window: Window) -> None: + """Starts a generation that writes into the catalog standing at the moment it is asked for. + + The library directory may move while the generation runs; the library still lands where the + generation was started, which is where the configuration it was started from points. + """ self._creator = InstructionsLibraryCreator(config, window) _primary = self.on_generation_progress @@ -189,9 +263,9 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: self._creator.set_callbacks( on_start=self.on_generation_start, - on_completed=self._complete_generation, + on_completed=partial(self._complete_generation, self._catalog), on_error=self.on_generation_error, - on_cancelled=self.on_generation_cancelled, + on_canceled=self.on_generation_canceled, on_progress=_on_progress, ) @@ -199,24 +273,38 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: def _complete_generation( self, + catalog: _Catalog, result: Tuple[InstructionLibraryKey, InstructionLibraryData], ) -> None: + """Writes the generated library into ``catalog``, the one the generation was started in, and + makes it that catalog's current library. + + The library stays in memory only where the catalog still stands there. A catalog left holds + the file and the choice, and remembers the library as loaded, so the reader coming back + loads it from the file. + """ key, library_data = result try: - self._library.save_data(key, library_data) - self._current_library_key = key + catalog.library.write_data(key, library_data) except OSError as exception: self.call(self.on_generation_error, exception) raise + with self._catalog_lock: + catalog.current_key = key + if catalog is self._catalog: + catalog.library.data[key] = library_data + else: + catalog.released_key = key + self.call(self.on_generation_completed) def is_generating(self) -> bool: - """A generation is in progress from the moment a creator is started until it is cleaned up. + """A generation is in progress from the moment a creator is started until it is released. - Creator presence is the source of truth: it spans the saving and finalising step that runs - after the worker thread clears its own ``is_running`` flag, so the generation reads as in - progress right up to cleanup. + Creator presence is the source of truth: it spans the saving step that runs after the + workers have ended and the release that follows the announcement, so the generation reads + as in progress right up to the moment nothing of it is left running. """ return self._creator is not None @@ -226,10 +314,11 @@ def tree(self) -> Tree: @property def current_library_key(self) -> Optional[InstructionLibraryKey]: - return self._current_library_key + """The library taken up as current in the catalog standing now.""" + return self._catalog.current_key def clear_current_library(self) -> None: - self._current_library_key = None + self._catalog.current_key = None @property def creator(self) -> Optional[InstructionsLibraryCreator]: @@ -239,18 +328,15 @@ def cancel_generation(self) -> None: if self._creator: self._creator.cancel() - def cleanup_creator(self) -> None: - if self._creator: - self._creator.cleanup() - self._creator = None - - def shutdown(self) -> None: - """Tears the library creator's process pool down synchronously for application exit. + def release_creator(self) -> None: + """Ends the creator's run and lets the creator go once its pool has ended. - 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 - stopped so the process reaps its workers before releasing shared resources.""" - if self._creator: + The generation reads as in progress until then, so nothing that waits on it — a conversion + preparing its library, a new generation, the application's exit — meets a worker still + alive. A creator that has announced its outcome has already ended its pool, so this returns + at once; at exit it cancels a generation still under way and waits for its workers. + """ + if self._creator is not None: self._creator.shutdown() self._creator = None @@ -268,14 +354,10 @@ def _is_library_file(self, filename: str) -> bool: return True - def _get_display_name(self, filename: str) -> str: - key = create_key_from_filename(filename) - return get_display_name_from_key(key) - def rebuild_tree(self) -> None: root = TreeNode(self._language_manager["instructions.library.label.libraries_node"], node_type=NodeType.ROOT) - for library_key in sorted(self._library_files.keys(), key=get_display_name_from_key): + for library_key in sorted(self._listed_libraries, key=get_display_name_from_key): self._build_library_node(library_key, root) self._tree.set_root(root) @@ -289,13 +371,15 @@ def _build_library_node( library_node = LibraryNode( display_name, library_key=library_key, + outdated=self._listed_libraries[library_key], parent=parent, ) - self._build_generator_nodes(library_node) + if not library_node.outdated: + self._build_generator_nodes(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/instruction/readiness.py b/src/sampletones_application/logic/instruction/readiness.py new file mode 100644 index 000000000..bbf7244cc --- /dev/null +++ b/src/sampletones_application/logic/instruction/readiness.py @@ -0,0 +1,9 @@ +from enum import Enum, auto + + +class LibraryReadiness(Enum): + """Where the library a conversion converts against stands while the conversion waits for it.""" + + PREPARING = auto() + READY = auto() + MISSING = auto() diff --git a/src/sampletones_application/logic/instruction/table.py b/src/sampletones_application/logic/instruction/table.py index 18808eea0..22ee8a5d9 100644 --- a/src/sampletones_application/logic/instruction/table.py +++ b/src/sampletones_application/logic/instruction/table.py @@ -8,7 +8,7 @@ ) from sampletones_core.constants.general import DUTY_CYCLES, NOISE_PERIODS from sampletones_core.utils.frequencies import pitch_to_name -from sampletones_shared.utils.serialization import hash_model +from sampletones_shared.utils.hashing import hash_model class InstructionTableLogic: diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py deleted file mode 100644 index edb7361e7..000000000 --- a/src/sampletones_application/logic/main/converter.py +++ /dev/null @@ -1,339 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Optional, Protocol - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior -from sampletones_application.services.result import ( - ConversionResult, - ServiceCancelled, - ServiceError, - ServiceIntermediate, - ServiceProgress, - ServiceStarted, - ServiceSuccess, -) -from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_application.utils.progress import SystemProgress -from sampletones_application.view_model.main.converter import ( - ACTIVE_PHASES, - ConversionPhase, - ConverterViewModel, -) -from sampletones_core.configs import Config -from sampletones_core.parallelization import ETAEstimator, TaskProgress -from sampletones_core.reconstructions.converter import get_output_path -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 - - -@dataclass(frozen=True) -class ConversionSuccess: - """The outcome a completed conversion hands to its listener. - - Carries what the follow-up load offer needs: whether a single file or a - directory was converted, and where its reconstruction was written.""" - - is_file: bool - output_path: Optional[Path] - - -class ConversionServiceProtocol(Protocol): - """The slice of the conversion service the converter logic drives. - - Typing the collaborator structurally keeps the logic layer bound to the - service's result contract alone; the composition root supplies the real - service. - """ - - def subscribe(self, handler: Callable[[ConversionResult], None]) -> None: ... - - def start(self, config: Config, input_path: Path) -> None: ... - - def cancel(self) -> None: ... - - def cleanup(self) -> None: ... - - def shutdown(self) -> None: ... - - def is_running(self) -> bool: ... - - -class ConverterLogic(CallbackMixin): - def __init__( - self, - config_manager: ConfigManager, - conversion_service: ConversionServiceProtocol, - *, - scheduling: SchedulingBehavior, - language_manager: LanguageManager, - is_operation_active: Callable[[], bool], - ) -> None: - self._language_manager = language_manager - self._config_manager = config_manager - self._service = conversion_service - self._scheduling = scheduling - 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._phase: ConversionPhase = ConversionPhase.IDLE - self._input_path: Optional[Path] = None - self._output_path: Optional[Path] = None - self._is_file: bool = True - self._system_progress = SystemProgress() - - self._service.subscribe(self._on_service_result) - - self.on_view_changed: Optional[Callable[[ConverterViewModel], None]] = None - self.on_success: Optional[Callable[[ConversionSuccess], None]] = None - 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_load_file: Optional[PathCallback] = None - self.on_load_directory: Optional[VoidCallback] = None - self.on_cancelled: 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 is_active(self) -> bool: - """A conversion is occupying resources from the moment of request (the WAITING phase, during - which the library is prepared and the run is scheduled) until it reaches a terminal phase.""" - return self._phase in ACTIVE_PHASES - - def emit_initial_view(self) -> None: - self._emit_view_model(self._msg_idle, 0.0) - - def refresh_view(self) -> None: - """Re-emits the idle view so the Convert button reflects whether another exclusive operation - is active. Only the idle phase carries the Convert button; the other phases disable it by - phase alone, so re-emitting them would add nothing.""" - if self._phase == ConversionPhase.IDLE: - self._emit_view_model(self._msg_idle, 0.0) - - def set_input_path(self, input_path: Path, convert: bool = False) -> None: - config = self._config_manager.config.model_copy() - if not self._assign_paths(input_path, config): - return - - if not self.is_active: - self._phase = ConversionPhase.IDLE - self._emit_view_model(self._msg_idle, 0.0) - - if convert: - self.start_conversion() - - def start_conversion(self) -> None: - 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: - self.call(self.on_no_generators) - return - - self._phase = ConversionPhase.WAITING - self._emit_view_model(self._language_manager["main.converter.message.status_waiting"], 0.0) - self.call(self.generate_library) - self._wait_for_library_and_start() - - def cancel(self) -> None: - if self._service.is_running(): - self._phase = ConversionPhase.CANCELLING - self._emit_view_model(self._msg_cancelling, 0.0) - self._system_progress.error() - self._service.cancel() - elif self._phase == ConversionPhase.WAITING: - self.call(self.cancel_library_generation) - self._on_cancellation_complete() - - def close(self) -> None: - try: - self._service.cleanup() - finally: - self._system_progress.clear() - 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) - else: - self.call(self.on_load_directory) - - self.close() - - def cleanup(self) -> None: - self._service.shutdown() - self._system_progress.clear() - - def _on_service_result(self, result: ConversionResult) -> None: - match result: - case ServiceStarted(total=total): - self._system_progress.start(total) - case ServiceProgress() as progress: - 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 ServiceError(exception=exception): - self._on_conversion_error(exception) - case ServiceCancelled(): - self._on_cancellation_complete() - - def _handle_progress_result(self, progress: ServiceProgress[Path]) -> None: - if self._phase == ConversionPhase.CANCELLING: - total = max(progress.total, 1) - self._emit_view_model( - self._msg_cancelling, - progress.completed / total, - ) - 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 - ) - - if eta_string: - status_text += self._language_manager["global.dialog.template.time_estimation"].format( - eta_string=eta_string - ) - - display_input_path = ( - to_path(str(progress.current_item)) if progress.current_item is not None else self._input_path - ) - self._emit_view_model( - status_text, - progress.completed / total, - input_path=display_input_path, - ) - - def _handle_library_progress(self, progress: TaskProgress) -> None: - if self._phase != ConversionPhase.WAITING: - return - - total = max(progress.total, 1) - fraction = progress.completed / total - self._emit_view_model(self._language_manager["main.converter.message.status_generating_library"], fraction) - - def _assign_paths(self, input_path: Path, config: Config) -> bool: - try: - self._output_path = get_output_path(config, input_path) - self._input_path = input_path - self._is_file = input_path.is_file() - except FileNotFoundError as exception: - logger.error("Input file does not exist") - self.call(self.on_error, exception) - return False - except OSError as exception: - logger.error("Invalid path") - self.call(self.on_error, exception) - return False - - return True - - def _wait_for_library_and_start(self) -> None: - if self._phase != ConversionPhase.WAITING: - return - - if not self.call(self.is_library_available): - CallbackQueue.add( - self._wait_for_library_and_start, - priority=self._scheduling.priorities.schedule, - delay=self._scheduling.delays.schedule, - ) - else: - self._start_conversion() - - 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) - - def _on_conversion_complete(self, output_path: Path) -> None: - if output_path.exists(): - self._output_path = output_path - - 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, - ), - ) - - def _on_conversion_error(self, exception: Exception) -> None: - self._system_progress.error() - self._phase = ConversionPhase.FAILED - self._emit_view_model(self._language_manager["main.converter.message.status_error"], 0.0) - self._schedule_return_to_idle() - if isinstance(exception, NoFilesToProcessError): - self.call(self.on_no_files_to_process) - else: - 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._schedule_return_to_idle() - self.call(self.on_cancelled) - - def _schedule_return_to_idle(self) -> None: - CallbackQueue.add( - self.close, - priority=self._scheduling.priorities.schedule, - delay=self._scheduling.delays.cancel, - ) - - 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.""" - if self._phase in ACTIVE_PHASES: - return self._language_manager["main.converter.label.cancel_button"] - - base = ( - self._language_manager["main.converter.label.convert_sample_button"] - if self._is_file - else self._language_manager["main.converter.label.convert_directory_button"] - ) - if input_path is None: - return base - - return self._language_manager["main.converter.template.convert_label_template"].format(base, input_path.name) - - def _emit_view_model( - self, - status_text: str, - progress: float, - input_path: Optional[Path] = None, - ) -> None: - display_output = ( - self._output_path if self._output_path is not None else self._config_manager.get_reconstructions_directory() - ) - display_input = input_path if input_path is not None else self._input_path - view_model = ConverterViewModel( - phase=self._phase, - status_text=status_text, - action_label=self._compose_action_label(display_input), - progress=progress, - input_path=display_input, - output_path=display_output, - is_file=self._is_file, - other_operation_active=self._is_operation_active(), - ) - self.call(self.on_view_changed, view_model) diff --git a/src/sampletones_application/logic/main/converter/__init__.py b/src/sampletones_application/logic/main/converter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/main/converter/derive.py b/src/sampletones_application/logic/main/converter/derive.py new file mode 100644 index 000000000..8ec6a5994 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/derive.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Sequence, Tuple + +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_core.constants.enums import 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 + + +@dataclass(frozen=True) +class ConversionSetup: + """What a mixed 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 + + +def derive_conversion_setup( + sources: SourceList, + levels: MixLevels, + *, + hierarchy_mode: HierarchyMode, +) -> ConversionSetup: + """Turns the levels a reader gathered into the recordings and the setup a conversion runs with. + + A recording left holding no channel takes no part: it reaches neither the mix nor the entries. + What remains is numbered in level order, which is the id the conversion records per frame and a + stem selection later reads back. + """ + playing = [_recordings_of(sources, level) for level in levels.levels] + ordered = [recording for level in playing for recording in level] + + entries = [StemEntry(id=stem_id, settings=recording.settings) for stem_id, recording in enumerate(ordered)] + return ConversionSetup( + sources=tuple(recording.path for recording in ordered), + stems=StemsConfig( + entries=tuple(entries), + hierarchy=_hierarchy(playing, hierarchy_mode), + ), + ) + + +def _recordings_of(sources: SourceList, level: Sequence[Path]) -> List[Recording]: + """The recordings of one level that hold a channel, which is what takes part in the mix.""" + playing = [] + for path in level: + recording = sources.recording(path) + if recording is not None and recording.settings.channels: + playing.append(recording) + + return playing + + +def _hierarchy( + playing: Sequence[Sequence[Recording]], + hierarchy_mode: HierarchyMode, +) -> StemsHierarchy: + levels: List[Tuple[int, ...]] = [] + stem_id = 0 + for level in playing: + if not level: + continue + + levels.append(tuple(stem_id + offset for offset in range(len(level)))) + stem_id += len(level) + + return StemsHierarchy(levels=tuple(levels), mode=hierarchy_mode) diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py new file mode 100644 index 000000000..47ff081f6 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Optional, Self + +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter import ConversionPlan + + +@dataclass(frozen=True) +class Destination: + """What a run converts and where the reconstruction it writes lands. + + The output path is the one the run's plan names as its destination, so the path the panel + shows and the path the run writes are one answer. + """ + + input_path: Optional[Path] + output_path: Optional[Path] + is_file: bool + + @classmethod + def unset(cls) -> Self: + """The destination a converter opens with, before a reader has picked anything.""" + return cls(input_path=None, output_path=None, is_file=True) + + def aimed_at(self, config: Config, plan: Optional[ConversionPlan]) -> Self: + """The destination the plan a run amounts to names. + + A run of one names the document it writes, which is what a reader converting a single file + is looking at; a larger one names the folder holding every reconstruction it writes. A run + with nobody taking part names nothing of its own, so the destination it last held stands + until a recording joins it. + """ + if plan is None: + return self + + return replace(self, output_path=plan.destination(config)) + + def named_after(self, sources: SourceList) -> Self: + """What a run names itself by, read from the sources gathered for it. + + A setup holding one row is that row: a recording names the document it makes, a folder + names the tree it mirrors. Several rows name none of them, so the run reads as what its + destination says instead. + """ + rows = sources.rows + if len(rows) != 1: + return replace(self, input_path=None, is_file=True) + + key = rows[0].key + return replace(self, input_path=key.path, is_file=not key.names_folder) + + def writing_to(self, output_path: Path) -> Self: + """The destination a completed run wrote, which is the document a reader would open.""" + return replace(self, output_path=output_path) diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py new file mode 100644 index 000000000..1a8ece25c --- /dev/null +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -0,0 +1,166 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import FrozenSet, Optional, Self, Tuple + +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import SettingsChange, SettingsSlot +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class Gathering: + """The sources a reader gathered, and the order the ones a mix converts pick in. + + The list holds whatever was gathered — recordings named one by one, folders standing for the + recordings below them — and says what each converts under. A run that writes one reconstruction + per recording converts the list as it stands, so the list is unbounded: a folder of thousands is + the case that run exists for. + + The levels are the mix's side. They name the recordings one reconstruction is built from, in + the order those recordings pick in, and there are only ever as many as a mix can hold. A mix + converts loose recordings alone, so turning to one flattens the folders standing in the list. + """ + + sources: SourceList + levels: MixLevels + + @classmethod + def empty(cls) -> Self: + """The setup a converter opens with, which a reader fills by gathering sources.""" + return cls(sources=SourceList(), levels=MixLevels()) + + @property + def count(self) -> int: + """How many recordings the list holds, folders counting for what they stand for.""" + return self.sources.count + + @property + def row_count(self) -> int: + """How many rows the list draws, a folder standing as one.""" + return self.sources.row_count + + @property + def ceiling(self) -> int: + """How many recordings one mix reaches, whatever the list holds.""" + return self.levels.ceiling + + @property + def room(self) -> int: + """How many more recordings the mix has room to take.""" + return self.levels.room + + @property + def fits_a_mix(self) -> bool: + """One mix has room for every recording gathered, so turning to one asks the reader nothing.""" + return self.count <= self.ceiling + + @property + def paths(self) -> Tuple[Path, ...]: + """Where every gathered recording stands, in the order the list holds it.""" + return self.sources.paths + + @property + def recordings(self) -> Tuple[Recording, ...]: + """Every gathered recording, in the order the list holds it.""" + return self.sources.recordings + + def recording(self, path: Path) -> Optional[Recording]: + """The gathered recording at ``path``, where the list holds one.""" + return self.sources.recording(path) + + def folder_root_of(self, path: Path) -> Optional[Path]: + """The folder a gathered recording was found below, where one stands for it.""" + return self.sources.folder_root_of(path) + + def listing(self, recording: Recording) -> Self: + """One more recording in the list, which a per-recording run converts as it stands.""" + return replace(self, sources=self.sources.add_recording(recording)) + + def listing_folder(self, folder: Folder) -> Self: + """One more folder in the list, standing for every recording gathered below it.""" + return replace(self, sources=self.sources.add_folder(folder)) + + def mixing(self, recording: Recording) -> Self: + """One more recording in the list and in the mix, where the mix has room for it. + + A mix reaches a fixed number of recordings, so a setup with no room left stands as it is; + a path already gathered keeps the settings and the place it has. + """ + if not self.room: + return self + + return replace( + self, + sources=self.sources.add_recording(recording), + levels=self.levels.add(recording.path), + ) + + def remove(self, key: SourceKey) -> Self: + """The setup without the row ``key`` names, which leaves the list and the mix alike.""" + sources = self.sources.remove(key) + standing = frozenset(sources.paths) + levels = self.levels + for path in self.levels.paths: + if path not in standing: + levels = levels.remove(path) + + return replace(self, sources=sources, levels=levels) + + def written( + self, + path: Path, + slot: SettingsSlot, + value: FrozenSet[ChannelName], + ) -> Self: + """The setup with one recording's slot settled to ``value``.""" + recording = self.recording(path) + if recording is None: + return self + + return replace(self, sources=self.sources.written(recording.key, slot, value)) + + def changed(self, key: SourceKey, change: SettingsChange) -> Self: + """The setup with ``change`` made to the settings of every recording ``key`` stands for.""" + return replace(self, sources=self.sources.changed(key, change)) + + def settled( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + held: bool, + ) -> Self: + """The setup with ``channel_name`` settled on every recording ``key`` stands for.""" + return replace(self, sources=self.sources.settled(key, slot, channel_name, held)) + + def toggled(self, key: SourceKey, slot: SettingsSlot, channel_name: ChannelName) -> Self: + """The setup one gesture on ``key`` leaves behind, settled the way the row reads.""" + return replace(self, sources=self.sources.toggled(key, slot, channel_name)) + + def with_levels(self, levels: MixLevels) -> Self: + """The setup as rewritten levels leave it, the recordings standing as they were.""" + return replace(self, levels=levels) + + def mixing_only(self, recordings: Tuple[Recording, ...]) -> Self: + """The setup a mix runs from: exactly these recordings, loose, in the order they are named. + + This is what answering for a mix leaves behind — the folders give up the recordings they + stood for, and what the reader did not name goes with them. A recording is handed in whole + rather than by path, so one the list already holds keeps what it was given and one joining + from a folder arrives under the settings a recording joins with. + """ + sources = SourceList() + levels = MixLevels() + for recording in recordings: + sources = sources.add_recording(recording) + levels = levels.add(recording.path) + + return replace(self, sources=sources, levels=levels) + + def unmixed(self) -> Self: + """The setup a per-recording run converts: the list as it stands, the mix let go.""" + return replace(self, levels=MixLevels()) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py new file mode 100644 index 000000000..e33dab690 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -0,0 +1,651 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional, Sequence, Tuple + +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.output import OutputKind +from sampletones_application.constants.sources import SettingsField, SourceKind +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.instruction.readiness import LibraryReadiness +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.messages import ConverterMessages +from sampletones_application.logic.main.converter.run import ( + ConversionRequest, + ConversionRun, + ConversionServiceProtocol, + ConversionSuccess, + RunReport, +) +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.setup import ( + conversion_plan, + playing_sources, +) +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.converter.view import ( + compose_view, + inspected_settings, + source_settings, + stem_rows, +) +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import ( + CHANNEL_SLOT, + SLOTS_BY_FIELD, + SettingsChange, + SettingsSlot, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.main.converter import ( + ConversionPhase, + ConverterViewModel, +) +from sampletones_application.view_model.main.source import SourceSettingsPanelViewModel +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.library import InstructionLibraryKey, LibraryState +from sampletones_core.reconstructions.converter import ConversionPlan +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.exceptions import NoFilesToProcessError +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import PathCallback, PathsCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +def _driven(settings: StemSettings, channel_name: ChannelName, drive: float) -> StemSettings: + """The settings pushing one channel with ``drive``, where the recording occupies it.""" + if channel_name not in settings.channel_set: + return settings + + return settings.with_drive(channel_name, drive) + + +class ConverterLogic(CallbackMixin): + """What the Main tab's converter offers, and the one place its parts are settled against. + + The setup a reader builds is one value (:class:`ConverterState`), and every gesture rewrites a + part of it and hands the whole back to ``_rewrite``, which follows it wherever it reaches: the + destination the run now names, and the view the panel draws. The run itself is held apart, so + a conversion under way reports where it stands without knowing what it was set up from. + """ + + def __init__( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + conversion_service: ConversionServiceProtocol, + *, + scheduling: SchedulingBehavior, + language_manager: LanguageManager, + is_operation_active: Callable[[], bool], + ) -> None: + self._config_manager = config_manager + self._session_manager = session_manager + self._scheduling = scheduling + self._is_operation_active = is_operation_active + self._messages = ConverterMessages(language_manager) + self._state = ConverterState( + settings=RunSettings( + joining=session_manager.converter_settings, + output=session_manager.converter_output, + hierarchy_mode=session_manager.converter_hierarchy_mode, + ), + gathering=Gathering.empty(), + destination=Destination.unset(), + selected=None, + ) + self._rows = self._read_rows() + + self._run = ConversionRun(conversion_service, messages=self._messages) + self._run.on_report = self._on_report + self._run.on_success = self._on_run_success + self._run.on_error = self._on_run_error + self._run.on_canceled = self._on_run_canceled + + self.on_view_changed: Optional[Callable[[ConverterViewModel], None]] = None + self.on_success: Optional[Callable[[ConversionSuccess], None]] = None + 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[PathsCallback] = None + self.on_load_file: Optional[PathCallback] = None + self.on_load_directory: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None + self.prepare_library: Optional[VoidCallback] = None + self.cancel_library_generation: Optional[VoidCallback] = None + self.library_readiness: Optional[Callable[[Path, InstructionLibraryKey], LibraryReadiness]] = None + self.library_state: Optional[Callable[[Path], LibraryState]] = None + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self._settings.mixes + + @property + def source_count(self) -> int: + """How many recordings the stems list holds.""" + return self._state.gathering.count + + @property + def room_for_sources(self) -> int: + """How many more recordings the mix has room for.""" + return self._state.gathering.room + + @property + def mix_ceiling(self) -> int: + """How many recordings one mix reaches, which is the room a reader picks one within.""" + return self._state.gathering.ceiling + + @property + def list_fits_a_mix(self) -> bool: + """One mix has room for the whole list, so turning to one takes it as it stands.""" + return self._state.gathering.fits_a_mix + + @property + def gathered_paths(self) -> Tuple[Path, ...]: + """Every gathered recording, which is what a run writing one apiece converts.""" + return self._state.gathering.paths + + @property + def gathered_rows(self) -> Tuple[StemRowViewModel, ...]: + """The gathered sources as one run of rows, which is what a reader picking a mix reads.""" + return stem_rows(self._state.gathering, mixes=False) + + def rows_offered(self, found: Sequence[Path]) -> Tuple[StemRowViewModel, ...]: + """The recordings among ``found`` the setup has yet to gather, as one row apiece. + + A mix reaches a fixed number of recordings, so a folder bringing in more than the room + left is put to a reader as the same question the output switch asks: which of these to mix. + The rows are what the folder offers, which the question stands beside the recordings the + mix is built from so that the reader chooses between the two. + """ + standing = frozenset(self.gathered_paths) + offered = tuple(self._gathered(path) for path in found if path not in standing) + return stem_rows(Gathering(sources=SourceList(rows=offered), levels=MixLevels()), mixes=False) + + @property + def is_active(self) -> bool: + """A conversion is occupying resources, from the request until it settles.""" + return self._run.is_active + + def emit_initial_view(self) -> None: + self._emit(self._messages.idle, 0.0) + + def refresh_view(self) -> None: + """Re-emits the idle view so the Convert button reflects whether another exclusive operation + is active. Only the idle phase carries the Convert button; the other phases disable it by + phase alone, so re-emitting them would add nothing.""" + if self._run.phase == ConversionPhase.IDLE: + self._emit(self._messages.idle, 0.0) + + def gather_recordings(self, paths: Sequence[Path]) -> None: + """Gathers recordings into the setup, each converted under what a recording joins with. + + A path already gathered keeps the row and the settings it has. A mix reaches a fixed + number of recordings, so a full one takes no more; a per-recording run takes whatever is + offered, which is what converting a whole folder amounts to. + """ + gathering = self._state.gathering + for path in paths: + gathering = self._joined(gathering, self._gathered(path)) + + self._rewrite(self._state.with_gathering(gathering)) + + def gather_folder(self, root: Path, found: Sequence[Path]) -> None: + """Gathers a folder standing for the recordings ``found`` below it. + + A run writing one reconstruction per recording mirrors this folder's tree for what it + holds; a mix takes the recordings loose, which is what flattening leaves. The recordings + are handed in because reading them off the disk is work of its own, reported to the reader + while it runs. + """ + if self.mixes: + self.gather_recordings(found) + return + + self._rewrite(self._state.with_gathering(self._gathering_folder(root, found))) + + def take_up_recording(self, path: Path) -> None: + """Lists the recording a Reconstruct names, to be converted into a reconstruction of its own. + + A Reconstruct asks for one reconstruction per recording, so a mix standing in the converter + gives way to a list of that kind, and a list of that kind takes the recording beside what it + holds. The reader starts the run once the list and its settings are as they want them. + """ + self._turn_to_one_apiece() + self.gather_recordings([path]) + + def take_up_folder(self, root: Path, found: Sequence[Path]) -> None: + """Lists the folder a Reconstruct names, standing for the recordings ``found`` below it. + + The folder joins a list writing one reconstruction per recording, as :meth:`take_up_recording` + describes. + """ + self._turn_to_one_apiece() + self.gather_folder(root, found) + + def _turn_to_one_apiece(self) -> None: + """Lets a mix go for an empty list writing one reconstruction per recording.""" + if not self.mixes: + return + + self._rewrite( + self._state.with_settings(self._settings.with_output(OutputKind.PER_RECORDING)).with_gathering( + Gathering.empty() + ) + ) + + def select_row(self, path: Path, kind: SourceKind) -> None: + """Names the row a reader is inspecting, which the settings card edits.""" + self._rewrite(self._state.with_selected(SourceKey(kind=kind, path=path))) + + def clear_selection(self) -> None: + """Lets the inspected row go, which leaves the settings card and the keys with none.""" + self._rewrite(self._state.with_selected(None)) + + def remove_source(self, path: Path) -> None: + """Takes one gathered recording out of the setup.""" + self._rewrite(self._state.with_gathering(self._state.gathering.remove(SourceKey.recording(path)))) + + def remove_folder(self, root: Path) -> None: + """Takes a folder out of the setup, along with every recording it stands for.""" + self._rewrite(self._state.with_gathering(self._state.gathering.remove(SourceKey.folder(root)))) + + @property + def source_settings_view(self) -> SourceSettingsPanelViewModel: + """What the settings card shows: the row a reader picked, or what a recording joins with.""" + return source_settings(self._state, live=self.live) + + @property + def live(self) -> bool: + """Whether a gesture reaches the setup, which a conversion holding resources answers.""" + return not self._run.is_active + + def toggle_slot(self, field: SettingsField, channel_name: ChannelName) -> None: + """Settles one choice on ``channel_name`` where the settings card is pointed. + + A picked row settles the same way a folder's own box does — already agreeing lets the + choice go, every other reading takes it up — so one gesture answers for a folder and for + a recording alike. + """ + slot = SLOTS_BY_FIELD[field] + held = self._inspected_agreement(slot, channel_name).settles_to + self._edit_inspected(lambda settings: slot.settled(settings, channel_name, held)) + + def set_drive(self, channel_name: ChannelName, drive: float) -> None: + """Names how hard one channel is driven, where the settings card is pointed. + + A drive belongs to a channel a recording occupies, so a recording leaving the channel + free stands as it is. + """ + self._edit_inspected(lambda settings: _driven(settings, channel_name, drive)) + + def set_channel_cap(self, channel_cap: int) -> None: + """Names how many of its channels one recording may sound in a frame, where the card points.""" + self._edit_inspected(lambda settings: settings.with_channel_cap(channel_cap)) + + def toggle_channel(self, channel_name: ChannelName) -> None: + """Settles one channel on the row a reader picked out, which the channel's key reaches. + + The key answers for the row the settings card is pointed at, so a press reaches exactly + the recordings the box beside that row reaches. With no row picked out there is nothing + for the press to settle, and it leaves the list as it stands. + """ + if self._state.selected is None: + return + + self.toggle_slot(SettingsField.CHANNELS, channel_name) + + def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: + """Names the channels one recording may take, and picks its row out. + + A click on a row always picks it, the boxes beside its name included, so the settings + card follows the row whose box moved. + """ + gathering = self._state.gathering.written(path, CHANNEL_SLOT, channels) + self._rewrite(self._state.with_gathering(gathering).with_selected(SourceKey.recording(path))) + + def toggle_folder_channel(self, root: Path, channel_name: ChannelName) -> None: + """Settles one channel on every recording a folder stands for, and picks the folder out. + + A folder its recordings already agree on lets the channel go; every other reading settles + the whole folder on it, so one gesture always moves the group somewhere. The click picks + the folder's row, as a click on its name does. + """ + folder = SourceKey.folder(root) + gathering = self._state.gathering.toggled(folder, CHANNEL_SLOT, channel_name) + self._rewrite(self._state.with_gathering(gathering).with_selected(folder)) + + def move_source_within_level(self, path: Path, offset: int) -> None: + """Moves a recording past the neighbor it shares a level with.""" + self._relevel(self._state.gathering.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._relevel(self._state.gathering.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._relevel(self._state.gathering.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._relevel(self._state.gathering.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._relevel(self._state.gathering.levels.move_to_new_level(path, position)) + + def set_output(self, output: OutputKind) -> None: + """Names what the run writes: one reconstruction per recording, or one from them all. + + A mix converts loose recordings and holds a fixed number of them, so turning to one + flattens the folders standing in the list and keeps what fits. Turning away leaves the + list as it is and lets the picking order go. + """ + if output == self._settings.output: + return + + gathering = self._state.gathering + settled = ( + gathering.mixing_only(gathering.recordings[: gathering.ceiling]) if output.mixes else gathering.unmixed() + ) + self._rewrite(self._state.with_settings(self._settings.with_output(output)).with_gathering(settled)) + + def mix_only(self, paths: Sequence[Path]) -> None: + """Names the recordings a mix converts, gathering each one the list has still to take up. + + This is the answer to both places a mix is put to the reader: narrowing a list longer than + one holds, and choosing between what the mix stands on and what a folder offers beside it. + Either way the answer names the whole mix, so what it leaves out goes and what it names + joins — a recording already listed keeping the settings it has. + """ + gathering = self._state.gathering + mixed = tuple(self._standing(gathering, path) for path in tuple(paths)[: gathering.ceiling]) + self._rewrite( + self._state.with_settings(self._settings.with_output(OutputKind.MIXED)).with_gathering( + gathering.mixing_only(mixed) + ) + ) + + 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._rewrite(self._state.with_settings(self._settings.with_hierarchy_mode(hierarchy_mode))) + + 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._declines_to_start(): + return + + if not self._state.gathering.count: + logger.warning("Nothing is gathered to convert") + return + + plan = conversion_plan(self._state) + if plan is None: + self.call(self.on_no_generators) + return + + standing_targets = self._standing_targets(plan) + if standing_targets and not confirmed: + self.call(self.on_target_exists, standing_targets) + return + + config = self._config_manager.config.model_copy() + library_key = self._config_manager.key + self._run.wait( + ConversionRequest( + config=config, + plan=plan, + library_key=library_key, + library_state=self.query( + self.library_state, + config.library_directory / library_key.filename, + default=LibraryState.MISSING, + ), + ) + ) + self.call(self.prepare_library) + self._wait_for_library_and_start() + + def _declines_to_start(self) -> bool: + """Whether another exclusive operation holds the resources a conversion would take.""" + if not self._is_operation_active(): + return False + + logger.warning("A conversion or library generation is already in progress") + return True + + def cancel(self) -> None: + if self._run.is_running: + self._run.cancel() + elif self._run.phase == ConversionPhase.WAITING: + self.call(self.cancel_library_generation) + self._run.abandon() + + def close(self) -> None: + try: + self._run.close() + finally: + self._emit(self._messages.idle, 0.0) + + def handle_load_request(self) -> None: + written = self._run.written + if len(written) == 1: + self.call(self.on_load_file, written[0]) + else: + self.call(self.on_load_directory) + + self.close() + + def cleanup(self) -> None: + self._run.cleanup() + + @property + def _settings(self) -> RunSettings: + return self._state.settings + + def _edit_inspected(self, change: SettingsChange) -> None: + """Makes one edit where the settings card is pointed. + + A picked row hands the edit to every recording it stands for; with no row picked the card + edits what a recording joins with, so a reader settles the shape the recordings gathered + from then on arrive in. + """ + selected = self._state.selected + if selected is None: + joining = change(self._settings.joining) + self._rewrite(self._state.with_settings(self._settings.with_joining(joining))) + return + + self._rewrite(self._state.with_gathering(self._state.gathering.changed(selected, change))) + + def _inspected_agreement(self, slot: SettingsSlot, channel_name: ChannelName) -> Agreement: + """How the settings the card is editing read on ``channel_name`` in ``slot``.""" + return Agreement.over(channel_name in slot.read(settings) for settings in inspected_settings(self._state)) + + def _gathered(self, path: Path) -> Recording: + """A recording joining the list, holding the settings a recording joins with.""" + return Recording(path=path, settings=self._joining_settings) + + def _standing(self, gathering: Gathering, path: Path) -> Recording: + """The recording ``path`` names: the one the list holds, or one joining it.""" + recording = gathering.recording(path) + return recording if recording is not None else self._gathered(path) + + def _gathering_folder(self, root: Path, found: Sequence[Path]) -> Gathering: + """The setup with ``root`` standing as one row, or as it stands where the folder is empty.""" + recordings = tuple(self._gathered(path) for path in found) + if not recordings: + return self._state.gathering + + return self._state.gathering.listing_folder(Folder(root=root, recordings=recordings)) + + def _joined(self, gathering: Gathering, recording: Recording) -> Gathering: + """One more recording in the setup, joining the mix where the run is one.""" + return gathering.mixing(recording) if self.mixes else gathering.listing(recording) + + @property + def _joining_settings(self) -> StemSettings: + """What a recording is converted with when it joins the list, as the reader last left it.""" + return self._settings.joining + + def _relevel(self, levels: MixLevels) -> None: + """Takes up rewritten levels and follows them wherever the setup changed. + + The levels are the mix's own order, so a run writing one reconstruction apiece has none to + rewrite and a gesture reaching it changes nothing. + """ + if not self.mixes: + return + + self._rewrite(self._state.with_gathering(self._state.gathering.with_levels(levels))) + + def _rewrite(self, state: ConverterState) -> None: + """Takes up a setup a gesture rewrote and follows it wherever it reaches. + + The list stands inert while a conversion holds resources, so every gesture — a pick + included — is held to the same rule the drawn list keeps: the setup the reader sees is the + setup the logic holds. The run itself converts the request it was started from. + + A mix names its destination after the recordings that take part, so the path the panel + shows follows every gesture; a settled run returns to idle, since the screen has moved on + from the setup it reported. + """ + if not self.live: + return + + self._remember(state.settings) + self._state = self._redirected(state.selecting(state.selected)) + self._rows = self._read_rows() + self._run.return_to_idle() + self._emit(self._messages.idle, 0.0) + + def _remember(self, settings: RunSettings) -> None: + """Write down the shape of the run, so a launch opens where the last one left off. + + The settings a recording joins with and the run's own shape are carried between launches, + which is what makes the converter open on the setup the reader last worked in. + """ + if settings == self._settings: + return + + self._session_manager.set_converter_settings(settings.joining) + self._session_manager.set_converter_output(settings.output) + self._session_manager.set_converter_hierarchy_mode(settings.hierarchy_mode) + + def _redirected(self, state: ConverterState) -> ConverterState: + """The setup with its destination following the sources that take part in it.""" + destination = state.destination.named_after(state.gathering.sources) + return state.with_destination(destination.aimed_at(self._config_manager.config, conversion_plan(state))) + + def _standing_targets(self, plan: ConversionPlan) -> Tuple[Path, ...]: + """Every reconstruction ``plan`` would write over, in the order the run reaches them. + + A recording gathered from a folder is left where its reconstruction already stands, so a + repeated folder run picks up where the last one stopped and puts nothing to the reader. + A recording the reader named is written whenever the run goes, so all of those are named + together: the answer covers each one it is given. + """ + return plan.existing_targets(self._config_manager.config) + + def _wait_for_library_and_start(self) -> None: + """Begins the run once the library its request converts against is ready. + + A generation preparing the library is waited out. Once no generation holds it, a library + this build reads starts the run, and any other gives the request up. + """ + request = self._run.request + if self._run.phase != ConversionPhase.WAITING or request is None: + return + + readiness = self.call( + self.library_readiness, + request.library_directory, + request.library_key, + ) + match readiness: + case LibraryReadiness.READY: + self._run.begin(request) + case LibraryReadiness.MISSING: + self._run.abandon() + case _: + CallbackQueue.add( + self._wait_for_library_and_start, + priority=self._scheduling.priorities.schedule, + delay=self._scheduling.delays.schedule, + ) + + def _on_report(self, report: RunReport) -> None: + self._emit(report.status_text, report.progress, running_input=report.input_path) + + def _on_run_success(self, success: ConversionSuccess) -> None: + if success.is_single: + self._state = self._state.with_destination(self._state.destination.writing_to(success.written[0])) + + self.call(self.on_success, success) + + def _on_run_error(self, exception: Exception) -> None: + self._schedule_return_to_idle() + if isinstance(exception, NoFilesToProcessError): + self.call(self.on_no_files_to_process) + else: + self.call(self.on_error, exception) + + def _on_run_canceled(self) -> None: + self._schedule_return_to_idle() + self.call(self.on_canceled) + + def _schedule_return_to_idle(self) -> None: + CallbackQueue.add( + self.close, + priority=self._scheduling.priorities.schedule, + delay=self._scheduling.delays.cancel, + ) + + def _emit( + self, + status_text: str, + progress: float, + running_input: Optional[Path] = None, + ) -> None: + view_model = compose_view( + self._state, + phase=self._run.phase, + status_text=status_text, + action_label=self._action_label(), + progress=progress, + running_input=running_input, + reconstructions_directory=self._config_manager.get_reconstructions_directory(), + other_operation_active=self._is_operation_active(), + rows=self._rows, + ) + self.call(self.on_view_changed, view_model) + + def _read_rows(self) -> Tuple[StemRowViewModel, ...]: + """The gathered sources as the list draws them, read once for the setup now standing. + + Reading a row reaches the disk for whether its recording is still there, and a folder is + read down to the recordings it holds, so the reading is taken where the setup changes and + stands through every report a run makes about it. + """ + return stem_rows(self._state.gathering, mixes=self.mixes) + + def _action_label(self) -> str: + """What the button says the run writes, read from the recordings taking part in it.""" + return self._messages.action_label( + phase=self._run.phase, + mixes=self.mixes, + converted=playing_sources(self._state), + ) diff --git a/src/sampletones_application/logic/main/converter/messages.py b/src/sampletones_application/logic/main/converter/messages.py new file mode 100644 index 000000000..eaf3fce74 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -0,0 +1,131 @@ +from pathlib import Path +from typing import Final, Tuple + +from sampletones_application.categories.estimate import time_estimation +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.services.conversion.result import ConversionItem +from sampletones_application.services.result import ServiceProgress +from sampletones_application.view_model.main.converter import ACTIVE_PHASES, ConversionPhase +from sampletones_core.library import LibraryState +from sampletones_core.reconstructions.stage import ReconstructionStage + +SINGLE_SOURCE: Final[int] = 1 + + +class ConverterMessages: + """What the converter puts to a reader: the line under the bar and the label on its button. + + Every phrase the panel shows is composed here, so the words a run reports and the words a + settled setup reports read as one voice and the keys they come from stand in one place. + """ + + def __init__(self, language_manager: LanguageManager) -> None: + self._language_manager = language_manager + + @property + def idle(self) -> str: + return self._language_manager["main.converter.message.status_idle"] + + @property + def waiting(self) -> str: + return self._language_manager["main.converter.message.status_waiting"] + + @property + def canceling(self) -> str: + return self._language_manager["main.converter.message.status_canceling"] + + @property + def canceled(self) -> str: + return self._language_manager["main.converter.message.status_canceled"] + + @property + def completed(self) -> str: + return self._language_manager["main.converter.message.status_reconstruction_completed"] + + @property + def failed(self) -> str: + return self._language_manager["main.converter.message.status_error"] + + def progress_text(self, progress: ServiceProgress[ConversionItem]) -> str: + """What the run is doing, how far it has come, and how long it has left. + + A run writing one reconstruction names the file its job writes and the stage that + reconstruction is at, which is the whole of what a reader watching one reconstruction has. + That holds for a mix, a single recording and a rerun left with one recording to write. A + batch writes many at once, so a count of the ones written says where it stands. + """ + return ( + self._run_text(progress) + + self._stage_text(progress) + + time_estimation(self._language_manager, progress.eta_seconds) + ) + + def preparing_library(self, state: LibraryState) -> str: + """The line a run shows while its library is prepared: an update of a library another + version built, and a generation of a missing one.""" + if state is LibraryState.OUTDATED: + return self._language_manager["main.converter.message.status_updating_library"] + + return self._language_manager["main.converter.message.status_generating_library"] + + def action_label( + self, + *, + phase: ConversionPhase, + mixes: bool, + converted: Tuple[Path, ...], + ) -> str: + """The label the single action button shows. + + While a conversion holds resources the button cancels it. Otherwise it says what the run + writes, counted from the recordings taking part, so the button and the output switch read + as one sentence rather than two. + """ + if phase in ACTIVE_PHASES: + return self._language_manager["main.converter.label.cancel_button"] + + if len(converted) > SINGLE_SOURCE: + template = ( + self._language_manager["main.converter.template.mix_recordings"] + if mixes + else self._language_manager["main.converter.template.convert_recordings"] + ) + return template.format(count=len(converted)) + + if converted: + return self._language_manager["main.converter.template.convert_recording"].format(name=converted[0].stem) + + return self._language_manager["main.converter.label.convert_button"] + + def _run_text(self, progress: ServiceProgress[ConversionItem]) -> str: + item = progress.current_item + if progress.is_single and item is not None: + return self._language_manager["main.converter.template.single_progress_template"].format( + item.output_path.stem + ) + + return self._language_manager["main.converter.template.progress_template"].format( + progress.completed, progress.total + ) + + 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_name(step.stage), + completed=step.completed, + total=step.total, + ) + + def _stage_name(self, stage: ReconstructionStage) -> str: + match stage: + case ReconstructionStage.LOADING: + return self._language_manager["main.converter.message.stage_loading"] + case ReconstructionStage.MATCHING: + return self._language_manager["main.converter.message.stage_matching"] + case ReconstructionStage.DECODING: + return self._language_manager["main.converter.message.stage_decoding"] + case ReconstructionStage.GATHERING: + return self._language_manager["main.converter.message.stage_gathering"] diff --git a/src/sampletones_application/logic/main/converter/run.py b/src/sampletones_application/logic/main/converter/run.py new file mode 100644 index 000000000..48e7da89d --- /dev/null +++ b/src/sampletones_application/logic/main/converter/run.py @@ -0,0 +1,263 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Optional, Protocol, Tuple + +from sampletones_application.logic.main.converter.messages import ConverterMessages +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult +from sampletones_application.services.result import ( + ServiceCanceled, + ServiceError, + ServiceIntermediate, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.utils.progress import SystemProgress +from sampletones_application.view_model.main.converter import ACTIVE_PHASES, ConversionPhase +from sampletones_core.configs import Config +from sampletones_core.library import InstructionLibraryKey, LibraryState +from sampletones_core.parallelization import TaskProgress +from sampletones_core.reconstructions.converter import ConversionPlan +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +SYSTEM_PROGRESS_STEPS: Final[int] = 1000 + + +@dataclass(frozen=True) +class ConversionSuccess: + """The outcome a completed conversion hands to its listener: the reconstructions it wrote. + + One written reconstruction is one the reader can open straight away; several are a batch, + which the reader reaches as a folder.""" + + 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 + + +@dataclass(frozen=True) +class ConversionRequest: + """What a run converts, settled at the moment the conversion is asked for. + + A run first waits for the library it converts against, while the setup and the settings stay + in the reader's hands; the run converts what it was asked to, against the library the request + named, whatever moves meanwhile. ``library_state`` is where that library stood when the + conversion was asked for, which names the preparation the run waits on. + """ + + config: Config + plan: ConversionPlan + library_key: InstructionLibraryKey + library_state: LibraryState + + @property + def library_directory(self) -> Path: + """Where the library the run converts against stands.""" + return Path(self.config.general.library_directory) + + +@dataclass(frozen=True) +class RunReport: + """Where a run stands, in the words a reader watching it reads. + + ``input_path`` names the recording under way, which a batch changes as it goes; a run naming + none leaves the reader looking at what they picked. + """ + + status_text: str + progress: float + input_path: Optional[Path] + + +class ConversionServiceProtocol(Protocol): + """The slice of the conversion service a run drives. + + Typing the collaborator structurally keeps the logic layer bound to the + service's result contract alone; the composition root supplies the real + service. + """ + + def subscribe(self, handler: Callable[[ConversionResult], None]) -> None: ... + + def start(self, config: Config, plan: ConversionPlan) -> None: ... + + def cancel(self) -> None: ... + + def release(self) -> None: ... + + def is_running(self) -> bool: ... + + +class ConversionRun(CallbackMixin): + """One conversion from the moment it is requested to the moment it is closed. + + The run owns the phase, the service driving it and the taskbar progress that follows it, and + reports where it stands after every step it takes. What it converts is settled before it + starts: a run takes a plan and answers only for what happens to it. + """ + + def __init__( + self, + conversion_service: ConversionServiceProtocol, + *, + messages: ConverterMessages, + ) -> None: + self._service = conversion_service + self._messages = messages + self._system_progress = SystemProgress() + self._phase: ConversionPhase = ConversionPhase.IDLE + self._written: Tuple[Path, ...] = () + self._request: Optional[ConversionRequest] = None + + self._service.subscribe(self._on_service_result) + + self.on_report: Optional[Callable[[RunReport], None]] = None + self.on_success: Optional[Callable[[ConversionSuccess], None]] = None + self.on_error: Optional[Callable[[Exception], None]] = None + self.on_canceled: Optional[VoidCallback] = None + + @property + def phase(self) -> ConversionPhase: + return self._phase + + @property + def is_active(self) -> bool: + """A conversion is occupying resources from the moment of request (the WAITING phase, during + which the library is prepared and the run is scheduled) until it reaches a terminal phase.""" + return self._phase in ACTIVE_PHASES + + @property + def is_running(self) -> bool: + """The service holds the run, so canceling it is the service's business.""" + return self._service.is_running() + + @property + def written(self) -> Tuple[Path, ...]: + """The reconstructions the last completed run wrote.""" + return self._written + + @property + def request(self) -> Optional[ConversionRequest]: + """What the run in hand was asked to convert.""" + return self._request + + def wait(self, request: ConversionRequest) -> None: + """Takes up a request, while the library it converts against is prepared.""" + self._request = request + self._phase = ConversionPhase.WAITING + self._report(self._messages.waiting, 0.0) + + def begin(self, request: ConversionRequest) -> None: + """Hands the request to the service, which is where the conversion itself starts.""" + self._system_progress.initialize() + self._service.start(request.config, request.plan) + + def cancel(self) -> None: + """Asks the service to give up the run it holds.""" + self._phase = ConversionPhase.CANCELING + self._report(self._messages.canceling, 0.0) + self._system_progress.error() + self._service.cancel() + + def abandon(self) -> None: + """Gives up a request still standing this side of the service, which cancels it all the same.""" + self._settle_as_canceled() + + def close(self) -> None: + """Lets the run go, whatever it came to, and returns to idle.""" + try: + self._service.release() + finally: + self._system_progress.clear() + self._written = () + self._request = None + self.return_to_idle() + + def return_to_idle(self) -> None: + """Marks a settled run as done with, so the panel offers to convert again.""" + self._phase = ConversionPhase.IDLE + + def cleanup(self) -> None: + """Releases the service, which is the end of every run this object could hold.""" + self._service.release() + self._system_progress.clear() + + def _on_service_result(self, result: ConversionResult) -> None: + match result: + case ServiceStarted(total=total): + self._system_progress.start(total) + case ServiceProgress() as progress: + self._handle_progress_result(progress) + case ServiceIntermediate(data=progress): + self._handle_library_progress(progress) + case ServiceSuccess(value=written): + self._settle_as_complete(written) + case ServiceError(exception=exception): + self._settle_as_failed(exception) + case ServiceCanceled(): + self._settle_as_canceled() + + def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None: + if self._phase == ConversionPhase.CANCELING: + self._report(self._messages.canceling, progress.fraction) + return + + self._phase = ConversionPhase.RUNNING + self._system_progress.set( + round(progress.fraction * SYSTEM_PROGRESS_STEPS), + SYSTEM_PROGRESS_STEPS, + ) + self._report( + self._messages.progress_text(progress), + progress.fraction, + input_path=self._item_path(progress), + ) + + def _item_path(self, progress: ServiceProgress[ConversionItem]) -> Optional[Path]: + """The recording the run names itself by, where one is under way.""" + return progress.current_item.source if progress.current_item is not None else None + + def _handle_library_progress(self, progress: TaskProgress) -> None: + request = self._request + if self._phase != ConversionPhase.WAITING or request is None: + return + + total = max(progress.total, 1) + self._report(self._messages.preparing_library(request.library_state), progress.completed / total) + + def _settle_as_complete(self, written: Tuple[Path, ...]) -> None: + """Settles a finished run, telling its listener what was written before reporting. + + The reconstruction a run wrote is what a reader is then looking at, so the listener takes + it up first and the report that follows names it. + """ + self._written = written + self._phase = ConversionPhase.COMPLETED + self.call(self.on_success, ConversionSuccess(written=written)) + self._report(self._messages.completed, 1.0) + + def _settle_as_failed(self, exception: Exception) -> None: + self._system_progress.error() + self._phase = ConversionPhase.FAILED + self._report(self._messages.failed, 0.0) + self.call(self.on_error, exception) + + def _settle_as_canceled(self) -> None: + self._phase = ConversionPhase.CANCELED + self._report(self._messages.canceled, 0.0) + self.call(self.on_canceled) + + def _report( + self, + status_text: str, + progress: float, + input_path: Optional[Path] = None, + ) -> None: + self.call( + self.on_report, + RunReport(status_text=status_text, progress=progress, input_path=input_path), + ) diff --git a/src/sampletones_application/logic/main/converter/settings.py b/src/sampletones_application/logic/main/converter/settings.py new file mode 100644 index 000000000..beda3fd10 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, replace +from typing import Self + +from sampletones_application.constants.output import OutputKind +from sampletones_core.constants.enums import HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +@dataclass(frozen=True) +class RunSettings: + """The choices a run holds to, whatever it converts. + + ``joining`` is what a recording is given when it joins the setup, and each recording carries + its own settings from there, so what a run reaches is what its rows hold. The rest name the + shape of the run itself — what it writes, and how the levels take turns. + """ + + joining: StemSettings + output: OutputKind + hierarchy_mode: HierarchyMode + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self.output.mixes + + def with_joining(self, joining: StemSettings) -> Self: + """The settings every recording gathered from here on starts with.""" + return replace(self, joining=joining) + + def with_output(self, output: OutputKind) -> Self: + """The run writing one reconstruction per gathered recording, or one from them all.""" + return replace(self, output=output) + + def with_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> Self: + """The run taking its levels round by round, or one level exhausted before the next.""" + return replace(self, hierarchy_mode=hierarchy_mode) diff --git a/src/sampletones_application/logic/main/converter/setup.py b/src/sampletones_application/logic/main/converter/setup.py new file mode 100644 index 000000000..6584b6b3c --- /dev/null +++ b/src/sampletones_application/logic/main/converter/setup.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import Optional, Tuple + +from sampletones_application.logic.main.converter.derive import ( + ConversionSetup, + derive_conversion_setup, +) +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_core.reconstructions.converter import ( + BatchConversion, + BatchEntry, + ConversionPlan, + GroupConversion, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +def conversion_setup(state: ConverterState) -> ConversionSetup: + """The recordings and the stems setup a mix converts under, each carrying its own settings.""" + return derive_conversion_setup( + state.gathering.sources, + state.gathering.levels, + hierarchy_mode=state.settings.hierarchy_mode, + ) + + +def playing_sources(state: ConverterState) -> Tuple[Path, ...]: + """The recordings that take part, in the order the run reaches them.""" + if state.settings.mixes: + return conversion_setup(state).sources + + return tuple(entry.source for entry in batch_entries(state)) + + +def batch_entries(state: ConverterState) -> Tuple[BatchEntry, ...]: + """One entry per gathered recording still holding a channel. + + Each carries a setup of its own, so what a reader settled on a row is what that recording's + reconstruction records. The folder a recording was gathered from decides where it is written, + which is what makes a run over a folder mirror that folder's tree. + """ + gathering = state.gathering + entries = [] + for recording in gathering.sources.recordings: + if not recording.settings.channels: + continue + + entries.append( + BatchEntry( + source=recording.path, + stems=StemsConfig.single_entry(recording.settings), + base_directory=gathering.folder_root_of(recording.path), + ) + ) + + return tuple(entries) + + +def conversion_plan(state: ConverterState) -> Optional[ConversionPlan]: + """What a request amounts to: one reconstruction from the recordings gathered, or one apiece. + + A run with nobody taking part names no plan, which is what a converter aimed at nothing is. + """ + if state.settings.mixes: + setup = conversion_setup(state) + return GroupConversion(sources=setup.sources, stems=setup.stems) if setup.sources else None + + entries = batch_entries(state) + return BatchConversion(entries=entries) if entries else None diff --git a/src/sampletones_application/logic/main/converter/state.py b/src/sampletones_application/logic/main/converter/state.py new file mode 100644 index 000000000..0ec1f9b7a --- /dev/null +++ b/src/sampletones_application/logic/main/converter/state.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, replace +from typing import Optional, Self + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.sources.key import SourceKey + + +@dataclass(frozen=True) +class ConverterState: + """What the converter is set up to do, as one value every gesture rewrites a part of. + + The sides answer to each other — the settings say what a run hands out, the gathering says + which recordings take part, and the destination follows from both — so a gesture states the one + side it changes and hands the whole state back to be settled at once. ``selected`` names the + row a reader is inspecting, which the settings card edits and the list draws as picked out. + """ + + settings: RunSettings + gathering: Gathering + destination: Destination + selected: Optional[SourceKey] + + def with_settings(self, settings: RunSettings) -> Self: + return replace(self, settings=settings) + + def with_gathering(self, gathering: Gathering) -> Self: + return replace(self, gathering=gathering) + + def with_destination(self, destination: Destination) -> Self: + return replace(self, destination=destination) + + def with_selected(self, selected: Optional[SourceKey]) -> Self: + return replace(self, selected=selected) + + def selecting(self, selected: Optional[SourceKey]) -> Self: + """The state with ``selected`` inspected, where the list still holds the row it names.""" + if selected is not None and self.gathering.sources.row(selected) is None: + return replace(self, selected=None) + + return replace(self, selected=selected) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py new file mode 100644 index 000000000..d1e2877a4 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/view.py @@ -0,0 +1,275 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import FrozenSet, Optional, Tuple + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.sources.row import SourceRow +from sampletones_application.logic.main.sources.slots import BEND_SLOT, CHANNEL_SLOT +from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel +from sampletones_application.view_model.main.source import ( + ChannelSettingsViewModel, + InspectedSourceViewModel, + SourceSettingsPanelViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def compose_view( + state: ConverterState, + *, + phase: ConversionPhase, + status_text: str, + action_label: str, + progress: float, + running_input: Optional[Path], + reconstructions_directory: Path, + other_operation_active: bool, + rows: Tuple[StemRowViewModel, ...], +) -> ConverterViewModel: + """The panel's whole reading of the converter at one moment. + + ``running_input`` is the recording a batch is on, which stands in for what the reader gathered + while a run is under way; ``reconstructions_directory`` is where a converter that has gathered + nothing yet would write. ``rows`` are the gathered sources as :func:`stem_rows` last read + them, which stand for as long as the gathering does and are therefore read once a gesture + rather than once a progress report. + """ + settings = state.settings + destination = state.destination + return ConverterViewModel( + phase=phase, + status_text=status_text, + action_label=action_label, + progress=progress, + input_path=running_input if running_input is not None else destination.input_path, + output_path=_display_output(destination, reconstructions_directory), + is_file=destination.is_file, + other_operation_active=other_operation_active, + output=settings.output, + stem_sources=rows, + hierarchy_mode=settings.hierarchy_mode, + max_sources=state.gathering.ceiling, + selected_key=_selected_key(state), + ) + + +def source_settings(state: ConverterState, *, live: bool) -> SourceSettingsPanelViewModel: + """What the settings card shows: one line per channel, and the row it edits them on. + + With a row picked out the lines read through every recording that row stands for; with none + picked the card edits the settings a recording joins with, so a reader settles the shape the + recordings gathered from then on arrive in. + """ + inspected = inspected_settings(state) + return SourceSettingsPanelViewModel( + subject=inspected_source(state), + channels=tuple(_channel_reading(channel_name, inspected) for channel_name in ChannelName.items()), + caps=frozenset(settings.channel_cap for settings in inspected), + channels_used=max((len(settings.channels) for settings in inspected), default=0), + live=live, + ) + + +def inspected_settings(state: ConverterState) -> Tuple[StemSettings, ...]: + """The settings the card is editing: the picked row's recordings, or what a recording joins with.""" + selected = state.selected + if selected is None: + return (state.settings.joining,) + + row = state.gathering.sources.row(selected) + if row is None: + return (state.settings.joining,) + + return tuple(recording.settings for recording in row.recordings) + + +def inspected_source(state: ConverterState) -> Optional[InspectedSourceViewModel]: + """The row the card is editing, named the way the list names it.""" + selected = state.selected + if selected is None: + return None + + row = state.gathering.sources.row(selected) + if row is None: + return None + + return InspectedSourceViewModel( + name=selected.path.name if selected.names_folder else selected.path.stem, + kind=selected.kind, + holds=row.count, + ) + + +def _channel_reading( + channel_name: ChannelName, + inspected: Tuple[StemSettings, ...], +) -> ChannelSettingsViewModel: + """How the inspected recordings stand on one channel: its use, its bend and its drive.""" + return ChannelSettingsViewModel( + channel=channel_name, + use=Agreement.over(CHANNEL_SLOT.holds(settings, channel_name) for settings in inspected), + bend=Agreement.over(BEND_SLOT.holds(settings, channel_name) for settings in inspected), + drive=_agreed_drive(channel_name, inspected), + ) + + +def _agreed_drive( + channel_name: ChannelName, + inspected: Tuple[StemSettings, ...], +) -> Optional[float]: + """The drive the inspected recordings give one channel, and nothing where they differ. + + A recording leaving the channel unoccupied says nothing about how hard it is driven, so a + channel none of them occupies reads as the calibrated level its slider rests at. + """ + drives = {settings.drives[channel_name] for settings in inspected if channel_name in settings.channel_set} + if len(drives) == 1: + return drives.pop() + + return None if drives else UNIT_DRIVE + + +def _selected_key(state: ConverterState) -> Optional[str]: + """The row a reader is inspecting, as the list names it.""" + return None if state.selected is None else str(state.selected.path) + + +def stem_rows( + gathering: Gathering, + *, + mixes: bool, +) -> Tuple[StemRowViewModel, ...]: + """The gathered sources as the panel reads them, each stating where it stands. + + A row 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, since the channels a row holds are the whole of what its + reconstruction reaches. A source that has left the disk since it was gathered reports itself as + missing. A mix bands its recordings by the level each picks on; a run writing one reconstruction + apiece draws one band holding the whole list, folders included. + """ + placements = _mixed_placements(gathering) if mixes else _listed_placements(gathering) + return tuple(_row(placement) for placement in placements) + + +@dataclass(frozen=True) +class _Placement: + """One source and where it stands in the list the panel draws.""" + + source: SourceRow + path: Path + level: int + position: int + level_size: int + level_count: int + + +def _row(placement: _Placement) -> StemRowViewModel: + source = placement.source + key = source.key + channels, partial = _readings(source) + return StemRowViewModel( + key=str(placement.path), + kind=key.kind, + name=placement.path.name if key.names_folder else placement.path.stem, + path=placement.path, + held=_held(placement) if key.names_folder else (), + channels=channels, + partial_channels=partial, + offered_channels=ALL_CHANNELS, + available=placement.path.is_dir() if key.names_folder else placement.path.is_file(), + level=placement.level, + position=placement.position, + stem_id=None, + level_size=placement.level_size, + level_count=placement.level_count, + ) + + +def _held(placement: _Placement) -> Tuple[StemRowViewModel, ...]: + """The recordings a folder stands for, each answering for itself. + + They stand where the folder stands, since the folder is the row the list holds them under, and + each reads on a channel the way a recording does — plainly ticked or clear. + """ + return tuple( + StemRowViewModel( + key=str(recording.path), + kind=recording.key.kind, + name=recording.path.stem, + path=recording.path, + held=(), + channels=frozenset(CHANNEL_SLOT.read(recording.settings)), + partial_channels=frozenset(), + offered_channels=ALL_CHANNELS, + available=recording.path.is_file(), + level=placement.level, + position=placement.position, + stem_id=None, + level_size=placement.level_size, + level_count=placement.level_count, + ) + for recording in placement.source.recordings + ) + + +def _readings(source: SourceRow) -> Tuple[FrozenSet[ChannelName], FrozenSet[ChannelName]]: + """How the recordings a row stands for read on each channel. + + A channel every one of them holds is ticked, one some of them hold is half-lit, and the rest + are clear — which for a single recording is the plain ticked-or-clear reading. + """ + held = set() + partial = set() + for channel_name in ALL_CHANNELS: + agreement = Agreement.over( + channel_name in CHANNEL_SLOT.read(recording.settings) for recording in source.recordings + ) + if agreement is Agreement.ALL: + held.add(channel_name) + elif agreement is Agreement.SOME: + partial.add(channel_name) + + return frozenset(held), frozenset(partial) + + +def _mixed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: + levels = gathering.levels + return tuple( + _Placement( + source=recording, + path=path, + level=level_index, + position=position, + level_size=len(level), + level_count=levels.level_count, + ) + for level_index, level in enumerate(levels.levels) + for position, path in enumerate(level) + for recording in (gathering.recording(path),) + if recording is not None + ) + + +def _listed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: + rows = gathering.sources.rows + return tuple( + _Placement( + source=source, + path=source.key.path, + level=0, + position=position, + level_size=len(rows), + level_count=1, + ) + for position, source in enumerate(rows) + ) + + +def _display_output(destination: Destination, reconstructions_directory: Path) -> Path: + return destination.output_path if destination.output_path is not None else reconstructions_directory diff --git a/src/sampletones_application/logic/main/explorer.py b/src/sampletones_application/logic/main/explorer.py deleted file mode 100644 index 40a1ec061..000000000 --- a/src/sampletones_application/logic/main/explorer.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path -from typing import AbstractSet, Set - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.logic.main.explorer_manager import ExplorerManager -from sampletones_core.structures.tree import FileSystemNode, Tree - - -class ExplorerLogic: - def __init__( - self, - config_manager: ConfigManager, - *, - language_manager: LanguageManager, - open_directories: AbstractSet[Path], - ) -> None: - self._manager = ExplorerManager( - config_manager, - language_manager=language_manager, - open_directories=open_directories, - ) - - @property - def tree(self) -> Tree: - return self._manager.tree - - def refresh_tree(self) -> None: - self._manager.refresh_tree() - - def has_loaded_children(self, filepath: Path) -> bool: - return self._manager.has_loaded_children(filepath) - - def is_directory_open(self, filepath: Path) -> bool: - return self._manager.is_directory_open(filepath) - - def set_directory_open(self, filepath: Path, is_open: bool) -> None: - self._manager.set_directory_open(filepath, is_open) - - @property - def open_directories(self) -> Set[Path]: - return self._manager.open_directories - - def expand_directory(self, node: FileSystemNode) -> None: - self._manager.expand_directory(node) - - def collapse_all(self) -> None: - self._manager.collapse_all() - - def has_relevant_content(self, filepath: Path) -> bool: - return self._manager.has_relevant_content(filepath) diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 0458af984..4a5214a43 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -40,7 +40,7 @@ def __init__( self.config_manager = config_manager self._loaded_directories: Set[Path] = set() - self._open_directories: Set[Path] = {path for path in open_directories if path.is_dir()} + self._open_directories: Set[Path] = set(open_directories) self.depth = depth def refresh_tree(self) -> None: diff --git a/src/sampletones_application/logic/main/sources/__init__.py b/src/sampletones_application/logic/main/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/main/sources/folder.py b/src/sampletones_application/logic/main/sources/folder.py new file mode 100644 index 000000000..5e3062ad0 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/folder.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Iterable, Self, Tuple + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording + + +@dataclass(frozen=True) +class Folder: + """A directory a reader gathered, standing for the recordings found below it. + + This is where the folder relation lives: a recording states its own path and its settings, and + the folder holding it is what says a run mirrors that folder's tree for it. A recording taken + out of a folder is a loose recording, with nothing left over to say otherwise. + """ + + root: Path + recordings: Tuple[Recording, ...] + + @property + def key(self) -> SourceKey: + return SourceKey.folder(self.root) + + @property + def count(self) -> int: + return len(self.recordings) + + def with_recordings(self, recordings: Iterable[Recording]) -> Self: + return replace(self, recordings=tuple(recordings)) diff --git a/src/sampletones_application/logic/main/sources/key.py b/src/sampletones_application/logic/main/sources/key.py new file mode 100644 index 000000000..d8ec6adfc --- /dev/null +++ b/src/sampletones_application/logic/main/sources/key.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Self + +from sampletones_application.constants.sources import SourceKind + + +@dataclass(frozen=True) +class SourceKey: + """What a gesture names: one recording, or one folder standing for the recordings below it. + + A path alone leaves the two kinds to be told apart by looking them up, so a key carries the + kind it was made for and every layer reads the same answer. + """ + + kind: SourceKind + path: Path + + @classmethod + def recording(cls, path: Path) -> Self: + """The key naming the recording at ``path``.""" + return cls(kind=SourceKind.RECORDING, path=path) + + @classmethod + def folder(cls, root: Path) -> Self: + """The key naming the folder gathered at ``root``.""" + return cls(kind=SourceKind.FOLDER, path=root) + + @property + def names_folder(self) -> bool: + return self.kind is SourceKind.FOLDER diff --git a/src/sampletones_application/logic/main/sources/levels.py b/src/sampletones_application/logic/main/sources/levels.py new file mode 100644 index 000000000..4d5c6fc96 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/levels.py @@ -0,0 +1,174 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Self, Sequence, Tuple + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES + +Level = Tuple[Path, ...] + + +@dataclass(frozen=True) +class MixLevels: + """The recordings one mixed reconstruction is built from, 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. + + Only paths stand here. What each recording is converted with belongs to the list it was + gathered in, so the precedence structure and the settings each have one home. + + A mix reaches at most ``MAX_STEM_SOURCES`` recordings, which is the ceiling the hardware's four + channels and the assignment's per-frame budget leave worth mixing. + """ + + levels: Tuple[Level, ...] = () + + @classmethod + def of(cls, levels: Sequence[Sequence[Path]]) -> Self: + """Builds a value from levels given in any shape, keeping the ones that hold a recording.""" + return cls(levels=tuple(tuple(level) for level in levels if level)) + + @property + def paths(self) -> Tuple[Path, ...]: + """Every recording, in the order it is mixed and numbered.""" + return tuple(path for level in self.levels for path in level) + + @property + def count(self) -> int: + return len(self.paths) + + @property + def level_count(self) -> int: + return len(self.levels) + + @property + def ceiling(self) -> int: + """How many recordings one mix reaches, which is the ceiling every reader of it asks for.""" + return MAX_STEM_SOURCES + + @property + def room(self) -> int: + """How many more recordings this mix reaches.""" + return self.ceiling - self.count + + def holds(self, path: Path) -> bool: + return path in self.paths + + def level_of(self, path: Path) -> int: + """The level the recording picks on, counted from the first. + + Raises: + KeyError: If ``path`` is gathered in no level of this mix. + """ + for level_index, level in enumerate(self.levels): + if path in level: + return level_index + + raise KeyError(f"{path} is not gathered in this mix") + + def position_of(self, path: Path) -> int: + """The place the recording takes among the ones sharing its level.""" + return self.levels[self.level_of(path)].index(path) + + def add(self, path: Path) -> Self: + """Gathers another recording, on the level the first recordings picked on. + + A mix already at its ceiling stands as it is, so a caller reads ``room`` to know what it + can still gather. + """ + if self.holds(path) or not self.room: + return self + + levels = self._mutable() + if not levels: + return self.of([[path]]) + + levels[0].append(path) + 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 move_within_level(self, path: Path, offset: int) -> Self: + """Moves a recording past the neighbor it shares a level with, changing which ties first.""" + if not self.holds(path): + 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(path) + level.insert(position, path) + 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.""" + if not self.holds(path): + return self + + target = self.level_of(path) + offset + if not 0 <= target < self.level_count: + return self + + levels = self._without(path) + levels[target].append(path) + 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.""" + if not self.holds(path) or len(self.levels[self.level_of(path)]) == 1: + return self + + levels = self._without(path) + levels.insert(self.level_of(path) + 1, [path]) + 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.""" + if not self.holds(path) or path == target_path or not self.holds(target_path): + return self + + levels = self._without(path) + for level in levels: + if target_path in level: + level.insert(level.index(target_path), path) + 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. + """ + if not self.holds(path): + 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, [path]) + return self.of(levels) + + def _mutable(self) -> List[List[Path]]: + return [list(level) for level in self.levels] + + def _without(self, path: Path) -> List[List[Path]]: + """The levels with one recording taken out, keeping a level it emptied for the callers that count on it.""" + return [[held for held in level if held != path] for level in self.levels] diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py new file mode 100644 index 000000000..a5f91d7c2 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/list.py @@ -0,0 +1,252 @@ +from dataclasses import dataclass, replace +from functools import cached_property +from pathlib import Path +from typing import Dict, FrozenSet, Optional, Self, Tuple + +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.row import SourceRow +from sampletones_application.logic.main.sources.slots import SettingsChange, SettingsSlot +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class SourceList: + """The rows a reader gathered a run from, in the order they were added. + + A row is one recording the reader named, or a folder standing for every recording gathered + below it, and both kinds share the one order. A path stands here once: a folder takes over the + loose recordings it covers, carrying the settings they already had, and a recording another + folder holds stays where it is. + + The list holds whatever a reader gathers, a folder of thousands included. What a single mix + may hold is the mix's own ceiling rather than this one's, since a list of that size is exactly + what a run writing one reconstruction per recording is for. + """ + + rows: Tuple[SourceRow, ...] = () + + @cached_property + def recordings(self) -> Tuple[Recording, ...]: + """Every recording the list stands for, folders walked through to what they hold. + + This is the one reading that goes from rows to recordings; whoever needs the recordings a + run converts asks for them here. A list holding a folder of thousands is read many times + over in the course of one gesture, so the walk is taken once and held; the list is settled + rather than edited, so each reading belongs to the list that took it. + """ + return tuple(recording for row in self.rows for recording in row.recordings) + + @cached_property + def _by_path(self) -> Dict[Path, Recording]: + """Every recording under the path naming it, which is how a path is looked up.""" + return {recording.path: recording for recording in self.recordings} + + @cached_property + def _roots(self) -> Dict[Path, Path]: + """The folder each gathered recording was found below, where a folder stands for it.""" + return { + recording.path: row.key.path for row in self.rows if row.key.names_folder for recording in row.recordings + } + + @property + def paths(self) -> Tuple[Path, ...]: + return tuple(recording.path for recording in self.recordings) + + @property + def count(self) -> int: + """How many recordings the list stands for.""" + return sum(row.count for row in self.rows) + + @property + def row_count(self) -> int: + """How many rows a reader sees, a folder counting as the one row it draws.""" + return len(self.rows) + + def holds(self, path: Path) -> bool: + return path in self._by_path + + def recording(self, path: Path) -> Optional[Recording]: + return self._by_path.get(path) + + def row(self, key: SourceKey) -> Optional[SourceRow]: + """The row a key names, wherever it stands. + + A reader who opens a folder answers for one of the recordings inside it, so a key naming a + recording reaches that recording whether it was gathered by name or through the folder + holding it. + """ + listed = next((row for row in self.rows if row.key == key), None) + if listed is not None or key.names_folder: + return listed + + return self.recording(key.path) + + def folder_root_of(self, path: Path) -> Optional[Path]: + """The root of the folder holding ``path``, which is the tree a run mirrors for it. + + A recording the reader named answers with nothing, and its reconstruction sits directly in + the folder named after the channels it is handed. + """ + return self._roots.get(path) + + def add_recording(self, recording: Recording) -> Self: + """Gathers one recording the reader named, leaving a path already standing as it is.""" + if self.holds(recording.path): + return self + + return replace(self, rows=self.rows + (recording,)) + + def add_folder(self, folder: Folder) -> Self: + """Gathers ``folder``, taking over the loose recordings it covers. + + A loose recording the folder lists joins it holding the settings it already had, so what a + reader settled before gathering stands. A recording another folder holds stays there, which + is what keeps every path standing in the list once, and a folder left standing for nothing + stays out, since a row that names no recording names nothing a run would write. + """ + if self.row(folder.key) is not None: + return self + + gathered = self._gathered_by(folder) + if not gathered: + return self + + taken = frozenset(recording.path for recording in gathered) + kept = tuple(row for row in self.rows if row.key.names_folder or row.key.path not in taken) + return replace(self, rows=kept + (folder.with_recordings(gathered),)) + + def remove(self, key: SourceKey) -> Self: + """Lets go of what ``key`` names: a whole folder, or one recording wherever it stands. + + A folder goes together with every recording it holds, and a folder left holding nothing + goes as well, since it stands for nothing a run would write. + """ + if key.names_folder: + return replace(self, rows=tuple(row for row in self.rows if row.key != key)) + + return replace(self, rows=tuple(self._without_recording(key.path))) + + def changed(self, key: SourceKey, change: SettingsChange) -> Self: + """The list with ``change`` made to the settings of every recording ``key`` stands for. + + A folder changes by making the same edit to each recording it holds, so one gesture reads + the same whichever kind of row answered it. This is the one way a row's settings are + rewritten; whoever edits them states the edit and hands it here. + """ + rows: Tuple[SourceRow, ...] = () + for row in self.rows: + rows += self._row_with(row, key, change) + + return replace(self, rows=rows) + + def settled( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + held: bool, + ) -> Self: + """The list with ``channel_name`` settled in ``slot``, on every recording ``key`` stands for.""" + return self.changed(key, lambda settings: slot.settled(settings, channel_name, held)) + + def written( + self, + key: SourceKey, + slot: SettingsSlot, + channels: FrozenSet[ChannelName], + ) -> Self: + """The list with ``slot`` holding exactly ``channels`` on every recording ``key`` stands for. + + This is the gesture that hands a whole reading back at once, where ``settled`` answers one + channel at a time. + """ + return self.changed(key, lambda settings: slot.write(settings, channels)) + + def toggled( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + ) -> Self: + """The list one gesture on ``key`` leaves behind. + + A row every recording of which already makes the choice lets it go; every other reading + settles the whole row on it, so one gesture always moves a group somewhere. + """ + held = self.agreement(key, slot, channel_name).settles_to + return self.settled(key, slot, channel_name, held) + + def agreement( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + ) -> Agreement: + """How the recordings ``key`` stands for read on ``channel_name`` in ``slot``.""" + row = self.row(key) + if row is None: + return Agreement.NONE + + return Agreement.over(slot.holds(recording.settings, channel_name) for recording in row.recordings) + + def _gathered_by(self, folder: Folder) -> Tuple[Recording, ...]: + """The recordings ``folder`` takes on, each holding the settings it already stood with.""" + loose = self._loose_recordings() + standing = self._recordings_inside_folders() + return tuple( + loose.get(recording.path, recording) for recording in folder.recordings if recording.path not in standing + ) + + def _loose_recordings(self) -> Dict[Path, Recording]: + return { + recording.path: recording for row in self.rows if not row.key.names_folder for recording in row.recordings + } + + def _recordings_inside_folders(self) -> FrozenSet[Path]: + return frozenset(recording.path for row in self.rows if row.key.names_folder for recording in row.recordings) + + def _without_recording(self, path: Path) -> Tuple[SourceRow, ...]: + rows: Tuple[SourceRow, ...] = () + for row in self.rows: + if not row.key.names_folder: + if row.key.path != path: + rows += (row,) + continue + + kept = tuple(recording for recording in row.recordings if recording.path != path) + if kept: + rows += (Folder(root=row.key.path, recordings=kept),) + + return rows + + def _row_with( + self, + row: SourceRow, + key: SourceKey, + change: SettingsChange, + ) -> Tuple[SourceRow, ...]: + """The row as ``key`` leaves it. + + A key naming the row changes every recording it stands for; one naming a recording a + folder holds changes that recording where it stands, so a reader settles a folder and the + recordings inside it through the same gesture. + """ + if row.key == key: + changed = tuple(recording.with_settings(change(recording.settings)) for recording in row.recordings) + return (Folder(root=key.path, recordings=changed),) if key.names_folder else changed + + if not row.key.names_folder or not any(recording.path == key.path for recording in row.recordings): + return (row,) + + return ( + Folder( + root=row.key.path, + recordings=tuple( + recording.with_settings(change(recording.settings)) if recording.path == key.path else recording + for recording in row.recordings + ), + ), + ) diff --git a/src/sampletones_application/logic/main/sources/recording.py b/src/sampletones_application/logic/main/sources/recording.py new file mode 100644 index 000000000..64552856f --- /dev/null +++ b/src/sampletones_application/logic/main/sources/recording.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Self, Tuple + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +@dataclass(frozen=True) +class Recording: + """One audio file a run converts, together with the settings it converts under. + + The settings are the value the core records as the stem's own, so what a reader edits in the + list is what the reconstruction carries. A recording states its own path and nothing about + where it was gathered from: the folder holding it is what knows that. + """ + + path: Path + settings: StemSettings + + @property + def key(self) -> SourceKey: + return SourceKey.recording(self.path) + + @property + def recordings(self) -> Tuple[Self, ...]: + """The recordings this row stands for, which for one recording is itself.""" + return (self,) + + @property + def count(self) -> int: + return 1 + + def with_settings(self, settings: StemSettings) -> Self: + return replace(self, settings=settings) diff --git a/src/sampletones_application/logic/main/sources/row.py b/src/sampletones_application/logic/main/sources/row.py new file mode 100644 index 000000000..bb465db0e --- /dev/null +++ b/src/sampletones_application/logic/main/sources/row.py @@ -0,0 +1,24 @@ +from typing import Protocol, Tuple + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording + + +class SourceRow(Protocol): + """One row of the list a run is set up in. + + A row is either a recording the reader named or a folder standing for the recordings gathered + below it, and both answer the same three questions: what a gesture names it by, which + recordings it stands for, and how many those are. Every reader works through those answers, so + the two kinds are told apart in one place — the kind a key carries — rather than at each site + that walks the list. + """ + + @property + def key(self) -> SourceKey: ... + + @property + def recordings(self) -> Tuple[Recording, ...]: ... + + @property + def count(self) -> int: ... diff --git a/src/sampletones_application/logic/main/sources/scan.py b/src/sampletones_application/logic/main/sources/scan.py new file mode 100644 index 000000000..d1b90158e --- /dev/null +++ b/src/sampletones_application/logic/main/sources/scan.py @@ -0,0 +1,174 @@ +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path +from typing import Callable, Optional, Protocol, Tuple + +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +CountCallback = Callable[[int], None] +FailureCallback = Callable[[Exception], None] +FoundCallback = Callable[[Path, Tuple[Path, ...]], None] + + +class FolderScanServiceProtocol(Protocol): + """The calls a folder scan makes of the service that walks the tree.""" + + def subscribe(self, handler: Callable[[FolderScanResult], None]) -> None: ... + + def start(self, request: FolderScanRequest) -> None: ... + + def stop(self) -> None: ... + + +class ScanPhase(Enum): + """Where the reading of a folder stands, from the reader's request until the walk lets it go.""" + + IDLE = auto() + READING = auto() + WINDING_DOWN = auto() + + +@dataclass(frozen=True) +class AskedFolder: + """A folder asked to be read, and what hears its recordings once it is.""" + + root: Path + answer: FoundCallback + + +class FolderScan(CallbackMixin): + """The reading of a folder a reader points at, from the moment it is asked for until it is let go. + + A reading is idle, reading, or winding down. Stop ends the reading for the reader at once, while + the walk runs on to the next entry it meets, so the scan winds down until the walk is heard to + give up. A folder asked for in that span is read as soon as it has, the latest one asked for + taking the place of an earlier one. A folder asked for while another is being read is turned + away. + + The reports arrive on the render thread through the service, each naming the request it + answers, so a report of a reading already let go is set aside. + """ + + def __init__(self, service: FolderScanServiceProtocol) -> None: + self._service = service + self._phase: ScanPhase = ScanPhase.IDLE + self._reading: Optional[FolderScanRequest] = None + self._answer: Optional[FoundCallback] = None + self._next: Optional[AskedFolder] = None + + self.on_started: Optional[PathCallback] = None + self.on_progress: Optional[CountCallback] = None + self.on_stopped: Optional[VoidCallback] = None + self.on_failed: Optional[FailureCallback] = None + + service.subscribe(self._on_result) + + @property + def phase(self) -> ScanPhase: + return self._phase + + def start(self, root: Path, answer: FoundCallback) -> None: + """Reads what ``root`` holds and hands it to ``answer``, counting as the walk goes. + + The answer travels with the reading that earns it, so the same scan serves a gathering and + a conversion and each hears back from its own reading. One reading runs at a time: a folder + asked for while another is being read is turned away, and one asked for once Stop has closed + the window is read as soon as the stopped walk has given up. + """ + match self._phase: + case ScanPhase.IDLE: + self._begin(AskedFolder(root=root, answer=answer)) + case ScanPhase.READING: + return + case ScanPhase.WINDING_DOWN: + self._next = AskedFolder(root=root, answer=answer) + + def stop(self) -> None: + """Gives the reading up for the reader, which the walk hears at the next entry it meets.""" + if self._phase is not ScanPhase.READING: + return + + self._phase = ScanPhase.WINDING_DOWN + self._service.stop() + + def _begin(self, asked: AskedFolder) -> None: + request = FolderScanRequest(root=asked.root) + self._phase = ScanPhase.READING + self._reading = request + self._answer = asked.answer + self._service.start(request) + + def _on_result(self, result: FolderScanResult) -> None: + if result.request is not self._reading: + return + + match result: + case FolderScanStarted(request=request): + self._report_started(request.root) + case FolderScanProgress(count=count): + self._report_progress(count) + case FolderScanSuccess(request=request, recordings=recordings): + self._land_found(request.root, recordings) + case FolderScanCanceled(): + self._land_stopped() + case FolderScanError(exception=exception): + self._land_failed(exception) + + def _report_started(self, root: Path) -> None: + if self._phase is ScanPhase.READING: + self.call(self.on_started, root) + + def _report_progress(self, count: int) -> None: + if self._phase is ScanPhase.READING: + self.call(self.on_progress, count) + + def _land_found(self, root: Path, recordings: Tuple[Path, ...]) -> None: + """Hands the recordings to whoever asked, where the reader still waits for them. + + A walk that reached the end of its tree before it heard Stop answers a reader who gave it + up, so it ends the way a stopped one does. + """ + if self._phase is ScanPhase.WINDING_DOWN: + self._land_stopped() + return + + answer = self._answer + self._settle() + self.call(answer, root, recordings) + + def _land_stopped(self) -> None: + self._settle() + self.call(self.on_stopped) + self._read_next() + + def _land_failed(self, exception: Exception) -> None: + """Reports a reading that failed partway, and goes on to the folder asked for meanwhile.""" + reading = self._phase is ScanPhase.READING + self._settle() + if reading: + self.call(self.on_failed, exception) + else: + self.call(self.on_stopped) + + self._read_next() + + def _settle(self) -> None: + self._phase = ScanPhase.IDLE + self._reading = None + self._answer = None + + def _read_next(self) -> None: + asked = self._next + self._next = None + if asked is not None: + self._begin(asked) diff --git a/src/sampletones_application/logic/main/sources/slots.py b/src/sampletones_application/logic/main/sources/slots.py new file mode 100644 index 000000000..bf4e749ce --- /dev/null +++ b/src/sampletones_application/logic/main/sources/slots.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from typing import Callable, Dict, Final, FrozenSet, Tuple + +from sampletones_application.constants.sources import SettingsField +from sampletones_core.constants.enums import ALL_CHANNELS, TONE_CHANNELS, ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +SettingsReader = Callable[[StemSettings], FrozenSet[ChannelName]] +SettingsWriter = Callable[[StemSettings, FrozenSet[ChannelName]], StemSettings] +SettingsOffer = Callable[[StemSettings], FrozenSet[ChannelName]] +SettingsChange = Callable[[StemSettings], StemSettings] + + +def _channels_of(settings: StemSettings) -> FrozenSet[ChannelName]: + return settings.channel_set + + +def _channels_offered(_settings: StemSettings) -> FrozenSet[ChannelName]: + return ALL_CHANNELS + + +def _bends_of(settings: StemSettings) -> FrozenSet[ChannelName]: + return settings.bend_set + + +def _bends_offered(settings: StemSettings) -> FrozenSet[ChannelName]: + """A bend belongs to a channel the recording occupies whose hardware reads one.""" + return settings.channel_set & TONE_CHANNELS + + +@dataclass(frozen=True) +class SettingsSlot: + """One per-recording choice, in the form every reader of it works through. + + A slot states how the choice is read from a recording's settings, how a settled value is + written back, and which channels put it to a reader as those settings stand. The list, the + folder fold and the settings card all work through slots, so a further choice reaches each of + them as one more slot rather than as a field spelled out again in every layer. + """ + + field: SettingsField + read: SettingsReader + write: SettingsWriter + offered: SettingsOffer + + def offers(self, settings: StemSettings, channel_name: ChannelName) -> bool: + """Whether this choice is put to a reader on ``channel_name``, as ``settings`` stand.""" + return channel_name in self.offered(settings) + + def holds(self, settings: StemSettings, channel_name: ChannelName) -> bool: + """Whether ``settings`` makes this choice on ``channel_name``.""" + return channel_name in self.read(settings) + + def settled( + self, + settings: StemSettings, + channel_name: ChannelName, + held: bool, + ) -> StemSettings: + """The settings with ``channel_name`` settled in this slot.""" + reached = self.read(settings) + channels = reached | {channel_name} if held else reached - {channel_name} + return self.write(settings, frozenset(channels)) + + +CHANNEL_SLOT: Final[SettingsSlot] = SettingsSlot( + field=SettingsField.CHANNELS, + read=_channels_of, + write=StemSettings.with_channels, + offered=_channels_offered, +) + +BEND_SLOT: Final[SettingsSlot] = SettingsSlot( + field=SettingsField.BENDS, + read=_bends_of, + write=StemSettings.with_bends, + offered=_bends_offered, +) + +SETTINGS_SLOTS: Final[Tuple[SettingsSlot, ...]] = (CHANNEL_SLOT, BEND_SLOT) + +SLOTS_BY_FIELD: Final[Dict[SettingsField, SettingsSlot]] = {slot.field: slot for slot in SETTINGS_SLOTS} diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index fd6b4b954..c14a065be 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -2,14 +2,18 @@ from pathlib import Path from typing import Iterator, Optional -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import MAX_VOLUME +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.pitch import Note, RowPitch, Step, clamped_note, clamped_step 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 +42,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 +69,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: @@ -118,9 +122,10 @@ def save(self, path: Path) -> None: def replace_project(self, project: Project, *, clean: bool) -> None: """Installs a project restored from history and rebuilds every dependent view. - Undo and redo install the whole project at once here: ``on_project_replaced`` - fires to rebuild the tabs wholesale, mirroring how loading a project refreshes - them. The fine-grained ``on_mutation`` signal fires only for new user edits, so + Undo, redo and the rollback of a failed gesture install the whole project at once + here: ``on_project_replaced`` fires so each tab realigns with the restored project, + mirroring how loading a project refreshes them, and a tab showing one voice keeps it + by its id. The fine-grained ``on_mutation`` signal fires only for new user edits, so reinstalling a recorded snapshot leaves it quiet. ``clean`` reports whether the restored state is the one last saved to disk, letting the session drop the unsaved-changes flag when undo returns exactly to the save point. @@ -191,103 +196,152 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: """Embeds a reconstruction as a project sample, detaching its local source-audio origin. A project is a self-contained, shareable artifact, so a sample keeps only the reconstruction - itself and the display name given here. + itself and the display name given here. The sample holds the detached document, which is + the very one handed in where it names no location. """ - reconstruction.detach_source() - sample = Sample(name=name, reconstruction=reconstruction) - self.project.samples.append(sample) + sample = Sample(name=name, reconstruction=reconstruction.detached()) + 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 an instrument, 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) -> Reconstruction: """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 shows the same position. Detaching matches :meth:`add_sample`: whichever path embeds a - reconstruction, the project stays a self-contained, shareable artifact. + reconstruction, the project stays a self-contained, shareable artifact, and the sample holds + the very document handed in where it names no location. + + Returns: + Reconstruction: The document the sample now holds, in the form the project keeps it. """ - reconstruction.detach_source() - self.project.samples[sample_id].reconstruction = reconstruction + voice = self._sample(voice_id) + voice.reconstruction = reconstruction.detached() self._touch() - self._announce(self.on_samples_changed) + self._announce(self.on_voices_changed) self._announce(self.on_song_changed) + return voice.reconstruction - 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) - def _clamp_transpose(self, transpose: Optional[int]) -> Optional[int]: - if transpose is None: - return None - - return clamp(transpose, MIN_TRANSPOSE, MAX_TRANSPOSE) + @staticmethod + def _clamp_pitch(channel: ChannelName, pitch: Optional[RowPitch]) -> Optional[RowPitch]: + """A pitch held inside what the channel plays: a note within its range, a step within a row's.""" + match pitch: + case None: + return None + case Note(): + return clamped_note(channel, pitch.value) + case Step(): + return clamped_step(pitch.value) def _clamp_volume(self, volume: Optional[int]) -> Optional[int]: if volume is None: @@ -297,17 +351,17 @@ def _clamp_volume(self, volume: Optional[int]) -> Optional[int]: def _existing_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, ) -> Row: """Reads a row even before its pattern has been created. An order position may reference an empty (uncreated) pattern; partial and - clear edits treat that as a blank row, and :meth:`set_row` materialises the + clear edits treat that as a blank row, and :meth:`set_row` materializes 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,12 +369,12 @@ def _existing_row( def set_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, *, command: Optional[NoteCommand] = None, - transpose: Optional[int] = None, + pitch: Optional[RowPitch] = None, volume: Optional[int] = None, ) -> None: """Replaces the whole row with the given values; omitted fields are cleared. @@ -330,15 +384,15 @@ def set_row( """ row = Row( command=command, - transpose=self._clamp_transpose(transpose), + pitch=self._clamp_pitch(channel, pitch), 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,12 +402,12 @@ def set_row( def update_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, *, command: Optional[NoteCommand] = None, - transpose: Optional[int] = None, + pitch: Optional[RowPitch] = None, volume: Optional[int] = None, ) -> None: """Updates only the provided subcolumns, preserving the rest of the row. @@ -362,24 +416,24 @@ 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, - transpose=transpose if transpose is not None else existing.transpose, + pitch=pitch if pitch is not None else existing.pitch, volume=volume if volume is not None else existing.volume, ) def clear_row( self, - generator: GeneratorName, + channel: ChannelName, pattern_index: int, row_index: int, *, - instrument: bool = True, - transpose: bool = True, + voice: bool = True, + pitch: bool = True, volume: bool = True, ) -> None: """Clears the selected subcolumns of a row, preserving the rest. @@ -388,13 +442,13 @@ 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, - transpose=None if transpose else existing.transpose, + command=None if voice else existing.command, + pitch=None if pitch else existing.pitch, volume=None if volume else existing.volume, ) @@ -410,11 +464,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) @@ -494,11 +548,15 @@ def _touch(self) -> None: ``on_mutation`` fires for every mutation as it lands, batch or no batch, so the history keeps seeing each one inside the transaction that caused it — that - immediacy is what its completeness check rests on. It is invoked through a - direct ``None`` check so mutations stay silent in history-free contexts (tests, - tools), where the hook is intentionally unwired and :meth:`CallbackMixin.call` - would log a warning for each one. + immediacy is what its completeness check rests on. It fires once the stamp is + made, and also when stamping raises, so a gesture failing there is still rolled + back over a mutation the history counted. It is invoked through a direct ``None`` + check so mutations stay silent in history-free contexts (tests, tools), where the + hook is intentionally unwired and :meth:`CallbackMixin.call` would log a warning + for each one. """ - self._stamp() - if self.on_mutation is not None: - self.on_mutation() + try: + self._stamp() + finally: + if self.on_mutation is not None: + self.on_mutation() diff --git a/src/sampletones_application/logic/project/manager.py b/src/sampletones_application/logic/project/manager.py index 69a40d2e5..8b475c206 100644 --- a/src/sampletones_application/logic/project/manager.py +++ b/src/sampletones_application/logic/project/manager.py @@ -1,28 +1,42 @@ from pathlib import Path +from typing import Callable, Optional from sampletones_core.project import Project, ProjectContainer from sampletones_shared.logger import logger +from sampletones_shared.utils.callbacks import CallbackMixin from .session import ProjectSession +OnPathChangedCallback = Callable[[Optional[Path]], None] -class ProjectManager: + +class ProjectManager(CallbackMixin): """ - The single authority on which project is currently open and whether it is clean. + The single authority on which project is currently open, which file it stands for, and whether it is clean. - - It is a passive data holder; lifecycle events are emitted by its ``session``. - - Callers that need to react to lifecycle transitions should subscribe to - ``session.on_state_changed``. + - Lifecycle events are emitted by its ``session``; callers that need to react to lifecycle + transitions subscribe to ``session.on_state_changed``. + - The project stands for the file it was last loaded from or saved to, its :attr:`path`. A + project made here stands for a file once it is first saved. ``on_path_changed`` reports each new + path, so the session remembers the project the next run reopens. """ def __init__(self) -> None: self._session: ProjectSession = ProjectSession() self._current: Project = Project.create() + self._path: Optional[Path] = None + + self.on_path_changed: Optional[OnPathChangedCallback] = None @property def current(self) -> Project: return self._current + @property + def path(self) -> Optional[Path]: + """The file the open project was last loaded from or saved to, ``None`` for a project never written.""" + return self._path + @property def session(self) -> ProjectSession: return self._session @@ -41,22 +55,33 @@ def is_open(self) -> bool: def new(self) -> None: self._current = Project.create() + self._set_path(None) self._session.mark_loaded("") def close(self) -> None: self._current = Project.create() + self._set_path(None) self._session.mark_closed() def load(self, path: Path) -> None: logger.info(f"Loading project: {logger.format_path(path)}") self._current = ProjectContainer.load(path) + self._set_path(path) self._session.mark_loaded(path.stem) logger.info(f"Project {logger.format_path(path)} loaded successfully") def save(self, path: Path) -> None: ProjectContainer.save(self._current, path) + self._set_path(path) self._session.mark_saved(path.stem) + def _set_path(self, path: Optional[Path]) -> None: + if path == self._path: + return + + self._path = path + self.call(self.on_path_changed, path) + def mark_updated(self) -> None: self._session.mark_updated() 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..c5d50af92 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/audition.py @@ -0,0 +1,172 @@ +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.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) + + 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) -> 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. A device that refuses the audition reports through + ``on_audition_error``. + """ + try: + sounding = self._audio_device_manager.play( + audio, + update=True, + priority=PlaybackPriority.PREVIEW, + owner=self, + ) + except PlaybackError as exception: + 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/logic.py b/src/sampletones_application/logic/reconstruction/browser/logic.py index 4b512b023..f2b2e1ec6 100644 --- a/src/sampletones_application/logic/reconstruction/browser/logic.py +++ b/src/sampletones_application/logic/reconstruction/browser/logic.py @@ -1,7 +1,9 @@ from pathlib import Path +from typing import Optional, Tuple from sampletones_application.config.managers.config import ConfigManager from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel from sampletones_core.structures.tree import Tree from sampletones_shared.utils.system.filesystem import remove_path @@ -23,6 +25,10 @@ def refresh_tree(self) -> None: reconstructions_directory = self._config_manager.get_reconstructions_directory() self._browser_manager.set_reconstructions_directory(reconstructions_directory) + def recordings(self, path: Path) -> Optional[Tuple[NamedRecordingViewModel, ...]]: + """The recordings the reconstruction at ``path`` names, and None until it has been read.""" + return self._browser_manager.recordings(path) + def remove_path(self, path: Path) -> None: remove_path(path) self.refresh_tree() diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index 4fd3deef9..31337b894 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -1,8 +1,12 @@ from pathlib import Path -from typing import List, Tuple +from typing import List, Optional, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.reconstruction.browser.stems import ( + ReconstructionStemsReader, + RecordingsCallback, +) from sampletones_application.logic.reconstruction.browser.tree.collapse import ( collapse_single_child_containers, ) @@ -22,15 +26,21 @@ from sampletones_application.logic.reconstruction.browser.tree.scan import ( scan_reconstructions, ) +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode +from sampletones_shared.utils.callbacks import CallbackMixin -class BrowserManager: +class BrowserManager(CallbackMixin): """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. A refresh scans the directory, builds the configuration branch and the sample branch from that one reading, shapes what came out — empty headings pruned, lone headings folded into the row they lead to, siblings ordered — and publishes the result as the tree both browser tabs render. + + What each reconstruction holds is read from the documents themselves, one at a time and kept, + and announced through ``on_recordings_read`` for whoever asked before the reading landed. Both + tabs render one tree, so one reading answers for both. """ def __init__( @@ -42,9 +52,12 @@ def __init__( self._language_manager = language_manager self.config_manager = config_manager self.reconstructions_directory = config_manager.get_reconstructions_directory() + self.on_recordings_read: Optional[RecordingsCallback] = None self.tree = Tree() self._scan = ReconstructionScan(entries=()) + self._stems_reader = ReconstructionStemsReader() + self._stems_reader.on_recordings_read = self._announce_recordings def set_reconstructions_directory(self, directory: Path) -> None: self.reconstructions_directory = directory @@ -80,6 +93,13 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: order_children(container_root) return container_root + def recordings(self, path: Path) -> Optional[Tuple[NamedRecordingViewModel, ...]]: + """The recordings the reconstruction at ``path`` names, and None until it has been read.""" + return self._stems_reader.recordings(path) + + def _announce_recordings(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + self.call(self.on_recordings_read, path, recordings) + def get_all_reconstruction_files(self) -> List[Path]: return sorted({entry.path for entry in self._scan.reconstructions}) diff --git a/src/sampletones_application/logic/reconstruction/browser/stems.py b/src/sampletones_application/logic/reconstruction/browser/stems.py new file mode 100644 index 000000000..f02ff1959 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/stems.py @@ -0,0 +1,100 @@ +import threading +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple + +from sampletones_application.logic.reconstruction.ownership import named_recordings +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.logger import logger +from sampletones_shared.utils.callbacks import CallbackMixin + +RecordingsCallback = Callable[[Path, Tuple[NamedRecordingViewModel, ...]], None] + + +@dataclass(frozen=True) +class _ReadRecordings: + """What one reading of a document found, held against the state of the file it was read from.""" + + modified_at: Optional[float] + recordings: Tuple[NamedRecordingViewModel, ...] + + +class ReconstructionStemsReader(CallbackMixin): + """Answers which recordings a reconstruction on disk is made of, reading each document once. + + A row names a file, and only the file says what it holds, so the answer costs a read. The read + runs on a worker and the answer is kept against the moment the file was last written, which + makes a row asked about again answer at once and a regenerated document read afresh. A pointer + crossing many rows leaves the read of the row it comes to rest on, since a request replaces the + one still waiting. + + ``on_recordings_read`` announces an answer on the render thread, for the questions the reading + outlived. A document that cannot be read answers with nothing, which leaves whoever asked with + nothing to show. + """ + + def __init__(self) -> None: + self.on_recordings_read: Optional[RecordingsCallback] = None + + self._executor = LatestWinsExecutor() + self._lock = threading.Lock() + self._read: Dict[Path, _ReadRecordings] = {} + + def recordings(self, path: Path) -> Optional[Tuple[NamedRecordingViewModel, ...]]: + """The recordings the document names, in record order, and ``None`` while it stands unread. + + Asking for an unread document starts the reading that answers it, so one call both says + what is known and sets about learning the rest. + + Args: + path: The reconstruction file the row names. + + Returns: + Optional[Tuple[NamedRecordingViewModel, ...]]: The recordings, or None until the reading lands. + """ + modified_at = self._modified_at(path) + with self._lock: + read = self._read.get(path) + + if read is not None and read.modified_at == modified_at: + return read.recordings + + self._executor.submit(partial(self._read_document, path, modified_at)) + return None + + def _read_document(self, path: Path, modified_at: Optional[float]) -> None: + recordings = self._recordings_of(path) + with self._lock: + self._read[path] = _ReadRecordings( + modified_at=modified_at, + recordings=recordings, + ) + + CallbackQueue.add(self._announce, path, recordings) + + def _announce(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + self.call(self.on_recordings_read, path, recordings) + + @staticmethod + def _recordings_of(path: Path) -> Tuple[NamedRecordingViewModel, ...]: + """The recordings the document at ``path`` names, and nothing where it cannot be read.""" + try: + stems_data = Reconstruction.read_stems_data(path) + except (OSError, SampleToNESError) as exception: + logger.debug(f'Failed to read the recordings of "{path}": {exception}') + return () + + return named_recordings(stems_data) + + @staticmethod + def _modified_at(path: Path) -> Optional[float]: + """When the file was last written, and nothing where the disk does not answer for it.""" + try: + return path.stat().st_mtime + except OSError: + return None diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py index 1bb4026c3..24b0ae067 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py @@ -10,14 +10,19 @@ def collapse_single_child_containers(node: TreeNode) -> None: A heading leading to one row asks the reader to open a level that tells them nothing new, so the row takes the heading's name ahead of its own and rises into its place. Working from the deepest - rows upwards folds a whole chain at once, one separator per level: with a single configuration + rows upward folds a whole chain at once, one separator per level: with a single configuration present the configuration branch reads ``44.1 kHz·30 Hz·FFT·γ0·PTN`` as one row, and it grows back 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. + + A heading naming something somebody chose — an audio file, a source folder — hands the row a name + that is no longer the configuration's own text, and the row records that it gathered a plain name. + A reader of configuration text, such as the font a row is drawn in, meets it as the plain name it + has become. """ for child in list(node.children): collapse_single_child_containers(child) @@ -34,21 +39,31 @@ 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: child = node.children[0] child.name = _joined_name(node, child) + child.gathered_plain_name = child.gathered_plain_name or not node.states_configuration child.parent = node.parent node.parent = 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/prune.py b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py index 1bc61eef9..3f664b213 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/prune.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py @@ -8,7 +8,7 @@ def prune_empty_containers(node: TreeNode) -> None: """Drops the containers the browser invents that gather nothing, deepest first. A group or a sample is a heading the browser writes itself, so one left holding nothing says - nothing and leaves. Working from the deepest rows upwards lets a whole chain of such headings go + nothing and leaves. Working from the deepest rows upward lets a whole chain of such headings go at once, the branch root among them, which keeps a reconstructions directory holding nothing to show silent. A folder the disk holds stays where it is, since the configuration branch reads the disk as it is. 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..db1d0f0d5 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -1,15 +1,23 @@ 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.logic.shared.renders import RenderCache from sampletones_application.view_model.shared.waveform_data import WaveformData -from sampletones_core.audio import load_audio +from sampletones_core.audio import 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.reconstruction.recordings import load_recordings +from sampletones_core.reconstructions.reconstruction.renders import rendered_length +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,8 +26,7 @@ class ReconstructionData: name: str config: Config reconstruction: Reconstruction - original_audio: Optional[np.ndarray] - feature_data: FeatureData + stem_audios: Tuple[np.ndarray, ...] filepath: Optional[Path] @classmethod @@ -28,7 +35,7 @@ def load(cls, path: Path) -> Self: return cls._assemble( reconstruction, filepath=path, - name=cls._derive_name(reconstruction, path), + name=cls._derive_name(path), ) @classmethod @@ -39,6 +46,9 @@ def from_reconstruction(cls, reconstruction: Reconstruction, *, name: str) -> Se holds, so changes propagate live. Such a sample lives only in memory, hence ``filepath`` is ``None`` and its display name is supplied by the caller (the project sample's name). + + A document whose file was removed in the Browser is wrapped the same way, under the name + it had. It keeps the locations of its recordings, so its original audio loads again from them. """ return cls._assemble( reconstruction, @@ -50,35 +60,53 @@ def detached_copy(self, filepath: Path) -> Self: """Builds an independent, file-backed copy of this data anchored at ``filepath``. 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. + document. The copy holds a reconstruction object of its own, which shares every part with + the one it was copied from, 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 + copy shares the loaded recordings, so the original audio carries over without a reload. """ - reconstruction = self.reconstruction.model_copy(deep=True) + reconstruction = self.reconstruction.model_copy() return replace( self, reconstruction=reconstruction, config=reconstruction.config, - feature_data=FeatureData.load(reconstruction), filepath=filepath, - name=self._derive_name(reconstruction, filepath), + name=self._derive_name(filepath), ) 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 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. A document naming an entry this one holds no + recording for, such as one a restore brings back, comes back with no recording at all, + the way a document whose audio fails to load does. + """ + if not self.stem_audios: + return () + + positions = {entry.id: index for index, entry in enumerate(self.reconstruction.stems_data.config.entries)} + incoming = reconstruction.stems_data.config.entries + if any(entry.id not in positions for entry in incoming): + return () + + return tuple(self.stem_audios[positions[entry.id]] for entry in incoming) + @classmethod def _assemble( cls, @@ -87,63 +115,157 @@ def _assemble( filepath: Optional[Path], name: str, ) -> Self: - original_audio = cls._load_original_audio(reconstruction) - feature_data = FeatureData.load(reconstruction) - return cls( config=reconstruction.config, reconstruction=reconstruction, - original_audio=original_audio, - feature_data=feature_data, + stem_audios=cls._load_stem_audios(reconstruction), filepath=filepath, name=name, ) @staticmethod - def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str: - """Names the document after its source audio when present, otherwise after the file. + def _derive_name(filepath: Path) -> str: + """Names a document on disk after its file, for display and export. - A file-backed reconstruction keeps the audio's name for display and export; a detached - reconstruction (no source audio) falls back to the ``.stn`` filename. + The converter names the file from every recording the conversion read, and a recording + the document lets go of leaves that name standing, so the file carries the one name a + document keeps whatever it holds. """ - audio_filepath = reconstruction.audio_filepath - return audio_filepath.stem if audio_filepath is not None else filepath.stem + 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. - 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. - """ - audio_filepath = reconstruction.audio_filepath - if audio_filepath is None: - return None + Every recording is read at the level its conversion read it at, which the document + records, so one heard on its own sounds at the level it holds in the mix, and it keeps + that level once another recording leaves the document. - config = reconstruction.config + 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. + """ try: - return load_audio( - path=audio_filepath, - target_sample_rate=config.library.sample_rate, - normalize=config.general.normalize, - quantize=config.general.quantize, - ) - except (FileNotFoundError, IsADirectoryError, PermissionError, OSError): - logger.warning(f"Could not load original audio from '{audio_filepath}'. The original is unavailable") + return load_recordings(reconstruction) + 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) -> Optional[np.ndarray]: + """The original audio of the stems heard anywhere. + + Returns: + Optional[np.ndarray]: The mix of the recordings heard, silence where the recordings + loaded and none of them is heard, and ``None`` where no recording loaded, which leaves + the approximation on its own in playback and the display. + """ + if not self.stem_audios: return None - def waveform_data(self) -> WaveformData: - """Projects the slice of this data the waveform display renders.""" + 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(rendered_length(self.reconstruction), dtype=np.float32) + + return mix(recordings) + + def waveform_data( + self, + renders: RenderCache, + selection: Optional[StemSelection] = None, + ) -> WaveformData: + """Projects the slice of this data the waveform display renders, the audio read from ``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(renders) + + return self._filtered_waveform( + renders, + selection, + self.reconstruction.stems_data, + ) + + def _unfiltered_waveform(self, renders: RenderCache) -> WaveformData: + """The whole document: every channel's rendered audio and the full original.""" + return self._waveform_data( + self.original_audio, + renders.channels(self.reconstruction), + renders.mix(self.reconstruction), + ) + + def _filtered_waveform( + self, + renders: RenderCache, + selection: StemSelection, + stems_data: StemsData, + ) -> WaveformData: + """The selected stems' frames and their original mix, in the unfiltered shape.""" + approximations = filter_approximations( + stems_data, + renders.channels(self.reconstruction), + 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, + sample_rate=self.reconstruction.config.sample_rate, ) - def get_partials(self, generator_names: List[GeneratorName]) -> np.ndarray: - return self.waveform_data().partials(generator_names) + def get_partials( + self, + renders: RenderCache, + channel_names: List[ChannelName], + ) -> np.ndarray: + return self.waveform_data(renders).partials(channel_names) + + def partials_for( + self, + renders: RenderCache, + channel_names: List[ChannelName], + selection: StemSelection, + ) -> np.ndarray: + """Sums the selected channels with the unselected stems' frames silenced.""" + return self.waveform_data(renders, 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..7f9643183 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/edit.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import Optional, TypeAlias, Union + +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.transaction import CoalesceKey +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.reconstructions import Reconstruction + + +@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 + + @property + def history_action(self) -> HistoryAction: + return HistoryAction.EDIT_RECONSTRUCTION + + def coalesce_key(self, voice_id: str) -> Optional[CoalesceKey]: + """Consecutive edits of one sample run together, so a graph movement records one entry.""" + return (voice_id,) + + +@dataclass(frozen=True) +class StemRemoval: + """A recording taken out of the reconstruction, named as the history reports it.""" + + reconstruction: Reconstruction + stem_name: str + + @property + def history_action(self) -> HistoryAction: + return HistoryAction.EDIT_RECONSTRUCTION + + def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]: + """Each removal stands on its own, so one undo puts one recording back.""" + return None + + +@dataclass(frozen=True) +class Retune: + """The reconstruction re-timed to another NES frequency, every instruction carried over.""" + + reconstruction: Reconstruction + nes_frequency: int + + @property + def history_action(self) -> HistoryAction: + return HistoryAction.SET_NES_FREQUENCY + + def coalesce_key(self, _voice_id: str) -> Optional[CoalesceKey]: + """A retune joins the rate change it follows, so one undo restores the rate and the audio together.""" + return (self.nes_frequency,) + + +ReconstructionEdit: TypeAlias = Union[ChannelEdit, StemRemoval, Retune] diff --git a/src/sampletones_application/logic/reconstruction/editing.py b/src/sampletones_application/logic/reconstruction/editing.py new file mode 100644 index 000000000..fb211c06a --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/editing.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Optional, Protocol, Union + +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_core.constants.enums import 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 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[ChannelEnvelopesViewModel, 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..57e9e7515 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/editor.py @@ -0,0 +1,94 @@ +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, +) +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 holds_instrument(self) -> bool: + """Whether the tab was given an instrument, which it keeps until it lets go, even once the voice leaves.""" + return self._voice_id is not None + + @property + def instrument(self) -> Optional[Instrument]: + """The instrument in front of the tab, or ``None`` where it holds a reconstruction or nothing.""" + 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(), + ) + + return self._reconstruction_manager.current_features + + 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/envelopes.py b/src/sampletones_application/logic/reconstruction/envelopes.py new file mode 100644 index 000000000..14f9a0f01 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/envelopes.py @@ -0,0 +1,69 @@ +from typing import Dict + +from sampletones_application.logic.reconstruction.ownership import ( + ownership_lanes, + tells_owners_apart, +) +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_application.view_model.shared.ownership import OwnershipLaneViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection + + +def heard_envelopes( + reconstruction: Reconstruction, + selection: StemSelection, +) -> ChannelEnvelopesViewModel: + """The envelopes of the part each channel plays for the recordings a reader hears. + + A reconstruction exports one entry per channel whatever it sounds, so a subscript answers + for any of them and :attr:`Features.has_frames` says which ones play. What is drawn, what is + measured and what an export writes are one reading, so a recording switched off on a channel + leaves the envelopes it held there, and a channel every recording is switched off on + describes no frame at all. + + Args: + reconstruction: The reconstruction being read. + selection: The recordings the reader hears, channel by channel. + + Returns: + ChannelEnvelopesViewModel: One entry per channel the reconstruction exports. + """ + channels = { + ChannelName(generator_name): features + for generator_name, features in reconstruction.export_heard(selection).items() + } + return ChannelEnvelopesViewModel( + channels=channels, + ownership=_lanes(reconstruction, selection, channels), + ) + + +def _lanes( + reconstruction: Reconstruction, + selection: StemSelection, + channels: Dict[ChannelName, Features], +) -> Dict[ChannelName, OwnershipLaneViewModel]: + """The stretches under each channel's bars, held to the frames those bars describe. + + Every channel the panel plots takes a lane, whatever the reader has switched on beneath the + waveform: the channel boxes answer for the waveform, the stems card's muted tint and an + export's scope. A lane reaches the last frame the channel's readings describe, so it stands + over the bars drawn from them and no further. A dimension's bars are one reading of one + channel, so a document answering to a single owner has nothing beneath them to tell apart. + """ + stems_data = reconstruction.stems_data + if not tells_owners_apart(stems_data): + return {} + + owned = stems_data.assignments_by_channel + assignments = { + channel_name: owned[channel_name][: features.frame_count] + for channel_name, features in channels.items() + if features.has_frames and channel_name in owned + } + return ownership_lanes(assignments, selection.stems_for) diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py deleted file mode 100644 index a6a653f61..000000000 --- a/src/sampletones_application/logic/reconstruction/feature.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Dict, Optional, cast - -import numpy as np - -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.reconstructions import Reconstruction - - -@dataclass(frozen=True) -class FeatureData: - """The envelopes of every channel a reconstruction holds, keyed by channel. - - A reconstruction exports one entry per channel whatever it sounds, so a subscript answers - for any of them and :attr:`Features.has_frames` says which ones play. - """ - - generators: Dict[GeneratorName, Features] - - def __getitem__(self, generator_name: GeneratorName) -> Features: - return self.generators[generator_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) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index aec69a882..84c282f55 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -1,221 +1,322 @@ -from typing import Callable, Dict, FrozenSet, Optional +from typing import Callable, Dict, Optional, Protocol, Set -import numpy as np - -from sampletones_application.layout.behavior.scheduling.scheduling import ( - SchedulingBehavior, +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +from sampletones_application.logic.reconstruction.editing import ( + InstrumentEdit, + InstrumentEditingProtocol, +) +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, ) -from sampletones_application.logic.reconstruction.manager import ReconstructionManager -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, stands_by +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], - None, -] + +class PendingChangesProtocol(Protocol): + """The changes a reader made that are still on their way to the open document. + + The panel shows what the document will hold, so every reading of a reconstruction's envelopes + passes through the changes waiting to land. + """ + + def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewModel: + """The document's envelopes with every change on its way written over them.""" class ReconstructionInstrumentsLogic(CallbackMixin): + """What the instruments panel shows of the voice in front of it, and where an edit to it goes. + + A reconstruction's channel moved on the panel travels out as a :class:`ChannelChange`, which + the document takes in its turn. Until it lands, the envelopes the panel draws and the figures + measuring them read through the changes on their way, so they answer for what the document + will hold. + """ + def __init__( self, - reconstruction_manager: ReconstructionManager, - *, - scheduling: SchedulingBehavior, + editor: InstrumentEditingProtocol, + pending_changes: PendingChangesProtocol, ) -> None: - self.reconstruction_manager = reconstruction_manager - self._scheduling = scheduling + self._editor = editor + self._pending_changes = pending_changes - self._pending_reconstruction_update: Optional[ReconstructionUpdate] = None + self._silenced: Set[ChannelName] = set() self.on_view_changed: Optional[Callable[[ReconstructionInstrumentsViewModel], None]] = None - self.on_feature_data_changed: Optional[Callable[[Optional[Dict[GeneratorName, Features]]], None]] = None - self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None + self.on_feature_data_changed: Optional[Callable[[Optional[ChannelEnvelopesViewModel]], None]] = None + self.on_channel_changed: Optional[Callable[[ChannelChange], None]] = 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. + + A document opened, a recording removed and a new choice of what is heard each change what + the panel shows, so each redraws it from the document. The cards beside the panel describe + the same voice, so the render is reported once it has been made and they settle on it: an + edit to an instrument redraws its waveform here. + """ + self.call(self.on_view_changed, self._build_view_model(self._measured_channels())) + self.call(self.on_feature_data_changed, self._displayed_features()) + self.call(self.on_display_refreshed) + + def _displayed_features(self) -> Optional[ChannelEnvelopesViewModel]: + """The envelopes the panel plots: 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, and it answers to no recording, so it carries no stretches. + """ + instrument = self.instrument_edit + if instrument is not None: + return ChannelEnvelopesViewModel( + channels=self._instrument_channels(instrument), + ownership={}, + ) + + return self._reconstruction_envelopes() + + @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. + """Reports which channels play and the sizes they occupy, redrawing only a channel an edit silenced. + + This answers a document whose envelopes the panel already draws: a regeneration of the + panel's own edit, or a retune, which carries every envelope over. The byte figures and the + standing-by channels settle on what an instrument now exports, and the envelopes stay as + drawn, so a field the user is still typing in keeps what they wrote. A channel the edit + silenced stands by in the document once the regeneration lands, holding no frame, so the + panel draws it empty and the next edit starts from what the document holds. A document + rewritten anywhere else is drawn whole through :meth:`update_display`. + """ + self.call( + self.on_view_changed, + self._build_view_model(self._measured_channels()), + ) + self._redraw_silenced() + + def _redraw_silenced(self) -> None: + """Draws the channels an edit silenced as the document now holds them, once they stand by there.""" + envelopes = self._reconstruction_envelopes() + if envelopes is None: + return + + standing = { + channel_name: envelopes[channel_name] + for channel_name in self._silenced + if not envelopes[channel_name].has_frames + } + if not standing: + return + + self._silenced.difference_update(standing) + self.call( + self.on_feature_data_changed, + ChannelEnvelopesViewModel( + channels=standing, + ownership={ + channel_name: lane for channel_name, lane in envelopes.ownership.items() if channel_name in standing + }, + ), + ) + + def _measured_channels(self) -> Optional[Dict[ChannelName, Features]]: + """The channels of the reconstruction in front of the panel, as its figures measure them. - A regeneration replaces what an instrument exports, so the byte figures and the standing-by - channels settle on it. The envelopes themselves are left to the edit that started the - regeneration, so a field the user is still typing in keeps what they wrote. + A channel measures as the document will hold it once the changes on their way land. A + change silencing every frame of a channel leaves it standing by, so that channel measures + standing by from the moment the change is made, with no frame and no figure. """ - self.call(self.on_view_changed, self._build_view_model(self._current_generators())) + document = self._document_envelopes() + if document is None: + 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 + return { + channel_name: ( + features if features == document[channel_name] else self._as_regenerated(channel_name, features) + ) + for channel_name, features in self._pending_changes.drawn(document).channels.items() + } + + @staticmethod + def _as_regenerated(channel_name: ChannelName, features: Features) -> Features: + """The envelopes a channel holds once the regeneration has rebuilt it from ``features``. + + A channel the envelopes silence rests through every frame, which the document stands by, + describing no frame. + """ + if stands_by(channel_name, features): + return features.leave_to_channel(features.envelopes) + + return features + + def _reconstruction_envelopes(self) -> Optional[ChannelEnvelopesViewModel]: + """The envelopes of the reconstruction in front of the panel, the changes on their way included.""" + document = self._document_envelopes() + return None if document is None else self._pending_changes.drawn(document) + + def _document_envelopes(self) -> Optional[ChannelEnvelopesViewModel]: + """The envelopes the reconstruction in front of the panel holds, where it holds one and no instrument.""" + match self._editor.edited_instrument(): + case ChannelEnvelopesViewModel() as envelopes: + return envelopes + case _: + return None + + @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: - """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 - **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. + channels: Dict[ChannelName, Features], + ) -> VoiceFootprintViewModel: + """Measures the raw size of each playing channel's instrument. + + Each instrument is measured at the whole lengths its own envelopes state. A channel standing + by is written nowhere, so it is measured nowhere and the sample's total names the channels + that play. """ - 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: - self._schedule_reconstruction_update( - ReconstructionUpdate( - generator_name, - FeatureKey.INITIAL_PITCH, - value, - ) - ) - - def handle_bar_point_clicked( - 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, - ) - ) + """Moves the pitch one channel of a reconstruction has its frames measured against. - def handle_raw_data_changed( - 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, - ) - ) - - def _report_edited_size( - self, - generator_name: GeneratorName, - feature_key: FeatureKey, - data: np.ndarray, - ) -> None: - """Reports what the edited envelope costs as the edit arrives, ahead of its regeneration. - - Measuring the envelope the user just wrote keeps the figures answering what is on screen - while the reconstruction is still being rebuilt. The regenerated instruments report again - once they land, so the figures settle on the exported form. + 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 as a change of that channel. The panel offers + the pitch on a reconstruction's channels alone, so a change reaching it while it shows an + instrument, or a voice that has gone, draws the panel as it now stands and goes nowhere. """ - generators = self._current_generators() - if generators is None: - return - - self.call( - self.on_view_changed, - self._build_view_model( - self._with_edit( - generators, - generator_name, - feature_key, - data, + match self._editor.edited_instrument(): + case ChannelEnvelopesViewModel() as document: + self._change( + document, + ChannelChange( + channel_name=channel_name, + feature_key=FeatureKey.INITIAL_PITCH, + envelopes={}, + initial_pitch=value, + ), ) - ), - ) + case _: + self.update_display() - def _with_edit( + def handle_envelope_changed( self, - generators: Dict[GeneratorName, Features], - generator_name: GeneratorName, + channel_name: ChannelName, 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, + envelope: Envelope[int], ) -> None: - """Coalesces a burst of edits into the latest pending update, then hands it off promptly. + """Takes one dimension as an edit leaves it, values and loop point together. - The slot keeps only the newest update so events arriving within the short debounce - collapse into one. A dedicated, brief delay keeps the hand-off responsive; the - regeneration service then applies last-wins across whatever it receives, so the final - edit of a continuous drag is always applied. + 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. An instrument stands + on no audio, so an edit reaches it at once, while a reconstruction's dimension travels back + out as a change of its channel. An edit reaching a panel whose voice has gone draws the + panel as it now stands and goes nowhere. """ - self._pending_reconstruction_update = update - CallbackQueue.add( - self._on_reconstruction_update_scheduled, - priority=self._scheduling.priorities.schedule, - delay=self._scheduling.delays.reconstruction_update, - ) + match self._editor.edited_instrument(): + case InstrumentEdit(): + self._editor.write_envelope(feature_key, envelope) + self.update_display() + case ChannelEnvelopesViewModel() as document: + self._change( + document, + ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: envelope}, + initial_pitch=None, + ), + ) + case None: + self.update_display() - def _on_reconstruction_update_scheduled(self) -> None: - if self._pending_reconstruction_update is None: - return + def _change(self, document: ChannelEnvelopesViewModel, change: ChannelChange) -> None: + """Sends a change on to the document, and reports the figures it leaves at once. - generator_name, feature_key, data = 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, - ) + The channel is read as the change leaves it before the change goes, so whether it + silences the channel is known by the time the rebuild lands. The figures are reported + afterwards, measuring what the edit leaves ahead of its rebuild. - 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" + Args: + document: The envelopes the open document holds. + change: What the reader moved. + """ + drawn = self._pending_changes.drawn(document) + self._note_silenced(change.channel_name, change.rebased(drawn[change.channel_name])) + self.call(self.on_channel_changed, change) + self.call(self.on_view_changed, self._build_view_model(self._measured_channels())) - return current_features[generator_name] + def _note_silenced(self, channel_name: ChannelName, features: Features) -> None: + """Remembers whether the latest edit of a channel silences it, which its rebuild then shows.""" + if stands_by(channel_name, features): + self._silenced.add(channel_name) + else: + self._silenced.discard(channel_name) diff --git a/src/sampletones_application/logic/reconstruction/listening.py b/src/sampletones_application/logic/reconstruction/listening.py new file mode 100644 index 000000000..4ed21375d --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/listening.py @@ -0,0 +1,138 @@ +from typing import Dict, FrozenSet, Optional, Set + +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID +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 + + +class StemListening: + """Which recordings of the open reconstruction a reader is listening to, channel by channel. + + One choice serves every reading of the document: the frames it admits are the frames the + waveform draws, the envelopes the instruments panel shows, the figures measuring them, what + playback sounds and what an export writes, and they are the frames an edit reaches. Holding + it beside the open reconstruction is what lets those readings answer from one state. + + The choice is the reader's, so it travels across an edit rather than being remade: a channel + a recording keeps holding frames on keeps whatever was chosen for it, and one the recording + reaches for the first time joins heard. + + Soloing a recording silences every other one and remembers the choice it replaced, so soloing + it again returns to that choice. A choice made by hand afterwards stands as the new state. + """ + + def __init__(self) -> None: + self._offered: Dict[int, FrozenSet[ChannelName]] = {} + self._heard: Dict[int, FrozenSet[ChannelName]] = {} + self._heard_before_solo: Optional[Dict[int, FrozenSet[ChannelName]]] = None + + @property + def offered(self) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each recording holds frames on, which is where its row offers a box.""" + return dict(self._offered) + + @property + def heard(self) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each recording is heard on, as the reader has left them.""" + return dict(self._heard) + + @property + def selection(self) -> StemSelection: + """What the reader hears, read the way the filters ask for it. + + The card keeps the channels each recording is heard on; a 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._heard.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()} + ) + + def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: + """The recordings heard on one channel, which is the scope an edit writes in.""" + return self.selection.stems_for(channel_name) + + def adopt(self, stems_data: StemsData) -> None: + """Carries the reader's choice onto the record as it now stands. + + A recording appearing for the first time offers everything it holds and is heard on all + of it, which is the same rule read against the nothing it offered before, so a freshly + opened reconstruction is heard whole. + """ + offered = offered_channels(stems_data) + self._heard = { + stem_id: (self._heard.get(stem_id, frozenset()) & channels) + | (channels - self._offered.get(stem_id, frozenset())) + for stem_id, channels in offered.items() + } + self._offered = offered + self._heard_before_solo = None + + def set_channels(self, stem_id: int, channels: FrozenSet[ChannelName]) -> None: + """Takes the channels one recording is heard on, as a box on its row leaves them.""" + self._heard[stem_id] = channels + self._heard_before_solo = None + + def solo(self, stem_id: int) -> None: + """Hears one recording on every channel it offers and silences the others. + + The choice in force when the first solo starts is remembered, so soloing the recording + again returns to it, whichever recordings were soloed in between. Where nothing is + remembered, every recording is heard whole. A recording offering no channel has nothing + to hear alone. + """ + if not self._offered.get(stem_id): + return + + if self._is_alone(stem_id): + restored = self._heard_before_solo if self._heard_before_solo is not None else dict(self._offered) + self._heard_before_solo = None + self._heard = restored + return + + if not any(self._is_alone(other_id) for other_id in self._offered): + self._heard_before_solo = dict(self._heard) + + self._heard = { + offered_id: channels if offered_id == stem_id else frozenset() + for offered_id, channels in self._offered.items() + } + + def release(self) -> None: + """Lets go of the choice, which is what closing the reconstruction it describes does.""" + self._offered = {} + self._heard = {} + self._heard_before_solo = None + + def _is_alone(self, stem_id: int) -> bool: + """Whether the recording is heard on every channel it offers while no other is heard at all.""" + offered = self._offered.get(stem_id, frozenset()) + if not offered or self._heard.get(stem_id, frozenset()) != offered: + return False + + return all(not channels for other_id, channels in self._heard.items() if other_id != stem_id) + + +def offered_channels(stems_data: StemsData) -> Dict[int, FrozenSet[ChannelName]]: + """The channels each recording holds frames on, which is what its row offers a box for. + + A recording 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. A recording + holding no frame anywhere has left the document, so every recording offers a box somewhere + once any of them holds a frame. The frames the reader wrote gather under the authored stem, + which offers its boxes the same way and appears exactly where it holds a frame. + """ + 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 == AUTHORED_STEM_ID: + offered.setdefault(AUTHORED_STEM_ID, set()).add(channel_name) + elif stem_id in offered: + offered[stem_id].add(channel_name) + + return {stem_id: frozenset(channels) for stem_id, channels in offered.items()} diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index d88078b7a..7d9c986f9 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -1,37 +1,51 @@ +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 -from sampletones_application.logic.reconstruction.feature import FeatureData +from sampletones_application.logic.reconstruction.envelopes import heard_envelopes +from sampletones_application.logic.reconstruction.listening import StemListening from sampletones_application.logic.reconstruction.session import ReconstructionSession +from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) from sampletones_core.reconstructions import Reconstruction from sampletones_shared.logger import logger 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, is_same_path +from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer class ReconstructionManager(CallbackMixin): """ The single authority on which reconstruction is currently loaded. - - It abstracts two loading modes — file-backed and in-memory — behind the + - It abstracts two loading modes — file-backed and a project sample's — behind the same interface. - - For the in-memory case, edits mutate the original object by identity; - no copy is made. + - A project sample is known by its voice id, which the project keeps across a history + restore and a reopened file, so whoever follows the project finds the voice the open + document is. - Dirty and load state are tracked by a separate session object. """ - def __init__(self, *, scheduling: SchedulingBehavior) -> None: + def __init__( + self, + *, + scheduling: SchedulingBehavior, + renders: RenderCache, + ) -> None: self._scheduling = scheduling + self._renders = renders self._session: ReconstructionSession = ReconstructionSession() self._current_reconstruction: Optional[ReconstructionData] = None - self._current_features: Optional[FeatureData] = None - self._reconstruction_hash: str = "" - self._coefficient: float = 1.0 + self._current_features: Optional[ChannelEnvelopesViewModel] = None + self._voice_id: Optional[str] = None + self._listening: StemListening = StemListening() self.on_reconstruction_loaded: Optional[VoidCallback] = None self.on_reconstruction_closed: Optional[VoidCallback] = None @@ -40,9 +54,14 @@ def __init__(self, *, scheduling: SchedulingBehavior) -> None: def session(self) -> ReconstructionSession: return self._session + @property + def renders(self) -> RenderCache: + """Where the audio of the open document is rendered and kept for as long as the screen reads it.""" + return self._renders + def load_reconstruction(self, path: Path) -> None: logger.info(f"Loading reconstruction: {logger.format_path(path)}") - self._adopt_reconstruction(ReconstructionData.load(path)) + self._adopt_reconstruction(ReconstructionData.load(path), voice_id=None) self._session.mark_loaded(path.name) self.call(self.on_reconstruction_loaded) logger.info(f"Reconstruction {logger.format_path(path)} loaded successfully") @@ -52,38 +71,61 @@ def load_reconstruction_object( reconstruction: Reconstruction, *, name: str, + voice_id: str, ) -> None: - """Loads an in-memory reconstruction (e.g. a project sample's) for editing. + """Loads a project sample's reconstruction for editing. - Mirrors :meth:`load_reconstruction` for an object already in memory, so edits - made in the reconstruction tab mutate the same instance the caller holds. The - display name is supplied by the caller, since a detached reconstruction has no - source path to derive it from. + Mirrors :meth:`load_reconstruction` for an object already in memory. The display name is + supplied by the caller, since a detached reconstruction has no source path to derive it + from. The document remembers the sample's voice id, which is how an edit finds the sample + to write back into and how a project change finds the voice the tab shows. """ self._adopt_reconstruction( ReconstructionData.from_reconstruction(reconstruction, name=name), + voice_id=voice_id, ) self._session.mark_loaded(name) self.call(self.on_reconstruction_loaded) - def _adopt_reconstruction(self, reconstruction_data: ReconstructionData) -> None: + def _adopt_reconstruction( + self, + reconstruction_data: ReconstructionData, + *, + voice_id: Optional[str], + ) -> None: """Makes ``reconstruction_data`` the open document and refreshes its derived state. - The coefficient and cached features track whichever reconstruction is open, so every - rebinding funnels through here to recompute them in one place. + The reader's listening choice and the cached features track whichever reconstruction is + open, so every rebinding funnels through here to recompute them in one place. The + listening is carried onto the new record before the features are read, so the envelopes + answer for the part the reader is listening to as it now stands. ``voice_id`` names the + project sample the document is, and ``None`` a standalone document. """ self._current_reconstruction = reconstruction_data - self._coefficient = reconstruction_data.reconstruction.coefficient + self._voice_id = voice_id + self._listening.adopt(reconstruction_data.reconstruction.stems_data) self._load_reconstruction_features() def _load_reconstruction_features(self) -> None: + """Reads the envelopes of the part the reader is listening to. + + Raises: + RuntimeError: If no reconstruction is open to read. + """ if self._current_reconstruction is None: raise RuntimeError("No reconstruction is loaded when trying to load features") reconstruction = self._current_reconstruction.reconstruction - feature_data = FeatureData.load(reconstruction) - self._current_features = feature_data - self._reconstruction_hash = hash_model(reconstruction) + self._current_features = heard_envelopes(reconstruction, self._listening.selection) + + def refresh_features(self) -> None: + """Reads the envelopes again after a change to what the reader is listening to. + + A box on the stems card moves which recordings a channel is heard on, and the plot, + the figures and an export all read from the same envelopes, so they follow it together. + """ + if self._current_reconstruction is not None: + self._load_reconstruction_features() def save_reconstruction(self, filepath: Optional[Path] = None) -> bool: """Writes the open reconstruction to disk, reporting whether the write happened. @@ -111,14 +153,18 @@ def save_reconstruction_as(self, filepath: Path) -> None: The write happens first; on success the open document rebinds to an independent, file-backed copy anchored at ``filepath``. A reconstruction that was a project sample is - thereby severed from the project: the copy is a distinct object, so later edits reach only - the saved file and leave the sample intact. A failed write leaves the open document as is. + thereby severed from the project: the copy is a distinct object that names no voice, so + later edits reach only the saved file and leave the sample intact. A failed write leaves + the open document as it was, still the voice it was. """ if self._current_reconstruction is None: return self._write_to_file(self._current_reconstruction.reconstruction, filepath) - self._adopt_reconstruction(self._current_reconstruction.detached_copy(filepath)) + self._adopt_reconstruction( + self._current_reconstruction.detached_copy(filepath), + voice_id=None, + ) self._session.mark_saved(self._current_reconstruction.name) @staticmethod @@ -129,10 +175,9 @@ def _write_to_file(reconstruction: Reconstruction, filepath: Path) -> None: def detach_current_reconstruction(self) -> None: """Re-binds the open reconstruction to its detached, in-memory form. - Adding a file-backed reconstruction to the sequencer turns it into a project sample: its - source audio is detached and it maps to no standalone file. The open document adopts that - form so the reconstruction view reflects the owned sample, keeping the same reconstruction - object so live editing continues. + Removing the file a standalone document was loaded from leaves the document with no file + of its own, so it stays open in memory until the reader saves it elsewhere. It keeps the + same reconstruction object, so live editing continues. """ if self._current_reconstruction is None: return @@ -144,17 +189,19 @@ 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 - previous object is left untouched for the history to retain. + The open document rebinds to the fresh reconstruction object and stays the voice it was, + while the previous object is left untouched for the history to retain. """ if self._current_reconstruction is None: return - self._adopt_reconstruction(self._current_reconstruction.with_reconstruction(reconstruction)) + self._adopt_reconstruction( + self._current_reconstruction.with_reconstruction(reconstruction), + voice_id=self._voice_id, + ) def mark_updated(self) -> None: self._session.mark_updated() @@ -162,8 +209,8 @@ def mark_updated(self) -> None: def close_reconstruction(self) -> None: self._current_reconstruction = None self._current_features = None - self._reconstruction_hash = "" - self._coefficient = 1.0 + self._voice_id = None + self._listening.release() self._session.mark_closed() CallbackQueue.add( self.call, @@ -172,23 +219,43 @@ 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]: return self._current_reconstruction @property - def current_features(self) -> Optional[FeatureData]: + def current_features(self) -> Optional[ChannelEnvelopesViewModel]: return self._current_features + @property + def voice_id(self) -> Optional[str]: + """The project voice the open document is, or ``None`` for a standalone document or none.""" + return self._voice_id + + @property + def is_project_sample(self) -> bool: + """Whether the open document is a sample of the project, whose edits belong to the project.""" + return self._voice_id is not None + + @property + def listening(self) -> StemListening: + """Which recordings of the open document the reader is listening to.""" + return self._listening + @property def reconstruction(self) -> Optional[Reconstruction]: if self._current_reconstruction is None: @@ -207,9 +274,18 @@ def filepath(self) -> Optional[Path]: def is_file_backed(self) -> bool: return self.filepath is not None + def is_backed_by(self, path: Path) -> bool: + """Whether the open document stands for the file at ``path``, however the path is spelled. + + A conversion can write over the file the open document was loaded from or last saved to, + and removing a file from the browser can take the open document's own away. + """ + filepath = self.filepath + return filepath is not None and is_same_path(filepath, path) + @property - def 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/ownership.py b/src/sampletones_application/logic/reconstruction/ownership.py new file mode 100644 index 000000000..cd5d32ebe --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/ownership.py @@ -0,0 +1,110 @@ +from typing import AbstractSet, Callable, Dict, Final, Mapping, Sequence, Tuple + +from sampletones_application.logic.reconstruction.listening import offered_channels +from sampletones_application.view_model.shared.ownership import ( + OwnershipLaneViewModel, + OwnershipRunViewModel, +) +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.ownership import heard_frame, owner_runs + +DISTINGUISHABLE_OWNERS: Final[int] = 2 + +HeardOn = Callable[[ChannelName], AbstractSet[int]] + + +def named_recordings(stems_data: StemsData) -> Tuple[NamedRecordingViewModel, ...]: + """The recordings a document names, in record order, each with the id that picks its color. + + Every surface painting a recording reads its color from the id it was converted as, so a + list of these and a column of swatches beside them agree with the stretches the ribbon draws. + A record whose recordings and entries fail to answer for each other names nothing, a name + worth reading being one that sits where its color does. + + Args: + stems_data: The record the document carries. + + Returns: + Tuple[NamedRecordingViewModel, ...]: One per recording, in record order. + """ + sources = stems_data.sources_by_id + entries = stems_data.config.entries + if sources.keys() != stems_data.config.entries_by_id.keys(): + return () + + return tuple(NamedRecordingViewModel(stem_id=entry.id, name=sources[entry.id].name) for entry in entries) + + +def tells_owners_apart(stems_data: StemsData) -> bool: + """Whether the document holds owners a color has something to tell apart. + + An owner is a recording the record names, or the row the frames a reader wrote gather under: + both take a color of their own and both stand on the stems card, so a document holding one + recording and an edit beside it has two owners to tell apart. + + Args: + stems_data: The record the document carries. + + Returns: + bool: True where two or more owners stand on the record. + """ + return len(offered_channels(stems_data)) >= DISTINGUISHABLE_OWNERS + + +def ownership_lanes( + assignments: Mapping[ChannelName, Sequence[int]], + heard_on: HeardOn, +) -> Dict[ChannelName, OwnershipLaneViewModel]: + """A lane per channel of ``assignments``, each divided into the stretches its owners hold. + + Each surface states its own reading as the assignments it passes — which channels stand, and + how far each lane runs — so the rule dividing a channel into stretches is written once and + the ribbon under the waveform and the band under an instrument's bars agree by construction. + Whether a lane is worth drawing at all is the caller's own question: a surface with room for + only one reading, such as an instrument's bars, asks ``tells_owners_apart`` first and skips + this call where a document answering to a single owner has nothing to tell apart; a surface + reading every channel of a document at once, such as the waveform's ribbon, has a plainer + question to answer instead — whether a channel plays — and takes a lane for every one that + does. + + Args: + assignments: The stem holding each frame, per channel the surface draws. + heard_on: The recordings the reader hears on a channel. + + Returns: + Dict[ChannelName, OwnershipLaneViewModel]: One lane per channel given, in that order. + """ + return { + channel_name: _ownership_lane(channel_name, stem_ids, heard_on(channel_name)) + for channel_name, stem_ids in assignments.items() + } + + +def _ownership_lane( + channel_name: ChannelName, + stem_ids: Sequence[int], + heard: AbstractSet[int], +) -> OwnershipLaneViewModel: + """One channel's lane: the stretches it divides into, each under the recording holding it. + + A stretch keeps the recording holding it and says whether the reader hears it there, so + the lane names an owner wherever the record does and shows the reader's choice on top of it. + A resting stretch answers to no recording, so it takes no stretch of its own and leaves the + ground of whatever surface draws the lane showing through. + """ + return OwnershipLaneViewModel( + channel_name=channel_name, + runs=tuple( + OwnershipRunViewModel( + start_frame=run.start, + end_frame=run.end, + stem_id=run.stem_id, + heard=heard_frame(run.stem_id, heard), + ) + for run in owner_runs(stem_ids) + if run.stem_id != RESTING_STEM_ID + ), + ) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 39e0a7353..dc6660ad2 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -1,30 +1,76 @@ from pathlib import Path -from typing import Callable, Dict, FrozenSet, List, Optional, Protocol, Tuple +from typing import ( + Callable, + Dict, + Final, + FrozenSet, + List, + Optional, + Protocol, + Sequence, + Tuple, +) import numpy as np from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.sources import SourceKind +from sampletones_application.logic.export.instrument.source import ExportableInstrument from sampletones_application.logic.reconstruction.data import ReconstructionData +from sampletones_application.logic.reconstruction.listening import StemListening from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.view_model.reconstruction.reconstruction import ( - ReconstructionPathState, +from sampletones_application.logic.reconstruction.ownership import ownership_lanes +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +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.rate import RateLock +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.ownership import OwnershipRibbonViewModel +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.algorithm import AUTHORED_STEM_ID +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.data import StemsData +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.empty() class ExportServiceProtocol(Protocol): @@ -44,14 +90,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,70 +108,95 @@ 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.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 + self.on_ownership_changed: Optional[Callable[[OwnershipRibbonViewModel], None]] = None + self.on_heard_changed: Optional[VoidCallback] = 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) 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._reconstruction_manager.renders, + self._stem_selection, + ), + self._selected_channels, ) + self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) self._emit_audio_data() - def update_reconstruction(self) -> None: + def update_reconstruction(self, *, refit_waveform: bool = False) -> None: + """Re-answers every reading of the document after an edit. + + ``refit_waveform`` names an edit that moved the audio's own length, such as a retune, + so the waveform's view is re-fitted to the new span rather than held at a position the + old length no longer answers to. + """ reconstruction_data = self._reconstruction_data 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.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._reconstruction_manager.renders, + self._stem_selection, + ), + self._selected_channels, + refit=refit_waveform, ) + self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) 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 +204,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 +219,55 @@ 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, + rate_lock=self._rate_lock(reconstruction_data), ) + def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLock]: + """Why the open document keeps its rate, or ``None`` while the tab may retime it. + + A sample of the project follows the project's rate. A document with no file keeps its + rate until it is saved to one. + """ + if self._reconstruction_manager.is_project_sample: + return RateLock.PROJECT_SAMPLE + + if reconstruction_data.filepath is None: + return RateLock.NO_FILE + + return None + 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.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, + rate_lock=None, ), ) @@ -182,74 +276,280 @@ 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(), - generators, + reconstruction_data.waveform_data( + self._reconstruction_manager.renders, + self._stem_selection, + ), + channels, ) + self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) self._emit_audio_data() - def request_export_instrument_dialog( + def set_stem_channels( self, - generator_name: GeneratorName, + stem_id: int, + channels: FrozenSet[ChannelName], ) -> None: - """Asks for the destination one generator slice is written to. + """Adopts the channels one recording is heard on and re-answers playback and the waveform. - 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. + The choice is listening state, so it filters what plays, what the waveform shows and + what the instruments panel draws while the document stands as it is. + """ + self._listening.set_channels(stem_id, channels) + self._refresh_listening() - Args: - generator_name: The generator whose slice is written. + def solo_stem(self, stem_id: int) -> None: + """Hears one recording alone, or returns to the choice it replaced when it already is. + + Like the boxes on its row, the solo is listening state, so it re-answers what plays and + what the waveform and the instruments panel show while the document stands as it is. """ + self._listening.solo(stem_id) + self._refresh_listening() + + def _refresh_listening(self) -> None: + """Re-answers every reading of the document after the listening choice changed.""" + self._reconstruction_manager.refresh_features() 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()) - self.call( - self.on_open_export_instrument_dialog, - instrument_name, - default_path, - generator_name, + self.on_stems_view_changed, + self._build_stems_view_model(reconstruction_data), + ) + self.call( + self.on_waveform_load_changed, + reconstruction_data.waveform_data( + self._reconstruction_manager.renders, + self._stem_selection, + ), + self._selected_channels, + ) + self.call(self.on_ownership_changed, self._build_ownership_ribbon(reconstruction_data)) + self._emit_audio_data() + self.call(self.on_heard_changed) + + @property + def _listening(self) -> StemListening: + """Which recordings the reader is listening to, which the open document holds.""" + return self._reconstruction_manager.listening + + @property + def _stem_selection(self) -> StemSelection: + return self._listening.selection + + def _build_ownership_ribbon( + self, + reconstruction_data: ReconstructionData, + ) -> OwnershipRibbonViewModel: + """The recordings behind each stretch of what the document plays. + + A lane stands for every channel the document plays, and paints the stretches the + recordings heard there hold, so the ribbon reads as the waveform above it sounds — one + recording throughout paints one unbroken stretch, which still answers whether a channel + is sounding and by whom. A channel the reader has switched off keeps its lane and stands + empty, since the lanes answer for the document while what fills them answers for the + listening: the rows beneath the waveform hold still while a reader picks their way + through it. + """ + stems_data = reconstruction_data.reconstruction.stems_data + owned = stems_data.assignments_by_channel + assignments: Dict[ChannelName, Sequence[int]] = { + channel_name: owned[channel_name] if channel_name in self._selected_channels else () + for channel_name in self._in_channel_order(self._playing_channels) + if channel_name in owned + } + lanes = tuple(ownership_lanes(assignments, self.heard_on).values()) + return OwnershipRibbonViewModel( + lanes=lanes, + frame_length=reconstruction_data.reconstruction.config.frame_length, + total_frames=max((len(owned[lane.channel_name]) for lane in lanes), default=0), + ) + + def heard_on(self, channel_name: ChannelName) -> FrozenSet[int]: + """The recordings the reader hears on one channel, which is the scope an edit writes in. + + What is heard and what is edited are one choice, so an edit reaches exactly the frames + the waveform draws, and a recording switched off on this channel reads there as it stands. + """ + return self._listening.heard_on(channel_name) + + def _build_stems_view_model( + self, + reconstruction_data: ReconstructionData, + ) -> ReconstructionStemsViewModel: + """The recorded assignment as the stems list draws it, banded by the picking levels. + + Each row reads as the recording the document remembers, so one whose file this machine + no longer names — a sample embedded in a project — keeps its name, its boxes and its + place among the levels. A document whose record names its recordings nowhere shows the + card's empty state. + """ + reconstruction = reconstruction_data.reconstruction + stems_data = reconstruction.stems_data + if not stems_data.sources: + return ReconstructionStemsViewModel( + reconstruction_loaded=True, + stems=EMPTY_STEMS_LIST, + ) + + levels = self._levels_with_edits(stems_data) + rows = tuple( + self._stem_row( + stems_data, + stem_id, + level_index, + position, + len(level), + 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), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=False, + selected_key=None, + ), + hierarchy_mode=stems_data.config.hierarchy.mode, + ) + + def _levels_with_edits(self, stems_data: StemsData) -> List[List[int]]: + """The picking levels, with the frames the reader wrote standing in a level of their own. + + The hierarchy orders the recordings a conversion picked between; what the reader wrote + answers to none of it, so it stands after them all wherever it holds a frame. + """ + levels = [list(level) for level in stems_data.config.hierarchy.levels] + if AUTHORED_STEM_ID in self._listening.offered: + levels.append([AUTHORED_STEM_ID]) + + return levels + + def _stem_row( + self, + stems_data: StemsData, + stem_id: int, + level: int, + position: int, + level_size: int, + level_count: int, + ) -> StemRowViewModel: + """One recording's row: what it is called, where it lives, and the boxes it offers.""" + source = stems_data.sources_by_id.get(stem_id) + return StemRowViewModel( + key=str(stem_id), + kind=SourceKind.RECORDING if source is not None else SourceKind.EDITS, + name=source.name if source is not None else "", + path=source.path if source is not None else None, + held=(), + channels=self._listening.heard.get(stem_id, frozenset()), + partial_channels=frozenset(), + offered_channels=self._listening.offered.get(stem_id, frozenset()), + available=source is not None and source.path is not None and source.path.is_file(), + level=level, + position=position, + stem_id=stem_id if stem_id in stems_data.config.entries_by_id else None, + level_size=level_size, + level_count=level_count, ) + def exportable_instrument( + self, + channel_name: ChannelName, + ) -> Optional[ExportableInstrument]: + """The loaded reconstruction's ``channel_name`` slice, ready to be given a destination. + + 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: + channel_name: The channel whose slice is written. + + The slice holds what the reader is listening to, which is what the panel beside it + draws, so an export writes what stands on screen. + + 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. + """ + features = self._heard_features()[channel_name] + if not features.has_frames: + return None + + return ExportableInstrument( + name=self._get_instrument_name(channel_name), + source=InstrumentSource( + channel=channel_name, + features=features, + nes_frequency=self._nes_frequency(), + tuning=self._tuning(), + ), + ) + + def _heard_features(self) -> ChannelEnvelopesViewModel: + """The envelopes of the part the reader is listening to, as the open document reads them. + + Raises: + AssertionError: If no reconstruction is loaded. + """ + features = self._reconstruction_manager.current_features + if features is None: + raise AssertionError("Expected reconstruction data to be loaded before reading its envelopes") + + return features + 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,132 +560,120 @@ 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: logger.warning("No reconstruction data available for instruments export") return - base_name = destination.stem - request = SampleExport( - name=base_name, - instruments=tuple( - self._instrument_export( - generator_name, - feature, - instrument_slice_name(base_name, generator_name), - ) - for generator_name, feature in reconstruction_data.feature_data.generators.items() - if feature.has_frames - ), - nes_frequency=self._nes_frequency(), - ) + request = self._sample_request(destination.stem) 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. + def sample_request(self) -> SampleExport: + """The loaded reconstruction's slices as one export, under the reconstruction's own name. - Args: - destination: The destination the export was confirmed with. - scope: The scope about to be written. - - Returns: - TrackerFormat: The format to write in. + A format asking for its choices in a setup of its own names the export before a + destination exists, so the slices take the name the reconstruction is known by. Raises: - ValueError: If no format able to express ``scope`` claims the extension. + AssertionError: If no reconstruction is loaded. """ - 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") + reconstruction_data = self._reconstruction_data + if not reconstruction_data: + raise AssertionError("Expected reconstruction data to be loaded before exporting instruments") + + return self._sample_request(reconstruction_data.name) - return tracker_format + def _sample_request(self, name: str) -> SampleExport: + """The slice of every playing channel of the reconstruction as one export named ``name``. + + Each slice takes its channel suffix from the name, and a channel describing no frame of + what the reader is listening to is left to rest. + """ + return SampleExport( + name=name, + instruments=tuple( + self._instrument_export( + channel_name, + feature, + instrument_slice_name(name, channel_name), + ) + for channel_name, feature in self._heard_features().channels.items() + if feature.has_frames + ), + nes_frequency=self._nes_frequency(), + tuning=self._tuning(), + ) 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 +681,28 @@ 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._reconstruction_manager.renders, + 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 +712,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 +731,17 @@ 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: + selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection) + if selected_original_audio is None: return None - return AudioData.from_array(original_audio, sample_rate) + 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._reconstruction_manager.renders, + self._selected_channels, + self._stem_selection, + ) return AudioData.from_array(partial_approximation, sample_rate) def _build_path_view_models( @@ -452,9 +751,10 @@ def _build_path_view_models( """Resolves the reconstruction-file and original-audio locations for display. Each location is reported independently. A file-backed reconstruction knows its own file; - a detached one (a project sample) reports not-applicable. Its source audio is available when - the recorded file loaded, not-found when a path is recorded yet its content is unavailable, - and not-applicable when the reconstruction has been detached from its origin. + one with no file, such as a project sample or a document whose file was removed, reports + not-applicable. Its source audio is available when the recorded file loaded, not-found when + a path is recorded yet its content is unavailable, and not-applicable when the + reconstruction has been detached from its origin. """ reconstruction_file = self._build_file_path_view_model(reconstruction_data.filepath) original_audio = self._build_audio_path_view_model( @@ -470,36 +770,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/reconstruction/rewrites/__init__.py b/src/sampletones_application/logic/reconstruction/rewrites/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/rewrites/queue.py b/src/sampletones_application/logic/reconstruction/rewrites/queue.py new file mode 100644 index 000000000..6e69f08e0 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/rewrites/queue.py @@ -0,0 +1,309 @@ +from dataclasses import dataclass, replace +from typing import Callable, List, Optional + +from sampletones_application.logic.reconstruction.edit import ( + ChannelEdit, + ReconstructionEdit, + Retune, + StemRemoval, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.regeneration import ( + RegenerationServiceProtocol, +) +from sampletones_application.logic.reconstruction.rewrites.steps import ( + AfterEdits, + ChannelChange, + RateChange, + Rewrite, + StemRemovalRequest, +) +from sampletones_application.services.regeneration.result import ( + RegeneratedInstrument, + RegenerationResult, +) +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.removal import ( + can_remove_stem, + without_stem, +) +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +@dataclass(frozen=True) +class Rebuild: + """A channel change the regeneration is rebuilding, beside the document it started from. + + Attributes: + change: What the reader moved. + base: The document the rebuild started from, or ``None`` once an outside replacement put + that document away. + """ + + change: ChannelChange + base: Optional[Reconstruction] + + def lands_on(self, reconstruction: Optional[Reconstruction]) -> bool: + """Whether the result belongs to ``reconstruction``, the document it was computed from.""" + return self.base is not None and self.base is reconstruction + + +class ReconstructionRewrites(CallbackMixin): + """The steps that change the open document, taken one at a time in the order the reader asked. + + Every edit of the Reconstructions tab's document passes through here: a channel the reader + moved, a recording taken out, a new NES frequency, and a gesture that reads or puts away the + whole document. Each step reads the document the step before it left, so one edit never + overwrites another and a rebuild always starts from what stands. + + Channel changes wait while a rebuild runs, and a change joins the one waiting at the end of the + line when both move the same channel, so a drag collapses into the place it ended. A rebuild's + result lands only on the document it was computed from. A removal or a whole-document gesture + changes what the panel was drawn on, so a channel change asked for while one waits is refused, + and the panel is redrawn once the line empties. + + The hooks carry the outcome to whoever shows the document: ``on_edit`` with each edit that + lands, ``on_dropped`` when the panel draws a change that will never land, ``on_failed`` with a + rebuild's failure, and ``on_busy_changed`` as the line fills and empties. + """ + + def __init__( + self, + reconstruction_manager: ReconstructionManager, + regeneration: RegenerationServiceProtocol, + ) -> None: + self._manager = reconstruction_manager + self._regeneration = regeneration + self._waiting: List[Rewrite] = [] + self._running: Optional[Rebuild] = None + self._redraw_owed: bool = False + self._reported_busy: bool = False + + self.on_edit: Optional[Callable[[ReconstructionEdit], None]] = None + self.on_dropped: Optional[VoidCallback] = None + self.on_failed: Optional[Callable[[Exception], None]] = None + self.on_busy_changed: Optional[Callable[[bool], None]] = None + + regeneration.subscribe(self._on_result) + + @property + def is_busy(self) -> bool: + """Whether a rebuild runs or a step waits, which is the span the open document is being rewritten.""" + return self._running is not None or bool(self._waiting) + + def request(self, rewrite: Rewrite) -> None: + """Puts a step at the end of the line, and takes it at once where nothing runs before it. + + A channel change joins a waiting change of the same channel, and a new rate replaces a + waiting one, so what the reader asked for last is what lands. + """ + match rewrite: + case ChannelChange() as change: + self._enqueue_change(change) + case RateChange() as rate_change: + self._enqueue_rate(rate_change) + case StemRemovalRequest() | AfterEdits(): + self._waiting.append(rewrite) + + self._advance() + self._settle() + + def drop(self) -> None: + """Lets go of the edits meant for a document an outside replacement puts away. + + The channel changes, removals and rates waiting leave the line, and the running rebuild's + result no longer lands. A whole-document gesture the reader asked for keeps its place, so a + save, a load or an undo still happens, in order, on the document now open. The replacement + draws the document it puts in place, which answers a redraw still owed. + """ + self._waiting = [step for step in self._waiting if isinstance(step, AfterEdits)] + self._redraw_owed = False + if self._running is not None: + self._running = replace(self._running, base=None) + + self._settle() + + def drawn(self, envelopes: ChannelEnvelopesViewModel) -> ChannelEnvelopesViewModel: + """The document's envelopes with every change still on its way written over them, in order. + + The instruments panel draws these, so what the reader sees is what the document will hold + once the line empties. A rebuild whose document was put away writes nothing. + + Args: + envelopes: The envelopes the open document holds. + """ + changes = self._pending_changes() + if not changes: + return envelopes + + channels = dict(envelopes.channels) + for change in changes: + channels[change.channel_name] = change.rebased(channels[change.channel_name]) + + return ChannelEnvelopesViewModel(channels=channels, ownership=envelopes.ownership) + + def _pending_changes(self) -> List[ChannelChange]: + """The channel changes on their way to the open document, the running one first.""" + changes: List[ChannelChange] = [] + if self._running is not None and self._running.lands_on(self._manager.reconstruction): + changes.append(self._running.change) + + changes.extend(step for step in self._waiting if isinstance(step, ChannelChange)) + return changes + + @property + def _reshaping_waits(self) -> bool: + """Whether a removal or a whole-document gesture waits, either of which reshapes what the panel drew on.""" + return any(isinstance(step, (StemRemovalRequest, AfterEdits)) for step in self._waiting) + + def _enqueue_change(self, change: ChannelChange) -> None: + if self._reshaping_waits: + logger.info(f"A change of {change.channel_name} was drawn before a step that reshapes the document") + self._redraw_owed = True + return + + match self._waiting[-1] if self._waiting else None: + case ChannelChange() as tail if tail.channel_name == change.channel_name: + self._waiting[-1] = tail.merged(change) + case _: + self._waiting.append(change) + + def _enqueue_rate(self, rate_change: RateChange) -> None: + match self._waiting[-1] if self._waiting else None: + case RateChange(): + self._waiting[-1] = rate_change + case _: + self._waiting.append(rate_change) + + def _advance(self) -> None: + """Takes the waiting steps in order until a rebuild runs or the line empties.""" + while self._running is None and self._waiting: + self._take(self._waiting.pop(0)) + + def _take(self, step: Rewrite) -> None: + match step: + case ChannelChange() as change: + self._rebuild(change) + case StemRemovalRequest() as removal: + self._remove(removal) + case RateChange() as rate_change: + self._retune(rate_change) + case AfterEdits(gesture=gesture): + gesture() + + def _rebuild(self, change: ChannelChange) -> None: + """Starts the rebuild of a changed channel from the document and the listening as they stand. + + The rebuild is marked running before it starts, since a result can arrive before the start + returns. + """ + reconstruction = self._manager.reconstruction + envelopes = self._manager.current_features + if reconstruction is None or envelopes is None: + logger.info(f"A change of {change.channel_name} found no document open") + return + + self._running = Rebuild(change=change, base=reconstruction) + self._regeneration.start( + reconstruction, + change.channel_name, + change.rebased(envelopes[change.channel_name]), + self._manager.listening.heard_on(change.channel_name), + ) + + def _remove(self, removal: StemRemovalRequest) -> None: + """Takes a recording out, where a step before it left the recording and another beside it.""" + reconstruction = self._manager.reconstruction + if reconstruction is None or not can_remove_stem(reconstruction, removal.stem_id): + logger.info(f"The removal of stem {removal.stem_id} no longer applies to the open document") + return + + self.call( + self.on_edit, + StemRemoval( + reconstruction=without_stem(reconstruction, removal.stem_id), + stem_name=removal.stem_name, + ), + ) + + def _retune(self, rate_change: RateChange) -> None: + """Re-times the document to the rate asked for, where it runs at another one.""" + reconstruction = self._manager.reconstruction + if reconstruction is None: + logger.info(f"A rate of {rate_change.nes_frequency} Hz found no document open") + return + + retuned = reconstruction.with_nes_frequency(rate_change.nes_frequency) + if retuned is reconstruction: + logger.info(f"The open document already runs at {rate_change.nes_frequency} Hz") + return + + self.call( + self.on_edit, + Retune( + reconstruction=retuned, + nes_frequency=rate_change.nes_frequency, + ), + ) + + def _on_result(self, result: RegenerationResult) -> None: + """Lands a finished rebuild on the document it was computed from, then takes the next step. + + Raises: + RuntimeError: If a result arrives while no rebuild runs. + """ + rebuild = self._running + if rebuild is None: + raise RuntimeError("A regeneration result arrived while no rebuild was running") + + self._running = None + match result: + case ServiceSuccess(value=outcome): + self._land(rebuild, outcome) + case ServiceError(exception=exception): + self.call(self.on_failed, exception) + self._let_go(rebuild) + + self._advance() + self._settle() + + def _land(self, rebuild: Rebuild, outcome: RegeneratedInstrument) -> None: + if not rebuild.lands_on(self._manager.reconstruction): + logger.info(f"A rebuild of {rebuild.change.channel_name} finished for a document put away since") + self._let_go(rebuild) + return + + self.call( + self.on_edit, + ChannelEdit( + reconstruction=outcome.reconstruction, + channel_name=rebuild.change.channel_name, + feature_key=rebuild.change.feature_key, + ), + ) + + def _let_go(self, rebuild: Rebuild) -> None: + """Redraws the panel where it still draws a change that will never land. + + A rebuild an outside replacement dropped needs nothing, since the replacement drew the + document it put in place. + """ + if rebuild.base is not None: + self.call(self.on_dropped) + + def _settle(self) -> None: + """Pays a redraw owed once the line empties, and reports the line filling or emptying.""" + if not self.is_busy and self._redraw_owed: + self._redraw_owed = False + self.call(self.on_dropped) + + busy = self.is_busy + if busy != self._reported_busy: + self._reported_busy = busy + self.call(self.on_busy_changed, busy) diff --git a/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py new file mode 100644 index 000000000..46cc25c3a --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/rewrites/regeneration.py @@ -0,0 +1,24 @@ +from typing import AbstractSet, Callable, Protocol + +from sampletones_application.services.regeneration.result import RegenerationResult +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +class RegenerationServiceProtocol(Protocol): + """The slice of the regeneration service the rewrites drive. + + Typing the collaborator structurally keeps the logic layer bound to the service's result + contract alone; the composition root supplies the real service. + """ + + def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: ... + + def start( + self, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: ... diff --git a/src/sampletones_application/logic/reconstruction/rewrites/steps.py b/src/sampletones_application/logic/reconstruction/rewrites/steps.py new file mode 100644 index 000000000..dc83b7736 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/rewrites/steps.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Optional, TypeAlias, Union + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope +from sampletones_shared.types.callback import VoidCallback + + +@dataclass(frozen=True) +class ChannelChange: + """What a reader moved on one channel of the instruments panel. + + A change carries the dimensions the reader moved alone, and at its turn it is written over the + envelopes the document then holds. The rest of the channel is read at that turn, so what a step + before it rebuilt stands, and a frame a removal released keeps resting. + + Attributes: + channel_name: The channel the reader moved. + feature_key: The dimension moved last, which the history names the edit by. + envelopes: Each dimension moved, as the reader left it. + initial_pitch: The pitch the arpeggio is measured against, where the reader moved it. + """ + + channel_name: ChannelName + feature_key: FeatureKey + envelopes: Mapping[FeatureKey, Envelope[int]] + initial_pitch: Optional[int] + + def merged(self, later: ChannelChange) -> ChannelChange: + """This change followed by ``later``, as one change carrying what both moved. + + A dimension both moved takes the later value, and so does the pitch where the later change + moved it, so a drag collapses into the place it ended. + + Raises: + ValueError: If ``later`` moves another channel. + """ + if later.channel_name != self.channel_name: + raise ValueError(f"A change of {later.channel_name} merges into a change of that channel alone") + + return ChannelChange( + channel_name=self.channel_name, + feature_key=later.feature_key, + envelopes={**self.envelopes, **later.envelopes}, + initial_pitch=self.initial_pitch if later.initial_pitch is None else later.initial_pitch, + ) + + def rebased(self, features: Features) -> Features: + """The channel's envelopes as ``features`` holds them, with what this change moved written over them.""" + rebased = ( + features + if self.initial_pitch is None + else features.model_copy(update={"initial_pitch": self.initial_pitch}) + ) + for feature_key, envelope in self.envelopes.items(): + rebased = rebased.with_envelope(feature_key, envelope) + + return rebased + + +@dataclass(frozen=True) +class StemRemovalRequest: + """A reader taking a recording out of the document, named as the history reports it.""" + + stem_id: int + stem_name: str + + +@dataclass(frozen=True) +class RateChange: + """The document re-timed to another NES frequency.""" + + nes_frequency: int + + +@dataclass(frozen=True) +class AfterEdits: + """A gesture that reads or puts away the whole document, run once the edits before it have landed.""" + + gesture: VoidCallback + + +Rewrite: TypeAlias = Union[ChannelChange, StemRemovalRequest, RateChange, AfterEdits] diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py index 0cc91e353..a5a5baf1e 100644 --- a/src/sampletones_application/logic/render/logic.py +++ b/src/sampletones_application/logic/render/logic.py @@ -1,11 +1,11 @@ from pathlib import Path from typing import Callable, Dict, Optional, Tuple +from sampletones_application.categories.estimate import time_estimation 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.logic.project.controller import ProjectController -from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import ( RowSynthesizer, SongLength, @@ -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, @@ -31,7 +31,7 @@ available_audio_formats, available_depths, ) -from sampletones_core.parallelization import ETAEstimator +from sampletones_core.constants.enums import ALL_CHANNELS from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -68,11 +68,11 @@ def __init__( self._session_manager = session_manager 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._language_manager = language_manager + self._msg_canceling = language_manager["settings.render.message.status_canceling"] + 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"] self._stage_messages: Dict[RenderStage, str] = { RenderStage.SYNTHESIS: language_manager["settings.render.message.status_synthesis"], RenderStage.ENCODING: language_manager["settings.render.message.status_encoding"], @@ -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: @@ -187,8 +187,8 @@ def cancel(self) -> None: if not self._service.is_running(): return - self._phase = RenderPhase.CANCELLING - self._status_text = self._msg_cancelling + self._phase = RenderPhase.CANCELING + self._status_text = self._msg_canceling self._emit_view() self._service.cancel() @@ -206,27 +206,22 @@ 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: """Puts a pass's report on the bar, holding the message a stop was asked under.""" - if self._phase == RenderPhase.CANCELLING: + if self._phase == RenderPhase.CANCELING: return 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.""" - status_text = self._stage_messages[stage] - eta_string = ETAEstimator.format_duration(eta_seconds) - if eta_string: - status_text += self._eta_template.format(eta_string=eta_string) - - return status_text + """What the pass is doing, and how long it has left.""" + return self._stage_messages[stage] + time_estimation(self._language_manager, eta_seconds) def _on_render_complete(self, destination: Path) -> None: self._phase = RenderPhase.COMPLETED @@ -239,9 +234,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..55f9ff705 100644 --- a/src/sampletones_application/logic/sequencer/browser.py +++ b/src/sampletones_application/logic/sequencer/browser.py @@ -1,9 +1,11 @@ from pathlib import Path +from typing import Optional, Tuple 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_application.view_model.shared.recording import NamedRecordingViewModel +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 @@ -28,6 +30,10 @@ def refresh_tree(self) -> None: reconstructions_directory = self._config_manager.get_reconstructions_directory() self._browser_manager.set_reconstructions_directory(reconstructions_directory) + def recordings(self, path: Path) -> Optional[Tuple[NamedRecordingViewModel, ...]]: + """The recordings the reconstruction at ``path`` names, and None until it has been read.""" + return self._browser_manager.recordings(path) + def load_reconstruction(self, path: Path) -> Reconstruction: """Loads a reconstruction file for inspection before adding. @@ -44,19 +50,22 @@ def add_reconstruction( ) -> Sample: """Adds an already-loaded reconstruction as a sample. - The sample embeds the reconstruction object and can be renamed afterwards + The sample embeds the reconstruction object and can be renamed afterward from the samples panel. """ return self._controller.add_sample(reconstruction, name=name) def replace_reconstruction( self, - sample_id: str, + voice_id: str, reconstruction: Reconstruction, - ) -> None: + ) -> Reconstruction: """Substitutes an existing sample's reconstruction with an already-loaded one. The sample keeps its identity, so the patterns referencing it sound the new reconstruction while their rows stay as they were. + + Returns: + Reconstruction: The document the sample now holds, in the form the project keeps it. """ - self._controller.replace_sample_reconstruction(sample_id, reconstruction) + return 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..a56bfbc69 100644 --- a/src/sampletones_application/logic/sequencer/channels.py +++ b/src/sampletones_application/logic/sequencer/channels.py @@ -3,11 +3,10 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName from sampletones_shared.utils.callbacks import CallbackMixin -ALL_CHANNELS: Final[FrozenSet[GeneratorName]] = frozenset(GeneratorName.items()) -_NO_CHANNELS: Final[FrozenSet[GeneratorName]] = frozenset() +_NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() class SequencerChannelsLogic(CallbackMixin): @@ -22,19 +21,19 @@ class SequencerChannelsLogic(CallbackMixin): silences the other three and remembers the set it replaced, so leaving the solo returns to the mix it interrupted. - The synthesiser reads :attr:`active_channels` on every rendered row, so a change is heard + The synthesizer reads :attr:`active_channels` on every rendered row, so a change is heard while playback continues. """ 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]: - """The channels that sound, the mask the synthesiser mixes.""" + def active_channels(self) -> FrozenSet[ChannelName]: + """The channels that sound, the mask the synthesizer mixes.""" return ALL_CHANNELS - self._muted def build_channels(self) -> SequencerChannelsViewModel: @@ -43,19 +42,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 +91,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/fields.py b/src/sampletones_application/logic/sequencer/clipboard/fields.py index 3e17c3aa5..442c342dd 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/fields.py +++ b/src/sampletones_application/logic/sequencer/clipboard/fields.py @@ -9,6 +9,7 @@ ValueT = TypeVar("ValueT") HEXADECIMAL_BASE: Final[int] = 16 +DECIMAL_BASE: Final[int] = 10 @dataclass(frozen=True) @@ -66,6 +67,14 @@ def read_hexadecimal(field: str) -> Optional[int]: return int(digits, HEXADECIMAL_BASE) +def read_decimal(field: str) -> Optional[int]: + """The number a field of decimal digits names, present while every character is one.""" + if not field or not field.isdecimal(): + return None + + return int(field, DECIMAL_BASE) + + def store_reading( values: Dict[KeyT, Optional[ValueT]], key: KeyT, 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..236b0413d 100644 --- a/src/sampletones_application/logic/sequencer/clipboard/tracker.py +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -18,17 +18,21 @@ MIN_TRANSPOSE, SILENT_VOLUME, ) -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +from sampletones_core.project.voices.note_off import NoteOff from sampletones_core.utils.display import ( NOTE_OFF, display_id, - display_transpose, + display_pitch, display_volume, ) -from sampletones_shared.constants.symbols import PLUS, SIGNS +from sampletones_core.utils.frequencies import PERIOD_NAME_SUFFIX +from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PLAYED_PITCH_VALUE_KIND +from sampletones_shared.constants.symbols import PIPE, PLUS, SIGNS from .fields import ( FieldReading, + read_decimal, read_hexadecimal, read_placeholder, state_mixed, @@ -39,9 +43,9 @@ 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)) +PITCH_WIDTH: Final[int] = len(display_pitch(None)) VOLUME_WIDTH: Final[int] = len(display_volume(None)) @@ -105,10 +109,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,15 +127,10 @@ 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( - block.transposes, - key, - display_transpose, - TRANSPOSE_WIDTH, - ) + return self._state_pitch(block.pitches, key) case SubColumn.VOLUME: return self._state_number( block.volumes, @@ -156,12 +155,23 @@ 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) + @staticmethod + def _state_pitch( + pitches: Dict[BlockKey, Optional[RowPitch]], + key: BlockKey, + ) -> str: + """What the pitch column prints at a cell: the note name or the signed step the grid shows.""" + if key not in pitches: + return state_mixed(PITCH_WIDTH) + + return display_pitch(pitches[key]) + @staticmethod def _state_number( values: Dict[BlockKey, Optional[int]], @@ -180,9 +190,9 @@ 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]] = {} + pitches: Dict[BlockKey, Optional[RowPitch]] = {} volumes: Dict[BlockKey, Optional[int]] = {} for row_offset, line in enumerate(lines): fields = line.replace(COLUMN_SEPARATOR, " ").split() @@ -193,7 +203,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, @@ -201,9 +211,9 @@ def _read_rows( ) case SubColumn.TRANSPOSE: read = store_reading( - transposes, + pitches, key, - self._read_transpose(field), + self._read_pitch(field), ) case SubColumn.VOLUME: read = store_reading( @@ -217,7 +227,7 @@ def _read_rows( return TrackerBlock( notes=notes, - transposes=transposes, + pitches=pitches, volumes=volumes, ) @@ -238,26 +248,40 @@ 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]]: - """The transpose a signed field states, present while it lies in the range a row accepts.""" - placeholder: Optional[FieldReading[int]] = read_placeholder(field) + def _read_pitch(field: str) -> Optional[FieldReading[RowPitch]]: + """The pitch a field states: a signed step within the range a row accepts, or a note name. + + A sign opens a step, read in decimal digits. Anything else is a note name: a period name + ends in the suffix the grid prints for noise, and every other name is a note the tonal + channels play. Names are read in either case, so a name typed by hand lands the note the + grid would print. + """ + placeholder: Optional[FieldReading[RowPitch]] = read_placeholder(field) if placeholder is not None: return placeholder sign = field[:1] - magnitude = read_hexadecimal(field[1:]) - if sign not in SIGNS or magnitude is None: - return None + if sign in SIGNS: + magnitude = read_decimal(field[1:]) + if magnitude is None: + return None + + step = magnitude if sign == PLUS else -magnitude + if not MIN_TRANSPOSE <= step <= MAX_TRANSPOSE: + return None + + return FieldReading.of(Step(value=step)) - transpose = magnitude if sign == PLUS else -magnitude - if not MIN_TRANSPOSE <= transpose <= MAX_TRANSPOSE: + kind = PERIOD_VALUE_KIND if field.upper().endswith(PERIOD_NAME_SUFFIX) else PLAYED_PITCH_VALUE_KIND + value = kind.sanitized_name_to_value.get(kind.sanitize(field)) + if value is None: return None - return FieldReading.of(transpose) + return FieldReading.of(Note(value=value)) @staticmethod def _read_volume(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..ed0e93dc9 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.project.patterns.pitch import RowPitch +from sampletones_core.utils.display import display_id, display_pitch, display_transpose, display_volume 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,47 +66,64 @@ 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 + can be handed straight to ``SequencerHistoryRecorder.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 sample + and an instrument read apart down the list of entries. Every line about a voice + the pool holds, whether the gesture removes, replaces, renames, moves or duplicates it, + names the voice the same way, by its position and by its name, so a reader finds it in + the list whichever gesture the line records. A line about an added voice names it by + its name, which is what the adding hook carries. A line about an edit inside a voice + names the voice by its position, beside what the edit touched. """ 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], - transpose: Optional[int], + channel: Optional[ChannelName], + voice_id: Optional[str], + pitch: Optional[RowPitch], 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: + if pitch is not None: segments.append(self._subcolumn(SubColumn.TRANSPOSE)) segments.append( self._segment( - display_transpose(transpose), + display_pitch(pitch), HistoryDetailRole.TRANSPOSE, ), ) @@ -118,29 +139,27 @@ def edit_row( 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 +183,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 +221,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 +239,115 @@ 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 remove_sample(self, sample_id: str) -> Segments: - return ( - self._sample(sample_id, colon=True), - self._name(self._samples_logic.sample_name(sample_id)), - ) + def add_instrument(self, name: str) -> Segments: + return (self._name(name, VoiceKind.INSTRUMENT),) + + def remove_voice(self, voice_id: str) -> Segments: + return self._named_voice(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._named_voice(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 voice as it stands and the name it takes. - def move_sample(self, sample_id: str, to_index: int) -> Segments: + Both names 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._named_voice(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: - return ( - self._sample(sample_id, colon=True), - self._name(self._samples_logic.sample_name(sample_id)), - ) + def move_voice(self, voice_id: str, to_index: int) -> Segments: + """Describes a move as the voice where it stands and the position it takes. - def set_sample_loop(self, sample_id: str, loop: bool) -> Segments: - word = HistoryDetailWord.LOOP_ON if loop else HistoryDetailWord.LOOP_OFF + The caller builds this detail while the voice still stands where the move takes it from. + """ return ( - self._sample(sample_id, colon=True), - HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE), + *self._named_voice(voice_id), + self._arrow(), + self._value(display_id(to_index)), ) + def duplicate_voice(self, voice_id: str) -> Segments: + return self._named_voice(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 an instrument'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 +364,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 +396,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 +411,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 +446,39 @@ 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 _named_voice(self, voice_id: str) -> Segments: + """A voice the pool holds, read as its position and its name in the color of its kind.""" + return ( + self._voice(voice_id, colon=True), + self._voice_name(voice_id), ) - def _sample( + 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 _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..18eba6002 100644 --- a/src/sampletones_application/logic/sequencer/playback/protocol.py +++ b/src/sampletones_application/logic/sequencer/playback/protocol.py @@ -8,17 +8,17 @@ class ChannelGeneratorProtocol(Protocol): """Minimal generator interface required by the synthesis engine. - Each NES channel's generator synthesises one tick of audio from an + Each NES channel's generator synthesizes one tick of audio from an instruction. The concrete type is generic (``Generator[InstructionT, - TimerT]``); this protocol captures only the surface the synthesiser + TimerT]``); this protocol captures only the surface the synthesizer actually uses so that invariant generic instantiations (e.g. ``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 + ``frame_length`` is settable so the synthesizer can give each tick the span its clock states, which is what keeps a rendered tick lasting ``1 / nes_frequency`` seconds at a sample rate the tick divides unevenly. """ diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index a30e3b573..84b7bdf22 100644 --- a/src/sampletones_application/logic/sequencer/playback/song_player.py +++ b/src/sampletones_application/logic/sequencer/playback/song_player.py @@ -13,6 +13,7 @@ from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_core.audio import AudioDeviceManager from sampletones_core.project.song_position import SongPosition +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -57,6 +58,10 @@ class SongPlayerLogic(CallbackMixin): All playback controls are forwarded here from the UI panel. Position updates are forwarded to the coordinator via callbacks. + + Every transition of the playback reaches ``on_change_audio_state``: a start, a pause, a resume, + a stop, the song ending by itself and a playback error. Whoever shows the transport follows the + song through that one hook, whichever gesture moved it. Position updates leave the hook quiet. """ def __init__( @@ -81,6 +86,7 @@ def __init__( self.on_position_changed: Optional[Callable[[int, int], None]] = None self.on_view_changed: Optional[Callable[[SongPlayerViewModel], None]] = None self.on_error: Optional[Callable[[Exception], None]] = None + self.on_change_audio_state: Optional[VoidCallback] = None def play(self) -> None: self._last_error = None @@ -90,16 +96,23 @@ def play_from(self, order_position: int, row_index: int = 0) -> None: self._start_from(SongPosition(order_position=order_position, row_index=row_index)) def _start_from(self, position: SongPosition) -> None: + """Starts the song at ``position``, which the playhead takes once the service has the output. + + Raises: + NoOutputDeviceError: If no output device is in force, which leaves the playhead where it + stands. + """ if not self._project_controller.is_open: return - self._position = position - self._awaiting_seek_order = None self._service.start( order_position=position.order_position, row_index=position.row_index, ) + self._position = position + self._awaiting_seek_order = None self._emit_view() + self._notify_audio_state_changed() def pause_or_resume(self) -> None: if self._service.is_paused: @@ -108,13 +121,15 @@ def pause_or_resume(self) -> None: self._service.pause() else: self.play() + return self._emit_view() + self._notify_audio_state_changed() 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 @@ -137,10 +152,18 @@ def relocate(self, order_position: int) -> None: self._awaiting_seek_order = order_position def stop(self) -> None: + """Silences the song and puts the playhead back at its start. + + ``on_change_audio_state`` fires when the song held the output, which is when a stop moves + it from one state to another. + """ + was_engaged = self._service.alive self._service.stop() self._position = SongPosition() self._awaiting_seek_order = None self._emit_view() + if was_engaged: + self._notify_audio_state_changed() def is_playing(self) -> bool: return self._service.is_playing @@ -192,12 +215,17 @@ def _on_service_result(self, result: SongPlayerResult) -> None: self._position = SongPosition() self._awaiting_seek_order = None self._emit_idle_view() + self._notify_audio_state_changed() case SongPlaybackError(error=error): self._last_error = str(error) if str(error) else type(error).__name__ self._position = SongPosition() self._awaiting_seek_order = None self.call(self.on_error, error) self._emit_view() + self._notify_audio_state_changed() + + def _notify_audio_state_changed(self) -> None: + self.call(self.on_change_audio_state) def _emit_view(self) -> None: self.call( @@ -208,10 +236,9 @@ def _emit_view(self) -> None: def _emit_idle_view(self) -> None: """Pushes a definitively stopped view. - ``SongPlaybackStopped`` is the authoritative end-of-playback signal, so the flags are - forced off here. The worker thread may still be closing its audio stream and briefly report - itself as playing; forcing the flags off keeps the stopped view authoritative and lets the - playing highlight settle correctly. + ``SongPlaybackStopped`` is the authoritative end-of-playback signal, and the service hands its + stream back before sending it, so the view reads the song silent and the playing highlight + settles. """ self.call( self.on_view_changed, 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..cf3272322 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 SONG_TICK_BOUNDS, SongTiming from .rates import EngineRates -from .timing import SongTiming @dataclass(frozen=True) @@ -28,12 +28,11 @@ class SongLength: def measure(cls, project: Project, *, sample_rate: int) -> Self: """The length ``project`` runs to when rendered at ``sample_rate``. - Every pattern holds the song's row count, so one groove covers the whole order and the - tick total is that groove's, once for each position the order plays. + The song ends where the frame one past the order's last would start, which is the tick + total the walk through the song reaches. """ - groove = SongTiming.from_project(project).groove() return cls( - ticks=project.song.order_length() * groove.total_ticks, + ticks=SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()), rates=EngineRates.from_project(project, sample_rate), ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py deleted file mode 100644 index 52a88b7e4..000000000 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py +++ /dev/null @@ -1,43 +0,0 @@ -from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH -from sampletones_core.instructions import ( - InstructionUnion, - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) - - -def apply_modifiers( - instruction: InstructionUnion, - transpose: int, - row_volume: int, -) -> InstructionUnion: - """Bends one tick's instruction by the transpose and volume the pattern has reached. - - A sample carries the instructions it was reconstructed from; a pattern states how loud and how - high it is played. Each channel takes both in the terms it understands: the pulse channels - scale their volume and shift their pitch, the triangle shifts its pitch and sounds while the - row asks for more than half volume, and the noise channel scales its volume and walks its - period around the sixteen the hardware offers. - - Args: - instruction: The tick's instruction as the sample holds it. - transpose: The semitone offset the pattern has reached, held within the pitch range. - row_volume: The level the pattern has reached, scaling the instruction's own. - - Returns: - InstructionUnion: A copy of the instruction as the channel sounds it. - """ - 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)) - return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) - case TriangleInstruction(): - effective_pitch = max(MIN_PITCH, min(MAX_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 - return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py index b256c6b06..b70e4a133 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py @@ -12,7 +12,7 @@ class EngineRates: Each rate is owned elsewhere: the project states how many instructions the engine consumes each second, and whoever takes the audio states the rate it is rendered at — the output device for playback, the chosen format for a file. Together they fix how many samples one - tick spans, so the synthesiser follows both. + tick spans, so the synthesizer follows both. Attributes: nes_frequency: The engine ticks consumed each second. diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py index 5af877fe1..d12d35ee9 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 synthesizer: 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. + generator: The synthesizer filling the channel's ticks. + 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..d2b827c08 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 SONG_TICK_BOUNDS, 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 @@ -72,8 +65,7 @@ def __init__( self._active_channels = active_channels self._sample_rate = sample_rate self._position = SongPosition() - self._timing: SongTiming = SongTiming.from_project(project_source.project) - self._groove: Groove = self._timing.groove() + self._timing: SongTiming = SongTiming.from_project(project_source.project, bounds=SONG_TICK_BOUNDS) self._channels: Optional[ChannelBank] = None self._elapsed_ticks: int = 0 @@ -104,12 +96,12 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: song = project.song self._position.wrap_overflow(song.rows_per_pattern) channels = self._bank() - self._ensure_groove(project) + self._follow_timing(project) frames = RowFrames.from_clock( channels.clock, elapsed_ticks=self._elapsed_ticks, - ticks=self._groove.ticks[self._position.row_index], + ticks=self._timing.row_ticks(self._position.order_position, self._position.row_index), ) position_before = replace(self._position) @@ -153,20 +145,18 @@ def _current_rates(self) -> EngineRates: self._sample_rate(), ) - def _ensure_groove(self, project: Project) -> None: - """Rebuilds the groove when the row rate or the metre it is spread over changes. + def _follow_timing(self, project: Project) -> None: + """Takes up the project's timing when the row rate or the meter it is spread over changes. - An engine that holds a row for a whole number of ticks reaches a fractional row rate by - varying that number from row to row, and the groove is where those counts are decided. - Rebuilding only on a timing edit keeps a tempo change immediate while the distribution - itself, which spans a whole pattern, is computed once. + The timing answers how long a row lasts from the row's place in the song, and it keeps the + bar plans it has computed. Replacing it only on a timing edit keeps a tempo change heard + from the next row on, while the plans of an unchanged timing are computed once. """ - timing = SongTiming.from_project(project) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) if timing == self._timing: return self._timing = timing - self._groove = timing.groove() def _mix_channels( self, @@ -176,9 +166,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 +180,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, channel_name, project.voice): + 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/timing.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py deleted file mode 100644 index 96c72dd78..000000000 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py +++ /dev/null @@ -1,47 +0,0 @@ -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 - - -@dataclass(frozen=True) -class SongTiming: - """Everything a project's groove is built from, held together so a change is one comparison. - - 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. - """ - - rate: RowRate - metre: Metre - - @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( - project.settings, - rows=project.song.rows_per_pattern, - ), - ) - - 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 - settings can ask for, which leaves the groove free to realize the rate exactly. - """ - return calculate_groove( - self.rate, - self.metre, - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ) 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..df43843b5 100644 --- a/src/sampletones_application/logic/sequencer/tracker/block.py +++ b/src/sampletones_application/logic/sequencer/tracker/block.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from typing import Dict, Optional, Tuple, Union -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.pitch import RowPitch +from sampletones_core.project.voices.note_off import NoteOff BlockNote = Union[str, NoteOff] BlockKey = Tuple[int, int] @@ -21,8 +22,8 @@ class TrackerBlock: that the block says nothing about that cell — which is how a sample column its channels disagree over stays transparent to whatever it is pasted onto. - Notes, transposes and volumes are kept in maps of their own so the kind of a value is - structural. It also fixes the order a write takes: every note lands before the transposes and + Notes, pitches and volumes are kept in maps of their own so the kind of a value is + structural. It also fixes the order a write takes: every note lands before the pitches and volumes sharing its row, which matters where a sample-column note clears the channels around it. @@ -31,5 +32,5 @@ class TrackerBlock: """ notes: Dict[BlockKey, Optional[BlockNote]] - transposes: Dict[BlockKey, Optional[int]] + pitches: Dict[BlockKey, Optional[RowPitch]] volumes: Dict[BlockKey, Optional[int]] diff --git a/src/sampletones_application/logic/sequencer/tracker/context.py b/src/sampletones_application/logic/sequencer/tracker/context.py new file mode 100644 index 000000000..e0d2bd572 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/context.py @@ -0,0 +1,69 @@ +from typing import Callable, List, Tuple + +from sampletones_application.view_model.sequencer.tracker import ( + SequencerContextRowViewModel, + SequencerRowViewModel, +) + +FrameRows = Callable[[int], Tuple[SequencerRowViewModel, ...]] +ContextRows = Tuple[SequencerContextRowViewModel, ...] + + +def rows_before( + frame_index: int, + reach: int, + frame_rows: FrameRows, +) -> ContextRows: + """The last ``reach`` rows the song plays before ``frame_index``, in playing order. + + The walk goes back a frame at a time, so a frame shorter than what is left to reach gives all + of its rows and the one before it gives the rest. It ends at the song's first frame. + + Args: + frame_index: The frame the rows lead into. + reach: How many rows to gather. + frame_rows: Reads the rows of one frame. + + Returns: + ContextRows: At most ``reach`` rows, the nearest to the frame last. + """ + gathered: List[SequencerContextRowViewModel] = [] + frame = frame_index - 1 + while len(gathered) < reach and frame >= 0: + rows = frame_rows(frame) + taken = rows[max(0, len(rows) - (reach - len(gathered))) :] + gathered[:0] = [SequencerContextRowViewModel(frame_index=frame, row=row) for row in taken] + frame -= 1 + + return tuple(gathered) + + +def rows_after( + frame_index: int, + frame_count: int, + reach: int, + frame_rows: FrameRows, +) -> ContextRows: + """The first ``reach`` rows the song plays after ``frame_index``, in playing order. + + The walk goes on a frame at a time, so a frame shorter than what is left to reach gives all of + its rows and the one after it gives the rest. It ends at the song's last frame. + + Args: + frame_index: The frame the rows follow. + frame_count: How many frames the order holds. + reach: How many rows to gather. + frame_rows: Reads the rows of one frame. + + Returns: + ContextRows: At most ``reach`` rows, the nearest to the frame first. + """ + gathered: List[SequencerContextRowViewModel] = [] + frame = frame_index + 1 + while len(gathered) < reach and frame < frame_count: + rows = frame_rows(frame) + taken = rows[: reach - len(gathered)] + gathered.extend(SequencerContextRowViewModel(frame_index=frame, row=row) for row in taken) + frame += 1 + + return tuple(gathered) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index c0ef980dc..b9ffe91c7 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -1,5 +1,5 @@ from collections.abc import Hashable -from typing import Callable, Dict, Optional, TypeVar +from typing import Callable, Dict, List, Optional, TypeVar from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.slot import ( @@ -7,10 +7,11 @@ 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.pitch import RowPitch 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,10 +32,10 @@ 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), - transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), + notes=self._read_subcolumn(region, base, SubColumn.VOICE, self._note_of), + pitches=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._pitch_of), volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of), ) @@ -49,7 +50,9 @@ def _read_subcolumn( A cell holding a definite value keeps it, an empty one keeps its emptiness, and a cell whose channels disagree leaves its key out — which is what carries the sample column's - mixed reading over as a value the paste passes by. + mixed reading over as a value the paste passes by. A sample column cell answering for no + channel, a pitch or a volume where no sample plays, keeps the empty reading its grid + shows there. """ values: Dict[BlockKey, ValueT] = {} for row_offset, row_index in enumerate(region.rows): @@ -57,31 +60,66 @@ def _read_subcolumn( if slot.subcolumn is not subcolumn: continue - agreement = self._agree(row_index, slot.generator, select) - if agreement.is_unanimous: - values[(row_offset, region.first_slot + position - base)] = agreement.value + key = ( + row_offset, + region.first_slot + position - base, + ) + agreement = self._agree( + row_index, + slot.channel, + subcolumn, + select, + ) + if agreement.is_absent: + values[key] = select(None) + elif agreement.is_unanimous: + values[key] = agreement.value return values def _agree( self, row_index: int, - generator: Optional[GeneratorName], + channel: Optional[ChannelName], + subcolumn: SubColumn, 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, - so a block states about a cell exactly what the grid it came from shows there. + column answers for the channels it spans in that subcolumn, 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( + spanned, + row_index, + ) + ) + for spanned in self._sample_column_span(row_index, subcolumn) ) + def _sample_column_span( + self, + row_index: int, + subcolumn: SubColumn, + ) -> List[ChannelName]: + """The channels the sample column answers for in one subcolumn of a row. + + The voice slot speaks for the samples the row names, and the pitch and volume for the + channels a value typed there reaches. + """ + match subcolumn: + case SubColumn.VOICE: + return self._tracker.note_channels(row_index) + case SubColumn.TRANSPOSE | SubColumn.VOLUME: + return self._tracker.relevant_channels(row_index) + @staticmethod def _note_of(row: Optional[Row]) -> Optional[BlockNote]: """The note a row carries: the id of the sample it names, or the cut it holds. @@ -90,16 +128,16 @@ 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: return None @staticmethod - def _transpose_of(row: Optional[Row]) -> Optional[int]: - return row.transpose if row is not None else None + def _pitch_of(row: Optional[Row]) -> Optional[RowPitch]: + return row.pitch if row is not None else None @staticmethod def _volume_of(row: Optional[Row]) -> Optional[int]: diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index b9ad587ff..571bfd979 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -1,53 +1,66 @@ -from typing import Callable, Dict, FrozenSet, List, Optional, Set +from typing import Callable, Dict, FrozenSet, List, Mapping, Optional, Set, Tuple from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.tracker.context import rows_after, rows_before +from sampletones_application.view_model.sequencer.kind import ( + voice_kind, +) from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import ( + NO_REACH, SequencerCellViewModel, 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.pitch import Note, RowPitch, Step, note_for_channel, shifted_pitch from sampletones_core.project.patterns.row import NoteCommand, Row +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_channels from sampletones_core.utils.display import ( display_command, display_id, - display_transpose, + display_pitch, display_volume, ) from sampletones_shared.utils.callbacks import CallbackMixin _EMPTY_CELL = SequencerCellViewModel( - instrument=display_id(None), - transpose=display_transpose(None), + voice=display_id(None), + transpose=display_pitch(None), volume=display_volume(None), + kind=None, + pitch=None, + level=None, + command=None, ) class SequencerTrackerLogic(CallbackMixin): """Builds the tracker grid and module-options view models from the project. - Holds the only piece of grid-local UI state, the visible order frame, and - translates raw panel events into :class:`ProjectController` mutations. The + Holds the grid-local UI state, the visible order frame and how far the grid reaches past it, + and translates raw panel events into :class:`ProjectController` mutations. The 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. """ def __init__(self, project_controller: ProjectController) -> None: self._controller = project_controller self._frame_index: int = 0 + self._reach: int = NO_REACH self.on_settings_changed: Optional[Callable[[SequencerSettingsViewModel], None]] = None self.on_tracker_changed: Optional[Callable[[SequencerTrackerViewModel], None]] = None @@ -67,20 +80,46 @@ def settings(self) -> SequencerSettingsViewModel: ) def build_grid(self) -> SequencerTrackerViewModel: + """The shown frame's rows, with as many rows of the song on each side as the grid reaches.""" song = self._controller.project.song frame_count = song.order_length() 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())) return SequencerTrackerViewModel( frame_index=frame_index, frame_count=frame_count, - rows=rows, + rows=self.frame_rows(frame_index), + lead=rows_before(frame_index, self._reach, self.frame_rows), + trail=rows_after(frame_index, frame_count, self._reach, self.frame_rows), ) + def frame_rows(self, frame_index: int) -> Tuple[SequencerRowViewModel, ...]: + """The rows of one frame, each channel read in the terms of the voice it carries there. + + The reading starts afresh at the frame's first row, so a row reads the same whether its own + frame is shown or it stands beside another. + """ + patterns = self._frame_patterns(frame_index) + carried: Dict[ChannelName, Optional[VoiceUnion]] = {channel: None for channel in ChannelName.items()} + return tuple(self._build_row(index, patterns, carried) for index in range(self._row_count_at(frame_index))) + def frame_row_count(self) -> int: - """Rows the current frame holds, the height a whole-frame edit spans. + """Rows the shown frame holds, the height a whole-frame edit spans.""" + return self._row_count_at(self._frame_index) + + def set_reach(self, rows: int) -> None: + """Has the grid carry ``rows`` rows of the song on each side of the frame. + + The tracker centers the row it follows, so it asks for the room half its height takes, + and the grid is built again once that room changes. + """ + if rows == self._reach: + return + + self._reach = rows + self.push_tracker() + + def _row_count_at(self, frame_index: int) -> int: + """Rows a frame holds. A frame is as tall as its longest pattern. Empty (None) slots contribute no pattern, so a frame whose channels are all empty falls back to @@ -92,28 +131,28 @@ def frame_row_count(self) -> int: if song.order_length() == 0: return 0 - lengths = [pattern.length for pattern in self._frame_patterns().values()] + lengths = [pattern.length for pattern in self._frame_patterns(frame_index).values()] if lengths: return max(lengths) return song.rows_per_pattern - def _frame_patterns(self) -> Dict[GeneratorName, Pattern]: - """The patterns the current frame's channels point at. + def _frame_patterns(self, frame_index: int) -> Dict[ChannelName, Pattern]: + """The patterns a frame's channels point at. A channel contributes an entry once its slot names a pattern the song holds, so the result covers exactly the channels carrying content at this frame. """ song = self._controller.project.song - if self._frame_index >= song.order_length(): + if 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[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,292 +183,343 @@ 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 - it names is the row's whole note, while transpose and volume follow the - channels that column governs. + 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 reach the + channels a sample plays on at the row, as :meth:`relevant_channels` reads them. """ - instrument = subcolumn is SubColumn.INSTRUMENT - transpose = subcolumn is SubColumn.TRANSPOSE + voice = subcolumn is SubColumn.VOICE + pitch = 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, - transpose=transpose, + voice=voice, + pitch=pitch, 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, - transpose=transpose, + pitch=pitch, volume=volume, ) def write_cell( self, row_index: int, - generator: Optional[GeneratorName], - sample_id: Optional[str], - transpose: Optional[int], + channel: Optional[ChannelName], + voice_id: Optional[str], + pitch: Optional[RowPitch], volume: Optional[int], ) -> None: - """Writes the value a cell edit carries, keeping the rest of the cell as it stands. + """Writes the values a cell edit carries, keeping the rest of the cell as it stands. - An edit names one subcolumn, so a sample takes the write whenever one - arrives, and an offset lands on its own otherwise. + A voice is placed first, and the pitch and the volume reach the cell after it, so a pitch + typed with a marked voice lands on the channels the voice was just placed on: in the sample + column the sample spreads over its channels, then the pitch reaches them. """ - if sample_id is not None: - self.place_note(row_index, generator, sample_id) - elif transpose is not None or volume is not None: + if voice_id is not None: + self.place_note(row_index, channel, voice_id) + + if pitch is not None or volume is not None: self.set_cell_subcolumn( row_index, - generator, - transpose=transpose, + channel, + pitch=pitch, volume=volume, ) 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, + pitch: Optional[RowPitch] = None, volume: Optional[int] = None, ) -> None: - if generator is None: + if channel is None: self.set_sample_subcolumn( row_index, - transpose=transpose, + pitch=pitch, volume=volume, ) else: self.set_row( - generator, + channel, row_index, - transpose=transpose, + pitch=pitch, volume=volume, ) def set_row( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, *, command: Optional[NoteCommand] = None, - transpose: Optional[int] = None, + pitch: Optional[RowPitch] = 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, - transpose=transpose, + pitch=pitch, 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, - transpose: bool = False, + voice: bool = False, + pitch: 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, - transpose=transpose, + voice=voice, + pitch=pitch, 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, - transpose: bool = False, + voice: bool = False, + pitch: 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, - transpose=transpose, + voice=voice, + pitch=pitch, 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. An instrument 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 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 - def set_note_off(self, generator: GeneratorName, 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()) + carried: Optional[VoiceUnion] = None + for row in pattern.rows[: row_index + 1]: + carried = self._carried_voice(row, carried) + + return carried + + def set_note_off(self, channel: ChannelName, row_index: int) -> None: + """Writes a note-off into one channel's cell, materializing the pattern if needed.""" + 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, row_index: int, *, - transpose: Optional[int] = None, + pitch: Optional[RowPitch] = None, volume: Optional[int] = None, ) -> None: - """Synchronises a subcolumn across the row's relevant channels. + """Writes a pitch or a volume to the channels a sample plays on at the row. - Transpose and volume exist independently of an instrument: 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. + A row placing a sample reaches that sample's whole span, and a row below it reaches + the channels the sample is still playing on, as :meth:`relevant_channels` reads them. + A row where no sample plays takes nothing, and gains no pattern. A note reaches each + channel as that channel names it, so the noise channel takes the period the pitch names. """ - 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, + pitch=self._channel_pitch(channel, pitch), volume=volume, ) + @staticmethod + def _channel_pitch( + channel: ChannelName, + pitch: Optional[RowPitch], + ) -> Optional[RowPitch]: + """The pitch one channel takes from the sample column: a note as the channel names it, a step as it stands.""" + match pitch: + case Note(): + return note_for_channel(channel, pitch.value) + case _: + return pitch + def clear_sample_subcolumn( self, row_index: int, *, - transpose: bool = False, + pitch: 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, + pitch=pitch, volume=volume, ) def adjust_transpose( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, delta: int, ) -> None: - """Shifts a channel cell's transpose by ``delta`` semitones. + """Shifts a channel cell's pitch by ``delta`` semitones, on the face the cell holds. - An unset transpose counts as zero, so the first nudge writes exactly - ``delta``; the controller clamps the result to the transpose range. + An unset pitch counts as a step of zero, so the first nudge writes exactly ``delta`` as a + step; a note moves to another note. The result is held within the range its face allows. """ self.set_row( - generator, + channel, row_index, - transpose=self._current_transpose(generator, row_index) + delta, + pitch=shifted_pitch(self._current_pitch(channel, row_index), delta, channel), ) def adjust_volume( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, delta: int, ) -> None: @@ -439,40 +529,40 @@ 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 return pattern.rows[row_index] - def _current_transpose( + def _current_pitch( self, - generator: GeneratorName, + channel: ChannelName, row_index: int, - ) -> int: - row = self.row(generator, row_index) - if row is None or row.transpose is None: - return 0 + ) -> RowPitch: + row = self.row(channel, row_index) + if row is None or row.pitch is None: + return Step(value=0) - return row.transpose + return row.pitch - 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 @@ -483,38 +573,59 @@ def frame_index(self) -> int: return self._frame_index def select_frame(self, frame_index: int) -> None: + """Shows a frame, building the grid only when the frame changes. + + The grid follows every change to the song on its own, so the frame already shown stands as + built, and a followed song walking down its rows builds nothing. Whoever follows the frame + hears it either way. + """ + if frame_index == self._frame_index: + self.call(self.on_frame_changed, frame_index) + return + 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]: - """The channels a sample-column subcolumn edit reaches at ``row_index``. + def relevant_channels(self, row_index: int) -> List[ChannelName]: + """The channels a sample-column transpose or volume edit reaches at ``row_index``. - Follows the row's sample channels when one governs it, and otherwise every - channel, matching where :meth:`set_sample_subcolumn` writes. + These are the channels the row's samples span where it names any, and otherwise the + channels a sample from an earlier row of the frame is still playing on, matching where + :meth:`set_sample_subcolumn` writes. A row where no sample plays reaches none. """ return self._subcolumn_generators(row_index) - def _pattern_index_at_frame(self, generator: GeneratorName) -> Optional[int]: + def note_channels(self, row_index: int) -> List[ChannelName]: + """The channels the sample column's voice slot reads at ``row_index``. + + The slot speaks for the samples a row names, and for every channel on a row naming + none, so a row cut on every channel reads, and copies, as a cut. Mirrors + :attr:`SequencerRowViewModel.note_channels`. + """ + spanned = self.referenced_channels(row_index) or frozenset(ChannelName.items()) + return [channel for channel in ChannelName.items() if channel in spanned] + + 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]: - """Materialises a pattern for an empty slot at the current frame, on first edit. + def _create_frame_pattern(self, channel: ChannelName) -> Optional[int]: + """Materializes 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 creates a fresh pattern and assigns it to that order position, so the @@ -525,120 +636,180 @@ 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]: - """Channels a sample-column transpose/volume edit writes to. + def _subcolumn_generators(self, row_index: int) -> List[ChannelName]: + """Channels a sample-column transpose or volume edit writes to. - Falls back to every channel when no sample constrains the row, mirroring - :attr:`SequencerRowViewModel.subcolumn_generators`. + A row naming a sample reaches that sample's span. A row naming none reaches the channels + whose voice in force is a sample, read down the frame the way :meth:`carried_voice` reads + it, so a channel cut since or now carrying an instrument takes nothing. Mirrors + :attr:`SequencerRowViewModel.offset_channels`. """ - referenced = self.referenced_generators(row_index) - if not referenced: - return GeneratorName.items() - - return [generator for generator in GeneratorName.items() if generator in referenced] + spanned = self.referenced_channels(row_index) or self._sample_carriers( + {channel: self.carried_voice(channel, row_index) for channel in ChannelName.items()} + ) + return [channel for channel in ChannelName.items() if channel in spanned] + + @staticmethod + def _sample_carriers( + carried: Mapping[ChannelName, Optional[VoiceUnion]], + ) -> FrozenSet[ChannelName]: + """The channels among ``carried`` whose voice in force is a sample.""" + return frozenset( + channel + for channel, voice in carried.items() + if voice is not None and voice_kind(voice).places_across_channels + ) - 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 - every channel. + references no channel, and :meth:`relevant_channels` then reads the samples + still playing from earlier rows. """ - 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. An 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, and the channels still playing a sample are the ones a + pitch or a volume typed in the sample column reaches. + """ + 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) 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), + carried_channels=self._sample_carriers(carried), ) + 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) -> SequencerCellViewModel: + """One cell's three readings, the pitch printed in the face it was written in. + + A note reads as the note it names and a step as the step, whichever voice the row names, + so the grid shows what the reader typed. + """ return SequencerCellViewModel( - instrument=display_command( - self._controller.project.samples, + voice=display_command( + self._controller.project.voices, row.command, ), - transpose=display_transpose(row.transpose), + transpose=display_pitch(row.pitch), volume=display_volume(row.volume), + kind=self._named_kind(row.command), + pitch=row.pitch, + level=row.volume, + command=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 + 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..47c88a353 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -11,8 +11,9 @@ 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.patterns.pitch import RowPitch +from sampletones_core.project.voices.note_off import NoteOff from .block import BlockKey, BlockNote, TrackerBlock from .tracker import SequencerTrackerLogic @@ -35,12 +36,12 @@ def write(self, block: TrackerBlock, cell: TrackerCell) -> None: """Writes a block anchored at a cell, the cell supplying the column and the block the rest. Each kind of subcolumn is written in a pass of its own, notes first: a note through the - sample column decides the whole row, so the transposes and volumes sharing that row land + sample column decides the whole row, so the pitches 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.pitches, cell, base, self._write_pitch) self._write_pass(block.volumes, cell, base, self._write_volume) def clear(self, region: TrackerRegion) -> None: @@ -49,7 +50,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 +59,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 +75,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( + def _write_pitch( self, row_index: int, - generator: Optional[GeneratorName], - transpose: Optional[int], + channel: Optional[ChannelName], + pitch: Optional[RowPitch], ) -> None: - if transpose is None: + if pitch is None: self._tracker.clear_cell_subcolumn( row_index, - generator, + channel, SubColumn.TRANSPOSE, ) else: self._tracker.set_cell_subcolumn( row_index, - generator, - transpose=transpose, + channel, + pitch=pitch, ) 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..6ba283ede --- /dev/null +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -0,0 +1,337 @@ +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.logic.shared.renders import RenderCache +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.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 sample as the reconstruction behind it, an instrument 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, + renders: RenderCache, + ) -> None: + self._controller = project_controller + self._session_manager = session_manager + self._audio_device_manager = audio_device_manager + self._scheduling = scheduling + self._renders = renders + 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 the raw size of one voice's instruments, every item their envelopes carry. + + 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 self._renders.mix(sample.reconstruction) + 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 as exception: + self.call(self.on_autoplay_error, exception) diff --git a/src/sampletones_application/logic/shared/audio_player.py b/src/sampletones_application/logic/shared/audio_player.py index c250f1ba6..7d439e3cd 100644 --- a/src/sampletones_application/logic/shared/audio_player.py +++ b/src/sampletones_application/logic/shared/audio_player.py @@ -1,10 +1,10 @@ from typing import Callable, Optional from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager -from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_shared.exceptions import PlaybackError +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -20,14 +20,14 @@ def __init__( ): self.audio_device_manager = audio_device_manager self.audio_data: AudioData = AudioData.empty(sample_rate) - self._current_position: int = 0 self.on_position_changed = on_position_changed self.on_change_audio_state = on_change_audio_state @property def current_position(self) -> int: - return self._current_position + """Where this player's own playback stands on the device, sounding or held paused.""" + return self.audio_device_manager.position_of(self) def load_audio_data(self, audio_data: AudioData) -> None: self.audio_data = audio_data @@ -38,30 +38,56 @@ def clear_audio(self) -> None: self.stop() self.audio_data = AudioData.empty(self.audio_data.sample_rate) - def _set_current_position(self, position: int) -> None: - self._current_position = max( - 0, - min( - position, - self.audio_data.samples, - ), - ) - def _on_device_position_changed(self, position: int) -> None: - if self.audio_data.is_loaded(): - self._set_current_position(position) - self.call(self.on_position_changed, position) + """Carries a report from the thread writing the audio to the render thread. + + The mark the report moves is a widget, so the report crosses the way every background result + does. The device reports the start of the audio once, as it winds down, and a position past + it after each buffer it writes. + """ + CallbackQueue.add(self._report_position, position == START_OF_AUDIO) + + def _report_position(self, wound_down: bool) -> None: + """Reports where the playback stands, read as the report arrives on the render thread. - if position == 0: + A report travels while the reader may seek, so the position is read from the device where the + report arrives, and a report overtaken by a seek draws the seek. A playback winding down is + also when the transport's labels follow the state it left. + + Args: + wound_down: Whether the report is the device's last for the playback. + """ + self.call(self.on_position_changed, self.current_position) + if wound_down: self._notify_audio_state_changed() - def set_position(self, position: int) -> None: - if self.audio_data.is_loaded(): - self._set_current_position(position) - self.audio_device_manager.set_position(position) - self.call(self.on_position_changed, position) + def seek(self, position: int) -> bool: + """Moves this player's own playback to a sample, whether it sounds or stands paused. + + The device reports positions as it writes, and a paused one writes nothing, so the player + reports the sample it moved to, which carries the mark there during a pause as well. - def play(self) -> None: + Args: + position: The sample to move to, clamped to the audio. + + Returns: + bool: Whether this player held the output, and so moved its own playback. + """ + if not self.audio_device_manager.set_position(position, owner=self): + return False + + self.call(self.on_position_changed, self.current_position) + return True + + def play(self, *, start: int) -> None: + """Takes the output and sounds the loaded audio from a sample. + + Args: + start: The sample playback begins at, clamped to the audio. + + Raises: + NoOutputDeviceError: If no output device is in force. + """ if not self.audio_data.is_loaded(): self._notify_audio_state_changed() return @@ -69,17 +95,12 @@ def play(self) -> None: self.audio_device_manager.set_position_callback( self._on_device_position_changed, ) - audio = self.audio_data.sample - - try: - self.audio_device_manager.play( - audio, - priority=PlaybackPriority.NORMAL, - owner=self, - ) - except ValueError as exception: - raise PlaybackError(f"Audio playback failed: {exception}") from exception - + self.audio_device_manager.play( + self.audio_data.sample, + priority=PlaybackPriority.NORMAL, + owner=self, + start=start, + ) self._notify_audio_state_changed() def pause(self) -> None: @@ -92,10 +113,7 @@ def resume(self) -> None: def stop(self) -> None: self.audio_device_manager.stop() - if self.audio_data.is_loaded(): - self._current_position = 0 - - self.call(self.on_position_changed, 0) + self.call(self.on_position_changed, START_OF_AUDIO) self._notify_audio_state_changed() @property diff --git a/src/sampletones_application/logic/shared/file_playback.py b/src/sampletones_application/logic/shared/file_playback.py new file mode 100644 index 000000000..f33d0a653 --- /dev/null +++ b/src/sampletones_application/logic/shared/file_playback.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import Callable, Optional + +from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_core.audio import AudioDeviceManager +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_mix +from sampletones_shared.exceptions import PlaybackError, SampleToNESError +from sampletones_shared.logger import logger +from sampletones_shared.paths import extensions +from sampletones_shared.utils.callbacks import CallbackMixin + + +class FilePlayback(CallbackMixin): + """Wherever a file is named and asked to sound, this is what answers. + + A reconstruction is read and its approximation played; an audio file is played from disk. The + browser's tree, a row in the converter's list and a menu item all name a file the same way, so + what a suffix means and what a failure to read one reports stand in one place. + """ + + def __init__(self, audio_device_manager: AudioDeviceManager) -> None: + self._audio_device_manager = audio_device_manager + + self.on_error: Optional[Callable[[Exception], None]] = None + + @staticmethod + def plays(path: Path) -> bool: + """Whether this is a file the player knows how to sound.""" + suffix = path.suffix.lower() + return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO + + def play(self, path: Path) -> None: + """Play a file on demand, preempting the auxiliary preview and the players. + + Asking for a file by name is a deliberate action, so it sounds at ``NORMAL`` priority and + outranks the reconstruction and sequencer players. + """ + self.play_at(path, PlaybackPriority.NORMAL) + + def play_at(self, path: Path, priority: PlaybackPriority) -> None: + """Play a file at the priority the gesture asking for it carries. + + A device that refuses the playback reports through ``on_error``, the way a file that + fails to read does. + """ + try: + self._play_file(path, priority) + except PlaybackError as exception: + self.call(self.on_error, exception) + + def _play_file(self, path: Path, priority: PlaybackPriority) -> None: + match path.suffix.lower(): + case extensions.EXT_FILE_RECONSTRUCTION: + self._play_reconstruction(path, priority) + case suffix if suffix in extensions.EXT_FILES_AUDIO: + self._audio_device_manager.play_file(path, update=False, priority=priority) + + def _play_reconstruction(self, path: Path, priority: PlaybackPriority) -> None: + try: + reconstruction = Reconstruction.load(path) + except (OSError, SampleToNESError) as exception: + logger.error_with_traceback(exception, f"Failed to play reconstruction file: {path}") + self.call(self.on_error, exception) + return + + self._audio_device_manager.play(rendered_mix(reconstruction), update=False, priority=priority) diff --git a/src/sampletones_application/logic/shared/player.py b/src/sampletones_application/logic/shared/player.py index c4833a888..49d8a7d44 100644 --- a/src/sampletones_application/logic/shared/player.py +++ b/src/sampletones_application/logic/shared/player.py @@ -4,6 +4,7 @@ from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_application.view_model.shared.player import PlayerViewModel from sampletones_core.audio import AudioDeviceManager +from sampletones_core.constants.audio import START_OF_AUDIO from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -31,10 +32,17 @@ def clear_audio(self) -> None: self._emit_view() def play(self) -> None: - try: - self._audio_player.play() - finally: - self._emit_view() + self._play(start=START_OF_AUDIO) + + def play_from(self, position: int) -> None: + """Puts the playhead at a sample: an engaged player moves there and goes on sounding or stays + paused, and an idle one starts sounding from there. + + Args: + position: The sample the playhead is put at, clamped to the audio. + """ + if not self._audio_player.seek(position): + self._play(start=position) def pause(self) -> None: self._audio_player.pause() @@ -69,6 +77,12 @@ def is_engaged(self) -> bool: """Whether this player owns the live output, sounding or held paused.""" return self._audio_player.is_playing + def _play(self, *, start: int) -> None: + try: + self._audio_player.play(start=start) + finally: + self._emit_view() + def _on_position_changed(self, position: int) -> None: self._emit_view() self.call(self.on_position_changed, position) diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py index 208546746..709d1bbbe 100644 --- a/src/sampletones_application/logic/shared/project_source.py +++ b/src/sampletones_application/logic/shared/project_source.py @@ -1,26 +1,9 @@ -import copy from dataclasses import dataclass -from typing import Dict, Protocol, Self +from typing import Protocol, Self from sampletones_core.project import Project -def snapshot_project(project: Project) -> Project: - """Captures an independent copy of a project that shares reconstruction audio. - - The song, settings, metadata and sample shells are deep-copied so later edits - to the live project leave the snapshot untouched. Each sample's reconstruction - is shared by reference, so the snapshot reuses those multi-megabyte audio - arrays. Reconstruction edits are copy-on-write — each installs a fresh - reconstruction — so the shared reconstruction stays valid for the life of the - snapshot. - """ - shared_reconstructions: Dict[int, object] = { - id(sample.reconstruction): sample.reconstruction for sample in project.samples - } - return copy.deepcopy(project, shared_reconstructions) - - class ProjectSource(Protocol): """Where a reader of the open document finds the project it works on. @@ -51,5 +34,5 @@ class ProjectSnapshot: @classmethod def capture(cls, source: ProjectSource) -> Self: - """Takes the document ``source`` currently holds, copied through :func:`snapshot_project`.""" - return cls(project=snapshot_project(source.project)) + """Takes the document ``source`` currently holds, through :meth:`Project.snapshot`.""" + return cls(project=source.project.snapshot()) diff --git a/src/sampletones_application/logic/shared/renders.py b/src/sampletones_application/logic/shared/renders.py new file mode 100644 index 000000000..b6aacf2a9 --- /dev/null +++ b/src/sampletones_application/logic/shared/renders.py @@ -0,0 +1,133 @@ +import threading +import weakref +from collections import OrderedDict +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, List, Optional, Set, Tuple + +import numpy as np + +from sampletones_core.audio.mixing import mix +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators.render import render_instructions +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem + + +class RenderKind(StrEnum): + CHANNEL = "channel" + MIX = "mix" + + +@dataclass(frozen=True) +class RenderKey: + """What one render was made from: the identities of the streams it sounds and the configuration it ran at.""" + + kind: RenderKind + streams: Tuple[int, ...] + config: Config + + +class RenderCache: + """The audio the documents on screen render to, kept apart from the documents themselves. + + A reconstruction is a value that the undo history holds for as long as an entry names it, + so audio cached on it would live as long as the history does. The cache keeps renders + instead, each made from one channel's stream at one configuration, or one mix of such + streams. A stream an edit leaves alone is the very object the edited document holds, so its + render serves both documents. + + A render is keyed by the identity of the streams it sounds, which a lookup reads at once, + where a stream's value would take every instruction it carries to hash. A render goes with + the stream it was made from, and beyond ``budget_bytes`` the renders read longest ago go + first, so the cache keeps audio alone. The render thread reads the cache, and a stream's + collection may drop its renders from any thread, so every change to the cache is made + under one lock. + """ + + def __init__(self, *, budget_bytes: int) -> None: + self._budget_bytes = budget_bytes + self._renders: OrderedDict[RenderKey, np.ndarray] = OrderedDict() + self._watched: Set[int] = set() + self._held_bytes = 0 + self._lock = threading.Lock() + + @property + def held_bytes(self) -> int: + """How many bytes the renders the cache keeps take.""" + return self._held_bytes + + def channels(self, reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: + """The audio each channel in play renders from the stream it carries.""" + return { + stream.channel_name: self._channel(stream, reconstruction.config) + for stream in self._sounding(reconstruction) + } + + def mix(self, reconstruction: Reconstruction) -> np.ndarray: + """The whole reconstruction, summed from the channels that play.""" + streams = self._sounding(reconstruction) + key = RenderKey(RenderKind.MIX, tuple(id(stream) for stream in streams), reconstruction.config) + cached = self._read(key) + if cached is not None: + return cached + + mixed = mix([self._channel(stream, reconstruction.config) for stream in streams]) + self._keep(key, mixed, streams) + return mixed + + @staticmethod + def _sounding(reconstruction: Reconstruction) -> List[InstructionsItem]: + """The streams that describe a frame, in channel order, the channels a render sounds.""" + return [stream for stream in reconstruction.instructions_data if stream.instructions] + + def _channel(self, stream: InstructionsItem, config: Config) -> np.ndarray: + key = RenderKey(RenderKind.CHANNEL, (id(stream),), config) + cached = self._read(key) + if cached is not None: + return cached + + rendered = render_instructions( + [data.instruction for data in stream.instructions], + stream.channel_name, + config, + ) + self._keep(key, rendered, [stream]) + return rendered + + def _read(self, key: RenderKey) -> Optional[np.ndarray]: + with self._lock: + rendered = self._renders.get(key) + if rendered is not None: + self._renders.move_to_end(key) + + return rendered + + def _keep( + self, + key: RenderKey, + rendered: np.ndarray, + streams: List[InstructionsItem], + ) -> None: + with self._lock: + if key in self._renders: + return + + self._renders[key] = rendered + self._held_bytes += rendered.nbytes + for stream in streams: + if id(stream) not in self._watched: + self._watched.add(id(stream)) + weakref.finalize(stream, self._forget, id(stream)) + + while self._held_bytes > self._budget_bytes and len(self._renders) > 1: + _, evicted = self._renders.popitem(last=False) + self._held_bytes -= evicted.nbytes + + def _forget(self, stream_id: int) -> None: + """Drops every render a collected stream sounds in, before its identity can name another.""" + with self._lock: + self._watched.discard(stream_id) + for key in [key for key in self._renders if stream_id in key.streams]: + self._held_bytes -= self._renders.pop(key).nbytes diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index c42332c9a..562f658a0 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -3,14 +3,10 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_core.audio import AudioDeviceManager -from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode -from sampletones_shared.exceptions import SampleToNESError -from sampletones_shared.logger import logger -from sampletones_shared.paths import extensions from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -19,25 +15,26 @@ class TreeLogic(CallbackMixin): def __init__( self, session_manager: SessionManager, - audio_device_manager: AudioDeviceManager, + file_playback: FilePlayback, *, scheduling: SchedulingBehavior, ) -> None: self._session_manager = session_manager - self._audio_device_manager = audio_device_manager + self._file_playback = file_playback self._scheduling = scheduling self._lock_counter: int = 0 self._is_locked: bool = False self._thread_lock = threading.RLock() + self._retry_on_release: Optional[VoidCallback] = None + self._pending_autoplay_node: Optional[FileSystemNode] = None self._pending_search_query: Optional[str] = None self.on_lock_state_changed: Optional[Callable[[bool], None]] = None self.on_favorite_changed: Optional[Callable[[FileSystemNode], None]] = None self.on_search_update_needed: Optional[VoidCallback] = None - self.on_autoplay_error: Optional[Callable[[Exception], None]] = None def lock(self) -> None: with self._thread_lock: @@ -46,17 +43,44 @@ def lock(self) -> None: self._notify_lock_state(False) + def lock_unless_locked(self, retry: Optional[VoidCallback]) -> bool: + """Takes the lock when it stands free, answering whether it did. + + A lock already held keeps ``retry`` for its release instead, so a rebuild asked for while a + generation, a load or another rebuild holds the tree runs once they have let it go. Only the + latest ``retry`` is kept, since each one reads the model as it stands when it runs. + """ + with self._thread_lock: + if self._is_locked: + if retry is not None: + self._retry_on_release = retry + return False + + self._lock_counter += 1 + self._is_locked = True + + self._notify_lock_state(False) + return True + def unlock(self) -> None: with self._thread_lock: self._lock_counter -= 1 unlocked = self._lock_counter <= 0 + retry: Optional[VoidCallback] = None if unlocked: self._lock_counter = 0 self._is_locked = False + retry, self._retry_on_release = self._retry_on_release, None if unlocked: self._notify_lock_state(True) + if retry is not None: + CallbackQueue.add( + retry, + priority=self._scheduling.priorities.gui_action, + ) + def _notify_lock_state(self, is_unlocked: bool) -> None: """Deliver the tree-enabled change on the main thread. @@ -89,21 +113,16 @@ def cancel_autoplay(self) -> None: self._pending_autoplay_node = None def play_node(self, node: FileSystemNode) -> None: - """Play a file on demand, preempting the auxiliary preview and the players. - - Unlike autoplay this ignores the session autoplay flag and uses ``NORMAL`` - priority: it is a deliberate user action, so it always plays and outranks the - reconstruction/sequencer players. - """ - self._play_file(node, PlaybackPriority.NORMAL) + """Play the file a browser node stands for, where it is one the player can sound.""" + if node.node_type == NodeType.FILE: + self._file_playback.play(node.filepath) def is_playable_file(self, node: TreeNode) -> bool: - """Whether the node is a file this logic knows how to play (reconstruction or audio).""" + """Whether the node is a file the player knows how to sound.""" if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return False - suffix = node.filepath.suffix.lower() - return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO + return FilePlayback.plays(node.filepath) def _execute_autoplay(self) -> None: if self._pending_autoplay_node is not None: @@ -111,34 +130,9 @@ def _execute_autoplay(self) -> None: self._pending_autoplay_node = None def _autoplay_file(self, node: FileSystemNode) -> None: - if self._session_manager.autoplay: - self._play_file(node, PlaybackPriority.PREVIEW) - - def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return - - match node.filepath.suffix.lower(): - case extensions.EXT_FILE_RECONSTRUCTION: - try: - reconstruction = Reconstruction.load(node.filepath) - self._audio_device_manager.play( - reconstruction.approximation, - update=False, - priority=priority, - ) - except (OSError, SampleToNESError) as exception: - logger.error_with_traceback( - exception, - f"Failed to play reconstruction file: {node.filepath}", - ) - self.call(self.on_autoplay_error, exception) - case suffix if suffix in extensions.EXT_FILES_AUDIO: - self._audio_device_manager.play_file( - node.filepath, - update=False, - priority=priority, - ) + """Sound what a selection selects, where the session says a selection sounds at all.""" + if self._session_manager.autoplay and node.node_type == NodeType.FILE: + self._file_playback.play_at(node.filepath, PlaybackPriority.PREVIEW) def is_node_favorite(self, node: TreeNode) -> bool: if not isinstance(node, FileSystemNode): 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..1308c4fa9 100644 --- a/src/sampletones_application/parameters/main.py +++ b/src/sampletones_application/parameters/main.py @@ -5,7 +5,9 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.colors.stem import StemColors 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,7 +26,9 @@ class MainTabParameters: config_height: int main: MainLayout inputs: InputsLayout + stems: StemsListLayout path_colors: PathColors + stem_colors: StemColors tree_colors: TreeColors scheduling: SchedulingBehavior @@ -36,7 +40,9 @@ 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, + stem_colors=general.colors.stems, tree_colors=TreeColors.create( general.colors, accent=general.colors.paths.hover, diff --git a/src/sampletones_application/parameters/reconstruction.py b/src/sampletones_application/parameters/reconstruction.py index 42adf0170..90046a897 100644 --- a/src/sampletones_application/parameters/reconstruction.py +++ b/src/sampletones_application/parameters/reconstruction.py @@ -4,8 +4,11 @@ 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.colors.stem import StemColors +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 +32,12 @@ class ReconstructionTabParameters: pitch_stepper_style: PitchStepperStyle copy_width: int feature_colors: FeatureColors + channel_colors: ChannelColors + stem_colors: StemColors path_colors: PathColors path_status_color: BaseColor tree_colors: TreeColors + stems: StemsListLayout scheduling: SchedulingBehavior @classmethod @@ -45,11 +51,14 @@ 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, + stem_colors=general.colors.stems, 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..3cb6da566 100644 --- a/src/sampletones_application/parameters/sequencer.py +++ b/src/sampletones_application/parameters/sequencer.py @@ -4,7 +4,9 @@ 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.stem import StemColors from sampletones_application.layout.general.inputs import InputsLayout from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout from sampletones_application.layout.tabs.sequencer import SequencerLayout @@ -33,6 +35,8 @@ class SequencerTabParameters: inputs: InputsLayout plus_minus: PlusMinusButtonsLayout feature_colors: FeatureColors + channel_colors: ChannelColors + stem_colors: StemColors tree_colors: TreeColors muted_color: BaseColor scheduling: SchedulingBehavior @@ -50,6 +54,8 @@ 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, + stem_colors=general.colors.stems, 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.py b/src/sampletones_application/services/conversion.py deleted file mode 100644 index 5b07c9ed6..000000000 --- a/src/sampletones_application/services/conversion.py +++ /dev/null @@ -1,126 +0,0 @@ -from pathlib import Path -from typing import Optional - -from sampletones_application.services.base import ServiceBase -from sampletones_application.services.result import ( - ConversionResult, - ServiceCancelled, - ServiceError, - ServiceIntermediate, - ServiceProgress, - ServiceStarted, - ServiceSuccess, -) -from sampletones_core.configs import Config -from sampletones_core.parallelization import ETAEstimator, TaskProgress, TaskStatus -from sampletones_core.reconstructions.converter import ReconstructionConverter -from sampletones_shared.logger import logger -from sampletones_shared.utils.system.paths import to_path - - -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 - 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. - """ - - def __init__(self, priority: int = 0) -> None: - super().__init__(priority) - self._converter: Optional[ReconstructionConverter] = None - self._eta_estimator: Optional[ETAEstimator] = None - - def start(self, config: Config, input_path: Path) -> 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.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, - ) - self._converter.start() - - def cancel(self) -> None: - if self._converter and self._converter.is_running(): - self._converter.cancel() - - def cleanup(self) -> None: - if self._converter is not None: - self._converter.cleanup() - self._converter = None - - self._eta_estimator = None - - def shutdown(self) -> None: - """Tears the converter's process pool down synchronously for application exit. - - The pool spawns its workers, so the process must reap them before it releases - the shared resources they depend on; this blocks until the pool has stopped.""" - if self._converter is not None: - self._converter.shutdown() - self._converter = None - - self._eta_estimator = None - - def is_running(self) -> bool: - return self._converter is not None and ( - self._converter.is_running() or self._converter.status == TaskStatus.PENDING - ) - - def _on_start(self) -> None: - assert self._converter is not None - total = self._converter.total_tasks - self._eta_estimator = ETAEstimator(total=total) - self._emit(ServiceStarted(total=total)) - - def _on_progress( - self, - 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, - ) - ) - case _: - pass - - def _on_completed(self, output_path: Path) -> None: - self._emit(ServiceSuccess(value=output_path)) - - def _on_error(self, exception: Exception) -> None: - self._emit(ServiceError(exception=exception)) - - def _on_cancelled(self) -> None: - self._emit(ServiceCancelled()) - - def forward_library_progress( - self, - _status: TaskStatus, - progress: TaskProgress, - ) -> None: - self._emit(ServiceIntermediate(data=progress)) 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..f53dd89a9 --- /dev/null +++ b/src/sampletones_application/services/conversion/result.py @@ -0,0 +1,52 @@ +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 its job reads and which file it writes 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 and its output. + """ + + source: Path + output_path: 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/service.py b/src/sampletones_application/services/conversion/service.py new file mode 100644 index 000000000..811467a9f --- /dev/null +++ b/src/sampletones_application/services/conversion/service.py @@ -0,0 +1,173 @@ +from pathlib import Path +from typing import Optional, Tuple + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.conversion.result import ( + ConversionItem, + ConversionResult, + ReconstructionStep, +) +from sampletones_application.services.result import ( + NOTHING_UNDER_WAY, + ServiceCanceled, + ServiceError, + ServiceIntermediate, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_core.configs import Config +from sampletones_core.parallelization import ETAEstimator, TaskProgress, TaskStatus +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 + + +class ConversionService(ServiceBase[ConversionResult]): + """ + Translates raw ``ReconstructionConverter`` callbacks into a uniform result stream. + + 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. + """ + + def __init__(self, priority: int = 0) -> None: + super().__init__(priority) + self._converter: Optional[ReconstructionConverter] = None + self._eta_estimator: Optional[ETAEstimator] = None + + def start(self, config: Config, plan: ConversionPlan) -> None: + if self.is_running(): + logger.warning("Conversion is already in progress") + return + + self.release() + 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_canceled=self._on_canceled, + ) + self._converter.start() + + def cancel(self) -> None: + if self._converter and self._converter.is_running(): + self._converter.cancel() + + def release(self) -> None: + """Ends the converter's run and lets the converter go once its pool has ended. + + A converter that has announced its outcome has already ended its pool, so this returns at + once; a run still under way is canceled and its workers waited for, which is what the + application's exit asks. + """ + if self._converter is not None: + self._converter.shutdown() + self._converter = None + + self._eta_estimator = None + + def is_running(self) -> bool: + return self._converter is not None and ( + self._converter.is_running() or self._converter.status == TaskStatus.PENDING + ) + + def _on_start(self) -> None: + assert self._converter is not None + total = self._converter.total_tasks + self._eta_estimator = ETAEstimator(total=total) + self._emit(ServiceStarted(total=total)) + + def _on_progress( + self, + task_status: TaskStatus, + task_progress: TaskProgress, + ) -> None: + match task_status: + case TaskStatus.RUNNING | TaskStatus.CANCELING: + self._emit(self._reading(task_progress)) + case _: + pass + + def _reading(self, task_progress: TaskProgress) -> ServiceProgress[ConversionItem]: + """Where the run stands, counted in the unit the run's own size makes readable. + + A run writing one reconstruction counts to one, so the part of that reconstruction done is + what a reader follows: the reading carries the stage the reconstruction is at, and the bar + and the estimate move with it. A run writing several counts the reconstructions it has + written, which is the unit a reader recognizes and the one the estimate is taken in, since + such a run holds as many reconstructions under way at once as it has workers. + """ + writes_one = task_progress.is_single + under_way = task_progress.partial if writes_one else NOTHING_UNDER_WAY + step = self._step(task_progress) if writes_one else None + return ServiceProgress( + completed=task_progress.completed, + total=task_progress.total, + current_item=self._item(task_progress, step), + eta_seconds=self._estimate(task_progress.completed + under_way), + partial=under_way, + ) + + def _estimate(self, covered: float) -> Optional[float]: + """How long the run has left, read from what it has covered in the unit it counts in.""" + if self._eta_estimator is None: + return None + + return self._eta_estimator.update(covered) + + def _item( + self, + task_progress: TaskProgress, + step: Optional[ReconstructionStep], + ) -> Optional[ConversionItem]: + """The reconstruction the run names itself by, where it has a job to name. + + A run works on as many reconstructions as it has workers and names the one it has been at + longest, so a reader watching a batch sees the run move through its recordings. The item + names that job's recording and the file it writes. + """ + converter = self._converter + job = converter.job_under_way(task_progress.completed) if converter is not None else None + if job is None: + return None + + return ConversionItem(source=job.sources[0], output_path=job.output_path, step=step) + + @staticmethod + def _step(task_progress: TaskProgress) -> Optional[ReconstructionStep]: + """What the reconstruction under way is doing, once it has said something about itself. + + A run writing one reconstruction holds one line open, so the step standing on it is that + reconstruction's own. + """ + 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_canceled(self) -> None: + self._emit(ServiceCanceled()) + + def forward_library_progress( + self, + _status: TaskStatus, + progress: TaskProgress, + ) -> None: + self._emit(ServiceIntermediate(data=progress)) diff --git a/src/sampletones_application/services/export/error.py b/src/sampletones_application/services/export/error.py index a7405674d..761cf57f8 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) @@ -10,11 +10,11 @@ class ExportError: """A failed export, carrying the exception the result dialog reports. 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. + kind: The artifact the run set out to produce. + 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/kind.py b/src/sampletones_application/services/export/kind.py index 5d65376c1..ae795b774 100644 --- a/src/sampletones_application/services/export/kind.py +++ b/src/sampletones_application/services/export/kind.py @@ -2,7 +2,7 @@ class ExportKind(str, Enum): - """The artefact one export run produced, naming the dialog that reports it.""" + """The artifact one export run produced, naming the dialog that reports it.""" WAV = "wav" INSTRUMENT = "instrument" 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..fcebdc019 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -1,41 +1,70 @@ +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.exporters.skipped import NO_SKIPPED_ROWS +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 +72,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, skipped_rows=NO_SKIPPED_ROWS) + 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. @@ -120,34 +151,67 @@ def _submit( slices rather than the destination itself. Args: - kind: The artefact the run produces, naming the dialog that reports it. + kind: The artifact 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, + skipped_rows=artifact.skipped_rows, + ) + ) - 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..a89e99d12 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -1,10 +1,11 @@ from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Tuple from sampletones_application.services.export.kind import ExportKind +from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat @dataclass(frozen=True) @@ -12,14 +13,17 @@ class ExportSuccess: """A completed export, with the path it wrote and what the file kept. Attributes: - kind: The artefact the run produced. + kind: The artifact 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. + skipped_rows: The rows of the song written as a note cut, because the voice they name + has no instrument on their channel. """ kind: ExportKind filepath: Path - tracker_format: Optional[TrackerFormat] + export_format: Optional[ExportFormat] truncation: Optional[EnvelopeTruncation] + skipped_rows: Tuple[SkippedRow, ...] diff --git a/src/sampletones_application/services/folder_scan/__init__.py b/src/sampletones_application/services/folder_scan/__init__.py new file mode 100644 index 000000000..e030e0274 --- /dev/null +++ b/src/sampletones_application/services/folder_scan/__init__.py @@ -0,0 +1,21 @@ +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_application.services.folder_scan.service import FolderScanService + +__all__ = [ + "FolderScanCanceled", + "FolderScanError", + "FolderScanProgress", + "FolderScanRequest", + "FolderScanResult", + "FolderScanService", + "FolderScanStarted", + "FolderScanSuccess", +] diff --git a/src/sampletones_application/services/folder_scan/result.py b/src/sampletones_application/services/folder_scan/result.py new file mode 100644 index 000000000..15c88e8f4 --- /dev/null +++ b/src/sampletones_application/services/folder_scan/result.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple, Union + + +@dataclass(frozen=True, eq=False) +class FolderScanRequest: + """One folder a reader asked to have read. + + A request is told apart from every other one by its identity, so a report from a reading the + reader gave up on reads as late, even where the next reading is of the same folder. + + Attributes: + root: The folder whose recordings are read. + """ + + root: Path + + +@dataclass(frozen=True) +class FolderScanStarted: + """The reading of a folder has begun.""" + + request: FolderScanRequest + + +@dataclass(frozen=True) +class FolderScanProgress: + """How many recordings the reading has met so far.""" + + request: FolderScanRequest + count: int + + +@dataclass(frozen=True) +class FolderScanSuccess: + """Every recording below the folder, in name order.""" + + request: FolderScanRequest + recordings: Tuple[Path, ...] + + +@dataclass(frozen=True, eq=False) +class FolderScanError: + """A reading that failed partway, with the failure it raised.""" + + request: FolderScanRequest + exception: Exception + + +@dataclass(frozen=True) +class FolderScanCanceled: + """A reading that gave up at the reader's Stop.""" + + request: FolderScanRequest + + +FolderScanResult = Union[ + FolderScanStarted, + FolderScanProgress, + FolderScanSuccess, + FolderScanError, + FolderScanCanceled, +] diff --git a/src/sampletones_application/services/folder_scan/service.py b/src/sampletones_application/services/folder_scan/service.py new file mode 100644 index 000000000..7e6b45ad0 --- /dev/null +++ b/src/sampletones_application/services/folder_scan/service.py @@ -0,0 +1,100 @@ +import threading +from functools import partial +from pathlib import Path +from typing import Final, List, Optional + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_application.utils.parallelization.thread import BackgroundWorkCanceled, SingleThreadExecutor +from sampletones_core.reconstructions.converter.paths import walk_entries +from sampletones_shared.paths.extensions import is_audio_file + +REPORT_EVERY: Final[int] = 64 +REPORT_DUE: Final[int] = 0 + + +class FolderScanService(ServiceBase[FolderScanResult]): + """Reads the recordings below a folder beside the interface, counting them as it goes. + + A folder a reader points at holds a handful of recordings or a disk's worth, and finding out + costs what the tree costs, which is seconds where the tree is large. The walk runs on a worker + of its own and reports how many recordings it has met. It gives up at the next entry it meets + once the reader stops it, and once the application shuts down, so a walk ends with the run. + Every report names the request it answers. + """ + + def __init__(self, priority: int) -> None: + super().__init__(priority) + self._executor = SingleThreadExecutor() + self._stopping = threading.Event() + + def start(self, request: FolderScanRequest) -> None: + """Reads the folder ``request`` names on the worker. + + One reading runs at a time, so a reading started as the last one unwinds waits for it to end. + Each reading listens for a Stop of its own. + """ + stopping = threading.Event() + self._stopping = stopping + self._executor.execute(partial(self._run, request, stopping), wait=True) + + def stop(self) -> None: + """Asks the latest reading to give up, which it does at the next entry it meets.""" + self._stopping.set() + + def _run(self, request: FolderScanRequest, stopping: threading.Event) -> None: + """Reads the folder and reports how the reading ended. + + A shutdown ends the reading with no report, since nothing is left to hear it. + """ + if SingleThreadExecutor.is_shutting_down(): + return + + self._emit(FolderScanStarted(request=request)) + try: + recordings = self._gather(request, stopping) + except BackgroundWorkCanceled: + return + except Exception as exception: # pylint: disable=broad-exception-caught + self._emit(FolderScanError(request=request, exception=exception)) + return + + if recordings is None: + self._emit(FolderScanCanceled(request=request)) + return + + self._emit(FolderScanSuccess(request=request, recordings=tuple(sorted(recordings)))) + + def _gather(self, request: FolderScanRequest, stopping: threading.Event) -> Optional[List[Path]]: + """The recordings met below the folder, or ``None`` where the reader stopped the walk. + + Every entry the tree holds is offered, so a folder of thousands holding a handful of + recordings answers Stop and a shutdown as promptly as one holding thousands. + + Raises: + BackgroundWorkCanceled: If the application shuts down while the walk runs. + """ + found: List[Path] = [] + for path in walk_entries(request.root): + if SingleThreadExecutor.is_shutting_down(): + raise BackgroundWorkCanceled + + if stopping.is_set(): + return None + + if not is_audio_file(path): + continue + + found.append(path) + if len(found) % REPORT_EVERY == REPORT_DUE: + self._emit(FolderScanProgress(request=request, count=len(found))) + + return found 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.py b/src/sampletones_application/services/regeneration.py deleted file mode 100644 index 5602803d6..000000000 --- a/src/sampletones_application/services/regeneration.py +++ /dev/null @@ -1,141 +0,0 @@ -from dataclasses import dataclass -from typing import List, cast - -import numpy as np - -from sampletones_application.services.base import ServiceBase -from sampletones_application.services.result import ( - ServiceCancelled, - 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.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]): - """Recomputes one generator's instructions and audio for a reconstruction. - - The result is a fresh reconstruction carrying the updated generator data; the - source reconstruction is left intact. Producing a new object lets callers swap - the edited reconstruction in while any history snapshot that shares the prior - object stays valid. - - Requests are serialized on a :class:`LatestWinsExecutor`: while a job runs, further - requests coalesce to the latest one, so a continuous stream of edits collapses to a - single applied result — the final value. ``is_running`` reports whether that worker is - still busy, letting the view fade the reconstruction for the whole busy span. - """ - - def __init__(self, priority: int = 0) -> None: - super().__init__(priority) - self._executor = LatestWinsExecutor() - self._cancelled: bool = False - - def start( - self, - reconstruction: Reconstruction, - generator_name: GeneratorName, - features: Features, - feature_key: FeatureKey, - value: FeatureValue, - ) -> bool: - if self._cancelled: - return False - - return self._executor.submit( - lambda: self._run( - reconstruction, - generator_name, - features, - feature_key, - value, - ) - ) - - def is_running(self) -> bool: - return self._executor.is_running - - def cancel(self) -> None: - self._cancelled = True - - def _run( - self, - reconstruction: Reconstruction, - generator_name: GeneratorName, - features: Features, - feature_key: FeatureKey, - value: FeatureValue, - ) -> None: - if self._cancelled: - self._emit(ServiceCancelled()) - return - try: - exporter_class = GENERATOR_NAME_TO_EXPORTER_MAP[generator_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) - audio = self._render(generator, instructions) - - updated = reconstruction.model_copy(deep=True) - updated.update_generator_data( - generator_name, - instructions, - audio, - features.initial_pitch, - features.held_features, - ) - self._emit( - ServiceSuccess( - value=RegeneratedInstrument( - reconstruction=updated, - generator_name=generator_name, - feature_key=feature_key, - ) - ) - ) - except Exception as exception: # pylint: disable=broad-exception-caught - self._emit(ServiceError(exception=exception)) - - @staticmethod - def _render( - generator: GeneratorUnion, - instructions: List[InstructionUnion], - ) -> np.ndarray: - """Synthesizes the frames the instructions describe, one after another. - - An instrument whose every dimension is left to the channel describes no frame, and - sounds as the silence of an empty waveform. - """ - if not instructions: - return np.zeros(0, dtype=np.float32) - - return np.concatenate( - [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type] - ) 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..a341bfd05 --- /dev/null +++ b/src/sampletones_application/services/regeneration/result.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Union + +from sampletones_application.services.result import ( + ServiceError, + ServiceSuccess, +) +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class RegeneratedInstrument: + """A reconstruction with one channel rebuilt from the envelopes the regeneration was handed. + + The caller keeps what it asked for, so the result carries the rebuilt document alone. + + Attributes: + reconstruction: The fresh reconstruction carrying the rebuilt channel. + """ + + reconstruction: Reconstruction + + +RegenerationResult = Union[ + ServiceSuccess[RegeneratedInstrument], + ServiceError, +] diff --git a/src/sampletones_application/services/regeneration/service.py b/src/sampletones_application/services/regeneration/service.py new file mode 100644 index 000000000..d0f3d6f65 --- /dev/null +++ b/src/sampletones_application/services/regeneration/service.py @@ -0,0 +1,86 @@ +from functools import partial +from typing import AbstractSet, List, cast + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.regeneration.result import ( + RegeneratedInstrument, + RegenerationResult, +) +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction + + +class RegenerationService(ServiceBase[RegenerationResult]): + """Recomputes one generator's instructions for a reconstruction. + + An edit reaches the frames the reader hears on the channel, so ``heard`` travels with it: + a frame of a recording the reader left out stands as it is, which is what lets one + recording's part be shaped while the recordings beside it carry on. + + The result is a fresh reconstruction carrying the updated generator data; the + source reconstruction is left intact. Producing a new object lets callers swap + the edited reconstruction in while any history snapshot that shares the prior + object stays valid. + + One job runs at a time on a single worker thread. Its caller starts the next job once the + previous result has arrived, so each rebuild starts from the document the one before it left. + """ + + def __init__(self, priority: int = 0) -> None: + super().__init__(priority) + self._executor = SingleThreadExecutor() + + def start( + self, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + """Rebuilds ``channel_name`` of ``reconstruction`` from ``features`` on the worker thread. + + Args: + reconstruction: The document the rebuild starts from, which stays as it is. + channel_name: The channel rebuilt. + features: The envelopes the channel is rebuilt from. + heard: The recordings whose frames the rebuild writes. + """ + self._executor.execute( + partial( + self._run, + reconstruction, + channel_name, + features, + heard, + ), + wait=True, + ) + + def _run( + self, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + try: + exporter_class = CHANNEL_TO_EXPORTER_MAP[channel_name] + instructions = cast( + List[InstructionUnion], + exporter_class.from_features(features), + ) + + updated = reconstruction.with_channel_data( + channel_name, + instructions, + features.initial_pitch, + features.held_features, + heard=heard, + ) + self._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=updated))) + except Exception as exception: # pylint: disable=broad-exception-caught + self._emit(ServiceError(exception=exception)) diff --git a/src/sampletones_application/services/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..a4f195b6c 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, @@ -25,14 +25,14 @@ class SongRenderService(ServiceBase[RenderResult]): """Renders a whole song to a file on a background thread, reporting each pass as it runs. - The synthesiser arrives per call, so the service holds no opinion on what a song sounds + The synthesizer arrives per call, so the service holds no opinion on what a song sounds like: it drives the same kernel the player drives, one row at a time, and hands each row to a sink. The sink decides what becomes of a row — straight to the encoder, or spilled and 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. Canceling 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/render/sink.py b/src/sampletones_application/services/render/sink.py index 4f46dae3a..018888cda 100644 --- a/src/sampletones_application/services/render/sink.py +++ b/src/sampletones_application/services/render/sink.py @@ -23,7 +23,7 @@ class RenderSink(Protocol): """Where a render's rows go on their way to the destination file. A sink is entered for the length of one render: rows arrive through ``write`` in the order - they are synthesised, and ``finish`` completes whatever the sink still owes the destination. + they are synthesized, and ``finish`` completes whatever the sink still owes the destination. Leaving the sink closes what it opened and clears what was only ever temporary; ``discard`` is how a caller that decided against the result removes the file itself. @@ -50,9 +50,9 @@ def discard(self) -> None: ... class DirectRenderSink: - """Writes each row to the destination as it is synthesised. + """Writes each row to the destination as it is synthesized. - One pass over the song, at the level the synthesiser produced: the encoder receives a row as + One pass over the song, at the level the synthesizer produced: the encoder receives a row as soon as it exists, so the file grows with the render and nothing is held between the two. """ @@ -101,7 +101,7 @@ def discard(self) -> None: class NormalizingRenderSink: """Spills the render, then writes it at the scale that brings its peak to full. - The loudest sample is known only once the last row is synthesised, so the rows are spilled + The loudest sample is known only once the last row is synthesized, so the rows are spilled beside the destination as they arrive and read back in blocks against the peak they turned out to hold. The destination is opened for the second pass alone, which is what makes the encoder see the finished levels rather than the raw ones. diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index 331013ab3..5581aa779 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -1,11 +1,12 @@ 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 +SINGLE_ITEM: Final[int] = 1 + @dataclass(frozen=True) class ServiceStarted: @@ -14,10 +15,38 @@ 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 is_single(self) -> bool: + """The operation is measured in one item, whose own progress is the whole of the reading.""" + return self.total <= SINGLE_ITEM + + @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 +60,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/retune.py b/src/sampletones_application/services/retune/retune.py deleted file mode 100644 index 64e6d01e1..000000000 --- a/src/sampletones_application/services/retune/retune.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import List, Sequence, Tuple - -from sampletones_application.services.base import ServiceBase -from sampletones_application.services.result import ServiceError, ServiceSuccess -from sampletones_application.services.retune.result import RetuneResult -from sampletones_application.services.retune.sample import RetunedSample -from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor -from sampletones_core.reconstructions import Reconstruction - -RetuneTarget = Tuple[str, Reconstruction] - - -class SampleRetuneService(ServiceBase[RetuneResult]): - """Re-synthesizes embedded samples' stored reconstructions to a new NES frequency. - - Changing the project rate leaves each sample's persistent rendered waveform — the array - the Reconstructions tab shows for editing — at its old rate; song playback already - re-renders live, so this only refreshes those editing waveforms. The batch runs on a - background queue and emits one ``ServiceSuccess(RetunedSample)`` per sample so the caller - can apply each as it completes. - - Requests run on a :class:`LatestWinsExecutor`, so a fresh rate change supersedes an - in-flight batch: the newest batch runs once the current one finishes, and the caller - keeps only the results whose rate matches the project's current rate. - """ - - def __init__(self, priority: int = 0) -> None: - super().__init__(priority) - self._executor = LatestWinsExecutor() - - def start(self, targets: Sequence[RetuneTarget], nes_frequency: int) -> bool: - pending = list(targets) - return self._executor.submit(lambda: self._run(pending, nes_frequency)) - - def is_running(self) -> bool: - return self._executor.is_running - - def _run(self, targets: List[RetuneTarget], nes_frequency: int) -> None: - try: - for sample_id, reconstruction in targets: - retuned = reconstruction.with_nes_frequency(nes_frequency) - self._emit( - ServiceSuccess( - value=RetunedSample( - sample_id=sample_id, - reconstruction=retuned, - ) - ) - ) - except Exception as exception: # pylint: disable=broad-exception-caught - self._emit(ServiceError(exception=exception)) diff --git a/src/sampletones_application/services/retune/sample.py b/src/sampletones_application/services/retune/sample.py index 4dfacbefc..2010487c3 100644 --- a/src/sampletones_application/services/retune/sample.py +++ b/src/sampletones_application/services/retune/sample.py @@ -7,9 +7,17 @@ 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 - the right project sample as each result arrives. + Carries the ``voice_id`` so the caller can swap the retuned reconstruction into + the right project sample as each result arrives, and the ``source`` it was retuned + from, so the caller can tell whether the sample still holds it. + + Attributes: + voice_id: The sample the retune is for. + reconstruction: The reconstruction at the new frequency. + source: The reconstruction the sample held when the batch started, which the retune + was computed from. """ - sample_id: str + voice_id: str reconstruction: Reconstruction + source: Reconstruction diff --git a/src/sampletones_application/services/retune/service.py b/src/sampletones_application/services/retune/service.py new file mode 100644 index 000000000..521fc5233 --- /dev/null +++ b/src/sampletones_application/services/retune/service.py @@ -0,0 +1,52 @@ +from typing import List, Sequence, Tuple + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.services.retune.result import RetuneResult +from sampletones_application.services.retune.sample import RetunedSample +from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor +from sampletones_core.reconstructions import Reconstruction + +RetuneTarget = Tuple[str, Reconstruction] + + +class SampleRetuneService(ServiceBase[RetuneResult]): + """Re-synthesizes embedded samples' stored reconstructions to a new NES frequency. + + Changing the project rate leaves each sample's persistent rendered waveform — the array + the Reconstructions tab shows for editing — at its old rate; song playback already + re-renders live, so this only refreshes those editing waveforms. The batch runs on a + background queue and emits one ``ServiceSuccess(RetunedSample)`` per sample so the caller + can apply each as it completes. + + Requests run on a :class:`LatestWinsExecutor`, so a fresh rate change supersedes an + in-flight batch: the newest batch runs once the current one finishes, and the caller + keeps only the results whose rate matches the project's current rate. + """ + + def __init__(self, priority: int = 0) -> None: + super().__init__(priority) + self._executor = LatestWinsExecutor() + + def start(self, targets: Sequence[RetuneTarget], nes_frequency: int) -> bool: + pending = list(targets) + return self._executor.submit(lambda: self._run(pending, nes_frequency)) + + def is_running(self) -> bool: + return self._executor.is_running + + def _run(self, targets: List[RetuneTarget], nes_frequency: int) -> None: + try: + for voice_id, reconstruction in targets: + retuned = reconstruction.with_nes_frequency(nes_frequency) + self._emit( + ServiceSuccess( + value=RetunedSample( + voice_id=voice_id, + reconstruction=retuned, + source=reconstruction, + ) + ) + ) + except Exception as exception: # pylint: disable=broad-exception-caught + self._emit(ServiceError(exception=exception)) diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py deleted file mode 100644 index 2cdc565c5..000000000 --- a/src/sampletones_application/services/song_player/player.py +++ /dev/null @@ -1,358 +0,0 @@ -import threading -from collections import deque -from dataclasses import dataclass -from typing import Callable, Deque, Optional, Tuple - -import numpy as np -import pyaudio - -from sampletones_application.services.base import ServiceBase -from sampletones_application.services.song_player.constants import ( - PREFETCH_SECONDS, - STOP_JOIN_TIMEOUT, - STOP_POLL_TIMEOUT, -) -from sampletones_application.services.song_player.result import ( - SongPlaybackError, - SongPlaybackStopped, - SongPlayerResult, - SongPositionUpdate, -) -from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol -from sampletones_core.audio import AudioDeviceManager, clip_audio_inplace -from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE -from sampletones_core.project.song_position import SongPosition -from sampletones_shared.constants.audio import UNITY_GAIN -from sampletones_shared.logger import logger - - -@dataclass(frozen=True) -class _RenderedRow: - chunk: np.ndarray - position: SongPosition - - -class SongPlayerService(ServiceBase[SongPlayerResult]): - """Streams a song to the audio device through a render-ahead prefetch buffer. - - A synthesis thread renders rows into a buffer bounded by ``PREFETCH_SECONDS`` of - look-ahead audio; a writer thread drains that buffer to the device with blocking - ``stream.write()`` calls that pace playback at the audio clock. Keeping synthesis - off the writer thread lets the buffer absorb synthesis and scheduling jitter, so the - device stays fed and playback runs continuously. - - Each row's position is emitted as the writer hands that row to the device, so the - reported playhead tracks the audio the listener is hearing. The caller receives - ``SongPositionUpdate`` per row and ``SongPlaybackStopped`` when the song ends. - """ - - def __init__( - self, - audio_device_manager: AudioDeviceManager, - synthesizer: RowSynthesizerProtocol, - *, - should_loop: Callable[[], bool], - master_gain: Callable[[], float], - priority: int = 0, - ) -> None: - super().__init__(priority) - self._audio_device_manager = audio_device_manager - self._synthesizer = synthesizer - self._should_loop = should_loop - self._master_gain = master_gain - self._stop_event = threading.Event() - self._resume_event = threading.Event() - self._render_thread: Optional[threading.Thread] = None - self._write_thread: Optional[threading.Thread] = None - - self._buffer: Deque[Optional[_RenderedRow]] = deque() - self._buffer_condition = threading.Condition() - self._queued_samples: int = 0 - self._prefetch_samples: int = 0 - self._write_block_frames: int = DEFAULT_BUFFER_SIZE - self._playback_error: Optional[Exception] = None - - @property - def alive(self) -> bool: - return self._write_thread is not None and self._write_thread.is_alive() - - @property - def is_playing(self) -> bool: - return self.alive and self._resume_event.is_set() - - @property - def is_paused(self) -> bool: - return self.alive and not self._resume_event.is_set() - - def start( - self, - *, - order_position: int = 0, - row_index: int = 0, - ) -> None: - self.stop() - if self.alive: - logger.error(f"{self.class_name}: the previous writer still holds the output; start ignored") - return - - self._synthesizer.set_position(order_position, row_index) - self._synthesizer.reset() - self._playback_error = None - self._prefetch_samples = max( - 1, - round( - PREFETCH_SECONDS * self._audio_device_manager.sample_rate, - ), - ) - self._write_block_frames = self._audio_device_manager.buffer_size - self._stop_event.clear() - self._resume_event.set() - self._render_thread = threading.Thread( - target=self._render_loop, - daemon=True, - name="SongPlayerRenderWorker", - ) - self._write_thread = threading.Thread( - target=self._write_loop, - daemon=True, - name="SongPlayerWriteWorker", - ) - self._render_thread.start() - self._write_thread.start() - - def stop(self) -> None: - self._stop_event.set() - self._resume_event.set() - self._wake_buffer() - self._render_thread = self._join_worker(self._render_thread) - self._write_thread = self._join_worker(self._write_thread) - self._clear_buffer() - - def pause(self) -> None: - self._resume_event.clear() - - def resume(self) -> None: - self._resume_event.set() - - def seek(self, order_position: int) -> None: - """Moves the live playhead to another order while playback continues. - - This keeps the synthesiser's state (where ``start`` resets it), so voices sounding at the - moment of the move carry over to the new order — the playhead jumps while the audio plays on. - Rows already buffered ahead play out first, so the jump lands within one look-ahead window. - """ - if not self.alive: - return - - self._synthesizer.set_position(order_position, 0) - - def relocate(self, order_position: int) -> None: - """Repoints the playhead at another order while keeping the current row. - - Used to follow a structural order edit (insert/remove/move) so playback stays on the - frame it was sounding: the row within the frame continues from where it was, and - voices carry over (the synthesiser keeps its state). - """ - if not self.alive: - return - - self._synthesizer.set_position(order_position, self._synthesizer.row_index) - - def _join_worker(self, thread: Optional[threading.Thread]) -> Optional[threading.Thread]: - """Joins one worker; keeps the thread when it outlives the stop deadline. - - Keeping a surviving writer is what makes ``alive`` report the truth: the thread still - holds the output stream, so callers waiting on quiescence — the audio device before it - tears the backend down — can see that the stream is still outstanding. - """ - if thread is None: - return None - - thread.join(timeout=STOP_JOIN_TIMEOUT) - if thread.is_alive(): - logger.error(f"{self.class_name}: {thread.name} outlived the stop deadline") - return thread - - return None - - def _render_loop(self) -> None: - """Renders rows into the prefetch buffer until the song ends or a stop is requested. - - Wraps synthesis failures into ``_playback_error`` and closes the buffer with an - end marker so the writer thread reports the terminal result through ``CallbackQueue``, - keeping every result on the service's single result channel. - """ - try: - while not self._stop_event.is_set(): - if self._synthesizer.is_finished and not self._loop_to_start(): - self._enqueue_end() - return - - chunk, position = self._synthesizer.render_row() - self._enqueue_row(_RenderedRow(chunk=chunk, position=position)) - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"{self.class_name}: synthesis error") - self._playback_error = exception - self._enqueue_end() - - def _write_loop(self) -> None: - stream = self._open_stream() - if stream is None: - return - - try: - self._drain_to_stream(stream) - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback(exception, f"{self.class_name}: playback error") - self._playback_error = exception - self._emit_terminal() - finally: - self._audio_device_manager.close_output_stream(stream) - - def _open_stream(self) -> Optional[pyaudio.Stream]: - try: - sample_rate = self._audio_device_manager.sample_rate - stream = self._audio_device_manager.open_output_stream( - sample_rate=sample_rate, - buffer_size=self._audio_device_manager.buffer_size, - release=self.stop, - ) - logger.debug(f"{self.class_name}: audio stream opened at {sample_rate} Hz") - return stream - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error(f"{self.class_name}: failed to open audio stream: {exception}") - self._emit(SongPlaybackStopped()) - self._stop_event.set() - self._wake_buffer() - return None - - def _drain_to_stream(self, stream: pyaudio.Stream) -> None: - while not self._stop_event.is_set(): - self._resume_event.wait() - if self._stop_event.is_set(): - return - - popped, row = self._dequeue() - if not popped or self._stop_event.is_set(): - return - - if row is None: - self._emit_terminal() - return - - self._play_row(stream, row) - - def _play_row(self, stream: pyaudio.Stream, row: _RenderedRow) -> None: - """Hands one row to the device, reporting its position once the whole row is written. - - A row cut short by a stop reports no position, so the playhead reflects the audio the - device actually received. - """ - if len(row.chunk) and not self._write_chunk(stream, self._scale_to_gain(row.chunk)): - return - - self._emit(SongPositionUpdate(position=row.position)) - - 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 - 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. - """ - for offset in range(0, len(chunk), self._write_block_frames): - if self._stop_event.is_set(): - return False - - stream.write(chunk[offset : offset + self._write_block_frames].tobytes()) - - return True - - def _scale_to_gain(self, chunk: np.ndarray) -> np.ndarray: - """Scales one row by the live master gain, clipped to the output stream's range. - - The gain is read per row so a slider change is heard on the next row handed to the - device rather than after the render-ahead buffer drains. Clipping holds a boost above - unity within the float stream's [-1, 1] range, so the drive into the clip is the - audible cost of the boost. - """ - gain = self._master_gain() - if gain == UNITY_GAIN: - return chunk - - scaled = chunk * np.float32(gain) - return clip_audio_inplace(scaled) - - def _emit_terminal(self) -> None: - if self._playback_error is not None: - self._emit(SongPlaybackError(error=self._playback_error)) - else: - self._emit(SongPlaybackStopped()) - - def _enqueue_row(self, row: _RenderedRow) -> None: - """Appends a rendered row once the buffered look-ahead falls below the target. - - Bounding the buffer by queued audio duration holds a constant real-time cushion - whatever the row length, so short high-tempo rows stay as well-buffered as long ones - while the render-ahead latency stays bounded. - """ - with self._buffer_condition: - while not self._stop_event.is_set() and self._queued_samples >= self._prefetch_samples: - self._buffer_condition.wait(timeout=STOP_POLL_TIMEOUT) - - if self._stop_event.is_set(): - return - - self._buffer.append(row) - self._queued_samples += len(row.chunk) - self._buffer_condition.notify_all() - - def _enqueue_end(self) -> None: - with self._buffer_condition: - if self._stop_event.is_set(): - return - - self._buffer.append(None) - self._buffer_condition.notify_all() - - def _dequeue(self) -> Tuple[bool, Optional[_RenderedRow]]: - with self._buffer_condition: - while not self._stop_event.is_set() and not self._buffer: - self._buffer_condition.wait(timeout=STOP_POLL_TIMEOUT) - - if not self._buffer: - return False, None - - row = self._buffer.popleft() - if row is not None: - self._queued_samples -= len(row.chunk) - self._buffer_condition.notify_all() - - return True, row - - def _wake_buffer(self) -> None: - with self._buffer_condition: - self._buffer_condition.notify_all() - - def _clear_buffer(self) -> None: - with self._buffer_condition: - self._buffer.clear() - self._queued_samples = 0 - - def _loop_to_start(self) -> bool: - """Wraps the playhead back to the song start when looping is on; reports whether it looped. - - A song with no orders stays finished after the wrap, so looping reports ``False`` there and - playback stops. Resetting mirrors a fresh ``start`` so the loop replays the song from clean - channel state. - """ - if not self._should_loop(): - return False - - self._synthesizer.set_position(0, 0) - if self._synthesizer.is_finished: - return False - - self._synthesizer.reset() - return True diff --git a/src/sampletones_application/services/song_player/service.py b/src/sampletones_application/services/song_player/service.py new file mode 100644 index 000000000..6feb729a8 --- /dev/null +++ b/src/sampletones_application/services/song_player/service.py @@ -0,0 +1,405 @@ +import threading +from collections import deque +from dataclasses import dataclass +from typing import Callable, Deque, Optional, Tuple + +import numpy as np +import pyaudio + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.song_player.constants import ( + PREFETCH_SECONDS, + STOP_JOIN_TIMEOUT, + STOP_POLL_TIMEOUT, +) +from sampletones_application.services.song_player.result import ( + SongPlaybackError, + SongPlaybackStopped, + SongPlayerResult, + SongPositionUpdate, +) +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol +from sampletones_core.audio import AudioDeviceManager, clip_audio_inplace +from sampletones_core.audio.stream import write_to_stream +from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE +from sampletones_core.project.song_position import SongPosition +from sampletones_shared.constants.audio import UNITY_GAIN +from sampletones_shared.logger import logger + + +@dataclass(frozen=True) +class _RenderedRow: + chunk: np.ndarray + position: SongPosition + + +class SongPlayerService(ServiceBase[SongPlayerResult]): + """Streams a song to the audio device through a render-ahead prefetch buffer. + + A synthesis thread renders rows into a buffer bounded by ``PREFETCH_SECONDS`` of + look-ahead audio; a writer thread drains that buffer to the device with blocking + ``stream.write()`` calls that pace playback at the audio clock. Keeping synthesis + off the writer thread lets the buffer absorb synthesis and scheduling jitter, so the + device stays fed and playback runs continuously. + + Each row's position is emitted as the writer hands that row to the device, so the + reported playhead tracks the audio the listener is hearing. The caller receives + ``SongPositionUpdate`` per row and ``SongPlaybackStopped`` when the song ends. + """ + + def __init__( + self, + audio_device_manager: AudioDeviceManager, + synthesizer: RowSynthesizerProtocol, + *, + should_loop: Callable[[], bool], + master_gain: Callable[[], float], + priority: int = 0, + ) -> None: + super().__init__(priority) + self._audio_device_manager = audio_device_manager + self._synthesizer = synthesizer + self._should_loop = should_loop + self._master_gain = master_gain + self._stop_event = threading.Event() + self._resume_event = threading.Event() + self._holding_output = threading.Event() + self._render_thread: Optional[threading.Thread] = None + self._write_thread: Optional[threading.Thread] = None + + self._buffer: Deque[Optional[_RenderedRow]] = deque() + self._buffer_condition = threading.Condition() + self._queued_samples: int = 0 + self._prefetch_samples: int = 0 + self._write_block_frames: int = DEFAULT_BUFFER_SIZE + self._playback_error: Optional[Exception] = None + + @property + def alive(self) -> bool: + """Whether the song holds the output, from :meth:`start` until the writer hands its stream back. + + The writer lets go before it reports the song's end, so a listener reading ``alive`` on + ``SongPlaybackStopped`` or ``SongPlaybackError`` reads the output free. + """ + return self._holding_output.is_set() + + @property + def is_playing(self) -> bool: + return self.alive and self._resume_event.is_set() + + @property + def is_paused(self) -> bool: + return self.alive and not self._resume_event.is_set() + + def start( + self, + *, + order_position: int = 0, + row_index: int = 0, + ) -> None: + """Starts the song at a row, replacing any song this service is playing. + + The device manager is asked for the output first, so a machine offering no output device + refuses the song before any thread starts and leaves the playback as it stands. + + Raises: + NoOutputDeviceError: If no output device is in force. + """ + output = self._audio_device_manager.require_output() + self.stop() + if self.alive: + logger.error(f"{self.class_name}: the previous writer still holds the output; start ignored") + return + + self._synthesizer.set_position(order_position, row_index) + self._synthesizer.reset() + self._playback_error = None + self._prefetch_samples = max( + 1, + round( + PREFETCH_SECONDS * output.sample_rate, + ), + ) + self._write_block_frames = self._audio_device_manager.buffer_size + self._stop_event.clear() + self._resume_event.set() + self._holding_output.set() + self._render_thread = threading.Thread( + target=self._render_loop, + daemon=True, + name="SongPlayerRenderWorker", + ) + self._write_thread = threading.Thread( + target=self._write_loop, + daemon=True, + name="SongPlayerWriteWorker", + ) + self._render_thread.start() + self._write_thread.start() + + def stop(self) -> None: + self._stop_event.set() + self._resume_event.set() + self._wake_buffer() + self._render_thread = self._join_worker(self._render_thread) + self._write_thread = self._join_worker(self._write_thread) + self._clear_buffer() + + def pause(self) -> None: + self._resume_event.clear() + + def resume(self) -> None: + self._resume_event.set() + + def seek(self, order_position: int) -> None: + """Moves the live playhead to another order while playback continues. + + This keeps the synthesizer's state (where ``start`` resets it), so voices sounding at the + moment of the move carry over to the new order — the playhead jumps while the audio plays on. + Rows already buffered ahead play out first, so the jump lands within one look-ahead window. + """ + if not self.alive: + return + + self._synthesizer.set_position(order_position, 0) + + def relocate(self, order_position: int) -> None: + """Repoints the playhead at another order while keeping the current row. + + Used to follow a structural order edit (insert/remove/move) so playback stays on the + frame it was sounding: the row within the frame continues from where it was, and + voices carry over (the synthesizer keeps its state). + """ + if not self.alive: + return + + self._synthesizer.set_position(order_position, self._synthesizer.row_index) + + def _join_worker(self, thread: Optional[threading.Thread]) -> Optional[threading.Thread]: + """Joins one worker; keeps the thread when it outlives the stop deadline. + + A surviving writer still holds the output stream, and ``alive`` goes on reporting it, so + callers waiting on quiescence — the audio device before it tears the backend down — can see + that the stream is still outstanding. + """ + if thread is None: + return None + + thread.join(timeout=STOP_JOIN_TIMEOUT) + if thread.is_alive(): + logger.error(f"{self.class_name}: {thread.name} outlived the stop deadline") + return thread + + return None + + def _render_loop(self) -> None: + """Renders rows into the prefetch buffer until the song ends or a stop is requested. + + Wraps synthesis failures into ``_playback_error`` and closes the buffer with an + end marker so the writer thread reports the terminal result through ``CallbackQueue``, + keeping every result on the service's single result channel. + """ + try: + while not self._stop_event.is_set(): + if self._synthesizer.is_finished and not self._loop_to_start(): + self._enqueue_end() + return + + chunk, position = self._synthesizer.render_row() + self._enqueue_row(_RenderedRow(chunk=chunk, position=position)) + except Exception as exception: # pylint: disable=broad-exception-caught + logger.error_with_traceback(exception, f"{self.class_name}: synthesis error") + self._playback_error = exception + self._enqueue_end() + + def _write_loop(self) -> None: + """Drains the song to the device, hands the stream back, and then reports how the song ended. + + This is the writer thread's whole task, so a stream the device refuses ends here as the song's + error, and so do a write and a hand-back the device fails. Each is reported once, with the + output free. + """ + try: + stream = self._open_stream() + except Exception as exception: # pylint: disable=broad-exception-caught + self._end_refused(exception) + return + + ended = True + try: + ended = self._play_through(stream) + except Exception as exception: # pylint: disable=broad-exception-caught + logger.error_with_traceback(exception, f"{self.class_name}: playback error") + self._playback_error = exception + finally: + self._holding_output.clear() + + if ended: + self._emit_terminal() + + def _play_through(self, stream: pyaudio.Stream) -> bool: + """Drains the song to ``stream``, and then hands the stream back to the device however the drain ended. + + Returns: + True once the song reached its end, False when a stop ended the drain. + """ + try: + return self._drain_to_stream(stream) + finally: + self._audio_device_manager.close_output_stream(stream) + + def _open_stream(self) -> pyaudio.Stream: + sample_rate = self._audio_device_manager.sample_rate + stream = self._audio_device_manager.open_output_stream( + sample_rate=sample_rate, + buffer_size=self._audio_device_manager.buffer_size, + release=self.stop, + ) + logger.debug(f"{self.class_name}: audio stream opened at {sample_rate} Hz") + return stream + + def _end_refused(self, exception: Exception) -> None: + """Ends a song whose stream never opened: the renderer stops, the output goes free, and the error is told.""" + logger.error_with_traceback(exception, f"{self.class_name}: failed to open audio stream") + self._playback_error = exception + self._stop_event.set() + self._wake_buffer() + self._holding_output.clear() + self._emit_terminal() + + def _drain_to_stream(self, stream: pyaudio.Stream) -> bool: + """Hands the buffered rows to the device until the song ends or a stop comes. + + Returns: + True once the song reached its end, False when a stop ended the drain. + """ + while not self._stop_event.is_set(): + self._resume_event.wait() + if self._stop_event.is_set(): + return False + + popped, row = self._dequeue() + if not popped or self._stop_event.is_set(): + return False + + if row is None: + return True + + self._play_row(stream, row) + + return False + + def _play_row(self, stream: pyaudio.Stream, row: _RenderedRow) -> None: + """Hands one row to the device, reporting its position once the whole row is written. + + A row cut short by a stop reports no position, so the playhead reflects the audio the + device actually received. + """ + if len(row.chunk) and not self._write_chunk(stream, self._scale_to_gain(row.chunk)): + return + + self._emit(SongPositionUpdate(position=row.position)) + + 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 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. + """ + for offset in range(0, len(chunk), self._write_block_frames): + if self._stop_event.is_set(): + return False + + write_to_stream(stream, chunk[offset : offset + self._write_block_frames].tobytes()) + + return True + + def _scale_to_gain(self, chunk: np.ndarray) -> np.ndarray: + """Scales one row by the live master gain, clipped to the output stream's range. + + The gain is read per row so a slider change is heard on the next row handed to the + device rather than after the render-ahead buffer drains. Clipping holds a boost above + unity within the float stream's [-1, 1] range, so the drive into the clip is the + audible cost of the boost. + """ + gain = self._master_gain() + if gain == UNITY_GAIN: + return chunk + + scaled = chunk * np.float32(gain) + return clip_audio_inplace(scaled) + + def _emit_terminal(self) -> None: + if self._playback_error is not None: + self._emit(SongPlaybackError(error=self._playback_error)) + else: + self._emit(SongPlaybackStopped()) + + def _enqueue_row(self, row: _RenderedRow) -> None: + """Appends a rendered row once the buffered look-ahead falls below the target. + + Bounding the buffer by queued audio duration holds a constant real-time cushion + whatever the row length, so short high-tempo rows stay as well-buffered as long ones + while the render-ahead latency stays bounded. + """ + with self._buffer_condition: + while not self._stop_event.is_set() and self._queued_samples >= self._prefetch_samples: + self._buffer_condition.wait(timeout=STOP_POLL_TIMEOUT) + + if self._stop_event.is_set(): + return + + self._buffer.append(row) + self._queued_samples += len(row.chunk) + self._buffer_condition.notify_all() + + def _enqueue_end(self) -> None: + with self._buffer_condition: + if self._stop_event.is_set(): + return + + self._buffer.append(None) + self._buffer_condition.notify_all() + + def _dequeue(self) -> Tuple[bool, Optional[_RenderedRow]]: + with self._buffer_condition: + while not self._stop_event.is_set() and not self._buffer: + self._buffer_condition.wait(timeout=STOP_POLL_TIMEOUT) + + if not self._buffer: + return False, None + + row = self._buffer.popleft() + if row is not None: + self._queued_samples -= len(row.chunk) + self._buffer_condition.notify_all() + + return True, row + + def _wake_buffer(self) -> None: + with self._buffer_condition: + self._buffer_condition.notify_all() + + def _clear_buffer(self) -> None: + with self._buffer_condition: + self._buffer.clear() + self._queued_samples = 0 + + def _loop_to_start(self) -> bool: + """Wraps the playhead back to the song start when looping is on; reports whether it looped. + + A song with no orders stays finished after the wrap, so looping reports ``False`` there and + playback stops. Resetting mirrors a fresh ``start`` so the loop replays the song from clean + channel state. + """ + if not self._should_loop(): + return False + + self._synthesizer.set_position(0, 0) + if self._synthesizer.is_finished: + return False + + self._synthesizer.reset() + return True diff --git a/src/sampletones_application/services/synthesis/protocol.py b/src/sampletones_application/services/synthesis/protocol.py index 7b257f702..56aa43161 100644 --- a/src/sampletones_application/services/synthesis/protocol.py +++ b/src/sampletones_application/services/synthesis/protocol.py @@ -8,7 +8,7 @@ class RowSynthesizerProtocol(Protocol): """Streaming synthesis kernel a service drives, one row at a time. - This is the input contract every consumer of a song's audio takes; the concrete synthesiser + This is the input contract every consumer of a song's audio takes; the concrete synthesizer lives in the logic layer and satisfies it structurally. Each ``render_row`` call produces one row's worth of audio, advances the internal position cursor, and returns a snapshot of the cursor from before the advance so callers can post accurate position events. diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 6477969c8..7abc0fb12 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, @@ -38,7 +38,9 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.fps import FPSTimer +from sampletones_application.utils.gui.callbacks import hold_callbacks from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.render_thread import reset_render_thread from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, FOLLOW_MODE_SHORTCUT_IDS, @@ -51,10 +53,10 @@ 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 +from sampletones_shared.types.callback import Callback, PathCallback, VoidCallback _TAB_TAGS: Dict[Tab, str] = { Tab.MAIN: TAG_GLOBAL_TAB_MAIN, @@ -72,7 +74,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 +89,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 +103,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 +124,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. @@ -164,11 +169,13 @@ def setup( self, bindings: ShortcutBindings, *, - on_close: Callback, + on_close: VoidCallback, on_tab_changed: Callback, initial_menu_state: MenuBarViewModel, ) -> None: + reset_render_thread() dpg.create_context() + hold_callbacks() self._set_fonts() self._set_textures() self._register_shortcuts(bindings) @@ -178,7 +185,20 @@ def setup( self._setup_handlers() self._create_main_window(on_tab_changed, initial_menu_state) self._activate_background_work() - dpg.set_exit_callback(on_close) + dpg.set_exit_callback(ApplicationShell._window_close(on_close)) + + @staticmethod + def _window_close(on_close: VoidCallback) -> VoidCallback: + """``on_close`` as the window manager's close reaches it. + + The close is answered as a widget's callback is, handed as many of DearPyGui's sender, data + and user data as it declares. It declares none, so ``on_close`` is called with nothing. + """ + + def close() -> None: + on_close() + + return close def _activate_background_work(self) -> None: """Re-arm the background machinery for this run. @@ -241,6 +261,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 +294,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/compose.py b/src/sampletones_application/tags/compose.py index d30b33093..e6a40331d 100644 --- a/src/sampletones_application/tags/compose.py +++ b/src/sampletones_application/tags/compose.py @@ -1,7 +1,10 @@ import re from typing import Final +from sampletones_shared.utils.hashing import identity_digest + TAG_SEPARATOR: Final[str] = "." +TAG_DIGEST_LENGTH: Final[int] = 8 _WHITESPACE: Final[re.Pattern[str]] = re.compile(r"\s+") @@ -36,3 +39,20 @@ def compose_tag(*parts: str) -> str: raise ValueError("A tag needs at least one part") return TAG_SEPARATOR.join(_normalize_segment(part) for part in parts) + + +def identity_part(*parts: str) -> str: + """Composes the tag part standing for one identity, whatever text its name normalizes to. + + A tag part built from a runtime name arrives lowercased with its whitespace runs collapsed, so + two names that differ only in case or spacing spell the same segment. Adding this part beside + the name keeps each identity on a widget of its own, and leaves the name itself in the tag for + whoever reads a DearPyGui error. + + Args: + *parts: The pieces the identity is spelled in, in the order they belong. + + Returns: + str: A digest of the identity, short enough to read beside the name it stands for. + """ + return identity_digest(*parts, length=TAG_DIGEST_LENGTH) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 05eac7e15..8c56083f9 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -8,6 +8,12 @@ Widget.WINDOW, "main", ) +TAG_GLOBAL_CONTEXT_WINDOW = TagName( + Page.GLOBAL, + Panel.CONTEXT, + Widget.WINDOW, + "context", +) TAG_GLOBAL_TABS = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -62,6 +68,12 @@ Widget.FONT, "regular", ) +TAG_GLOBAL_FONT_REGULAR_TINY = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.FONT, + "regular_tiny", +) TAG_GLOBAL_FONT_REGULAR_SMALL = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -122,6 +134,30 @@ Widget.THEME, "secondary_button", ) +TAG_GLOBAL_THEME_STEP_BUTTON = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "step_button", +) +TAG_GLOBAL_THEME_STEP_BUTTON_LIT = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "step_button_lit", +) +TAG_GLOBAL_THEME_STEP_BUTTON_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "step_button_partial", +) +TAG_GLOBAL_THEME_STEP_BUTTON_DIM = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "step_button_dim", +) TAG_GLOBAL_THEME_DANGER_BUTTON = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -134,6 +170,12 @@ Widget.THEME, "focused_button", ) +TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "danger_button_focused", +) TAG_GLOBAL_THEME_SECTION_HEADER = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -260,6 +302,90 @@ Widget.THEME, "plus_minus_buttons", ) +TAG_GLOBAL_THEME_CHANNEL_MUTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_muted", +) +TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_pulse1_partial", +) +TAG_GLOBAL_THEME_CHANNEL_PULSE2_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_pulse2_partial", +) +TAG_GLOBAL_THEME_CHANNEL_TRIANGLE_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_triangle_partial", +) +TAG_GLOBAL_THEME_CHANNEL_NOISE_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_noise_partial", +) +TAG_GLOBAL_THEME_STEMS_DROP_STRIP = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_drop_strip", +) +TAG_GLOBAL_THEME_STEMS_GRID = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_grid", +) +TAG_GLOBAL_THEME_STEMS_MARKER = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_marker", +) +TAG_GLOBAL_THEME_STEMS_GROUP_ROW = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_group_row", +) +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_STEMS_PICK = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_pick", +) +TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_pick_partial", +) +TAG_GLOBAL_THEME_STEMS_SLOT_LABEL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_slot_label", +) TAG_GLOBAL_THEME_TOOLTIP = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -380,6 +506,12 @@ Widget.DIALOG, "reconstruction_saved", ) +TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "reconstruction_replaced", +) TAG_GLOBAL_DIALOG_ABOUT = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -476,12 +608,24 @@ 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, Widget.DIALOG, "no_project_open", ) +TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "no_audio_output", +) TAG_GLOBAL_DIALOG_PROJECT_OPEN = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -524,6 +668,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,7 +879,9 @@ 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_HEADING = "heading" SUF_GROUP_TRACEBACK = compose_tag(SUF_GROUP, "traceback") SUF_HANDLER_REGISTRY = compose_tag("handler", "registry") SUF_HANDLER_STATUS = compose_tag("handler", "status") @@ -707,18 +889,28 @@ 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_DRAWN = compose_tag("handler", "drawn") +SUF_HANDLER_LIST = compose_tag("handler", "list") SUF_LABEL = "label" +SUF_LEAD = "lead" SUF_PATH = "path" SUF_TEXT = "text" SUF_TEXT_FAVORITES = compose_tag(SUF_TEXT, "favorites") SUF_INPUT = "input" SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search") SUF_CHECKBOX = "checkbox" +SUF_SLIDER = "slider" SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites") +SUF_STRIP = "strip" +SUF_SWATCH = "swatch" SUF_TABLE = "table" +SUF_TABLE_COLUMN = compose_tag(SUF_TABLE, "column") +SUF_TABLE_GAP = compose_tag(SUF_TABLE, "gap") SUF_TOOLTIP = "tooltip" +SUF_TWISTY = "twisty" SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail") SUF_DIALOG_INFO = compose_tag("dialog", "info") +SUF_REPORT = "report" SUF_PANEL_LEFT = compose_tag("panel", "left") SUF_PANEL_CENTER = compose_tag("panel", "center") SUF_PANEL_RIGHT = compose_tag("panel", "right") @@ -726,3 +918,11 @@ SUF_COLLAPSE_BODY = compose_tag("collapse", "body") SUF_COLLAPSE_RAIL = compose_tag("collapse", "rail") SUF_COLLAPSE_CHEVRON = compose_tag("collapse", "chevron") +SUF_FOLDER = "folder" +SUF_REGION = "region" +SUF_ROW = "row" +SUF_SPACER_ABOVE = compose_tag("spacer", "above") +SUF_SPACER_BELOW = compose_tag("spacer", "below") +SUF_LEVEL = "level" +SUF_WELL = "well" +SUF_PAYLOAD = "payload" diff --git a/src/sampletones_application/tags/graphs.py b/src/sampletones_application/tags/graphs.py index db65e2c19..84e0d0246 100644 --- a/src/sampletones_application/tags/graphs.py +++ b/src/sampletones_application/tags/graphs.py @@ -22,6 +22,7 @@ ) SUF_GRAPH_PLOT = "plot" +SUF_GRAPH_SUBPLOTS = "subplots" SUF_GRAPH_X_AXIS = "x_axis" SUF_GRAPH_Y_AXIS = "y_axis" SUF_GRAPH_LEGEND = "legend" @@ -31,6 +32,12 @@ SUF_GRAPH_THEME = "theme" SUF_WAVEFORM_POSITION_INDICATOR = "position_indicator" SUF_WAVEFORM_OVERLAY = "overlay" +SUF_RIBBON_LANE = "lane" +SUF_RIBBON_GROUND = "ground" +SUF_OWNERSHIP_RUN = "ownership_run" +SUF_OWNERSHIP_HEARD = "heard" +SUF_OWNERSHIP_LEFT_OUT = "left_out" SUF_BAR_PLOT_ZERO_LINE = "zero_line" SUF_BAR_PLOT_HOVER_BAR = "hover_bar" SUF_HANDLER_MOUSE = compose_tag("handler", "mouse") +SUF_HANDLER_WHEEL = compose_tag("handler", "wheel") diff --git a/src/sampletones_application/tags/instructions.py b/src/sampletones_application/tags/instructions.py index d2cf7e656..71ff418ec 100644 --- a/src/sampletones_application/tags/instructions.py +++ b/src/sampletones_application/tags/instructions.py @@ -79,6 +79,12 @@ Widget.DIALOG, "regenerate_confirmation", ) +TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION = TagName( + Page.INSTRUCTIONS, + Panel.LIBRARY, + Widget.DIALOG, + "rebuild_confirmation", +) TAG_INSTRUCTIONS_LIBRARY_DIALOG_REMOVE_LIBRARY_CONFIRMATION = TagName( Page.INSTRUCTIONS, Panel.LIBRARY, @@ -169,6 +175,12 @@ Widget.THEME, "library", ) +TAG_INSTRUCTIONS_LIBRARY_THEME_OUTDATED = TagName( + Page.INSTRUCTIONS, + Panel.LIBRARY, + Widget.THEME, + "outdated", +) TAG_INSTRUCTIONS_LIBRARY_THEME_GENERATOR = TagName( Page.INSTRUCTIONS, Panel.LIBRARY, diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index f50d7bea7..21d497c6c 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, @@ -13,11 +14,11 @@ Widget.TABLE, "config_row", ) -TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL = TagName( +TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.ADVANCED, Widget.PANEL, - "reconstructor_cell", + "advanced_cell", ) TAG_MAIN_EXPLORER_TREE = TagName( Page.MAIN, @@ -49,11 +50,11 @@ Widget.GROUP, "controls", ) -TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING = TagName( +TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW = TagName( Page.MAIN, Panel.EXPLORER, Widget.DIALOG, - "converter_running", + "nothing_below", ) TAG_MAIN_EXPLORER_BUTTON_REFRESH = TagName( Page.MAIN, @@ -91,17 +92,11 @@ Widget.INPUT, "nes_frequency", ) -TAG_MAIN_RECONSTRUCTOR_PANEL = TagName( +TAG_MAIN_SOURCE_PANEL = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.PANEL, - "reconstructor", -) -TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE = TagName( - Page.MAIN, - Panel.RECONSTRUCTOR, - Widget.SLIDER, - "drive", + "source", ) TAG_MAIN_ADVANCED_PANEL = TagName( Page.MAIN, @@ -229,23 +224,174 @@ Widget.TOOLTIP, "convert", ) -TAG_MAIN_CONVERTER_WINDOW_SUMMARY = TagName( +TAG_MAIN_CONVERTER_GROUP_SUMMARY = TagName( Page.MAIN, Panel.CONVERTER, - Widget.WINDOW, + Widget.GROUP, "summary", ) -TAG_MAIN_CONVERTER_GROUP_SUMMARY = TagName( + +PRE_MAIN_SOURCE_CHANNEL = compose_tag( + Page.MAIN, + Panel.SOURCE, + "channel", +) +PRE_MAIN_SOURCE_STEP = compose_tag( + Page.MAIN, + Panel.SOURCE, + "step", +) +TAG_MAIN_SOURCE_TEXT_SUBJECT = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.TEXT, + "subject", +) +TAG_MAIN_SOURCE_TOOLTIP_SUBJECT = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.TOOLTIP, + "subject", +) +TAG_MAIN_SOURCE_TABLE_CHANNELS = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.TABLE, + "channels", +) +TAG_MAIN_SOURCE_GROUP_STEPS = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.GROUP, + "steps", +) +TAG_MAIN_CONVERTER_RADIO_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.RADIO, + "mode", +) +TAG_MAIN_CONVERTER_GROUP_ORDER = TagName( Page.MAIN, Panel.CONVERTER, Widget.GROUP, - "summary", + "order", +) +TAG_MAIN_CONVERTER_GROUP_INPUT = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.GROUP, + "input", +) +TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.COMBO, + "hierarchy_mode", +) +TAG_MAIN_CONVERTER_TOOLTIP_MODE = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TOOLTIP, + "mode", ) -TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT = TagName( +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, - "summary_hint", + "stems_hint", +) +TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.DIALOG, + "discard_stems", +) +TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.DIALOG, + "conversion_running", +) +TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.DIALOG, + "overwrite_target", +) +TAG_MAIN_CONVERTER_WINDOW_SCAN = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.WINDOW, + "scan", +) +TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TEXT, + "scan_folder", +) +TAG_MAIN_CONVERTER_PROGRESS_SCAN = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.PROGRESS, + "scan", +) +TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.BUTTON, + "stop_scan", +) +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", ) - -PRE_MAIN_RECONSTRUCTOR_GENERATOR = "gen" diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 90d3f9e03..a36b22b87 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,65 @@ Widget.PATH, "reconstruction_file", ) -TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_ORIGINAL_AUDIO = TagName( +TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, - Widget.PATH, - "original_audio", + Widget.INPUT, + "nes_frequency", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.GROUP, + "nes_frequency", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TOOLTIP, + "nes_frequency", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TOOLTIP, + "nes_frequency_locked", +) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TEXT, + "nes_frequency_locked", +) +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 +188,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..bd29a11ce 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -140,39 +140,69 @@ 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_VOICES_GROUP_NEW_INSTRUMENT = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.GROUP, + "new_instrument", +) +TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.TOOLTIP, + "new_instrument", +) +TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.TOOLTIP, + "new_instrument_no_project", ) -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..b7619e56a 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -93,6 +93,12 @@ Widget.CHECKBOX, "vsync", ) +TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE = TagName( + Page.SETTINGS, + Panel.DISPLAY, + Widget.CHECKBOX, + "show_frame_rate", +) TAG_SETTINGS_DISPLAY_BUTTON_OK = TagName( Page.SETTINGS, Panel.DISPLAY, @@ -390,3 +396,177 @@ Widget.BUTTON, "cancel", ) +TAG_SETTINGS_NSF_WINDOW = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.WINDOW, + "nsf", +) +TAG_SETTINGS_NSF_INPUT_TITLE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "title", +) +TAG_SETTINGS_NSF_INPUT_ARTIST = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "artist", +) +TAG_SETTINGS_NSF_INPUT_COPYRIGHT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "copyright", +) +TAG_SETTINGS_NSF_INPUT_LOOP_FRAME = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.INPUT, + "loop_frame", +) +TAG_SETTINGS_NSF_TEXT_TITLE_SIZE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "title_size", +) +TAG_SETTINGS_NSF_TEXT_ARTIST_SIZE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "artist_size", +) +TAG_SETTINGS_NSF_TEXT_COPYRIGHT_SIZE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "copyright_size", +) +TAG_SETTINGS_NSF_CHECKBOX_CHANNEL = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.CHECKBOX, + "channel", +) +TAG_SETTINGS_NSF_TEXT_NO_CHANNEL = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "no_channel", +) +TAG_SETTINGS_NSF_COMBO_REPEAT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.COMBO, + "repeat", +) +TAG_SETTINGS_NSF_GROUP_LOOP_FRAME = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.GROUP, + "loop_frame", +) +TAG_SETTINGS_NSF_TEXT_FRAME_COUNT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "frame_count", +) +TAG_SETTINGS_NSF_TEXT_LENGTH = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "length", +) +TAG_SETTINGS_NSF_COMBO_SCHEME = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.COMBO, + "scheme", +) +TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.TEXT, + "scheme_description", +) +TAG_SETTINGS_NSF_GROUP_DESTINATION = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.GROUP, + "destination", +) +TAG_SETTINGS_NSF_PATH_DESTINATION = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.PATH, + "destination", +) +TAG_SETTINGS_NSF_BUTTON_BROWSE = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.BUTTON, + "browse", +) +TAG_SETTINGS_NSF_BUTTON_CANCEL = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.BUTTON, + "cancel", +) +TAG_SETTINGS_NSF_BUTTON_EXPORT = TagName( + Page.SETTINGS, + Panel.NSF, + Widget.BUTTON, + "export", +) +TAG_SETTINGS_EXPORT_WINDOW = TagName( + Page.SETTINGS, + Panel.EXPORT, + 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/__init__.py b/src/sampletones_application/ui/elements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index e63e62f56..c85f5ade8 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -1,14 +1,18 @@ 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.tags.general import TAG_GLOBAL_CONTEXT_WINDOW 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 @@ -17,22 +21,49 @@ def context_menu() -> Iterator[None]: Every panel's context menu shares this popup style, so routing them through one builder keeps them from drifting apart. + + One context menu stands open at a time, so the application holds a single popup and builds + each menu into it: the items of the menu before go with the widgets and closures they held, and + the popup opens again at the pointer. A menu raised while another stands open takes its place in + the popup already shown. """ - with dpg.window( - popup=True, - no_move=True, - no_resize=True, - no_title_bar=True, - min_size=(0, 0), - modal=False, - ): + if dpg.does_item_exist(TAG_GLOBAL_CONTEXT_WINDOW): + dpg.delete_item(TAG_GLOBAL_CONTEXT_WINDOW, children_only=True) + dpg.configure_item(TAG_GLOBAL_CONTEXT_WINDOW, show=True) + else: + dpg.add_window( + tag=TAG_GLOBAL_CONTEXT_WINDOW, + popup=True, + no_move=True, + no_resize=True, + no_title_bar=True, + min_size=(0, 0), + modal=False, + ) + + dpg.push_container_stack(TAG_GLOBAL_CONTEXT_WINDOW) + try: yield + finally: + dpg.pop_container_stack() + + +def context_menu_under_pointer() -> bool: + """Whether a context menu stands open under the pointer, which a press over the panel below + it leaves to the menu.""" + return bool( + dpg.does_item_exist(TAG_GLOBAL_CONTEXT_WINDOW) + and dpg.is_item_shown(TAG_GLOBAL_CONTEXT_WINDOW) + and dpg.is_item_hovered(TAG_GLOBAL_CONTEXT_WINDOW) + ) def add_play_menu_item( label: str, on_play: VoidCallback, shortcut: str = "", + *, + enabled: bool = True, ) -> None: """Add the shared "Play" context-menu item; the caller supplies the play action. @@ -41,12 +72,14 @@ def add_play_menu_item( them all through one builder keeps the item from drifting apart across panels. ``shortcut`` is shown as the item's accelerator hint when the action has a bound key, - and left blank for sources reached only by clicking. + and left blank for sources reached only by clicking. ``enabled`` states that the source is + there to be heard, which a caller listing files answers from the disk. """ dpg.add_menu_item( label=label, shortcut=shortcut, callback=on_play, + enabled=enabled, ) @@ -78,3 +111,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/dialog.py b/src/sampletones_application/ui/elements/dialog.py index 4b29db792..a265b584c 100644 --- a/src/sampletones_application/ui/elements/dialog.py +++ b/src/sampletones_application/ui/elements/dialog.py @@ -1,6 +1,7 @@ from abc import ABC from typing import Final, List, Optional +from sampletones_application.layout.primitives import DialogGeometry from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG from sampletones_application.ui.elements.window import GUIWindow from sampletones_application.ui.themes.registry import ThemeRegistry @@ -30,8 +31,7 @@ class GUIDialogWindow(GUIWindow, ABC): def __init__( self, tag: str, - width: int, - height: int, + geometry: DialogGeometry, *, key_router: KeyRouter, shortcut_source: ShortcutSource, @@ -40,11 +40,7 @@ def __init__( self._shortcuts = shortcut_source self._navigator: Optional[DialogKeyboardNavigator] = None - super().__init__( - tag, - width, - height, - ) + super().__init__(tag, geometry) def _install_navigation( self, @@ -57,7 +53,7 @@ def _install_navigation( Args: stops: The controls the focus ring cycles, in reading order. - on_escape: What cancelling this dialog means. + on_escape: What canceling this dialog means. initial_index: The stop focus opens on, which points a prompt at the answer it expects. """ self._navigator = DialogKeyboardNavigator( 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/fonts/__init__.py b/src/sampletones_application/ui/elements/fonts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/fonts/font.py b/src/sampletones_application/ui/elements/fonts/font.py index 319f55784..790266666 100644 --- a/src/sampletones_application/ui/elements/fonts/font.py +++ b/src/sampletones_application/ui/elements/fonts/font.py @@ -3,6 +3,7 @@ class Font(Enum): REGULAR = "Regular" + REGULAR_TINY = "RegularTiny" REGULAR_SMALL = "RegularSmall" REGULAR_LARGE = "RegularLarge" ITALIC = "Italic" diff --git a/src/sampletones_application/ui/elements/fonts/registry.py b/src/sampletones_application/ui/elements/fonts/registry.py index 837e58b42..5d21a6882 100644 --- a/src/sampletones_application/ui/elements/fonts/registry.py +++ b/src/sampletones_application/ui/elements/fonts/registry.py @@ -19,6 +19,7 @@ TAG_GLOBAL_FONT_REGULAR, TAG_GLOBAL_FONT_REGULAR_LARGE, TAG_GLOBAL_FONT_REGULAR_SMALL, + TAG_GLOBAL_FONT_REGULAR_TINY, ) from sampletones_application.ui.elements.fonts.data import FontData from sampletones_application.ui.elements.fonts.font import Font @@ -31,6 +32,7 @@ class FontRegistry: _REGISTRY: ClassVar[Dict[Font, FontData]] = {} _SPECS: ClassVar[Dict[Font, Tuple[str, FontResource, Typeface, Step]]] = { Font.REGULAR: (TAG_GLOBAL_FONT_REGULAR, FontResource.REGULAR, Typeface.SANS, Step.MEDIUM), + Font.REGULAR_TINY: (TAG_GLOBAL_FONT_REGULAR_TINY, FontResource.REGULAR, Typeface.SANS, Step.TINY), Font.REGULAR_SMALL: (TAG_GLOBAL_FONT_REGULAR_SMALL, FontResource.REGULAR, Typeface.SANS, Step.SMALL), Font.REGULAR_LARGE: (TAG_GLOBAL_FONT_REGULAR_LARGE, FontResource.REGULAR, Typeface.SANS, Step.LARGE), Font.ITALIC: (TAG_GLOBAL_FONT_ITALIC, FontResource.ITALIC, Typeface.SANS, Step.MEDIUM), diff --git a/src/sampletones_application/ui/elements/graphs/__init__.py b/src/sampletones_application/ui/elements/graphs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index 3b83a6ed4..a0a327084 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -24,7 +24,9 @@ dpg_delete_item, dpg_is_item_hovered, ) +from sampletones_application.utils.gui.keyboard.modifiers import CTRL_ALT_SHIFT, capture_modifiers from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_application.utils.gui.press import LeftPress from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_shared.types.application import Sender @@ -41,7 +43,28 @@ class DrawStroke: value: float +@dataclass +class HeldStroke: + """The stroke a held press draws: the bar it drew on the frame before, which the next bar joins.""" + + last: Optional[DrawStroke] = None + + def take_last(self) -> Optional[DrawStroke]: + """The bar drawn on the frame before, leaving the stroke to record the bar this frame draws.""" + last = self.last + self.last = None + return last + + class GUIBarGraph(GUIGraph[BarLayer]): + """A plot of bars the reader draws by pressing and dragging over them. + + A press belongs to the plot it went down on: the plot's own click starts it and the release + ends it, and bars are drawn only in between. A button already held as the plot comes under + the pointer, such as the second press of a double-click that brought the plot forward, draws + nothing. + """ + tag: str parent: str width: int @@ -84,7 +107,7 @@ def __init__( self.on_bar_point_clicked: Optional[OnBarPointClickedCallback] = None self.on_bar_point_hovered: Optional[OnBarPointHoveredCallback] = None - self._draw_stroke: Optional[DrawStroke] = None + self._press: LeftPress[HeldStroke] = LeftPress() _min_x = layout.graph.min_x _max_x = layout.graph.max_x @@ -101,6 +124,7 @@ def __init__( (_min_x, _max_x), _y_range, layout.waveform.zoom_factor, + layout.waveform.pan_factor, ) def _create_content(self) -> None: @@ -137,9 +161,26 @@ def _create_content(self) -> None: def _setup_handlers(self) -> None: super()._setup_handlers() + dpg.add_item_clicked_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_press, + parent=self.event_handler_tag, + ) with dpg.handler_registry(tag=self.mouse_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_action) - dpg.add_mouse_click_handler(callback=self._on_mouse_action) + dpg.add_mouse_release_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_release, + ) + + def _on_press(self, sender: Sender) -> None: + """Starts a press on the plot, which draws the bar it lands on.""" + self._press.begin(HeldStroke()) + self._on_mouse_action(sender) + + def _on_release(self, _sender: Sender) -> None: + """Ends the press, so the next stroke starts afresh.""" + self._press.end() def _on_hover(self, sender: Sender, app_data: int, user_data: Any) -> None: super()._on_hover(sender, app_data, user_data) @@ -208,6 +249,25 @@ def load_data( self._update_ticks() self._update_ranges() + def reserve_band(self, share: float) -> Tuple[float, float]: + """Keeps a band beneath the plotted values, and answers where that band lies. + + A stretch drawn under the bars stands in a band of its own, so the plot lowers what it + spans by that share and the bars keep every value they reach. The reserve is measured + against the range the plot was built with, so reserving twice keeps one band. + + Args: + share: How much of the built range the band takes, beneath it. + + Returns: + Tuple[float, float]: The band's lower and upper edge. + """ + low, high = self._default_y_range + height = (high - low) * share + self.y_range = (low - height, high) + self._update_axes_limits() + return low - height, low + def _set_layer( self, layer: BarLayer, @@ -233,12 +293,17 @@ def _set_layer( self._update_ranges() def _update_ranges(self) -> None: + """The span the bars are drawn across, which holds room for a pair of them throughout. + + A dimension writing one value, or none at all, is drawn on the same grid as a long one, + so the axis keeps its marks and the plot reads as an empty stretch rather than a line. + """ x_min = -0.5 x_max = 1.0 + max( (layer.x_data.max() for layer in self.layers.values() if layer.x_data.size > 0), default=self._layout.graph.max_x, ) - self.x_range = (x_min, x_max) + self.x_range = (x_min, max(x_max, x_min + self._layout.bar_plot.minimum_span)) self._update_axes_limits() def _update_display(self) -> None: @@ -301,10 +366,17 @@ def _update_ticks(self) -> None: dpg.set_axis_ticks(self.y_axis_tag, tuple(zip(tick_labels, self.y_ticks))) def _on_mouse_action(self, _sender: Sender) -> None: - previous_stroke = self._draw_stroke - self._draw_stroke = None + """Answers one frame of the pointer over the plot, drawing the bar under it while a press is held. - if dpg.is_key_down(dpg.mvKey_LControl) or dpg.is_key_down(dpg.mvKey_LShift) or dpg.is_key_down(dpg.mvKey_LAlt): + The mouse move runs whether or not the button is down, so a release the plot never heard, + such as one outside the window, ends the press here once the button reads up, and a later + press begun elsewhere draws nothing. A stroke joins the bar it reaches to the one it drew on + the frame before. + """ + stroke = self._press.held() + previous_stroke = None if stroke is None else stroke.take_last() + + if capture_modifiers() & CTRL_ALT_SHIFT: return if not dpg_is_item_hovered(self.plot_tag) or self.current_data is None: @@ -332,14 +404,29 @@ def _on_mouse_action(self, _sender: Sender) -> None: self._set_hover_bar_position(bar_index, clamped_y) self.call(self.on_bar_point_hovered, name, bar_index) - if dpg.is_mouse_button_down(dpg.mvMouseButton_Left) or dpg.is_mouse_button_clicked(dpg.mvMouseButton_Left): - self._draw_bar(layer, bar_index, clamped_y, previous_stroke) + if stroke is not None and self._presses_a_bar(mouse_y): + self._draw_bar( + layer, + bar_index, + clamped_y, + stroke, + previous_stroke, + ) + + def _presses_a_bar(self, mouse_y: float) -> bool: + """Whether a press at ``mouse_y`` stands on the grid the bars are drawn across. + + A band reserved beneath the bars lies inside the plot and reads which stretch belongs to + whom, so a press there answers what the band shows and the values above it stand. + """ + return mouse_y >= self._default_y_range[0] def _draw_bar( self, layer: BarLayer, bar_index: int, value: float, + stroke: HeldStroke, previous_stroke: Optional[DrawStroke], ) -> None: if previous_stroke is not None and previous_stroke.index != bar_index: @@ -353,6 +440,6 @@ def _draw_bar( else: layer.y_data[bar_index] = value - self._draw_stroke = DrawStroke(index=bar_index, value=value) + stroke.last = DrawStroke(index=bar_index, value=value) self._update_display() self.call(self.on_bar_point_clicked, layer.y_data) diff --git a/src/sampletones_application/ui/elements/graphs/clock.py b/src/sampletones_application/ui/elements/graphs/clock.py new file mode 100644 index 000000000..bcdf2981d --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/clock.py @@ -0,0 +1,78 @@ +from math import floor +from typing import List, Tuple + +from sampletones_application.layout.graphs.clock import ClockLayout +from sampletones_shared.utils.time import format_clock + +ClockTick = Tuple[str, float] + + +def clock_step(span_seconds: float, layout: ClockLayout) -> float: + """The distance between marks a stretch of time is read at. + + The steps a clock divides evenly into keep every mark a reading a listener already knows, + and the smallest one leaving a readable handful of marks across the stretch is the one + taken. + + Args: + span_seconds: How much time the view covers. + layout: The steps on offer and the marks a stretch is read with. + + Returns: + float: The seconds one mark stands from the next. + """ + rough = span_seconds / layout.tick_count + return next((step for step in layout.steps_seconds if step >= rough), layout.steps_seconds[-1]) + + +def clock_decimals(step_seconds: float) -> int: + """How finely a reading divides a second, so that two neighboring marks read apart. + + Args: + step_seconds: The seconds one mark stands from the next. + + Returns: + int: The digits the fraction of a second is stated to. + """ + if step_seconds >= 1.0: + return 0 + if step_seconds >= 0.1: + return 1 + + return 2 + + +def clock_ticks( + start_seconds: float, + end_seconds: float, + rate: float, + layout: ClockLayout, +) -> List[ClockTick]: + """The marks a stretch of time is drawn with, each under the moment it stands at. + + A mark sits at a whole multiple of the step, so panning slides the same marks along rather + than renaming them, and the label states the moment as a clock reading at the precision the + step calls for. + + Args: + start_seconds: Where the view begins. + end_seconds: Where the view ends. + rate: The positions one second holds, which the marks are placed in. + layout: The steps on offer and the marks a stretch is read with. + + Returns: + List[ClockTick]: Each mark's label beside the position it is drawn at. + """ + if end_seconds <= start_seconds or rate <= 0.0: + return [] + + step = clock_step(end_seconds - start_seconds, layout) + decimals = clock_decimals(step) + first = floor(max(0.0, start_seconds) / step) + last = floor(end_seconds / step) + + return [ + (format_clock(index * step, decimals), index * step * rate) + for index in range(first, last + 1) + if index * step >= start_seconds + ] diff --git a/src/sampletones_application/ui/elements/graphs/gesture.py b/src/sampletones_application/ui/elements/graphs/gesture.py new file mode 100644 index 000000000..790bf0f57 --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/gesture.py @@ -0,0 +1,105 @@ +import math +from dataclasses import dataclass +from typing import Callable, Final, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.gui.frame import FrameCallbackManager + +# ImGui's io.MouseDoubleClickTime, which DearPyGui keeps at its default and exposes no setting for. +DOUBLE_CLICK_SECONDS: Final[float] = 0.30 + + +@dataclass(frozen=True) +class PlotPress: + """A left press on a plot: the sample under the pointer, where it went down, and when.""" + + sample: float + screen: Tuple[float, float] + time: float + + +class PlotClickGesture: + """Tells a click on a plot apart from the drag that pans it and the double-click that fits it. + + A press reads as a click when the pointer comes up within ``click_travel`` pixels of where it + went down. A click is reported once it is past being the first half of a double-click: when the + double-click window counted from its press has closed, or when the next press lands as a + separate click. A press ImGui recognizes as the second half of a double-click is the one the + plot fits its view on, so it takes the click before it along, and so does every press following + it within the window, which a burst of clicks is made of. + """ + + def __init__( + self, + *, + click_travel: float, + on_clicked: Callable[[float], None], + ) -> None: + self._click_travel = click_travel + self._on_clicked = on_clicked + self._press: Optional[PlotPress] = None + self._pending: Optional[PlotPress] = None + self._burst_at: Optional[float] = None + self._waiting = False + + def press(self) -> None: + now = dpg.get_total_time() + if dpg.is_mouse_button_double_clicked(dpg.mvMouseButton_Left) or self._within_burst(now): + self._burst_at = now + self._press = None + self._pending = None + return + + self._burst_at = None + + self._report_pending() + sample, _ = dpg.get_plot_mouse_pos() + screen_x, screen_y = dpg.get_mouse_pos(local=False) + self._press = PlotPress( + sample=sample, + screen=(screen_x, screen_y), + time=now, + ) + + def release(self) -> None: + press = self._press + self._press = None + if press is None or self._travel(press) > self._click_travel: + return + + self._pending = press + if not self._waiting: + self._waiting = True + self._await_double_click_window() + + def _within_burst(self, now: float) -> bool: + """Whether a press follows a double-click closely enough to be one more click of its burst.""" + return self._burst_at is not None and now - self._burst_at < DOUBLE_CLICK_SECONDS + + def _travel(self, press: PlotPress) -> float: + screen_x, screen_y = dpg.get_mouse_pos(local=False) + return math.hypot(screen_x - press.screen[0], screen_y - press.screen[1]) + + def _await_double_click_window(self) -> None: + """Reads the click waiting on every frame until its double-click window has closed. + + One wait stands at a time, answering for whichever click is waiting when it reads. + """ + pending = self._pending + if pending is None: + self._waiting = False + return + + if dpg.get_total_time() - pending.time < DOUBLE_CLICK_SECONDS: + FrameCallbackManager.set_frame_callback(self._await_double_click_window) + return + + self._waiting = False + self._report_pending() + + def _report_pending(self) -> None: + pending = self._pending + self._pending = None + if pending is not None: + self._on_clicked(pending.sample) diff --git a/src/sampletones_application/ui/elements/graphs/graph.py b/src/sampletones_application/ui/elements/graphs/graph.py index 19e9c21ce..12cf03230 100644 --- a/src/sampletones_application/ui/elements/graphs/graph.py +++ b/src/sampletones_application/ui/elements/graphs/graph.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Dict, Generic, Optional, Tuple +from typing import Any, ClassVar, Dict, Generic, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -15,12 +15,17 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.graphs.layers.type import LayerT +from sampletones_application.ui.elements.graphs.pan import PlotWheelPan 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_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_shared.types.application import Sender class GUIGraph(GUIPanel, ABC, Generic[LayerT]): + pans_with_wheel: ClassVar[bool] = True + def __init__( self, tag: str, @@ -31,6 +36,7 @@ def __init__( x_range: Tuple[float, float], y_range: Tuple[float, float], zoom_factor: float, + pan_factor: float, ): self.label = label self.plot_tag = compose_tag(tag, SUF_GRAPH_PLOT) @@ -41,6 +47,7 @@ def __init__( self.event_handler_tag = compose_tag(tag, SUF_HANDLER_REGISTRY) self.zoom_factor = zoom_factor + self.pan_factor = pan_factor self._x_range: Tuple[float, float] = x_range self._y_range: Tuple[float, float] = y_range self._default_x_range = self._x_range @@ -73,6 +80,25 @@ def _setup_handlers(self) -> None: with dpg.item_handler_registry(tag=self.event_handler_tag): dpg.add_item_hover_handler(callback=self._on_hover) + if self.pans_with_wheel: + PlotWheelPan( + tag=self.tag, + plot_tags=self._pannable_plot_tags(), + x_axis_tag=self.x_axis_tag, + y_axis_tag=self.y_axis_tag, + pan_factor=self.pan_factor, + x_bounds=lambda: self.x_range, + y_bounds=lambda: self.y_range, + on_panned=self._on_panned, + ).create() + + def _pannable_plot_tags(self) -> Sequence[str]: + """The plots the wheel pans the graph from, which are the plot and any linked to it.""" + return (self.plot_tag,) + + def _on_panned(self) -> None: + self._release_axes_limits() + def _bind_event_handler(self) -> None: dpg.bind_item_handler_registry(self.plot_tag, self.event_handler_tag) @@ -85,9 +111,13 @@ def _on_hover( _app_data: int, _user_data: Any, ) -> None: - shift = dpg.is_key_down(dpg.mvKey_LShift) - dpg.configure_item(self.x_axis_tag, lock_min=shift, lock_max=shift) - dpg.configure_item(self.y_axis_tag, lock_min=not shift, lock_max=not shift) + modifiers = capture_modifiers() + panning = Modifier.ALT in modifiers + shift = Modifier.SHIFT in modifiers + lock_x = panning or shift + lock_y = panning or not shift + dpg.configure_item(self.x_axis_tag, lock_min=lock_x, lock_max=lock_x) + dpg.configure_item(self.y_axis_tag, lock_min=lock_y, lock_max=lock_y) self._release_axes_limits() def add_layer(self, layer: LayerT) -> None: @@ -145,7 +175,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/src/sampletones_application/ui/elements/graphs/layers/__init__.py b/src/sampletones_application/ui/elements/graphs/layers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/graphs/ownership.py b/src/sampletones_application/ui/elements/graphs/ownership.py new file mode 100644 index 000000000..992992d69 --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/ownership.py @@ -0,0 +1,87 @@ +from typing import Dict, List, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import ( + SUF_GRAPH_THEME, + SUF_OWNERSHIP_HEARD, + SUF_OWNERSHIP_LEFT_OUT, + SUF_OWNERSHIP_RUN, +) +from sampletones_application.utils.gui.dpg import dpg_bind_item_theme, dpg_delete_item +from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_application.view_model.shared.ownership import OwnershipRunViewModel + + +class OwnershipRuns: + """Paints the recordings behind a stretch of a plot, each run a flat bar in its own color. + + Two surfaces show a frame's owner — the ribbon under the waveform and the stretch under an + instrument's bars — so both paint through this, and a recording is one color wherever it is + shown. Each surface states the span its own axis counts in and the band the bars sit in, so + the same runs land under a waveform read in samples and under an envelope read in frames. + + A theme is named after the reading it paints — the recording, the place it stands on the + record and whether the reader hears it — so two stretches of one reading share a theme + however many times a document divides, and a name reads the same from one run of the + application to the next. + """ + + def __init__(self, stem_colors: StemColors) -> None: + self._stem_colors = stem_colors + self._painted: Dict[str, List[str]] = {} + + def paint( + self, + y_axis_tag: str, + runs: Sequence[OwnershipRunViewModel], + *, + frame_span: float, + band: Tuple[float, float], + ) -> None: + """Draws one lane's stretches into an axis. + + Args: + y_axis_tag: The axis the stretches are drawn on. + runs: The stretches, in frame order. + frame_span: What one frame measures on the axis the plot counts along. + band: The lower and upper edge the stretches are drawn between. + """ + self._clear(y_axis_tag) + bottom, top = band + painted: List[str] = [] + for run in runs: + series_tag = compose_tag(y_axis_tag, SUF_OWNERSHIP_RUN, str(run.start_frame)) + dpg.add_shade_series( + [run.start_frame * frame_span, run.end_frame * frame_span], + y1=[bottom, bottom], + y2=[top, top], + tag=series_tag, + parent=y_axis_tag, + ) + self._bind(series_tag, run) + painted.append(series_tag) + + self._painted[y_axis_tag] = painted + + def _clear(self, y_axis_tag: str) -> None: + """Takes the stretches this axis carries away, which is what a repaint begins with.""" + for series_tag in self._painted.pop(y_axis_tag, []): + dpg_delete_item(series_tag) + + def _bind(self, series_tag: str, run: OwnershipRunViewModel) -> None: + """Binds the run its recording's fill, through the theme every like reading shares.""" + theme_tag = compose_tag( + SUF_OWNERSHIP_RUN, + SUF_GRAPH_THEME, + str(run.stem_id), + SUF_OWNERSHIP_HEARD if run.heard else SUF_OWNERSHIP_LEFT_OUT, + ) + if not dpg.does_item_exist(theme_tag): + color = self._stem_colors.for_stem(run.stem_id, heard=run.heard) + with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvShadeSeries): + dpg_add_palette_theme_color(dpg.mvPlotCol_Fill, color, category=dpg.mvThemeCat_Plots) + + dpg_bind_item_theme(series_tag, theme_tag) diff --git a/src/sampletones_application/ui/elements/graphs/pan.py b/src/sampletones_application/ui/elements/graphs/pan.py new file mode 100644 index 000000000..38cf30cf9 --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/pan.py @@ -0,0 +1,83 @@ +from typing import Callable, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_HANDLER_WHEEL +from sampletones_application.utils.gui.dpg import dpg_is_item_hovered +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + +AxisLimits = Tuple[float, float] + + +class PlotWheelPan: + """Pans a plot with the mouse wheel while Alt is held. + + Alt with the wheel moves the view along x, and Alt and Shift together move it along y, by + ``pan_factor`` of the span in view for each notch. Scrolling down moves x toward larger values, + the way a timeline advances, and scrolling up moves y toward larger values. A move stops at the + bounds the graph constrains its axes to. The wheel acts while the pointer is over the plot or + over any plot linked to it, such as the lanes standing beneath a waveform. + + The plot's own wheel zoom stays out of the way because the graph locks its axes while Alt is + held, so the wheel reaches the view through this class alone. + """ + + def __init__( + self, + *, + tag: str, + plot_tags: Sequence[str], + x_axis_tag: str, + y_axis_tag: str, + pan_factor: float, + x_bounds: Callable[[], AxisLimits], + y_bounds: Callable[[], AxisLimits], + on_panned: VoidCallback, + ) -> None: + self._registry_tag = compose_tag(tag, SUF_HANDLER_WHEEL) + self._plot_tags = tuple(plot_tags) + self._x_axis_tag = x_axis_tag + self._y_axis_tag = y_axis_tag + self._pan_factor = pan_factor + self._x_bounds = x_bounds + self._y_bounds = y_bounds + self._on_panned = on_panned + + def create(self) -> None: + """Registers the wheel handler, which reads the pointer and the modifiers as each notch lands.""" + with dpg.handler_registry(tag=self._registry_tag): + dpg.add_mouse_wheel_handler(callback=self._on_wheel) + + def _on_wheel(self, _sender: Sender, wheel: float) -> None: + modifiers = capture_modifiers() + if Modifier.ALT not in modifiers or not self._hovered(): + return + + if Modifier.SHIFT in modifiers: + self._pan(self._y_axis_tag, self._y_bounds(), wheel) + else: + self._pan(self._x_axis_tag, self._x_bounds(), -wheel) + + self._on_panned() + + def _hovered(self) -> bool: + return any(dpg_is_item_hovered(plot_tag) for plot_tag in self._plot_tags) + + def _pan( + self, + axis_tag: str, + bounds: AxisLimits, + notches: float, + ) -> None: + """Moves an axis by ``notches`` steps of the span in view, within ``bounds``.""" + low, high = dpg.get_axis_limits(axis_tag) + span = high - low + bound_low, bound_high = bounds + if span >= bound_high - bound_low: + return + + start = min(max(low + notches * self._pan_factor * span, bound_low), bound_high - span) + dpg.set_axis_limits(axis_tag, start, start + span) diff --git a/src/sampletones_application/ui/elements/graphs/ribbon.py b/src/sampletones_application/ui/elements/graphs/ribbon.py new file mode 100644 index 000000000..0f54f56be --- /dev/null +++ b/src/sampletones_application/ui/elements/graphs/ribbon.py @@ -0,0 +1,95 @@ +from typing import Dict, List, Mapping + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.layout.graphs import GraphsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.graphs import SUF_GRAPH_THEME, SUF_RIBBON_GROUND +from sampletones_application.ui.elements.graphs.ownership import OwnershipRuns +from sampletones_application.utils.gui.dpg import dpg_bind_item_theme +from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel +from sampletones_core.constants.enums import ChannelName + + +class GUIOwnershipRibbon: + """The recordings behind each stretch of the waveform, a lane of flat bars per channel. + + Each lane stands for one channel in play and runs the length of the document beneath the + waveform, painted in the color of the recording holding each stretch. A resting stretch shows + the ground the lane is laid on, so a reader tells at a glance which recording carried a + passage and where nothing was played. A lane is drawn for every channel the document plays, + whether the reconstruction answers to one recording or several, since even a single recording + throughout is worth telling from a channel standing by. + + A lane is painted into the row the waveform keeps for its channel, which is what holds the + two to one span: they begin and end together however wide the amplitude labels beside them + run, a stretch stands under the sound it names through every zoom, and the row prints the + channel's own letter beside it. + """ + + def __init__( + self, + *, + plot_tags: Mapping[ChannelName, str], + y_axis_tags: Mapping[ChannelName, str], + layout: GraphsLayout, + stem_colors: StemColors, + ) -> None: + self._plot_tags = dict(plot_tags) + self._y_axis_tags = dict(y_axis_tags) + self._layout = layout + self._stem_colors = stem_colors + self._view_model: OwnershipRibbonViewModel = OwnershipRibbonViewModel.empty() + self._runs = OwnershipRuns(stem_colors) + + def bind_theme(self) -> None: + """Lays each lane's ground, which is the color a resting stretch shows.""" + for channel_name, plot_tag in self._plot_tags.items(): + theme_tag = compose_tag(plot_tag, SUF_GRAPH_THEME, SUF_RIBBON_GROUND) + with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvPlot): + dpg_add_palette_theme_color( + dpg.mvPlotCol_PlotBg, + self._stem_colors.rest, + category=dpg.mvThemeCat_Plots, + ) + + dpg_bind_item_theme(self._y_axis_tags[channel_name], theme_tag) + + def update_view(self, view_model: OwnershipRibbonViewModel) -> None: + """Repaints the lanes for what the reader is listening to.""" + self._view_model = view_model + drawn = {lane.channel_name: lane for lane in view_model.lanes} + bottom = self._layout.ribbon.lane_gap / 2.0 + for channel_name, y_axis_tag in self._y_axis_tags.items(): + if not dpg.does_item_exist(y_axis_tag): + continue + + lane = drawn.get(channel_name) + self._runs.paint( + y_axis_tag, + lane.runs if lane is not None else (), + frame_span=view_model.frame_length, + band=(bottom, 1.0 - bottom), + ) + dpg.set_axis_limits(y_axis_tag, 0.0, 1.0) + dpg.set_axis_limits_constraints(y_axis_tag, 0.0, 1.0) + + def clear(self) -> None: + """Empties the lanes, which is what closing the document they describe does.""" + self.update_view(OwnershipRibbonViewModel.empty()) + + @property + def lane_heights(self) -> Dict[ChannelName, int]: + """The height each channel's row takes, which is nothing where it has no lane to show.""" + drawn = self.lane_channels if self._view_model.is_drawn else [] + return { + channel_name: self._layout.ribbon.lane_height if channel_name in drawn else 0 + for channel_name in ChannelName.items() + } + + @property + def lane_channels(self) -> List[ChannelName]: + """The channels the lanes stand for, in the order they are drawn.""" + return [lane.channel_name for lane in self._view_model.lanes] diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py index d36cc0a22..d184ad48c 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -25,6 +25,8 @@ class GUISpectrumGraph(GUIGraph[SpectrumLayer]): + pans_with_wheel = False + tag: str parent: str width: int @@ -58,6 +60,7 @@ def __init__( x_range=(layout.graph.min_x, layout.graph.max_x), y_range=(MIN_FREQUENCY, DEFAULT_SAMPLE_RATE / 2), zoom_factor=layout.waveform.zoom_factor, + pan_factor=layout.waveform.pan_factor, ) def _create_content(self) -> None: @@ -69,7 +72,7 @@ def _create_content(self) -> None: height=self.height, anti_aliased=True, no_mouse_pos=True, - fit_button=False, + fit_button=dpg.mvMouseButton_Left, pan_button=-1, ): dpg.add_plot_axis( @@ -97,7 +100,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 +118,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..29493d7f5 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -1,19 +1,32 @@ from enum import StrEnum -from typing import Any, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Final, List, Mapping, Optional, Sequence, Tuple, Union import dearpygui.dearpygui as dpg import numpy as np +from sampletones_application.categories.context import channel_letter 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.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.graphs import ( + SUF_GRAPH_PLOT, + SUF_GRAPH_SUBPLOTS, SUF_GRAPH_THEME, + SUF_GRAPH_X_AXIS, + SUF_GRAPH_Y_AXIS, + SUF_HANDLER_MOUSE, + SUF_RIBBON_LANE, SUF_WAVEFORM_OVERLAY, SUF_WAVEFORM_POSITION_INDICATOR, TAG_GLOBAL_GRAPH_THEME_INDICATOR, TAG_GLOBAL_GRAPH_THEME_OVERLAY, ) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.graphs.clock import ClockTick, clock_ticks +from sampletones_application.ui.elements.graphs.gesture import PlotClickGesture from sampletones_application.ui.elements.graphs.graph import GUIGraph from sampletones_application.ui.elements.graphs.layers.array import ArrayLayer from sampletones_application.ui.elements.graphs.layers.instruction import ( @@ -27,23 +40,33 @@ dpg_delete_children, dpg_delete_item, ) +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color from sampletones_application.utils.palette.colors.base import BaseColor 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.audio import START_OF_AUDIO +from sampletones_core.constants.enums import AudioSourceType, ChannelName from sampletones_core.library import InstructionLibraryFragment from sampletones_shared.types.application import Sender +from sampletones_shared.utils.time import seconds_from_samples 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" +WAVEFORM_ROWS: Final[int] = 1 + len(ChannelName.items()) +LANE_LETTER_POSITION: Final[float] = 0.5 +SINGLE_COLUMN: Final[int] = 1 +COLLAPSED_LANE_WEIGHT: Final[float] = 0.001 + + class GUIWaveformGraph(GUIGraph[Union[ArrayLayer, InstructionLayer]]): tag: str parent: str @@ -57,11 +80,13 @@ def __init__( parent: str, *, layout: GraphsLayout, + channel_colors: ChannelColors, language_manager: LanguageManager, status_bar: GUIStatusBar, ): self._language_manager = language_manager self._layout = layout + self._channel_colors = channel_colors self._status_bar = status_bar self._lbl_waveform_original = language_manager["global.graph.label.waveform_original"] @@ -74,11 +99,34 @@ def __init__( self.position_indicator_tag = compose_tag(tag, SUF_WAVEFORM_POSITION_INDICATOR) self.overlay_rectangle_tag = compose_tag(tag, SUF_WAVEFORM_OVERLAY) + self.mouse_handler_tag = compose_tag(tag, SUF_HANDLER_MOUSE) + self.lane_handler_tag = compose_tag(tag, SUF_RIBBON_LANE, SUF_HANDLER_REGISTRY) + + self.on_position_clicked: Optional[Callable[[int], None]] = None + self._click = PlotClickGesture( + click_travel=layout.waveform.click_travel, + on_clicked=self._on_plot_clicked, + ) + + self.subplots_tag = compose_tag(tag, SUF_GRAPH_SUBPLOTS) + self.lane_plot_tags: Dict[ChannelName, str] = { + channel_name: compose_tag(tag, SUF_RIBBON_LANE, channel_name.value, SUF_GRAPH_PLOT) + for channel_name in ChannelName.items() + } + self.lane_y_axis_tags: Dict[ChannelName, str] = { + channel_name: compose_tag(plot_tag, SUF_GRAPH_Y_AXIS) + for channel_name, plot_tag in self.lane_plot_tags.items() + } + self._lane_heights: Dict[ChannelName, int] = {channel_name: 0 for channel_name in ChannelName.items()} self.indicator_theme = ThemeRegistry.get(TAG_GLOBAL_GRAPH_THEME_INDICATOR) self.overlay_theme = ThemeRegistry.get(TAG_GLOBAL_GRAPH_THEME_OVERLAY) self.current_data: Optional[Union[InstructionLibraryFragment[Any], WaveformData]] = None + self._plays_what_it_draws = False + self._sample_rate: int = 0 + self._named_span: Optional[Tuple[float, float, int]] = None + self._series_themes: Dict[BaseColor, str] = {} self.current_position: int = 0 _min_x = layout.graph.min_x @@ -95,25 +143,38 @@ def __init__( (_min_x, _max_x), (_min_y, _max_y), layout.waveform.zoom_factor, + layout.waveform.pan_factor, ) def _create_content(self) -> None: _min_y = self._layout.graph.min_y _max_y = self._layout.graph.max_y - with dpg.plot( - label=self.label, - tag=self.plot_tag, - parent=self.tag, - width=self.width, - height=self.height, - anti_aliased=True, - no_mouse_pos=True, - no_box_select=True, - fit_button=False, - horizontal_mod=dpg.mvKey_LShift, - pan_button=dpg.mvMouseButton_Left, - zoom_rate=self.zoom_factor, + with ( + dpg.subplots( + WAVEFORM_ROWS, + SINGLE_COLUMN, + tag=self.subplots_tag, + parent=self.tag, + width=self.width, + height=self.height, + row_ratios=[float(self.height), *([COLLAPSED_LANE_WEIGHT] * len(ChannelName.items()))], + link_all_x=True, + no_title=True, + no_menus=True, + no_resize=True, + ), + dpg.plot( + label=self.label, + tag=self.plot_tag, + anti_aliased=True, + no_mouse_pos=True, + no_box_select=True, + fit_button=dpg.mvMouseButton_Left, + horizontal_mod=dpg.mvKey_LShift, + pan_button=dpg.mvMouseButton_Left, + zoom_rate=self.zoom_factor, + ), ): dpg.add_plot_legend( tag=self.legend_tag, @@ -136,18 +197,175 @@ def _create_content(self) -> None: self._add_position_indicator() self._set_overlay_rectangle() + self._create_lane_rows() self._bind_event_handler() self._update_axes_limits() + def _create_lane_rows(self) -> None: + """A row per channel beneath the waveform, each marked with the letter it stands for. + + The rows share the subplot's grid, so a lane begins and ends where the waveform's span + does however wide the amplitude labels beside them run, and each takes the waveform's + stretch through the linked axis, so zooming and panning carry them together. A row of its + own is what lets a lane print its channel's letter in that channel's color, and the letter + is drawn in the smallest face the application carries, so it stands within its lane. + """ + for channel_name, plot_tag in self.lane_plot_tags.items(): + with dpg.plot( + tag=plot_tag, + parent=self.subplots_tag, + no_mouse_pos=True, + no_box_select=True, + no_menus=True, + no_title=True, + no_frame=True, + ): + dpg.add_plot_axis( + dpg.mvXAxis, + tag=compose_tag(plot_tag, SUF_GRAPH_X_AXIS), + parent=plot_tag, + no_label=True, + no_tick_labels=True, + no_tick_marks=True, + no_gridlines=True, + ) + dpg.add_plot_axis( + dpg.mvYAxis, + tag=self.lane_y_axis_tags[channel_name], + parent=plot_tag, + no_label=True, + no_tick_marks=True, + no_gridlines=True, + ) + dpg.set_axis_ticks( + self.lane_y_axis_tags[channel_name], + self._lane_letter_ticks(channel_name), + ) + + FontRegistry.bind_to_item(plot_tag, Font.REGULAR_TINY) + self._bind_lane_theme(channel_name, plot_tag) + + self._bind_lane_hover() + + def _bind_lane_hover(self) -> None: + with dpg.item_handler_registry(tag=self.lane_handler_tag): + dpg.add_item_hover_handler(callback=self._on_lane_hover) + + for plot_tag in self.lane_plot_tags.values(): + dpg.bind_item_handler_registry(plot_tag, self.lane_handler_tag) + + def _bind_lane_theme(self, channel_name: ChannelName, plot_tag: str) -> None: + """Prints a lane's letter in the color the channel is drawn in everywhere else.""" + theme_tag = compose_tag(plot_tag, SUF_GRAPH_THEME) + with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvPlot): + dpg.add_theme_style(dpg.mvPlotStyleVar_PlotPadding, 0, 0, category=dpg.mvThemeCat_Plots) + dpg_add_palette_theme_color( + dpg.mvPlotCol_AxisText, + self._channel_colors.for_channel(channel_name), + category=dpg.mvThemeCat_Plots, + ) + + dpg_bind_item_theme(plot_tag, theme_tag) + + def set_height(self, height: int) -> None: + """Gives the waveform the height asked for, keeping the row beneath it at its own. + + The plot stands in a grid, which takes its size from the container rather than from the + plot, so the height a caller asks for reaches the grid and the two rows share it. + """ + self.height = height + self._resize_rows() + + def set_lane_heights(self, heights: Mapping[ChannelName, int]) -> None: + """Gives each channel's row the height it needs, closing the ones with nothing to show. + + A closed row carries a sliver of height rather than none, since the grid gives every row + some room to stay laid out in, so a channel's letter is cleared from its axis rather than + left to crowd that sliver. + """ + self._lane_heights = {channel_name: heights.get(channel_name, 0) for channel_name in ChannelName.items()} + for channel_name, height in self._lane_heights.items(): + dpg.set_axis_ticks( + self.lane_y_axis_tags[channel_name], + self._lane_letter_ticks(channel_name) if height else (), + ) + + self._resize_rows() + + def _lane_letter_ticks(self, channel_name: ChannelName) -> Tuple[Tuple[str, float], ...]: + """The one tick standing for a lane's letter, at the position it prints centered from.""" + return ((channel_letter(self._language_manager, channel_name), LANE_LETTER_POSITION),) + + def _resize_rows(self) -> None: + """Hands the grid the room the waveform and the rows beneath it take together.""" + lanes = [ + float(self._lane_heights[channel_name]) or COLLAPSED_LANE_WEIGHT for channel_name in ChannelName.items() + ] + dpg_configure_item( + self.subplots_tag, + height=self.height + sum(self._lane_heights.values()), + row_ratios=[float(self.height), *lanes], + ) + + def _setup_handlers(self) -> None: + super()._setup_handlers() + dpg.add_item_clicked_handler( + button=dpg.mvMouseButton_Left, + callback=self._click.press, + parent=self.event_handler_tag, + ) + with dpg.handler_registry(tag=self.mouse_handler_tag): + dpg.add_mouse_release_handler( + button=dpg.mvMouseButton_Left, + callback=self._click.release, + ) + + def _on_plot_clicked(self, sample: float) -> None: + """Reports the sample a click on the plot pointed at. + + A click names a sample of the audio a recording or an instruction plays, so it is reported + while the graph draws the audio its player sounds. + """ + if not self._plays_what_it_draws: + return + + self.call(self.on_position_clicked, round(sample)) + + def clear_layers(self) -> None: + """Empties the plot of what it draws, which leaves a click nothing to sound until a load.""" + super().clear_layers() + self._plays_what_it_draws = False + def set_overlay_range(self, start: float = 0.0, end: float = 0.0) -> None: self._set_overlay_rectangle(x_start=start, x_end=end) + def _pannable_plot_tags(self) -> Sequence[str]: + return (self.plot_tag, *self.lane_plot_tags.values()) + + def _on_panned(self) -> None: + super()._on_panned() + FrameCallbackManager.set_frame_callback(self._restate_zoomed_clock_ticks) + + def _on_lane_hover(self) -> None: + """Holds the lanes' own wheel zoom back while Alt is held, so the wheel pans them instead.""" + locked = Modifier.ALT in capture_modifiers() + for channel_name, plot_tag in self.lane_plot_tags.items(): + for axis_tag in (compose_tag(plot_tag, SUF_GRAPH_X_AXIS), self.lane_y_axis_tags[channel_name]): + dpg.configure_item(axis_tag, lock_min=locked, lock_max=locked) + def _on_hover(self, sender: Sender, app_data: Any, user_data: Any) -> None: super()._on_hover(sender, app_data, user_data) + FrameCallbackManager.set_frame_callback(self._restate_zoomed_clock_ticks) self._status_bar.set( self._msg_regenerating if self._reconstruction_dimmed - else self._language_manager["global.graph.message.waveform_navigation"] + else self._language_manager[ + ( + "global.graph.message.waveform_playable_navigation" + if self._plays_what_it_draws + else "global.graph.message.waveform_navigation" + ) + ] ) def _set_overlay_rectangle(self, x_start: float = 0.0, x_end: float = 0.0) -> None: @@ -172,32 +390,73 @@ 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 + self._plays_what_it_draws = True + self.current_position = START_OF_AUDIO self.add_layer( 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. + + An instrument 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._sample_rate = 0 + 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,17 +474,17 @@ 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. - The original-audio layer joins the reconstruction layer only when the source audio is - present, so a detached reconstruction or one whose source file is missing shows the - approximation on its own. + The original-audio layer joins the reconstruction layer while the source audio is loaded, + so a reconstruction whose recordings are missing or unreadable shows the approximation on + its own. """ 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,35 +496,52 @@ def _display_layers( def update_waveform_data( self, waveform_data: WaveformData, - selected_generators: Optional[List[GeneratorName]] = None, + selected_channels: Optional[List[ChannelName]] = None, + *, + refit: bool = False, ) -> None: + """Redraws the loaded waveform from fresh data, keeping the view the reader left it at. + + The plot holds exactly the layers the fresh data displays, drawn in the layers' order with + the audible source on top. ``refit`` names the update a retune is: the audio's own length + changed, so the view is re-fitted to the new span. + """ if not isinstance(self.current_data, WaveformData): return self.current_data = waveform_data - for layer in self._display_layers(waveform_data, selected_generators): - self.layers[layer.name] = layer + displayed = self._display_layers(waveform_data, selected_channels) + if any(layer.name not in self.layers for layer in displayed): + self._delete_series() + + self.layers = {layer.name: layer for layer in displayed} + + if refit: + self._update_ranges() self._update_display() 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): + self._sample_rate = waveform_data.sample_rate + self._plays_what_it_draws = True + for layer in self._display_layers(waveform_data, selected_channels): self.add_layer(layer) + self._restate_clock_ticks(*self.x_range) + 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 @@ -327,19 +603,31 @@ def _reorder_series(self) -> None: sample_layer = self.layers[self._lbl_waveform_original] reconstruction_layer = self.layers[self._lbl_waveform_reconstruction] - for layer in (sample_layer, reconstruction_layer): - dpg_delete_item(self._series_tag(layer.name)) - + self._delete_series() self.layers.clear() for layer in self._ordered_layers(sample_layer, reconstruction_layer): self.layers[layer.name] = layer self._update_display() + def _delete_series(self) -> None: + """Takes every layer's series off the axis, so the next display adds them back in the layers' order. + + DearPyGui draws sibling series in child order, and a series it already holds keeps its place. + """ + for layer_name in self.layers: + dpg_delete_item(self._series_tag(layer_name)) + def clear(self) -> None: + """Empties the plot down to the marks it keeps for whatever it draws next: the position + indicator and the overlay rectangle, both of which live among the axis's children.""" self._reconstruction_dimmed = False + self._sample_rate = 0 + self._release_clock_ticks() self.clear_layers() dpg_delete_children(self.y_axis_tag) + self.current_position = START_OF_AUDIO + self._add_position_indicator() self._set_overlay_rectangle() def set_autoscale(self, autoscale: bool) -> None: @@ -370,7 +658,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 +711,99 @@ 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 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. - 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. + 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, - ) + 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 _update_axes_limits(self) -> None: + """Takes the new bounds, then names the positions they put on screen. + + The bounds are the graph's own, so the marks follow them the moment they are taken + rather than waiting for a frame to state them back. + """ + super()._update_axes_limits() + self._restate_clock_ticks(*self.x_range) + + def _restate_zoomed_clock_ticks(self) -> None: + """Names the positions across the stretch a reader has zoomed the plot to. - dpg_bind_item_theme(series_tag, theme_tag) + A zoom moves the axis rather than the graph's own bounds, so the stretch is read back + from the axis, a frame after the gesture that moved it has been drawn. + """ + if not dpg.does_item_exist(self.x_axis_tag): + return + + start, end = dpg.get_axis_limits(self.x_axis_tag) + self._restate_clock_ticks(float(start), float(end)) + + def _restate_clock_ticks(self, start: float, end: float) -> None: + """Names each position along the time axis by the moment it stands at. + + The axis counts samples, so the stretch it covers turns into the seconds the recording + reaches there and the marks are placed across them. A plot drawing a fragment counts + samples alone, and the axis is handed back to the figures DearPyGui states for it. + """ + if not dpg.does_item_exist(self.x_axis_tag): + return + + named = (start, end, self._sample_rate) + if named == self._named_span: + return + + self._named_span = named + ticks = self._clock_ticks(start, end) + if ticks: + dpg.set_axis_ticks(self.x_axis_tag, tuple(ticks)) + else: + dpg.reset_axis_ticks(self.x_axis_tag) + + def _release_clock_ticks(self) -> None: + """Hands the axis back to the figures DearPyGui states for it, which an empty plot reads by.""" + self._named_span = None + if dpg.does_item_exist(self.x_axis_tag): + dpg.reset_axis_ticks(self.x_axis_tag) + + def _clock_ticks(self, start: float, end: float) -> List[ClockTick]: + """The marks the stretch between two sample positions carries, which a recording names.""" + if self._sample_rate <= 0: + return [] + + return clock_ticks( + seconds_from_samples(int(start), self._sample_rate), + seconds_from_samples(int(end), self._sample_rate), + self._sample_rate, + self._layout.clock, + ) def _add_position_indicator(self) -> None: dpg_delete_item(self.position_indicator_tag) diff --git a/src/sampletones_application/ui/elements/layout/centered.py b/src/sampletones_application/ui/elements/layout/centered.py new file mode 100644 index 000000000..6ed5bcb03 --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/centered.py @@ -0,0 +1,26 @@ +from contextlib import contextmanager +from typing import Final, Iterator + +import dearpygui.dearpygui as dpg + +EQUAL_SHARE: Final[float] = 1.0 + + +@contextmanager +def centered() -> Iterator[None]: + """Stand what the block builds in the middle of the width it is offered. + + The content sits in a column fitted to it, flanked by two columns sharing the rest of the width + equally. The fitted column follows the content from frame to frame, so a label that grows or + shrinks while it stands stays centered. + """ + with dpg.table(header_row=False): + dpg.add_table_column(width_stretch=True, init_width_or_weight=EQUAL_SHARE) + dpg.add_table_column(width_fixed=True) + dpg.add_table_column(width_stretch=True, init_width_or_weight=EQUAL_SHARE) + with dpg.table_row(): + dpg.add_spacer() + with dpg.group(): + yield + + dpg.add_spacer() diff --git a/src/sampletones_application/ui/elements/layout/collapse.py b/src/sampletones_application/ui/elements/layout/collapse.py index 616f4312d..71e7c3141 100644 --- a/src/sampletones_application/ui/elements/layout/collapse.py +++ b/src/sampletones_application/ui/elements/layout/collapse.py @@ -21,6 +21,7 @@ dpg_set_value, ) from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.hover import HoverWatch from sampletones_shared.utils.callbacks import CallbackMixin @@ -75,6 +76,7 @@ def __init__( self.on_toggle: Optional[Callable[[str, bool], None]] = None self._collapsed_height: Optional[int] = None + self._hover = HoverWatch(self._paint_bars) @property def collapsed(self) -> bool: @@ -167,7 +169,7 @@ def attach(self) -> None: """ dpg_delete_item(self.strip_handler_tag) with dpg.item_handler_registry(tag=self.strip_handler_tag): - dpg.add_item_hover_handler(callback=self._on_bar_hover) + dpg.add_item_hover_handler(callback=self._hover.report) dpg.bind_item_handler_registry(self.strip_tag, self.strip_handler_tag) self._bind_idle_theme(self.strip_tag) @@ -251,26 +253,27 @@ def _is_bar_hovered(self) -> bool: return bool(self.is_horizontal and dpg.does_item_exist(self.rail_tag) and dpg.is_item_hovered(self.rail_tag)) - def _on_bar_hover(self) -> None: - """Highlight whichever bar the pointer is over and settle the rest back to idle. - - An item hover handler fires only while its item is hovered, so a frame callback re-checks a - couple of frames on to catch the un-hover and restore the idle background. - """ - self._refresh_bar_theme(self.strip_tag) + def _paint_bars(self) -> bool: + """Highlight whichever bar the pointer is over and settle the rest back to idle, answering + whether one is highlighted.""" + hovered = self._paint_bar(self.strip_tag) if self.is_horizontal: - self._refresh_bar_theme(self.rail_tag) + hovered = self._paint_bar(self.rail_tag) or hovered + + return hovered - def _refresh_bar_theme(self, bar_tag: str) -> None: + def _paint_bar(self, bar_tag: str) -> bool: if not dpg.does_item_exist(bar_tag): - return + return False - if dpg.is_item_hovered(bar_tag): + hovered = bool(dpg.is_item_hovered(bar_tag)) + if hovered: self._bind_hovered_theme(bar_tag) - FrameCallbackManager.set_frame_callback(self._on_bar_hover, 2) else: self._bind_idle_theme(bar_tag) + return hovered + def _bind_idle_theme(self, bar_tag: str) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_COLLAPSE_HEADER).bind_to_item(bar_tag) diff --git a/src/sampletones_application/ui/elements/layout/columns.py b/src/sampletones_application/ui/elements/layout/columns.py index 010a57192..bb6f74e2a 100644 --- a/src/sampletones_application/ui/elements/layout/columns.py +++ b/src/sampletones_application/ui/elements/layout/columns.py @@ -1,13 +1,21 @@ from dataclasses import dataclass -from typing import Optional, Sequence +from typing import AbstractSet, Final, Optional, Sequence import dearpygui.dearpygui as dpg -from sampletones_application.tags.general import TAG_GLOBAL_THEME_PANEL_GROUND +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_TABLE_COLUMN, + SUF_TABLE_GAP, + TAG_GLOBAL_THEME_PANEL_GROUND, +) from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import StringCallback +_STRETCH_WEIGHT: Final[float] = 1.0 + @dataclass(frozen=True) class ColumnSpec: @@ -33,6 +41,11 @@ def stretches(self) -> bool: """Whether the column expands to absorb the space the fixed columns leave.""" return self.width == 0 + @property + def declared_size(self) -> float: + """The share a stretching column takes of what is left, or the width a fixed one holds.""" + return _STRETCH_WEIGHT if self.stretches else float(self.width) + class TabColumns: """The shared scaffold every tab lays its panels out on. @@ -55,7 +68,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 +107,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. @@ -118,6 +131,32 @@ def row( cls._bind_column_themes(columns) + @staticmethod + def stand_columns( + columns: Sequence[ColumnSpec], + standing: AbstractSet[str], + ) -> None: + """Divides a row built by :meth:`row` among the columns in ``standing``. + + A card the reader puts away leaves its column with nothing to hold, so the column is + disabled and the ones still standing divide the whole row between them, its padding + included. A gap stands where a column stands on each side of it, so what is left sits flush + to the row's edges and keeps one gap between neighbors. A column comes back at the size it + was declared with. + + Disabling is what lets a column go entirely: DearPyGui reads a weight of zero as the + default share, and the smallest positive weight still keeps a few pixels and the cell's + padding. + """ + preceded = False + for index, column in enumerate(columns): + stands = column.tag in standing + dpg_configure_item(compose_tag(column.tag, SUF_TABLE_COLUMN), enabled=stands) + if index > 0: + dpg_configure_item(compose_tag(column.tag, SUF_TABLE_GAP), enabled=stands and preceded) + + preceded = preceded or stands + @staticmethod def _bind_column_themes(columns: Sequence[ColumnSpec]) -> None: """Binds each column's declared depth theme, leaving card-hosting columns to their cards.""" @@ -149,20 +188,27 @@ 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 at the size it states, with a fixed gap between neighbors. + + A stretching column takes an explicit share rather than one read back from the card inside + it, so the row keeps the proportions it was declared with whatever its cards draw. Each + column and each gap is named after the cell it serves, which is how :meth:`stand_columns` + reaches them once the row is standing. + """ for index, column in enumerate(columns): if index > 0: dpg.add_table_column( width_fixed=True, init_width_or_weight=panel_gap, + tag=compose_tag(column.tag, SUF_TABLE_GAP), ) - if column.stretches: - dpg.add_table_column() - else: - dpg.add_table_column( - width_fixed=True, - init_width_or_weight=column.width, - ) + + dpg.add_table_column( + width_stretch=column.stretches, + width_fixed=not column.stretches, + init_width_or_weight=column.declared_size, + tag=compose_tag(column.tag, SUF_TABLE_COLUMN), + ) @staticmethod def _build_column(column: ColumnSpec) -> None: diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py new file mode 100644 index 000000000..fafb7e5dc --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from typing import Final, Self, Tuple + +Window = Tuple[int, int] + +GEOMETRY_TOLERANCE: Final[float] = 1.0 +UNMEASURED: Final[float] = 0.0 +ONE_ROW: Final[int] = 1 +MINIMUM_ROW_PITCH: Final[float] = 8.0 + + +@dataclass +class RowGeometry: + """The room one row takes, read from the rows a list has drawn. + + A region showing part of a long list decides two things from this: how many rows its height + holds, and which of them a scroll position reaches. Both are properties of the theme and the + font a row is drawn under rather than of any one list, so the reading is taken from whatever + rows have already been placed and shared by every region drawing rows of that shape. A folder + opens knowing what a row takes because the list the folder stands in measured it. + + A geometry that has yet to read anything works from ``opening`` — the room the layout says a + row takes — so a first draw builds about the rows it shows, and the reading taken from them + settles it from the next frame on. + + ``overscan`` is how many rows stand beyond each edge of what a region shows, so a scroll in + either direction meets rows that are already there. + """ + + overscan: int + pitch: float + opening: float + + @classmethod + def opening_at(cls, *, overscan: int, opening: float) -> Self: + """The reading a list starts from, which the height its layout gives a row opens.""" + return cls(overscan=overscan, pitch=UNMEASURED, opening=max(MINIMUM_ROW_PITCH, opening)) + + @property + def measured(self) -> bool: + """Whether a reading has been taken, which is what a region's own room is held to.""" + return self.pitch > UNMEASURED + + @property + def room(self) -> float: + """The room one row is worked from: what was read, or what the layout opens it at. + + ``MINIMUM_ROW_PITCH`` is the floor an opening is held to, so a window taken before + anything has been measured covers a region rather than a sliver of one. + """ + return self.pitch if self.measured else self.opening + + def size(self, height: float) -> int: + """How many rows a window over a region of this height holds, overscan included.""" + showing = max(ONE_ROW, int(height / self.room) + ONE_ROW) + return showing + 2 * self.overscan + + def windows(self, *, height: float, total: int) -> bool: + """Whether a list of this length outgrows the region, which is what asks for a window.""" + return total > self.size(height) + + def slice_of(self, *, offset: float, height: float, total: int) -> Window: + """The rows a scroll position reaches: where the window opens, and how many it holds. + + The window opens where the offset stands counted in the same rooms the undrawn rows are + reserved in, so the block a region builds covers the position it was chosen for whatever + the reading of a row stands at. The overscan above it is what a scroll back the way it + came meets, and the end of the list is what a region scrolled past its last window holds. + """ + if not self.windows(height=height, total=total): + return (0, total) + + count = self.size(height) + reached = int(offset / self.room) - self.overscan + return (max(0, min(reached, total - count)), count) + + def reserve(self, rows: int) -> int: + """The room a number of rows takes, which stands in place of the ones left undrawn.""" + return int(rows * self.room) + + def take(self, *, block: float, rows: int) -> bool: + """Read what a row takes from a block of drawn rows, reporting a reading worth redrawing. + + The block is measured whole rather than row by row, so whatever a table lays around its + rows is carried by the same number that reserves room for them. A move worth a pixel is + worth drawing again. + + A block giving less than ``MINIMUM_ROW_PITCH`` a row was measured while its rows stood + clipped or unplaced, since no theme draws a row that small. The reading already taken is + kept, and the answer is that nothing moved. + """ + if rows <= 0: + return False + + pitch = block / rows + if pitch < MINIMUM_ROW_PITCH: + return False + + moved = abs(pitch - self.pitch) > GEOMETRY_TOLERANCE + self.pitch = pitch + return moved diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py new file mode 100644 index 000000000..ab81f51b8 --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -0,0 +1,376 @@ +from typing import Callable, Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_LEAD, + SUF_SPACER_ABOVE, + SUF_SPACER_BELOW, +) +from sampletones_application.ui.elements.layout.geometry import RowGeometry, Window +from sampletones_application.ui.elements.layout.well import well +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_children +from sampletones_shared.types.callback import VoidCallback + +SliceBuilder = Callable[[int, int], None] +LeadBuilder = Callable[[str], None] + +NO_ROWS: Final[Window] = (0, 0) +NO_TOTAL: Final[int] = 0 +NO_GUTTER: Final[int] = 0 +NO_MARGIN: Final[int] = 0 +NO_LEAD: Final[float] = 0.0 +AUTO_HEIGHT: Final[int] = 0 +NO_SCROLL: Final[float] = 0.0 +SCROLL_TOLERANCE: Final[float] = 1.0 + + +class WindowedRegion: + """A recessed region that builds the rows it shows and reserves the room for the rest. + + A region sizes itself to what it holds up to a ceiling, and scrolls from there on, so the + card around it keeps its shape however long the list grows. Within it, only the rows the + reader can reach are built; the ones above and below stand as reserved room, which keeps the + scrollbar proportional to the whole list and makes drawing a list of thousands cost what + drawing a list of ten costs. + + A region may carry a **lead** — a heading standing above its rows, built and taken down with + them and scrolling with them. Its room counts toward the height the content asks for, so the + travel a window is mapped over covers the whole of what the region holds. + + The region owns every quantity the window is chosen by: the room it reserved, because it + placed it, and the height it holds, because it set it. So a caller draws and then settles, + and there is one order for the two. ``gutter`` is the room a scrollbar takes, which the region + holds clear at the right of its body while it stands without one, so what it holds keeps one + width across the moment it starts scrolling. + + The height a run of rows asks for is worked out from the reading of a row rather than read off + the widgets, since a region already held to its ceiling clips what it holds and would measure + its own ceiling back. A reading is therefore taken only while the region stands at the height + of what it holds, which :attr:`natural` reports. A reading arrives after the height it decides + has been set, so :attr:`settling` asks for the pass that holds the region to it. + + A region redrawn because the reader scrolled leaves the scroll where they put it. The rows it + holds change while the region itself stands, so a position written back would land against the + wheel that asked for the new rows and take the reader somewhere they never scrolled. A region + built in place of one a rebuild took down is a new widget standing at its top, so where the + reader had scrolled the one before it is named by :meth:`opens_at`, and handed back once the + frame that placed its rows has been drawn. + """ + + def __init__( + self, + *, + tag: str, + geometry: RowGeometry, + ceiling: int, + padding: int, + margin: int, + gutter: int, + indent: Optional[int] = None, + ) -> None: + self._tag = tag + self._geometry = geometry + self._ceiling = ceiling + self._padding = padding + self._margin = margin + self._gutter = gutter + self._indent = indent + self._above_tag = compose_tag(tag, SUF_SPACER_ABOVE) + self._below_tag = compose_tag(tag, SUF_SPACER_BELOW) + self._lead_tag = compose_tag(tag, SUF_LEAD) + self._body_tag = "" + self._height = float(ceiling) + self._lead = NO_LEAD + self._total = 0 + self._windowed = False + self._natural = True + self._reading_to_hold = False + self._resized = False + self._drawn: Window = NO_ROWS + self._resting = NO_SCROLL + self._restoring = False + + @property + def tag(self) -> str: + """The region itself, which is what an owner shows, hides and scrolls.""" + return self._tag + + @property + def body(self) -> str: + """The group the rows are built into, which a draw empties and fills.""" + return self._body_tag + + @property + def window(self) -> Window: + """The slice the region was last drawn from: where it opens, and how many rows it holds.""" + return self._drawn + + @property + def windowing(self) -> bool: + """The region holds back rows it has no room for, so a scroll asks it for different ones.""" + return self._windowed and self._drawn[1] < self._total + + @property + def standing(self) -> bool: + """The region's body stands built, which is what a draw fills and a settle reads.""" + return bool(dpg.does_item_exist(self._body_tag)) + + @property + def settling(self) -> bool: + """The region stands as something other than it will, so whoever drew it settles it again. + + A region holding rows back answers a scroll with a different slice, and one that has + just read what a row takes holds itself to that reading in the pass that follows. A region + that has just taken a new height stands at its old one until the frame after, and whatever + it is drawn inside measures it as it stands, so that too asks for another pass. Either way + what stands now is not what the region comes to rest as. A region whose body has been taken + down comes to rest where it is, so a window closing ends the pass it was keeping. + """ + return self.standing and (self.windowing or self._reading_to_hold or self._resized) + + @property + def natural(self) -> bool: + """The region stands at the height of what it holds, so what it holds measures true.""" + return self._natural + + @property + def offset(self) -> float: + """How far the region has been scrolled, read from the region itself.""" + return self._scroll(dpg.get_y_scroll) + + def opens_at(self, offset: float) -> None: + """Open the region where the reader had scrolled the one it stands in place of. + + A region goes down with the list around it and comes back a new widget at its top, so the + rows the reader was looking at are named by whoever held on to the position. + """ + self._resting = offset + self._restoring = True + + def create(self, parent: str, *, show: bool = True) -> None: + """Sink the region into ``parent``, sized to its rows until they reach its ceiling.""" + self._body_tag = well( + parent, + self._tag, + padding=self._padding, + margin=self._margin, + width=self._body_width(scrolling=False), + indent=self._indent, + show=show, + ) + + def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) -> None: + """Build the rows the region reaches, reserving the room the ones outside it would take. + + ``build`` is handed where the window opens and how many rows it holds, and adds them to + :attr:`body` between the two reserves. ``lead`` builds the heading standing above them, + into the group it is handed. The rows are chosen for where the reader stands, which is a + position they scrolled to themselves unless the region is opening in place of another. + + Before a row has been measured the region builds a first slice at its natural height and + reserves nothing, which is what gives :meth:`settle` a run of rows to read. + """ + if not self.standing: + return + + start, count = self._slice(self._reading, total) + dpg_delete_children(self._body_tag) + measuring = not self._geometry.measured + self._build_lead(lead) + self._reserve(self._above_tag, 0 if measuring else start) + build(start, count) + self._reserve(self._below_tag, 0 if measuring else total - start - count) + self._total = total + self._windowed = True + self._drawn = (start, count) + + def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder]) -> None: + """Build the region's contents entire, for content that is more than a run of rows. + + A region holding captions, strips or regions of its own has no one row to reserve room by, + so it stands as tall as what it holds and scrolls once that reaches its ceiling. What a row + takes is read from the runs of rows a region draws windows over, which is where a block of + one height stands on its own. + """ + if not self.standing: + return + + dpg_delete_children(self._body_tag) + self._build_lead(lead) + build() + self._total = NO_TOTAL + self._windowed = False + self._drawn = NO_ROWS + + def settle(self) -> bool: + """Size the region to what it holds and read what a row takes, a frame after a draw. + + Answers whether the rows standing are still the ones the region reaches, which is what + asks an owner to draw it again. A region whose body has been taken down asks for nothing. + """ + if not self.standing: + return False + + self._resized = False + self._take_lead() + if not self._windowed: + self._take_reading() + self._hold_content() + return self._restore() + + if not self._geometry.measured: + self._stand_at_natural_height() + self._reading_to_hold = self._take_reading() + return self._reading_to_hold + + self._hold_rows() + if self._restore(): + return False + + return self._slice(self.offset, self._total) != self._drawn + + @property + def _reading(self) -> float: + """The position the rows are chosen for: where the reader stands, or where they go back to.""" + return self._resting if self._restoring else self.offset + + def _restore(self) -> bool: + """Put the reader back where they stood, once the frame that placed the rows has drawn. + + Answers whether a scroll was written, since the frame that carries it out is the one whose + position the window is chosen from. + """ + if not self._restoring: + return False + + self._restoring = False + if abs(self.offset - self._resting) <= SCROLL_TOLERANCE: + return False + + dpg.set_y_scroll(self._tag, self._resting) + return True + + def _slice(self, offset: float, total: int) -> Window: + """The rows the region's scroll position reaches, in the list it is a window onto. + + The position is counted in the rooms the region reserves by, which is what the spacer + above the rows is built from, so the block stands over what the reader is looking at. + """ + return self._geometry.slice_of(offset=offset, height=self._height, total=total) + + def _build_lead(self, lead: Optional[LeadBuilder]) -> None: + """Open the group the heading stands in, and let its owner fill it.""" + if lead is None: + return + + dpg.add_group(tag=self._lead_tag, parent=self._body_tag) + lead(self._lead_tag) + + def _reserve(self, tag: str, rows: int) -> None: + """The room a run of undrawn rows would take, standing in place of them.""" + dpg.add_spacer(tag=tag, parent=self._body_tag, height=self._geometry.reserve(rows)) + + def _take_lead(self) -> None: + """Read the room the heading takes, which the room the whole content asks for counts in.""" + if not self._natural or not dpg.does_item_exist(self._lead_tag): + return + + self._lead = float(dpg.get_item_rect_size(self._lead_tag)[1]) + + def _take_reading(self) -> bool: + """Read what one row takes from the rows standing, and report a reading worth redrawing. + + The rows are counted off a block measured whole, so the reading holds whatever a table + lays around them. It is taken while the region stands at its natural height, which is + where the block measures the room its rows asked for. + """ + if not self._natural: + return False + + return self._geometry.take(block=self._body_height() - self._lead, rows=self._standing) + + @property + def _standing(self) -> int: + """How many rows of one height stand in the region, which a reading counts by. + + A windowed region reserves nothing while it measures, so the block it stands as is the + slice it drew; a whole-drawn one stands as every row it was given. + """ + return self._drawn[1] if self._windowed else self._total + + def _hold_rows(self) -> None: + """Size the region to the room its rows ask for, holding it at its ceiling from there on.""" + self._size_to(self._room_for(self._total)) + + def _hold_content(self) -> None: + """Size a whole-drawn region to what it holds, holding it at its ceiling from there on. + + What it holds is measured rather than worked out, since content that is more than a run of + rows has no one row to count by. A region already at its ceiling measures at least its own + height, which answers the ceiling the way an exact reading would. + """ + self._size_to(self._body_height() + 2 * self._margin) + + def _room_for(self, total: int) -> float: + """The room a list of this length asks for: the heading, the rows, and the margins.""" + return float(self._lead + self._geometry.reserve(total) + 2 * self._margin) + + def _stand_at_natural_height(self) -> None: + """Let the region take the height of what it holds, which is what a reading is read from.""" + self._take_height(self._body_height() + 2 * self._margin) + self._natural = True + dpg_configure_item(self._tag, height=AUTO_HEIGHT, auto_resize_y=True, no_scrollbar=True) + self._hold_gutter(scrolling=False) + + def _take_height(self, height: float) -> None: + """Stand at a height, noting whether it is one the region has yet to be drawn at. + + Every height the region takes is taken here, so one it has just moved to asks for the pass + that reads it back however the region came by it. + """ + self._resized = height != self._height + self._height = height + + def _size_to(self, content: float) -> None: + within = content <= self._ceiling + self._take_height(content if within else float(self._ceiling)) + self._natural = within + self._reading_to_hold = False + dpg_configure_item( + self._tag, + height=AUTO_HEIGHT if within else self._ceiling, + auto_resize_y=within, + no_scrollbar=within, + ) + self._hold_gutter(scrolling=not within) + + def _hold_gutter(self, *, scrolling: bool) -> None: + """Keep the body one width, whether the room at its right is a scrollbar or the gutter. + + A region past its ceiling draws a scrollbar, which takes that room out of the width the + body is measured against; one within its ceiling holds the same room as a gutter. So the + columns inside a region stand where they stand however long the list it holds grows. + """ + dpg_configure_item(self._body_tag, width=self._body_width(scrolling=scrolling)) + + def _body_width(self, *, scrolling: bool) -> int: + """What the body is drawn at: its own room, less the padding and whatever stands at its right. + + A scrollbar and the gutter take the same room, so the body comes out one width either way. + """ + return -(self._padding + (NO_GUTTER if scrolling else self._gutter)) + + def _body_height(self) -> float: + """How tall the rows drawn into the region stand, as the frame that placed them left them.""" + if not dpg.does_item_exist(self._body_tag): + return 0.0 + + return float(dpg.get_item_rect_size(self._body_tag)[1]) + + def _scroll(self, read: Callable[[str], float]) -> float: + if not dpg.does_item_exist(self._tag): + return 0.0 + + return float(read(self._tag)) 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..30e9d97c4 --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -0,0 +1,57 @@ +from typing import Optional + +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, + width: int, + indent: Optional[int] = None, + 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, and the region + sizes itself to the rows it holds, which whoever owns it holds to a ceiling of their own. + + Returns the inset body group content is added to, which opens at ``indent`` and comes out at + ``width``, the caller stating the room to hold clear at the right. A well sunk under a row of + its own indents to show what it belongs to while its right edge stays where every other row's + is, so the columns line up down the whole list. + ``margin`` opens the gap above the first row and below the last, which the row spacing between + the content and the spacers adds to. A well asked for a margin of zero opens its rows where the + well itself opens, which is what a well nested inside a list takes: its rows are a run of the + list, and the list's own rhythm carries them. + """ + body_tag = compose_tag(tag, SUF_GROUP) + with dpg.child_window( + tag=tag, + parent=parent, + width=-1, + auto_resize_y=True, + border=False, + no_scrollbar=True, + show=show, + ): + if margin: + dpg.add_spacer(height=margin) + + dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=width) + if margin: + 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/panel.py b/src/sampletones_application/ui/elements/panel.py index 7a510d4e2..f1a54ad61 100644 --- a/src/sampletones_application/ui/elements/panel.py +++ b/src/sampletones_application/ui/elements/panel.py @@ -64,6 +64,10 @@ def create_panel(self, parent: str) -> None: ... def collapsed(self) -> bool: return self._collapse is not None and self._collapse.collapsed + def card_open(self) -> bool: + """Whether the card stands open, which a panel's key scope reads at the moment of a press.""" + return not self.collapsed + @property def _body_container(self) -> str: """The container new body content attaches to: the collapse body group when collapsible, else the card. diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index 0c394378d..d520c82c2 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -8,12 +8,13 @@ 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 from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value -from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.hover import HoverWatch 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 @@ -22,6 +23,7 @@ from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.paths import ( DEFAULT_MAX_FILENAME_DISPLAY, + nearest_directory, open_path_in_explorer, shorten_filename, shorten_path, @@ -66,6 +68,8 @@ 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._hover = HoverWatch(self._paint_hover) self._create_text() self._create_handler() @@ -87,7 +91,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: @@ -107,21 +111,22 @@ def _create_handler(self) -> None: with dpg.item_handler_registry(tag=self.handler_tag): dpg.add_item_clicked_handler(callback=self._on_clicked) - dpg.add_item_hover_handler(callback=self._on_hover) + dpg.add_item_hover_handler(callback=self._hover.report) dpg.bind_item_handler_registry(self.tag, self.handler_tag) - def _on_hover(self) -> None: - if dpg.does_item_exist(self.tag): - if dpg.is_item_hovered(self.tag): - self._status_bar.set(self._status_message) - dpg_set_palette_color(self.tag, self.hover_color) - FrameCallbackManager.set_frame_callback( - self._on_hover, - 2, - ) - else: - dpg_set_palette_color(self.tag, self.color) + def _paint_hover(self) -> bool: + if not dpg.does_item_exist(self.tag): + return False + + hovered = bool(dpg.is_item_hovered(self.tag)) + if hovered: + self._status_bar.set(self._status_message) + dpg_set_palette_color(self.tag, self.hover_color) + else: + dpg_set_palette_color(self.tag, self.color) + + return hovered def _on_clicked(self) -> None: if not self.path.exists(): @@ -144,9 +149,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 +167,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): @@ -186,8 +198,4 @@ def _nearest_standing(self) -> Optional[Path]: if self.path.exists(): return self.path - for directory in self.path.parents: - if directory.is_dir(): - return directory - - return None + return nearest_directory(self.path) 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..5a7f346aa 100644 --- a/src/sampletones_application/ui/elements/plus_minus_buttons.py +++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import Enum, auto from typing import Any, Optional @@ -18,6 +19,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_delete_item +from sampletones_application.utils.gui.press import LeftPress from sampletones_shared.constants.symbols import MINUS, PLUS from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback @@ -33,6 +35,14 @@ class PlusMinusOrder(Enum): PLUS_FIRST = auto() +@dataclass +class HeldPress: + """A press that went down on one of the pair's buttons, and the time left before it repeats.""" + + direction: int + remaining: float + + class GUIPlusMinusButtons(CallbackMixin): """A styled pair of ``[-]``/``[+]`` buttons laid out in two table columns. @@ -40,8 +50,11 @@ class GUIPlusMinusButtons(CallbackMixin): decrements and ``[+]`` always increments, while ``order`` chooses which column each 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. + per click. A hold belongs to the button the press went down on and repeats while the + pointer stays on it, so a press carried in from elsewhere steps nothing. A hold ends on + the release, and on the first mouse move with the button up, which ends one whose release + landed outside the window. Either button can be enabled or disabled independently, so a + control can gray out a step that would have no effect. """ def __init__( @@ -66,13 +79,14 @@ def __init__( self._hold_repeat = hold_repeat self._font = font - self._hold_direction: Optional[int] = None - self._hold_timer: Optional[float] = None + self._press: LeftPress[HeldPress] = LeftPress() self._table_tag = compose_tag(tag, SUF_TABLE) self._decrement_button_tag = compose_tag(tag, SUF_BUTTON_DECREMENT) self._increment_button_tag = compose_tag(tag, SUF_BUTTON_INCREMENT) self._mouse_handler_tag = compose_tag(tag, SUF_HANDLER_REGISTRY) + self._decrement_handler_tag = compose_tag(self._decrement_button_tag, SUF_HANDLER_REGISTRY) + self._increment_handler_tag = compose_tag(self._increment_button_tag, SUF_HANDLER_REGISTRY) self._decrement_button: Optional[GUIButton] = None self._increment_button: Optional[GUIButton] = None @@ -90,7 +104,12 @@ def delete(self) -> None: """Removes the buttons and, when hold-repeat is armed, the shared mouse handler.""" self._clear_existing_items() - def _build(self, *, increment_enabled: bool, decrement_enabled: bool) -> None: + def _build( + self, + *, + increment_enabled: bool, + decrement_enabled: bool, + ) -> None: self._clear_existing_items() increment_leads = self._order is PlusMinusOrder.PLUS_FIRST with dpg.table( @@ -158,13 +177,31 @@ def _add_button( def _clear_existing_items(self) -> None: """Removes the widget's own items from a prior build so it rebuilds cleanly under the same - tags. Deleting the table removes its buttons; the mouse handler registry lives outside the - table, so it is removed on its own.""" - for tag in (self._mouse_handler_tag, self._table_tag): + tags. Deleting the table removes its buttons; the handler registries live outside the + table, so each is removed on its own.""" + for tag in ( + self._mouse_handler_tag, + self._decrement_handler_tag, + self._increment_handler_tag, + self._table_tag, + ): if dpg.does_item_exist(tag): dpg_delete_item(tag) def _setup_button_hold_handlers(self) -> None: + if self._decrement_button is not None: + self._bind_press_handler( + self._decrement_button.button_tag, + self._decrement_handler_tag, + self._on_decrement_pressed, + ) + if self._increment_button is not None: + self._bind_press_handler( + self._increment_button.button_tag, + self._increment_handler_tag, + self._on_increment_pressed, + ) + with dpg.handler_registry(tag=self._mouse_handler_tag): dpg.add_mouse_down_handler( button=dpg.mvMouseButton_Left, @@ -174,6 +211,25 @@ def _setup_button_hold_handlers(self) -> None: button=dpg.mvMouseButton_Left, callback=self._on_mouse_release, ) + dpg.add_mouse_move_handler(callback=self._on_mouse_move) + + @staticmethod + def _bind_press_handler( + button_tag: str, + handler_tag: str, + on_pressed: VoidCallback, + ) -> None: + """Hears a press going down on one button, which is where a hold begins. + + The handler goes on the button itself, since the group wrapping it reports no clicks. + """ + with dpg.item_handler_registry(tag=handler_tag): + dpg.add_item_clicked_handler( + button=dpg.mvMouseButton_Left, + callback=on_pressed, + ) + + dpg.bind_item_handler_registry(button_tag, handler_tag) def _step(self, direction: int) -> None: if direction > 0: @@ -187,57 +243,94 @@ def _on_increment(self, *_arguments: Any) -> None: def _on_decrement(self, *_arguments: Any) -> None: self._step(-1) + def _on_decrement_pressed(self) -> None: + self._arm_hold(-1) + + def _on_increment_pressed(self) -> None: + self._arm_hold(1) + + def _arm_hold(self, direction: int) -> None: + """Starts a hold on the button a press went down on, with a longer delay before the first repeat.""" + self._press.begin( + HeldPress( + direction=direction, + remaining=HOLD_INITIAL_DELAY_FACTOR * self._layout.hold_delay, + ) + ) + + def _buttons_exist(self) -> bool: + return bool(dpg.does_item_exist(self._decrement_button_tag)) and bool( + dpg.does_item_exist(self._increment_button_tag) + ) + def _on_mouse_down( self, sender: Sender, _app_data: Any, _user_data: Any, ) -> None: - if not dpg.does_item_exist(self._decrement_button_tag) or not dpg.does_item_exist(self._increment_button_tag): + if not self._buttons_exist(): dpg_delete_item(sender) return - is_decrement = self._decrement_button is not None and bool(self._decrement_button.is_item_hovered()) - is_increment = self._increment_button is not None and bool(self._increment_button.is_item_hovered()) - direction = self._update_hold_timer( - is_decrement, - is_increment, - dpg.get_delta_time(), - ) + direction = self._advance_hold(self._held_button_hovered(), dpg.get_delta_time()) if direction is not None: self._step(direction) + def _on_mouse_move( + self, + sender: Sender, + _app_data: Any, + _user_data: Any, + ) -> None: + """Ends a hold once the button reads up, from a handler that runs whether or not it is down.""" + if not self._buttons_exist(): + dpg_delete_item(sender) + return + + self._press.settle() + + def _held_button_hovered(self) -> bool: + """Whether the pointer stands on the button the hold began on.""" + held = self._press.held() + if held is None: + return False + + button = self._increment_button if held.direction > 0 else self._decrement_button + return button is not None and bool(button.is_item_hovered()) + def _on_mouse_release( self, _sender: Sender, _app_data: Any, _user_data: Any, ) -> None: - self._hold_timer = None - self._hold_direction = None + self._press.end() - def _update_hold_timer( + def _advance_hold( self, - is_decrement: bool, - is_increment: bool, + hovered: bool, delta_time: float, ) -> Optional[int]: - """Drives click-and-hold repetition: the first frame of a press arms the timer with a longer - initial delay, and each later frame repeats once the delay elapses. Returns the step direction on - the frames that should advance the value, otherwise None. - """ - if not is_decrement and not is_increment: - return None + """Counts a hold down by one frame, answering its direction on each frame that repeats it. + + The hold counts while the pointer stays on the button the press went down on, and each + repeat starts the shorter delay to the next one. - direction = -1 if is_decrement else 1 - if self._hold_direction != direction or self._hold_timer is None: - self._hold_direction = direction - self._hold_timer = HOLD_INITIAL_DELAY_FACTOR * self._layout.hold_delay + Args: + hovered: Whether the pointer stands on the button the hold began on. + delta_time: The seconds the frame took. + + Returns: + Optional[int]: The step direction on a frame that repeats the press, otherwise ``None``. + """ + held = self._press.held() + if held is None or not hovered: return None - self._hold_timer -= delta_time - if self._hold_timer > 0: + held.remaining -= delta_time + if held.remaining > 0: return None - self._hold_timer = self._layout.hold_delay - return direction + held.remaining = self._layout.hold_delay + return held.direction diff --git a/src/sampletones_application/ui/elements/seeded.py b/src/sampletones_application/ui/elements/seeded.py new file mode 100644 index 000000000..18f66dd1b --- /dev/null +++ b/src/sampletones_application/ui/elements/seeded.py @@ -0,0 +1,75 @@ +from abc import ABC, abstractmethod +from typing import Any, Generic, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.primitives import DialogGeometry +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +ViewModel = TypeVar("ViewModel") + + +class GUISeededDialogWindow(GUIDialogWindow, ABC, Generic[ViewModel]): + """A dialog drawn from one view model, re-seeded for as long as it stands. + + What a settings form or a running job shows is settled before its tree is built, so seeding + and drawing are one act with a rebuild in between: :meth:`open` seeds the window and raises + it, :meth:`update_view` seeds it again and re-draws the controls in place, and the rebuild + itself has nothing left to capture. A window drawn before it was seeded reports that rather + than showing a form of empty fields. + """ + + def __init__( + self, + tag: str, + geometry: DialogGeometry, + *, + subject: str, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._subject = subject + self._view_model: Optional[ViewModel] = None + + super().__init__( + tag, + geometry, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open(self, view_model: ViewModel) -> None: + """Shows the window seeded with what it is to draw.""" + self._view_model = view_model + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The values drawn were seeded by :meth:`open` before the tree rebuilt.""" + + def update_view(self, view_model: ViewModel) -> None: + """Re-seeds the window from where its subject stands, re-drawing the controls it stands with. + + A window still waiting for the screen keeps the newest seed, which it is built from when it + opens. + """ + self._view_model = view_model + if dpg.does_item_exist(self.tag): + self._render() + + @property + def view_model(self) -> ViewModel: + """What the window draws. + + Raises: + SystemError: when the window is drawn before :meth:`open` seeds it. + """ + if self._view_model is None: + raise SystemError(f"The {self._subject} window is drawn only from a view model it was opened with") + + return self._view_model + + @abstractmethod + def _render(self) -> None: + """Draws the controls from the seeded view model.""" diff --git a/src/sampletones_application/ui/elements/stems/__init__.py b/src/sampletones_application/ui/elements/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py new file mode 100644 index 000000000..86812c6a2 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -0,0 +1,233 @@ +from typing import List, Sequence + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.tags.general import ( + SUF_STRIP, + SUF_TABLE, + SUF_TEXT, + TAG_GLOBAL_THEME_SECTION_HEADER, + TAG_GLOBAL_THEME_STEMS_DROP_STRIP, + TAG_GLOBAL_THEME_STEMS_GRID, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.folder import FolderRenderer +from sampletones_application.ui.elements.stems.gestures import StemsGestures +from sampletones_application.ui.elements.stems.heading import StemsHeading +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.row import StemRowRenderer +from sampletones_application.ui.elements.stems.shape import ListShape, Reshape +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) + + +class LevelBands: + """The rows of a stems list, grouped into the levels they pick on. + + A band is a caption, a table of the rows standing on that level, and — where the list takes + drops — the strip above it a recording lands in to take a level of its own. Collapsing the + levels draws every row in one table, which is the shape a list takes where the bands record a + setup rather than offer somewhere to drop onto. + + An open folder breaks the run of rows it stands in, so the recordings it opens onto stand + between the rows above it and the rows below. Every table declares the same columns, so the + rows line up down the list however many folders break it. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + offer: StemsListOffer, + language_manager: LanguageManager, + rows: StemRowRenderer, + folders: FolderRenderer, + heading: StemsHeading, + open_folders: OpenFolders, + gestures: StemsGestures, + ) -> None: + self._tags = tags + self._layout = layout + self._offer = offer + self._rows = rows + self._folders = folders + self._heading = heading + self._open_folders = open_folders + self._gestures = gestures + self._level_template = language_manager["global.stems.template.level_caption"] + self._shape = ListShape.nothing() + + def reshape(self, view_model: StemsListViewModel) -> Reshape: + """What the view asks of the widgets standing: the whole list, some folders, or nothing. + + The shape is taken up as it is read, so a list that has answered a reshape once answers + the same view with a repaint from then on. + """ + shape = ListShape.of(view_model, self._open_folders) + asked = shape.against(self._shape) + self._shape = shape + return asked + + def forget(self) -> None: + """Let go of the shape standing, so the next reading is drawn rather than repainted.""" + self._shape = ListShape.nothing() + + def build_heading(self, view_model: StemsListViewModel, parent: str) -> None: + """Name the channels once above the rows, so a cell below them holds the box alone. + + The heading stands above whatever the list draws, banded or plain, and every table below + it is declared from the same grid. A list drawing no channel columns has nothing to name, + so the rows stand on their own. + """ + columns = self.columns(view_model) + self._folders.reads(columns) + if not columns.channels: + return + + self._heading.create(parent, columns) + self._heading.render(view_model.muted_channels) + + def repaint_heading(self, view_model: StemsListViewModel) -> None: + """Tone the channel names to the reading standing, which a tick moves without a redraw. + + Which channels are switched off elsewhere is drawn onto the widgets already standing, so + the names follow the boxes below them between one draw and the next. + """ + if not view_model.channels_in_play: + return + + self._heading.render(view_model.muted_channels) + + def build_rows(self, view_model: StemsListViewModel, start: int, count: int) -> None: + """One table of the rows a window reaches, in the grid the whole list stands in.""" + self._create_table(self._tags.segment(0), view_model, view_model.rows[start : start + count]) + + def build(self, view_model: StemsListViewModel) -> None: + """Build the bands the view names, into whatever the list has cleared for them.""" + if view_model.collapse_levels: + self._create_listing(view_model) + return + + for level_index in range(view_model.level_count): + self._create_strip(level_index) + self._create_caption(level_index) + self._create_table( + self._tags.level(level_index, SUF_TABLE), + view_model, + view_model.rows_on(level_index), + ) + + if view_model.level_count: + self._create_strip(view_model.level_count) + + def _create_listing(self, view_model: StemsListViewModel) -> None: + """Every row in one run, an open folder breaking it so its recordings stand below it. + + A folder's own row is a row like any other and stands in the table of the rows around it, + so the list keeps one rhythm down its whole length. What breaks the run is the region an + open folder opens onto, which is drawn between the row it belongs to and the next one. + """ + standing: List[StemRowViewModel] = [] + segment = 0 + for row in view_model.rows: + standing.append(row) + if row.stands_for_a_folder and self._open_folders.stands_open(row.key): + segment = self._flush(standing, view_model, segment) + self._folders.open(row, view_model) + + self._flush(standing, view_model, segment) + + def _flush(self, standing: List[StemRowViewModel], view_model: StemsListViewModel, segment: int) -> int: + """Draw the run of rows gathered since the last open folder, and open the next run empty.""" + if not standing: + return segment + + self._create_table(self._tags.segment(segment), view_model, tuple(standing)) + standing.clear() + return segment + 1 + + def _create_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._offer.dragging: + return + + strip = dpg.add_button( + label="", + tag=self._tags.level(position, SUF_STRIP), + parent=self._tags.body, + height=self._layout.level_strip_height, + user_data=position, + payload_type=self._tags.payload, + drop_callback=self._gestures.on_level_drop, + ) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_DROP_STRIP).bind_to_item(strip) + + def _create_caption(self, level_index: int) -> None: + """The band's own name, in the accent a section header takes, so a level reads as one.""" + caption = dpg.add_text( + self._level_template.format(level_index + 1).upper(), + tag=self._tags.level(level_index, SUF_TEXT), + parent=self._tags.body, + ) + FontRegistry.bind_to_item(caption, Font.BOLD) + ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(caption) + + def _create_table( + self, + tag: str, + view_model: StemsListViewModel, + rows: Sequence[StemRowViewModel], + ) -> None: + """One grid of rows, every band declaring the same columns so they line up across bands. + + The grid carries a row background so a group's own row can take a band of its own, and + states that background as clear, so every other row reads on the well behind it. Rules run + between the rows as well as between the columns, and around the grid's own edges, so the + run of rows an open folder breaks still reads as one ruled list down its whole length. + """ + columns = self.columns(view_model) + with dpg.table( + tag=tag, + parent=self._tags.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + borders_innerH=True, + borders_outerH=True, + row_background=True, + ) as grid: + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_GRID).bind_to_item(grid) + columns.declare() + for row in rows: + self._rows.create(row, view_model, columns) + + def columns(self, view_model: StemsListViewModel) -> StemsColumns: + """The grid every table of this list stands in, the heading above them included. + + A list holding a folder says so to the grid, which is what stands the columns around a + folder where the columns inside its own region stand and opens a loose row's name where a + folder's marker opens. + """ + return StemsColumns( + layout=self._layout, + channels=view_model.channels_in_play, + master=self._offer.master_box, + removable=self._offer.removal, + swatch=self._offer.swatch, + folders=view_model.holds_folders, + ) diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py new file mode 100644 index 000000000..4888238d1 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -0,0 +1,170 @@ +from dataclasses import dataclass +from typing import Final, Self, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_core.constants.enums import ChannelName + +NO_RESERVE: Final[int] = 0 +NO_INDENT: Final[int] = 0 +COLUMN_BORDER: Final[int] = 1 + + +@dataclass(frozen=True) +class StemsColumns: + """The columns every table of a stems grid stands in. + + A list draws one table per band, one for each run of rows a folder breaks, one inside each open + folder, and one for the heading above them all; the settings card draws one row of the same + grid. The reader meets them as a single grid because each declares these columns in the same + order at the same widths, which is why this declaration stands in one place and every table + asks it. + + The name column is the one that stretches, so a wider card spends its room on the recordings + rather than on the boxes beside them. A channel column holds one box. + + ``swatch`` states that a row leads with the color its recording is drawn in, which takes a + narrow column of its own between the box beside a row and the name it carries. + + ``folders`` states that the list this grid belongs to holds folders, which settles both ends + of the row. At the right, a folder draws its recordings inside a region of their own, and the + room that region spends there is held clear out here so the columns around a folder stand where + the columns inside it stand. At the left, a folder's own row leads with the marker that opens + it, and a row carrying none opens where that marker's glyph does. + """ + + layout: StemsListLayout + channels: Tuple[ChannelName, ...] + master: bool + removable: bool + swatch: bool + folders: bool + + @classmethod + def empty(cls, layout: StemsListLayout) -> Self: + """The grid before a list has drawn anything: no channel, and nothing beside the name.""" + return cls( + layout=layout, + channels=(), + master=False, + removable=False, + swatch=False, + folders=False, + ) + + @property + def channel_width(self) -> int: + """The room one channel's column takes, which the box standing in it decides.""" + return self.layout.channel_column_width + + @property + def reserve(self) -> int: + """The room held clear at the right of every table outside a folder, which is the region's. + + A list holding no folder opens no region, so its tables run to the edge. + """ + if not self.folders: + return NO_RESERVE + + return self.layout.folder_reserve + + @property + def reserve_width(self) -> int: + """The width the reserve column is declared at, so the room it holds is the region's. + + A column takes its own width plus the padding on either side of its cell and the rule drawn + beside it, so those come off the room the strip is meant to hold clear. + """ + return self.reserve - 2 * self.layout.cell_padding - COLUMN_BORDER + + @property + def reserved(self) -> bool: + """Whether the strip stands at the right of a row, which is the room a region spends. + + A grid holds the strip clear where there is room left to declare a column at, so a list + of folders ends every table on it and every other grid ends on the column before it. + """ + return self.reserve_width > 0 + + def declare(self) -> None: + """Add this grid's columns to the table currently being built.""" + if self.master: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.layout.master_column_width) + + if self.swatch: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.layout.swatch_size) + + dpg.add_table_column(width_stretch=True) + for _channel_name in self.channels: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.channel_width) + + if self.removable: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.layout.remove_button_width) + + if self.reserved: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.reserve_width) + + @property + def master_indent(self) -> int: + """How far the box beside a row sits in, so it stands in the middle of its own column.""" + return self._centered(self.layout.channel_box_width, within=self.layout.master_column_width) + + @property + def box_indent(self) -> int: + """How far a channel's box sits in, so it stands in the middle of its own column.""" + return self._centered(self.layout.channel_box_width, within=self.channel_width) + + def marker_indent(self, glyph: str, font: Font) -> int: + """How far a row carrying no marker sits in, so its name opens where a marker's glyph does. + + A folder's row leads with a button as wide as the marker column, and the glyph inside it + stands in the middle of that button. A recording standing loose in the same list opens at + that glyph, so the names read as one column while a row with no marker keeps the room a + marker would have spent. + """ + if not self.folders: + return NO_INDENT + + measured = dpg.get_text_size(glyph, font=FontRegistry.get_tag(font)) + if measured is None: + return NO_INDENT + + return self._centered(int(measured[0]), within=self.layout.twisty_width) + + def name_indent(self, label: str, font: Font) -> int: + """How far a channel's name sits in, so it stands over the middle of its own column. + + The name is measured in the face it is drawn in, so a column reads as one thing however + long the channel is called. + """ + measured = dpg.get_text_size(label, font=FontRegistry.get_tag(font)) + if measured is None: + return NO_INDENT + + return self._centered(int(measured[0]), within=self.channel_width) + + def open_leading_cells(self) -> None: + """Open the cells standing before the channels, which a heading leaves blank.""" + if self.master: + dpg.add_spacer() + + if self.swatch: + dpg.add_spacer() + + dpg.add_spacer() + + def open_trailing_cell(self) -> None: + """Open the cell standing after the channels, which a heading leaves blank.""" + if self.removable: + dpg.add_spacer() + + def _centered(self, span: int, *, within: int) -> int: + """The indent standing something of this width in the middle of a column of that width. + + A cell opens after its own padding and the column's width is what is left for what stands + in it, so the padding is already spent by the time the indent is measured from. + """ + return max(NO_INDENT, (within - span) // 2) diff --git a/src/sampletones_application/ui/elements/stems/expansion.py b/src/sampletones_application/ui/elements/stems/expansion.py new file mode 100644 index 000000000..9156949f2 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/expansion.py @@ -0,0 +1,38 @@ +from typing import AbstractSet, Set + + +class OpenFolders: + """The folders a reader has opened, held by the key their row is drawn under. + + Whether a folder stands open is what the reader last did to it rather than anything the setup + records, so the list keeps it for as long as it is on screen and the model stays clear of it. + A folder gathered afresh arrives closed, which is what a reader meets when a folder of + thousands joins the list. + """ + + def __init__(self) -> None: + self._open: Set[str] = set() + + def __bool__(self) -> bool: + return bool(self._open) + + @property + def keys(self) -> Set[str]: + """The folders standing open, which is what a draw builds a region for.""" + return set(self._open) + + def stands_open(self, key: str) -> bool: + return key in self._open + + def toggle(self, key: str) -> bool: + """Opens a closed folder and closes an open one, answering how it now stands.""" + if key in self._open: + self._open.discard(key) + return False + + self._open.add(key) + return True + + def hold_to(self, keys: AbstractSet[str]) -> None: + """Holds the memory to the folders the list still draws, a folder beyond them having left.""" + self._open &= set(keys) diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py new file mode 100644 index 000000000..2c8657887 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -0,0 +1,161 @@ +from dataclasses import replace +from functools import partial +from typing import Dict, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import NO_MARGIN, NO_SCROLL, WindowedRegion +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.row import StemRowRenderer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) + + +class FolderRenderer: + """One gathered folder: the row standing for it, and the recordings it opens onto. + + A folder arrives closed, reading as its name and how many recordings it brought in — one row + among the rows around it, standing in their table and taking the height they take. Opening it + sinks a region below that row, in which the recordings are drawn the way any other row is, so + a reader answers for one of them without leaving the list. The region holds a folder's worth + of rows and scrolls past that, building only the rows it shows — which is what makes opening a + folder of thousands cost what opening a folder of ten costs. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + geometry: RowGeometry, + open_folders: OpenFolders, + rows: StemRowRenderer, + ) -> None: + self._tags = tags + self._layout = layout + self._geometry = geometry + self._open_folders = open_folders + self._rows = rows + self._regions: Dict[str, WindowedRegion] = {} + self._resting: Dict[str, float] = {} + self._columns = StemsColumns.empty(layout) + + def reads(self, columns: StemsColumns) -> None: + """Takes up the grid the list is drawing, which a folder's own tables stand in too.""" + self._columns = columns + + def forget(self) -> None: + """Take up where each open folder stood, and let go of the regions a rebuild took down. + + A region goes down with the list around it and comes back a new widget at its top, so the + position it was scrolled to is held here and handed to the region that replaces it. + """ + self._resting = {key: region.offset for key, region in self._regions.items()} + self._regions.clear() + + @property + def following(self) -> bool: + """An open folder stands as something other than it will, so the list settles it again.""" + return any(region.settling for region in self._regions.values()) + + def settle(self) -> Tuple[str, ...]: + """Hold every open region to its ceiling and read what a row takes, a frame after a draw. + + Answers the folders whose rows have moved out from under what stands drawn, which is what + a list redraws to follow a scroll. + """ + return tuple(key for key, region in self._regions.items() if region.settle()) + + def redraw(self, key: str, view_model: StemsListViewModel) -> None: + """Build the slice one folder's region now reaches, leaving the rest of the list alone.""" + row = view_model.row(key) + region = self._regions.get(key) + if row is None or region is None: + return + + self._fill(region, row, view_model) + + def repaint(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Draw what the recordings in view currently hold onto the widgets they stand as. + + A recording inside a folder leaves the same way a loose one does, so it answers the same + rule about whether the list is holding on to what it has. + """ + region = self._regions.get(row.key) + if region is None: + return + + for held in self._reached(region, row): + self._rows.repaint(held, view_model) + + def open(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Sink the folder's region below its row and fill it with the rows it reaches. + + The folder's own row stands in the run of rows around it, so what is drawn here is the + space its recordings scroll in, which an open folder is what puts on screen. The region + holds its recordings flush to its own edges, so they carry on from the row above them and + the seam stays as narrow as the list's own rules. + """ + region = WindowedRegion( + tag=self._tags.region(row.key), + geometry=self._geometry, + ceiling=self._layout.folder_ceiling, + padding=self._layout.well_padding, + margin=NO_MARGIN, + gutter=self._layout.scrollbar_width, + indent=self._layout.well_padding + self._layout.folder_indent, + ) + region.create(self._tags.body) + region.opens_at(self._resting.get(row.key, NO_SCROLL)) + self._regions[row.key] = region + self._fill(region, row, view_model) + + def _fill( + self, + region: WindowedRegion, + row: StemRowViewModel, + view_model: StemsListViewModel, + ) -> None: + region.draw(row.holds, partial(self._create_rows, region, row, view_model), lead=None) + + def _create_rows( + self, + region: WindowedRegion, + row: StemRowViewModel, + view_model: StemsListViewModel, + start: int, + count: int, + ) -> None: + """One table of the recordings a region reaches, declaring the columns the list lines up on. + + The room the region spends at its own right is the room the grid outside it holds clear, so + the recordings inside a folder stand in the columns their neighbors stand in and none of + them leads with a marker. Rules run between these rows the way they run outside, and the + grids on either side of the region rule the two seams, so a folder's recordings read as + the same list as the rows above and below them however far the region is scrolled. + """ + held_columns = replace(self._columns, folders=False) + with dpg.table( + tag=self._tags.held(row.key), + parent=region.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + borders_innerH=True, + ): + held_columns.declare() + for held in row.held[start : start + count]: + self._rows.create(held, view_model, held_columns) + + @staticmethod + def _reached(region: WindowedRegion, row: StemRowViewModel) -> Tuple[StemRowViewModel, ...]: + """The recordings the region has widgets for, which are the ones a repaint reaches.""" + start, count = region.window + return row.held[start : start + count] diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py new file mode 100644 index 000000000..0b84ddaa0 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -0,0 +1,261 @@ +from typing import Any, Callable, Final, FrozenSet, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_TEXT, + SUF_TWISTY, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.host import StemsListHost +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.utils.gui.dpg import ( + dpg_delete_item, + dpg_get_item_user_data, + dpg_set_value, +) +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import MessageCallback, StringCallback + +ROW_WIDGET_KINDS: Final[Tuple[str, ...]] = (SUF_TEXT, SUF_CHANNELS, SUF_CHECKBOX, SUF_BUTTON, SUF_TWISTY) + +ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] +ChannelCallback = Callable[[str, ChannelName], None] +KeyOffsetCallback = Callable[[str, int], None] +KeyPairCallback = Callable[[str, str], None] + + +class StemsGestures: + """What a reader does to a stems list, read from DearPyGui's events and reported as keys. + + 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 read the row they landed on rather than needing a + handler apiece. Every event this class answers arrives as a widget and a payload; what + leaves it is the row a gesture named and what the reader asked of it. + + Three of those gestures reach past the row to whoever owns the list — picking a row out, + sounding it, and putting its menu up — so the list is asked whether it has an owner for each, + and a gesture with none rests here. A clicked row's widget is written from the view either + way, so it reads as the last reading of the list left it. + """ + + def __init__( + self, + tags: StemsTags, + *, + messages: StemsMessages, + status_bar: GUIStatusBar, + host: StemsListHost, + ) -> None: + self._tags = tags + self._messages = messages + self._status_bar = status_bar + self._host = host + self._view = StemsListViewModel.empty() + + self.on_channels_settled: Optional[ChannelsCallback] = None + self.on_channel_toggled: Optional[ChannelCallback] = None + self.on_removal_asked: Optional[StringCallback] = None + self.on_menu_asked: Optional[StringCallback] = None + self.on_row_activated: Optional[StringCallback] = None + self.on_row_revealed: Optional[StringCallback] = None + self.on_dropped_on_row: Optional[KeyPairCallback] = None + self.on_dropped_on_level: Optional[KeyOffsetCallback] = None + self.on_folder_toggled: Optional[StringCallback] = None + self.on_row_opened: Optional[StringCallback] = None + self.on_row_picked: Optional[StringCallback] = None + + def reads(self, view_model: StemsListViewModel) -> None: + """Takes up the view the list is drawing, which is what a gesture is answered against.""" + self._view = view_model + + def create_handlers(self) -> None: + """Register one handler registry per row-widget kind.""" + for kind in ROW_WIDGET_KINDS: + dpg_delete_item(self._tags.handlers(kind)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_TEXT)): + dpg.add_item_clicked_handler(callback=self._on_name_clicked) + dpg.add_item_double_clicked_handler(callback=self._on_name_double_clicked) + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.name)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_CHANNELS)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.channel)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_CHECKBOX)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.master)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_BUTTON)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.remove)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_TWISTY)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.twisty)) + + def bind(self, item: str, kind: str) -> None: + """Puts one row widget under the registry answering for its kind.""" + dpg.bind_item_handler_registry(item, self._tags.handlers(kind)) + + def on_channel_box( + self, + _sender: Sender, + _value: bool, + user_data: Tuple[str, ChannelName], + ) -> None: + """A box settles one channel on the row it belongs to. + + A recording answers with every box it now holds ticked, which is the whole of what it + stands on. A folder's box reads three ways, so it reports the channel alone and its owner + settles every recording below it — one gesture always moving the group somewhere. + """ + key, channel_name = user_data + row = self._view.row(key) + if row is None: + return + + if row.stands_for_a_folder: + self._report(self.on_channel_toggled, key, channel_name) + return + + channels = frozenset( + offered + for offered in self._view.boxes_of(row) + if self._ticked(key, offered, standing=offered in row.channels) + ) + self._report(self.on_channels_settled, key, channels) + + def _ticked(self, key: str, channel_name: ChannelName, *, standing: bool) -> bool: + """Whether a channel's box stands ticked, which is what the row reports it holds. + + A box the list has taken away answers with the reading it last stood for, so a row settles + on the channels it holds rather than on the ones whose boxes happen to be drawn. + """ + tag = self._tags.channel(key, channel_name) + return bool(dpg.get_value(tag)) if dpg.does_item_exist(tag) else standing + + def on_master_box(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._view.row(user_data) + if row is None: + return + + channels = frozenset(self._view.boxes_of(row)) if value else frozenset() + self._report(self.on_channels_settled, user_data, channels) + + def on_pick_box(self, _sender: Sender, _value: bool, user_data: str) -> None: + """The box beside a row picks the recordings it stands for, or lets them go.""" + self._report(self.on_row_picked, user_data) + + def on_remove_button(self, _sender: Sender, _app_data: Any, user_data: str) -> None: + self._report(self.on_removal_asked, user_data) + + def on_twisty(self, _sender: Sender, _app_data: Any, user_data: str) -> None: + """The marker beside a folder's name puts its recordings in view, or away again.""" + self._report(self.on_folder_toggled, user_data) + + def on_name_selected(self, _sender: Sender, _value: bool, user_data: str) -> None: + """Pick the row a click landed on, whichever way the widget swung. + + DearPyGui reports a selectable once per click, so a double-click that sounds a recording + picks its row the way a single click does. + """ + self._pick(user_data) + + def on_row_drop(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._report(self.on_dropped_on_row, app_data, target) + + def on_level_drop(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._report(self.on_dropped_on_level, app_data, position) + + def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: + """A right-click picks the row its menu stands over, where the owner puts one up. + + The menu prints the key that takes a row out, so the row the menu stands over is the row + that key reaches: picking it here is what holds the item and the key to one action. + """ + if not self._host.has_menu: + return + + key = self._named_by(app_data, dpg.mvMouseButton_Right) + if key is not None: + self._pick(key) + self._report(self.on_menu_asked, key) + + def _pick(self, key: str) -> None: + """Stand the row as the view holds it, and hand the pick on where an owner takes one. + + The widget is written from the view every time, so a row reads as the last reading left it + and a list recording no selection stands its rows plain. Where an owner answers, the + reading it settles arrives in the same frame and the row follows that instead. + """ + dpg_set_value(self._tags.row(key, SUF_TEXT), key == self._view.selected_key) + if self._host.activatable: + self._report(self.on_row_activated, key) + + def _on_name_double_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: + """A double-click opens what it landed on. + + A folder shows what it holds; a recording sounds where the owner plays one and shows where + it sits on disk where the owner reveals one, which is the deliberate gesture those two + deserve. + """ + key = self._named_by(app_data, dpg.mvMouseButton_Left) + if key is None: + return + + row = self._view.row(key) + if row is not None and row.stands_for_a_folder: + self._report(self.on_folder_toggled, key) + return + + if self._host.playable: + self._report(self.on_row_opened, key) + + if self._host.revealable: + self._report(self.on_row_revealed, key) + + @staticmethod + def _named_by(app_data: Tuple[int, int], button: int) -> Optional[str]: + """The row a mouse gesture landed on, for the button the gesture speaks for. + + The gesture is answered a frame after DearPyGui gathered it, by which time a rebuilt list + may have taken the widget away, so the answer is for the widgets still standing. + """ + mouse_button, clicked_item = app_data + if mouse_button != button: + return None + + key = dpg_get_item_user_data(clicked_item) + return key if isinstance(key, str) else None + + 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: + user_data = dpg_get_item_user_data(app_data) + if user_data is not None: + self._status_bar.set(message_function, user_data=user_data) + + return hover_callback + + def _report(self, callback: Optional[Callable[..., None]], *args: Any) -> None: + """Hands a gesture on where the list has an owner for it, and drops it where it has none.""" + if callback is not None: + callback(*args) diff --git a/src/sampletones_application/ui/elements/stems/heading.py b/src/sampletones_application/ui/elements/stems/heading.py new file mode 100644 index 000000000..dc1bc347b --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/heading.py @@ -0,0 +1,88 @@ +from typing import FrozenSet + +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_HEADING, + SUF_TABLE, + SUF_TEXT, + TAG_GLOBAL_THEME_CHANNEL_MUTED, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_core.constants.enums import ChannelName + + +class StemsHeading: + """The channels a stems grid stands under, named once above the rows. + + Naming a channel here leaves each cell below free for the box it holds, and the cell reads as + one channel because the name spans it. + """ + + def __init__( + self, + *, + prefix: str, + layout: StemsListLayout, + language_manager: LanguageManager, + ) -> None: + self._prefix = prefix + self._language_manager = language_manager + self._columns = StemsColumns.empty(layout) + + @property + def table(self) -> str: + """The table the channel names stand in, which is what lines them up with the rows.""" + return compose_tag(self._prefix, SUF_HEADING, SUF_TABLE) + + def name(self, channel_name: ChannelName) -> str: + """The tag one channel's name carries.""" + return compose_tag(self._prefix, SUF_HEADING, channel_name, SUF_TEXT) + + def create(self, parent: str, columns: StemsColumns) -> None: + """Draw the heading above the rows, in the columns those rows stand in. + + The grid below rules its own top edge, which is the line dividing the names from the rows + they stand over. + """ + self._columns = columns + with dpg.table( + tag=self.table, + parent=parent, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + ): + columns.declare() + self._create_names() + + def render(self, muted_channels: FrozenSet[ChannelName]) -> None: + """Tone each channel's name the way its boxes are toned, so a column reads as one.""" + for channel_name in self._columns.channels: + theme = ( + TAG_GLOBAL_THEME_CHANNEL_MUTED if channel_name in muted_channels else CHANNEL_THEME_TAGS[channel_name] + ) + ThemeRegistry.get(theme).bind_to_item(self.name(channel_name)) + + def _create_names(self) -> None: + with dpg.table_row(): + self._columns.open_leading_cells() + for channel_name in self._columns.channels: + label = channel_label(self._language_manager, channel_name) + name = dpg.add_text( + label, + tag=self.name(channel_name), + indent=self._columns.name_indent(label, Font.BOLD_SMALL), + ) + FontRegistry.bind_to_item(name, Font.BOLD_SMALL) + + self._columns.open_trailing_cell() diff --git a/src/sampletones_application/ui/elements/stems/host.py b/src/sampletones_application/ui/elements/stems/host.py new file mode 100644 index 000000000..d01db3110 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/host.py @@ -0,0 +1,23 @@ +from typing import Protocol + + +class StemsListHost(Protocol): + """Which gestures a stems list reaches an owner with, as the list itself answers them. + + Picking a row out, sounding it, showing where it sits on disk and putting its menu up each + reach past the row to whoever owns the list, and each is offered while an owner answers it. + The list holds those hooks and an owner sets them when it likes, so the answer is read at the + moment a gesture lands. + """ + + @property + def activatable(self) -> bool: ... + + @property + def playable(self) -> bool: ... + + @property + def revealable(self) -> bool: ... + + @property + def has_menu(self) -> bool: ... 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..fce0ddead --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -0,0 +1,381 @@ +from functools import partial +from typing import Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import NO_GUTTER, WindowedRegion +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.bands import LevelBands +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.folder import FolderRenderer +from sampletones_application.ui.elements.stems.gestures import ( + ChannelCallback, + ChannelsCallback, + KeyOffsetCallback, + KeyPairCallback, + StemsGestures, +) +from sampletones_application.ui.elements.stems.heading import StemsHeading +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.row import StemRowRenderer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_shared.types.callback import StringCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +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 composition draws them and each owner states what it offers a reader + (:class:`StemsListOffer`). Rows are keyed by the identity their owner reports gestures under, + and every column lines up across the bands because each table declares the same columns. + + The list holds the view it was last given and nothing beside it: the rows, the columns and + what a gesture may reach are all read from that one value. + + ``ceiling`` is the room its owner gives it: the list stands as tall as what it holds up to + that, and scrolls from there on. An owner drawing the list inside a space of its own states + the room that space has, so the list is the one thing in it that scrolls. + """ + + def __init__( + self, + *, + prefix: str, + layout: StemsListLayout, + ceiling: int, + glyphs: CommonGlyphs, + stem_colors: StemColors, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + offer: StemsListOffer, + ) -> None: + self._tags = StemsTags(prefix=prefix) + self._layout = layout + self._offer = offer + self._view = StemsListViewModel.empty() + self._open_folders = OpenFolders() + self._geometry = RowGeometry.opening_at( + overscan=layout.window_overscan, + opening=float(layout.name_height), + ) + self._region = WindowedRegion( + tag=self._tags.well, + geometry=self._geometry, + ceiling=ceiling, + padding=layout.well_padding, + margin=layout.well_margin, + gutter=NO_GUTTER, + ) + + self._messages = StemsMessages( + language_manager, + offer=offer, + open_folders=self._open_folders, + host=self, + ) + self._gestures = StemsGestures( + self._tags, + messages=self._messages, + status_bar=status_bar, + host=self, + ) + self._rows = StemRowRenderer( + self._tags, + layout=layout, + offer=offer, + glyphs=glyphs, + stem_colors=stem_colors, + open_folders=self._open_folders, + language_manager=language_manager, + messages=self._messages, + gestures=self._gestures, + ) + self._folders = FolderRenderer( + self._tags, + layout=layout, + geometry=self._geometry, + open_folders=self._open_folders, + rows=self._rows, + ) + self._heading = StemsHeading( + prefix=prefix, + layout=layout, + language_manager=language_manager, + ) + self._bands = LevelBands( + self._tags, + layout=layout, + offer=offer, + language_manager=language_manager, + rows=self._rows, + folders=self._folders, + heading=self._heading, + open_folders=self._open_folders, + gestures=self._gestures, + ) + + self.on_channels_changed: Optional[ChannelsCallback] = None + self.on_channel_toggled: Optional[ChannelCallback] = 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 + self.on_row_opened: Optional[StringCallback] = None + self.on_row_revealed: Optional[StringCallback] = None + self.on_row_picked: Optional[StringCallback] = None + self.on_selection_cleared: Optional[VoidCallback] = None + + self._gestures.on_channels_settled = lambda key, channels: self.call(self.on_channels_changed, key, channels) + self._gestures.on_channel_toggled = lambda key, channel: self.call(self.on_channel_toggled, key, channel) + self._gestures.on_removal_asked = lambda key: self.call(self.on_remove_requested, key) + self._gestures.on_menu_asked = lambda key: self.call(self.on_menu_requested, key) + self._gestures.on_row_activated = lambda key: self.call(self.on_row_activated, key) + self._gestures.on_dropped_on_row = lambda key, target: self.call(self.on_dropped_on_row, key, target) + self._gestures.on_dropped_on_level = lambda key, position: self.call(self.on_dropped_on_level, key, position) + self._gestures.on_row_opened = lambda key: self.call(self.on_row_opened, key) + self._gestures.on_row_revealed = lambda key: self.call(self.on_row_revealed, key) + self._gestures.on_row_picked = lambda key: self.call(self.on_row_picked, key) + self._gestures.on_folder_toggled = self.toggle_folder + + @property + def tags(self) -> StemsTags: + """The grammar the list's widgets are named under.""" + return self._tags + + @property + def tag(self) -> str: + """The recessed region the list is drawn in, which is what an owner shows and hides.""" + return self._tags.well + + @property + def activatable(self) -> bool: + """The owner answers a click on a row, so the list hands the row it picked on.""" + return self.on_row_activated is not None + + @property + def playable(self) -> bool: + """The owner sounds a recording, so a double-click on a row reaches something.""" + return self.on_row_opened is not None + + @property + def revealable(self) -> bool: + """The owner shows a recording on disk, so a double-click on a row reaches something.""" + return self.on_row_revealed is not None + + @property + def has_menu(self) -> bool: + """The owner puts a menu up over a row, so a right-click on one reaches something.""" + return self.on_menu_requested is not None + + def create(self, parent: str, *, show: bool = True) -> None: + """Build the list's recessed region, the handlers its rows share, and the watch over its body. + + A list built again stands on none of the widgets it drew before — a window raised a second + time takes its whole tree down between one opening and the next — so what it remembers + having drawn is let go of here, and the next reading it takes is a draw rather than a + repaint of widgets that are gone. + """ + self._bands.forget() + self._folders.forget() + self._gestures.create_handlers() + self._region.create(parent, show=show) + self._create_watch() + + def _create_watch(self) -> None: + """Watch the body for the frames it is drawn in, which are the frames the list settles on. + + DearPyGui reports an item visible on every frame it is drawn in and on no other, so the + watch is the settle's clock: it stands on while a region has something to settle, runs the + pass on each frame the list is on screen, and waits through the frames it is put away. + """ + dpg_delete_item(self._tags.body_handlers) + with dpg.item_handler_registry(tag=self._tags.body_handlers): + dpg.add_item_visible_handler(tag=self._tags.drawn, callback=self._settle, show=False) + + dpg.bind_item_handler_registry(self._region.body, self._tags.body_handlers) + + def update_view(self, view_model: StemsListViewModel) -> None: + """Take up a new reading of the setup: draw what it reshapes, repaint the rows either way. + + A reading that moved the recordings of one folder alone is answered inside that folder, so + the rows around it keep the widgets they stand as and the reader keeps the scroll they left. + """ + self._view = view_model + self._open_folders.hold_to({row.key for row in view_model.rows}) + self._messages.reads(view_model) + self._gestures.reads(view_model) + asked = self._bands.reshape(view_model) + if asked.whole: + self._rebuild(view_model) + else: + for key in asked.folders: + self._folders.redraw(key, view_model) + + if asked.redraws: + self._settle_soon() + + self._repaint(view_model) + + def _rebuild(self, view_model: StemsListViewModel) -> None: + """Draw the list afresh: the bands the view names, and a region under each open folder.""" + self._folders.forget() + if self._windows(view_model): + self._draw_window(view_model) + return + + self._region.draw_whole( + partial(self._bands.build, view_model), + lead=partial(self._bands.build_heading, view_model), + ) + + def _draw_window(self, view_model: StemsListViewModel) -> None: + """Draw the rows the well reaches, reserving the room the rest of them would take.""" + self._region.draw( + view_model.row_count, + partial(self._bands.build_rows, view_model), + lead=partial(self._bands.build_heading, view_model), + ) + + @staticmethod + def _windows(view_model: StemsListViewModel) -> bool: + """Whether the well draws a window over its rows rather than the whole run of them. + + A window slides over rows of one height, which is what a list of recordings alone is: a + list banded by levels stands captions and strips among its rows, and a folder stands a + region of its own under one. A folder answers for its own length inside that region, so + the well holds the rows around it entire. + """ + return view_model.collapse_levels and not view_model.holds_folders + + def _repaint(self, view_model: StemsListViewModel) -> None: + """Draw what the rows in view currently hold onto the widgets they stand as.""" + self._bands.repaint_heading(view_model) + for row in self._reached(view_model): + self._rows.repaint(row, view_model) + self._folders.repaint(row, view_model) + + def _reached(self, view_model: StemsListViewModel) -> Tuple[StemRowViewModel, ...]: + """The rows the well has widgets for, which are the ones a repaint reaches.""" + if not self._region.windowing: + return view_model.rows + + start, count = self._region.window + return view_model.rows[start : start + count] + + def row(self, key: str) -> Optional[StemRowViewModel]: + """The row a gesture named, as the list last rendered it.""" + return self._view.row(key) + + @property + def picked_key(self) -> Optional[str]: + """The row standing picked out, which is what a key press acts on. + + The reading is what holds the pick, so a key reaches the same row the settings card names + however the list is drawn: a long list scrolled past it builds its rows in slices, and a + card put away keeps them where they stand. + """ + return self._view.selected_key + + @property + def rearranges(self) -> bool: + """Whether the rows can be reordered, which the levels a row moves between are what decide. + + A gesture reaching the order from outside the row's own drag — a menu item — asks this, so + the moves a reader is offered are the moves the list is drawing the bands for, while the + list is live. + """ + return self._view.live and self._offer.drags(self._view) + + @property + def lets_a_row_go(self) -> bool: + """Whether a row may be taken out: the list is live, and it holds more than it keeps. + + A gesture reaching removal from outside the row's own button — a key press, a menu item — + asks this, so every way out of the list answers to the one rule the button reads. + """ + return self._view.live and self._rows.releasable(self._view) + + @property + def holds_several_rows(self) -> bool: + """Whether more than one row stands in the list, which is what a solo silences the rest of.""" + return self._view.row_count > 1 + + def soloed(self, key: str) -> bool: + """Whether the row is the one recording heard, which is what a menu names its solo by.""" + return self._view.soloed(key) + + def stands_open(self, key: str) -> bool: + """Whether the folder's recordings are in view, which is what a menu names its move by.""" + return self._open_folders.stands_open(key) + + def toggle_folder(self, key: str) -> None: + """Put a folder's recordings in view or away again, and draw the list as it now stands. + + A folder closing over the row picked out takes that row off the list, so the pick is + reported as gone and the reading that comes back names none. This is the one gesture that + moves a pick without landing on a row, which is what keeps the pick and the rows in step, + and like every pick it answers only while the list is live. + """ + if not self._view.live: + return + + closing = self._open_folders.stands_open(key) and self._holds_picked(key) + self._open_folders.toggle(key) + self.update_view(self._view) + if closing: + self.call(self.on_selection_cleared) + + def _holds_picked(self, key: str) -> bool: + """Whether the folder stands for the row picked out, which closing it would take away.""" + row = self._view.row(key) + picked = self._view.selected_key + return row is not None and any(held.key == picked for held in row.held) + + @property + def _following(self) -> bool: + """A region stands as something other than it will, so the list settles it once more.""" + return self._region.settling or self._folders.following + + def _settle_soon(self) -> None: + """Ask to read the drawn rows back on the frames the list is drawn in from here on. + + The list keeps this going for as long as a region it drew is holding rows back, so a + region refills itself rather than waiting on a frame hook an owner remembered to wire. A + list short enough to be drawn whole stops after the frame that placed it. + """ + dpg_configure_item(self._tags.drawn, show=True) + + def _settle(self) -> None: + """Read back what the regions drew, refill the ones a scroll has moved on from, and stop + watching once none of them holds rows it has yet to build. + + The watch runs this only on a frame the list was drawn in, so what it reads measures true + and a list put away costs nothing until it is back on screen. The open folders stand inside + the list's body, so they settle with it. + """ + if self._region.settle() and self._windows(self._view): + self._draw_window(self._view) + self._repaint(self._view) + + for key in self._folders.settle(): + self._folders.redraw(key, self._view) + row = self._view.row(key) + if row is not None: + self._folders.repaint(row, self._view) + + if not self._following: + dpg_configure_item(self._tags.drawn, show=False) diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py new file mode 100644 index 000000000..bd2db714e --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -0,0 +1,163 @@ +from typing import Any, Optional, Tuple + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.host import StemsListHost +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName + + +class StemsMessages: + """What a stems list puts to a reader: the hover explanation and the status-bar line. + + The status bar asks the widget under the pointer to explain itself, so there is one answer per + widget kind and each reads the row the widget carries. What a list offers decides which answer + a row gives, which is why the offer is stated once and read here. + """ + + def __init__( + self, + language_manager: LanguageManager, + *, + offer: StemsListOffer, + open_folders: OpenFolders, + host: StemsListHost, + ) -> None: + self._language_manager = language_manager + self._offer = offer + self._open_folders = open_folders + self._host = host + self._view = StemsListViewModel.empty() + 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._msg_folder = language_manager["global.stems.message.folder_tooltip"] + self._msg_edits = language_manager["global.stems.message.edits_tooltip"] + + def reads(self, view_model: StemsListViewModel) -> None: + """Takes up the view the list is drawing, which is what every answer is read from.""" + self._view = view_model + + def row_explanation(self, row: StemRowViewModel) -> str: + """What the row's hover states: where the source is, why it is grayed out where it + contributes nothing, and how it moves where the list lets it. + + A row the document remembers by name alone opens on that name, which is what a recording + an embedded sample was detached from reads as. + """ + lines = [str(row.path) if row.path is not None else row.name] + if row.stands_for_edits: + lines.append(self._msg_edits) + elif row.stands_for_a_folder: + lines.append(self._msg_folder) + elif 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._offer.drags(self._view): + lines.append(self._msg_drag) + + return "\n".join(lines) + + def name(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + """The status line a row's name puts up: what the gestures it takes would do to it.""" + row = self._row(user_data) + if row is None: + return "" + + if row.stands_for_a_folder: + return self._language_manager["global.stems.message.status_folder_row"].format( + name=row.name, + count=row.holds, + ) + + return " ".join(self._row_gestures(row)) + + def _row_gestures(self, row: StemRowViewModel) -> Tuple[str, ...]: + """What a reader can do to a recording, in the order the list offers it. + + A list that sounds or reveals a row says so, since a double-click is the one gesture + nothing on the row draws; a list that neither drags nor reveals reads as the name alone. + """ + lines: Tuple[str, ...] = () + if self._offer.drags(self._view): + lines += (self._language_manager["global.stems.message.status_row_drag"].format(name=row.name),) + if self._host.revealable: + lines += (self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name),) + + if self._host.playable: + lines += (self._language_manager["global.stems.message.status_row_play"].format(name=row.name),) + + return lines or (row.name,) + + def channel( + self, + *_args: Any, + user_data: Tuple[str, ChannelName], + **_kwargs: Any, + ) -> str: + key, channel_name = user_data + row = self._row(key) + if row is None: + return "" + + channel = channel_label(self._language_manager, channel_name) + if row.stands_for_a_folder: + return self._language_manager["global.stems.message.status_folder_channel"].format( + channel=channel, + name=row.name, + ) + + if channel_name in self._view.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(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._row(user_data) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_master"].format(name=row.name) + + def remove(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._row(user_data) + if row is None: + return "" + + if row.stands_for_a_folder: + return self._language_manager["global.stems.message.status_folder_remove"].format(name=row.name) + + return self._language_manager["global.stems.message.status_remove"].format(name=row.name) + + def twisty(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + """What the marker beside a folder's name does from where it now stands.""" + row = self._row(user_data) + if row is None: + return "" + + return self._language_manager[ + ( + "global.stems.message.status_folder_close" + if self._open_folders.stands_open(user_data) + else "global.stems.message.status_folder_open" + ) + ].format(name=row.name) + + def _row(self, key: str) -> Optional[StemRowViewModel]: + return self._view.row(key) diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py new file mode 100644 index 000000000..8c1f00eab --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import Final + +from sampletones_application.view_model.shared.stems import StemsListViewModel + + +@dataclass(frozen=True) +class StemsListOffer: + """What a stems list lets a reader do with a row, which is what its owner can answer for. + + The converter's gathered recordings, a reconstruction's recorded assignment and the question + of which recordings to mix are the same rows drawn the same way; what differs is the gestures + each owner honors. A list states that here, once, so a drawing step reads one declaration + rather than asking a flag of its own. + + ``picking`` states that the box beside a row picks the row for a mix rather than answering for + its channels, which is the reading a list asking which recordings to mix draws. + + ``swatch`` states that a row leads with the color its recording is drawn in, which a list + standing beside the ribbon draws so a name and the stretches it holds read as one. + """ + + master_box: bool + removal: bool + keeps_last_row: bool + dragging: bool + picking: bool + swatch: bool + + def drags(self, view_model: StemsListViewModel) -> bool: + """Whether a row is dragged, which the bands a drag rearranges are what it takes. + + A drag moves a recording between levels, so it reaches a list drawing them. Whoever draws + the row and whoever explains it both read this, so what a row offers and what it says it + offers are one answer. + """ + return self.dragging and not view_model.collapse_levels + + +GATHERED_SOURCES: Final[StemsListOffer] = StemsListOffer( + master_box=False, + removal=True, + keeps_last_row=False, + dragging=True, + picking=False, + swatch=False, +) + +RECORDED_ASSIGNMENT: Final[StemsListOffer] = StemsListOffer( + master_box=True, + removal=True, + keeps_last_row=True, + dragging=False, + picking=False, + swatch=True, +) + +PICKED_SOURCES: Final[StemsListOffer] = StemsListOffer( + master_box=True, + removal=False, + keeps_last_row=False, + dragging=False, + picking=True, + swatch=False, +) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py new file mode 100644 index 000000000..7bed61f4f --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -0,0 +1,411 @@ +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_GROUP, + SUF_SWATCH, + SUF_TEXT, + SUF_TOOLTIP, + SUF_TWISTY, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, + TAG_GLOBAL_THEME_STEMS_MARKER, + TAG_GLOBAL_THEME_STEMS_PICK, + TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL, + 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.stems.columns import NO_INDENT, StemsColumns +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.gestures import StemsGestures +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.themes.channels import ( + CHANNEL_THEME_TAGS, + PARTIAL_CHANNEL_THEME_TAGS, +) +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_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 +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender + +SWATCH_FILL: Final[str] = "fill" + + +class StemRowRenderer: + """One row of a stems list: the widgets it stands as, and what it currently holds. + + A row is the master box, the name, a box per channel in play, and the button that takes it + out — whichever of those the list offers. It answers for one row against the view it is drawn + from, and knows nothing about the bands the rows are grouped into. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + offer: StemsListOffer, + glyphs: CommonGlyphs, + stem_colors: StemColors, + open_folders: OpenFolders, + language_manager: LanguageManager, + messages: StemsMessages, + gestures: StemsGestures, + ) -> None: + self._tags = tags + self._layout = layout + self._offer = offer + self._stem_colors = stem_colors + self._glyphs = glyphs + self._open_folders = open_folders + self._language_manager = language_manager + self._messages = messages + self._gestures = gestures + self._lbl_remove = language_manager["global.stems.label.remove"] + self._folder_template = language_manager["global.stems.template.folder_row"] + + def create( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + columns: StemsColumns, + ) -> None: + """Build the widgets one row stands as, in the columns its grid was declared with. + + A folder's row takes a band of its own behind it, which is what reads a group apart + from the recordings standing loose around it, and the band lies within the row's height. + """ + with dpg.table_row(tag=self._tags.row(row.key, SUF_GROUP)) as line: + if row.stands_for_a_folder: + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_GROUP_ROW).bind_to_item(line) + + if self._offer.master_box: + self._create_master(row, view_model, columns) + + if self._offer.swatch: + self._create_swatch(row) + + self._create_name(row, view_model, columns) + for channel_name in view_model.channels_in_play: + self._create_channel(row, channel_name, columns) + + if self._offer.removal: + self._create_remove(row) + + if columns.reserved: + dpg.add_spacer() + + def repaint(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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. + + A folder reads out how many recordings it stands for, so its label is written here as well + as at the draw: a recording leaving the folder is answered inside the folder's own region, + and the count on the row above it follows from the same reading. + """ + live = view_model.live + for channel_name in view_model.boxes_of(row): + tag = self._tags.channel(row.key, channel_name) + agreement = row.agreement_on(channel_name) + dpg_configure_item(tag, enabled=live) + dpg_set_value(tag, agreement.reads_held) + ThemeRegistry.get(self._channel_theme(channel_name, agreement, view_model)).bind_to_item(tag) + + name_tag = self._tags.row(row.key, SUF_TEXT) + dpg_set_value(name_tag, row.key == view_model.selected_key) + dpg_configure_item(name_tag, enabled=live, label=self._row_label(row)) + dpg_set_value(self._tags.row(row.key, SUF_TOOLTIP), self._messages.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) + self._repaint_swatch(row) + + if self._offer.master_box: + master_tag = self._tags.row(row.key, SUF_CHECKBOX) + dpg_configure_item(master_tag, enabled=live and self._master_reaches(row, view_model)) + dpg_set_value(master_tag, self._master_value(row, view_model)) + self._tone_master(row, view_model) + + if self._offer.removal: + dpg_configure_item( + self._tags.row(row.key, SUF_BUTTON), + enabled=live and self.releasable(view_model, row), + ) + + if row.stands_for_a_folder: + dpg_configure_item(self._tags.row(row.key, SUF_TWISTY), enabled=live) + + def releasable(self, view_model: StemsListViewModel, row: Optional[StemRowViewModel] = None) -> bool: + """Whether a row may leave, which a list holding on to its last recording answers by its count. + + The button on the row reads this, and so does every gesture reaching removal from outside + the row — a key press, a menu item — so one rule answers them all. A row naming something + a removal stands outside of answers for itself, and it takes no part in the count, so the + last recording of a document the reader has written into keeps its place. + """ + if row is not None and not row.releasable: + return False + + return view_model.releasable_count > 1 or not self._offer.keeps_last_row + + def _repaint_swatch(self, row: StemRowViewModel) -> None: + """Keeps the square reading as the place its recording now holds on the record.""" + square = self._tags.row(row.key, SUF_SWATCH) + color = self._swatch_color(row) + if color is not None and dpg.does_item_exist(square): + self._paint_swatch(square, color) + + def _create_swatch(self, row: StemRowViewModel) -> None: + """The color the recording is painted in, standing where the row opens. + + A reader meets one color twice: on the stretches the recording holds in the ribbon and + here beside its name, so a bar answers to a row at a glance. A row standing for several + recordings leaves the square to the recordings themselves. + """ + color = self._swatch_color(row) + if color is None: + dpg.add_spacer() + return + + size = self._layout.swatch_size + with dpg.drawlist(width=size, height=self._layout.name_height): + top = self._swatch_top(size) + square = dpg.draw_rectangle( + (0.0, top), + (float(size), top + size), + tag=self._tags.row(row.key, SUF_SWATCH), + ) + + self._paint_swatch(square, color) + + def _swatch_top(self, size: int) -> float: + """How far down its cell the square opens, so it stands in the middle of the row's band. + + A row stands as tall as the name beside it plus the padding a cell keeps above and below, + while the square opens where that padding ends, so the padding is what it drops by. + """ + return (self._layout.name_height - size) / 2.0 + self._layout.cell_padding + + def _swatch_color(self, row: StemRowViewModel) -> Optional[BaseColor]: + """The color one row is known by, which the entry its recording was converted as picks. + + The frames a reader wrote answer to no recording, so they take the color the ribbon paints + them in wherever they stand. + """ + if row.stands_for_edits: + return self._stem_colors.authored + + if row.stem_id is None: + return None + + return self._stem_colors.for_recording(row.stem_id) + + @staticmethod + def _paint_swatch(square: Sender, color: BaseColor) -> None: + """Fills the square and rules its edge in one color, which the palette in place answers for.""" + dpg_set_palette_color(square, color, argument=SWATCH_FILL) + dpg_set_palette_color(square, color) + + def _create_master( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + columns: StemsColumns, + ) -> None: + """The box beside the row: what picks it for a mix, or what moves its channels at once.""" + master = dpg.add_checkbox( + tag=self._tags.row(row.key, SUF_CHECKBOX), + indent=columns.master_indent, + default_value=self._master_value(row, view_model), + user_data=row.key, + callback=self._gestures.on_pick_box if self._offer.picking else self._gestures.on_master_box, + ) + self._gestures.bind(master, SUF_CHECKBOX) + self._tone_master(row, view_model) + + def _master_reaches(self, row: StemRowViewModel, view_model: StemsListViewModel) -> bool: + """Whether the box beside the row answers a click: the pick has room, or the row has boxes.""" + if self._offer.picking: + return view_model.reaches(row) + + return row.offers_channels + + def _master_value(self, row: StemRowViewModel, view_model: StemsListViewModel) -> bool: + """What the box beside the row reads: whether it is picked, or whether it takes part.""" + if self._offer.picking: + return view_model.picking_of(row).reads_held + + return row.takes_part + + def _tone_master(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Fill a picking box where the folder it stands for is picked only in part. + + A tick states an answer the folder has yet to give, so a half-picked one reads clear and + takes the accent as a fill instead: the reader sees at a glance that some of what the + folder holds is going into the mix, and one click settles the whole of it either way. + """ + if not self._offer.picking: + return + + agreement = view_model.picking_of(row) + theme = TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL if agreement is Agreement.SOME else TAG_GLOBAL_THEME_STEMS_PICK + ThemeRegistry.get(theme).bind_to_item(self._tags.row(row.key, SUF_CHECKBOX)) + + def _create_name( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + columns: StemsColumns, + ) -> None: + """The row itself: what names the source, what you drag it by, and what you drop onto. + + A folder leads with the marker that opens it, and a recording standing loose beside one + opens where that marker's glyph does, so the names read as one column. The name takes the + height its boxes take, so the band a row reads as covers the whole of what stands beside it. + """ + with dpg.group(horizontal=True): + self._create_disclosure(row) + name = dpg.add_selectable( + label=self._row_label(row), + tag=self._tags.row(row.key, SUF_TEXT), + height=self._layout.name_height, + indent=self._name_indent(row, columns), + user_data=row.key, + callback=self._gestures.on_name_selected, + payload_type=self._tags.payload, + drop_callback=self._gestures.on_row_drop, + ) + if self._offer.drags(view_model): + with dpg.drag_payload(parent=name, drag_data=row.key, payload_type=self._tags.payload): + dpg.add_text(row.name) + + FontRegistry.bind_to_item(name, Font.BOLD_SMALL if row.stands_for_a_folder else Font.REGULAR_SMALL) + self._gestures.bind(name, SUF_TEXT) + show_tooltip( + name, + self._messages.row_explanation(row), + text_tag=self._tags.row(row.key, SUF_TOOLTIP), + ) + + def _name_indent(self, row: StemRowViewModel, columns: StemsColumns) -> int: + """How far the row's name sits in: a folder opens at its marker, anything else at the glyph.""" + if row.stands_for_a_folder: + return NO_INDENT + + return columns.marker_indent(self._glyphs.collapsed, Font.ICON) + + def _create_disclosure(self, row: StemRowViewModel) -> None: + """The marker a folder opens by, which stands beside the folder's own name. + + The marker is drawn to the height of the name it leads, its glyph filling that frame, + which stands a folder's row in the rhythm every other row keeps. Its frame is the room it + was given, so the shading the pointer draws covers the marker exactly. + """ + if not row.stands_for_a_folder: + return + + twisty = dpg.add_button( + label=self._twisty_glyph(row.key), + tag=self._tags.row(row.key, SUF_TWISTY), + width=self._layout.twisty_width, + height=self._layout.name_height, + user_data=row.key, + callback=self._gestures.on_twisty, + ) + FontRegistry.bind_to_item(twisty, Font.ICON) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_MARKER).bind_to_item(twisty) + self._gestures.bind(twisty, SUF_TWISTY) + + def _twisty_glyph(self, key: str) -> str: + """The marker stating whether the folder's recordings are in view.""" + if self._open_folders.stands_open(key): + return self._glyphs.expanded + + return self._glyphs.collapsed + + def _row_label(self, row: StemRowViewModel) -> str: + """What the row reads as: the source's name, and for a folder how many it stands for.""" + if not row.stands_for_a_folder: + return row.name + + return self._folder_template.format(name=row.name, count=row.holds) + + def _create_channel( + self, + row: StemRowViewModel, + channel_name: ChannelName, + columns: StemsColumns, + ) -> None: + """The box giving the recording a channel, where the recording holds frames on it. + + The box carries no label of its own: the heading names the channel once for the whole + column, and the box stands in the middle of that column under it. A recording holding no + frames 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._tags.channel(row.key, channel_name) + with dpg.group(horizontal=True, indent=columns.box_indent): + dpg.add_checkbox( + tag=checkbox_tag, + default_value=row.agreement_on(channel_name).reads_held, + user_data=(row.key, channel_name), + callback=self._gestures.on_channel_box, + ) + + self._gestures.bind(checkbox_tag, SUF_CHANNELS) + + def _create_remove(self, row: StemRowViewModel) -> None: + remove = dpg.add_button( + label=self._lbl_remove, + tag=self._tags.row(row.key, SUF_BUTTON), + width=self._layout.remove_button_width, + height=self._layout.remove_button_width, + user_data=row.key, + callback=self._gestures.on_remove_button, + ) + FontRegistry.bind_to_item(remove, Font.MONO_SMALL) + ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove) + self._gestures.bind(remove, SUF_BUTTON) + + def _channel_theme( + self, + channel_name: ChannelName, + agreement: Agreement, + view_model: StemsListViewModel, + ) -> str: + """The tone a channel's box takes: its own color, softened where the row half-holds it, + muted where a choice made elsewhere has switched the channel off.""" + if channel_name in view_model.muted_channels: + return TAG_GLOBAL_THEME_CHANNEL_MUTED + + if agreement is Agreement.SOME: + return PARTIAL_CHANNEL_THEME_TAGS[channel_name] + + return CHANNEL_THEME_TAGS[channel_name] diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py new file mode 100644 index 000000000..1f820128b --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass, replace +from typing import FrozenSet, List, Self, Tuple + +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class Reshape: + """What a new reading asks of a list that has already drawn one. + + ``whole`` asks for the list afresh, which is what a change to the columns, the banding or the + rows themselves comes to. ``folders`` names the folders whose own regions are drawn again, + which is what a change confined to the recordings a folder stands for comes to: the rows + around it keep the widgets they stand as, and the reader keeps the scroll they left. + """ + + whole: bool + folders: Tuple[str, ...] + + @classmethod + def everything(cls) -> Self: + """What a reading asks for once it moves more than a repaint settles.""" + return cls(whole=True, folders=()) + + @classmethod + def within(cls, folders: Tuple[str, ...]) -> Self: + """What a reading that moved the recordings of these folders alone asks for. + + Naming no folder is what a reading the list already stands at asks for, which a repaint + answers on its own. + """ + return cls(whole=False, folders=folders) + + @property + def redraws(self) -> bool: + """Widgets are built, so whoever answers settles the regions once the frame has drawn.""" + return self.whole or bool(self.folders) + + +@dataclass(frozen=True) +class RowPlacement: + """Where one row stands: what it is, which band holds it, and which boxes it draws. + + ``held`` names the recordings a folder stands for, so one of them leaving is met by the folder + it left, and the region that folder opens onto is the only thing drawn again. + """ + + key: str + level: int + offered: FrozenSet[ChannelName] + opened: bool + held: Tuple[str, ...] + + def stands_where(self, other: Self) -> bool: + """Both placements put the same row in the same place, whatever it now holds.""" + return replace(self, held=()) == replace(other, held=()) + + +@dataclass(frozen=True) +class ListShape: + """What the bands are built from, so a change here is a redraw and anything else a repaint. + + 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. + """ + + columns: Tuple[ChannelName, ...] + collapsed: bool + rows: Tuple[RowPlacement, ...] + + @classmethod + def of(cls, view_model: StemsListViewModel, open_folders: OpenFolders) -> Self: + """The shape a view amounts to, which is what a list compares against what it drew.""" + return cls( + columns=view_model.channels_in_play, + collapsed=view_model.collapse_levels, + rows=tuple( + RowPlacement( + key=row.key, + level=row.level, + offered=row.offered_channels, + opened=open_folders.stands_open(row.key), + held=tuple(recording.key for recording in row.held), + ) + for row in view_model.rows + ), + ) + + @classmethod + def nothing(cls) -> Self: + """The shape a list stands at before it has drawn anything.""" + return cls(columns=(), collapsed=False, rows=()) + + def against(self, standing: Self) -> Reshape: + """What a list standing at ``standing`` is asked for to come to this shape. + + A folder opening or closing, a row arriving or leaving, and a change to the columns or the + banding all reach the whole list, since the tables are built around them. A recording + leaving the folder it was gathered under reaches that folder alone: the region below its + row draws a row for each recording the folder still stands for, and the row itself reads + out how many that is. + """ + if self.columns != standing.columns or self.collapsed != standing.collapsed: + return Reshape.everything() + + if len(self.rows) != len(standing.rows): + return Reshape.everything() + + folders: List[str] = [] + for row, stood in zip(self.rows, standing.rows): + if not row.stands_where(stood): + return Reshape.everything() + + if row.held != stood.held: + folders.append(row.key) + + return Reshape.within(tuple(folders)) diff --git a/src/sampletones_application/ui/elements/stems/tags.py b/src/sampletones_application/ui/elements/stems/tags.py new file mode 100644 index 000000000..c5eb688d3 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass + +from sampletones_application.tags.compose import compose_tag, identity_part +from sampletones_application.tags.general import ( + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_FOLDER, + SUF_GROUP, + SUF_HANDLER_DRAWN, + SUF_HANDLER_REGISTRY, + SUF_LEVEL, + SUF_PAYLOAD, + SUF_REGION, + SUF_ROW, + SUF_TABLE, + SUF_WELL, +) +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class StemsTags: + """The tag grammar one stems list draws under. + + Every widget a list builds is named from the list's own prefix, so the grammar stands in one + place and whatever addresses a row — the list, its handlers, a test — spells it the same way. + + A row is named by its key, which is the path the recording was gathered from. A tag part + lowercases that path and collapses its whitespace, so the key stands beside an identity part + that keeps two recordings of one normalized name — ``Kick.wav`` and ``kick.wav`` gathered + together — on widgets of their own. + """ + + prefix: str + + @property + def well(self) -> str: + """The recessed region the list is drawn in, which is what an owner shows and hides.""" + return compose_tag(self.prefix, SUF_WELL) + + @property + def body(self) -> str: + """The group the bands are built into, which a rebuild empties.""" + return compose_tag(self.well, SUF_GROUP) + + @property + def body_handlers(self) -> str: + """The registry watching the body, which is where the frames it is drawn in are reported.""" + return compose_tag(self.body, SUF_HANDLER_REGISTRY) + + @property + def drawn(self) -> str: + """The handler reporting each frame the body is drawn in, standing on while the list settles.""" + return compose_tag(self.body, SUF_HANDLER_DRAWN) + + @property + def table(self) -> str: + """The one table every row stands in while the levels are collapsed.""" + return compose_tag(self.prefix, SUF_TABLE) + + def segment(self, position: int) -> str: + """One run of rows standing between two folders, each declaring the same columns. + + A folder breaks the run it stands in, so the recordings on either side of one line up in + tables of their own; the first of them is the list's own table, and the only one a list + holding no folder draws. + """ + return self.table if position == 0 else compose_tag(self.table, str(position)) + + def folder(self, key: str, suffix: str) -> str: + """The tag one of an open folder's own widgets carries: its region, or the rows in it.""" + return compose_tag(self.prefix, SUF_FOLDER, key, identity_part(key), suffix) + + def region(self, key: str) -> str: + """The bounded space a folder's recordings scroll in while it stands open.""" + return self.folder(key, SUF_REGION) + + def held(self, key: str) -> str: + """The table the recordings inside one open folder stand in.""" + return self.folder(key, compose_tag(SUF_REGION, SUF_TABLE)) + + @property + def payload(self) -> str: + """The kind of payload this list's drags carry, so one list's rows land in it alone.""" + return compose_tag(self.prefix, SUF_PAYLOAD) + + def handlers(self, kind: str) -> str: + """The registry the widgets of one kind share, which is where their hover is answered.""" + return compose_tag(self.prefix, kind, SUF_HANDLER_REGISTRY) + + def row(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, identity_part(key), suffix) + + def level(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(self, key: str, channel_name: ChannelName) -> str: + """The tag the box giving ``key`` a channel carries.""" + return self.row(key, compose_tag(SUF_CHANNELS, channel_name, SUF_CHECKBOX)) diff --git a/src/sampletones_application/ui/elements/table/__init__.py b/src/sampletones_application/ui/elements/table/__init__.py new file mode 100644 index 000000000..e69de29bb 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/trace.py b/src/sampletones_application/ui/elements/trace.py index 40d08639a..e3b2d60b8 100644 --- a/src/sampletones_application/ui/elements/trace.py +++ b/src/sampletones_application/ui/elements/trace.py @@ -19,7 +19,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.gui.clipboard import copy_to_clipboard +from sampletones_application.utils.gui.clipboard.copy_button import copy_to_clipboard class GUITraceback: @@ -30,6 +30,7 @@ def __init__( parent: str, exception: Exception, language_manager: LanguageManager, + height: int, theme: Optional[Theme] = None, button_theme: Optional[Theme] = None, ) -> None: @@ -43,6 +44,7 @@ def __init__( ), ) + self._height = height self._lbl_copy = language_manager["global.traceback.label.copy"] self.theme = ThemeRegistry.resolve(theme, TAG_GLOBAL_THEME_TRACEBACK) @@ -60,7 +62,7 @@ def __init__( default_value=self._text, multiline=True, readonly=True, - height=400, + height=self._height, width=-1, ) diff --git a/src/sampletones_application/ui/elements/tree/__init__.py b/src/sampletones_application/ui/elements/tree/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 2428bd6eb..391c1a50b 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, @@ -266,6 +266,7 @@ def _launch_tree_rebuild(self, refresh: VoidCallback) -> None: refresh, lambda: self._collect_specs(self.tree_tag), root_tag=self.tree_tag, + retry=self.rebuild_tree, on_finished=self._on_rebuild_finished, ) diff --git a/src/sampletones_application/ui/elements/tree/protocol.py b/src/sampletones_application/ui/elements/tree/protocol.py index 83aa4dd1d..ee9bf1c67 100644 --- a/src/sampletones_application/ui/elements/tree/protocol.py +++ b/src/sampletones_application/ui/elements/tree/protocol.py @@ -1,6 +1,7 @@ -from typing import Protocol +from typing import Optional, Protocol from sampletones_core.structures.tree import FileSystemNode, TreeNode +from sampletones_shared.types.callback import VoidCallback class TreeLogicProtocol(Protocol): @@ -26,6 +27,8 @@ def locked(self) -> bool: ... def lock(self) -> None: ... + def lock_unless_locked(self, retry: Optional[VoidCallback]) -> bool: ... + def unlock(self) -> None: ... def is_node_favorite(self, node: TreeNode) -> bool: ... diff --git a/src/sampletones_application/ui/elements/tree/tag.py b/src/sampletones_application/ui/elements/tree/tag.py index c4e375e81..cad282c86 100644 --- a/src/sampletones_application/ui/elements/tree/tag.py +++ b/src/sampletones_application/ui/elements/tree/tag.py @@ -1,29 +1,20 @@ -from typing import Final - -from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.compose import compose_tag, identity_part from sampletones_core.structures.tree import TreeNode -from sampletones_shared.utils.serialization import calculate_hash - -NODE_TAG_DIGEST_LENGTH: Final[int] = 8 - -_IDENTITY_SEPARATOR: Final[str] = "\x00" def compose_node_tag(node: TreeNode, *, panel_tag: str) -> str: """Composes the widget tag of one tree row: readable by the names above it, unique by its path. - The names read the row back to whoever inspects the widget tree, and the digest states the exact - path — each ancestor's node type together with its name — so every row the names alone spell - alike keeps a tag of its own: a folder and the audio beside it, or two labels differing only in - spacing or case. The separator the digest joins on is one the disk gives no name, which is what - makes one identity reach one digest. + The names read the row back to whoever inspects the widget tree, and the identity part states + the exact path — each ancestor's node type together with its name — so every row the names + alone spell alike keeps a tag of its own: a folder and the audio beside it, or two labels + differing only in spacing or case. """ names = "_".join(str(ancestor.name) for ancestor in node.path) - return compose_tag(panel_tag, f"node_{names}", _node_digest(node)) + return compose_tag(panel_tag, f"node_{names}", _node_identity(node)) -def _node_digest(node: TreeNode) -> str: - identity = _IDENTITY_SEPARATOR.join( - part for ancestor in node.path for part in (ancestor.node_type.value, str(ancestor.name)) +def _node_identity(node: TreeNode) -> str: + return identity_part( + *(part for ancestor in node.path for part in (ancestor.node_type.value, str(ancestor.name))), ) - return calculate_hash(identity, length=NODE_TAG_DIGEST_LENGTH) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 6a3c48f04..6801df411 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -45,10 +45,12 @@ TAG_INSTRUCTIONS_LIBRARY_THEME_GENERATOR, TAG_INSTRUCTIONS_LIBRARY_THEME_GROUP, TAG_INSTRUCTIONS_LIBRARY_THEME_INSTRUCTION, + TAG_INSTRUCTIONS_LIBRARY_THEME_OUTDATED, ) 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 @@ -68,22 +70,24 @@ from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.gui.dpg import ( dpg_configure_item, + dpg_get_item_user_data, dpg_get_value, dpg_is_item_hovered, dpg_set_value, ) from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import ( + DetailSwatch, create_detail_tooltip, populate_detail_tooltip, ) 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, @@ -92,7 +96,6 @@ from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( - ConfigGroupNode, ConfigNode, FileSystemNode, LibraryNode, @@ -110,7 +113,6 @@ PathCallback, VoidCallback, ) -from sampletones_shared.utils.system.paths import open_path_in_explorer NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset() @@ -173,8 +175,9 @@ 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._lbl_detail_stems = language_manager["global.context.label.detail_stems"] self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None self.on_add_to_sequencer: Optional[PathCallback] = None @@ -195,13 +198,15 @@ def _launch_rebuild( collect: Callable[[], List[NodeSpec]], *, root_tag: str, + retry: Optional[VoidCallback], on_finished: Optional[VoidCallback] = None, ) -> None: """Rebuild the subtree under ``root_tag``: prepare it off-thread, emit it on the main thread. Runs on the background traversal worker and walks through five steps: - 1. A rebuild already in flight holds the lock, so return and let it finish. + 1. A lock already held — a rebuild in flight, a load, a generation — keeps ``retry`` for its + release, which asks for this rebuild again once the tree stands free. 2. Acquire the lock; responsibility for releasing it passes to the emit pipeline. 3. ``refresh`` updates the model and the filter is resolved against it, then ``collect`` resolves it into a flat :class:`NodeSpec` list -- every per-node @@ -214,10 +219,9 @@ def _launch_rebuild( A failure before the handoff releases the lock so the tree stays interactive. """ - if self.locked: + if not self._logic.lock_unless_locked(retry): return - self.lock() handed_off = False try: refresh() @@ -295,8 +299,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 +345,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 +418,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 @@ -429,7 +433,7 @@ def _append_spec( node=node, node_tag=node_tag, parent_tag=parent, - label=node.name, + label=self._node_label(node), name_font=self._resolve_node_name_font(node), leaf=leaf, open_on_arrow=open_on_arrow, @@ -466,7 +470,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: @@ -497,11 +501,20 @@ def _create_hover_callback( self, status_bar_callback: Optional[MessageCallback], ) -> Callback: + """The hover callback of a row, which names the row in the status bar and its detail tooltip. + + The hover is reported a frame after it happened, by which time a rebuilt tree may have taken + the row away, so the callback answers for the rows still standing. + """ + def hover_callback( _sender: Sender, app_data: int, ) -> None: - user_data = dpg.get_item_user_data(app_data) + user_data = dpg_get_item_user_data(app_data) + if user_data is None: + return + if status_bar_callback is not None: self._status_bar.set(status_bar_callback, user_data=user_data) self._update_detail_tooltip(user_data) @@ -523,23 +536,60 @@ def _create_detail_tooltip(self, tree_window_tag: str) -> None: def _update_detail_tooltip(self, user_data: Any) -> None: """Reveals the detail tooltip for a hovered node that carries details, hiding it otherwise. - The reveal is gated on a change of owning node, so the tooltip content is rebuilt once per - node. + A hover is reported for every frame the pointer rests on a row, and the row it belongs to is + read first, so the content is built once per node and a row asked about a document asks for + it once. """ if not isinstance(user_data, tuple): return node, node_tag = user_data + if self._detail_tooltip_owner_tag == node_tag: + return + + self._show_detail_tooltip(node, node_tag) + + def _show_detail_tooltip(self, node: TreeNode, node_tag: str) -> None: + """Fills the tooltip with what the node answers, and hides it where the node answers nothing.""" detail_items = self._node_detail_items(node) - if detail_items: - if self._detail_tooltip_owner_tag != node_tag: - populate_detail_tooltip(self._detail_tooltip_tag, detail_items) - self._detail_tooltip_owner_tag = node_tag - dpg_configure_item(self._detail_tooltip_tag, show=True) + detail_recordings = self._node_detail_recordings(node) + if not detail_items and not detail_recordings: + self._hide_detail_tooltip() + return + + populate_detail_tooltip( + self._detail_tooltip_tag, + detail_items, + swatch_glyph=self._glyphs.common.swatch, + swatch_label=self._lbl_detail_stems, + swatches=detail_recordings, + ) + self._detail_tooltip_owner_tag = node_tag + dpg_configure_item(self._detail_tooltip_tag, show=True) + + def refresh_detail_tooltip(self) -> None: + """Rebuilds the tooltip where it stands, which an answer arriving after it was built asks for. + The rebuild belongs to the row the tooltip is showing, so it stands still once the pointer + has moved on. + """ + owner_tag = self._detail_tooltip_owner_tag + if owner_tag is None: return - self._hide_detail_tooltip() + node = self._node_at(owner_tag) + if node is not None: + self._show_detail_tooltip(node, owner_tag) + + @staticmethod + def _node_at(node_tag: str) -> Optional[TreeNode]: + """The node a row was created for, read back from the widget the row is.""" + user_data = dpg_get_item_user_data(node_tag) + if not isinstance(user_data, tuple): + return None + + node, _ = user_data + return node if isinstance(node, TreeNode) else None def _hide_detail_tooltip(self) -> None: if self._detail_tooltip_owner_tag is None: @@ -569,7 +619,10 @@ def single_click_callback( sender: Sender, app_data: Tuple[int, int], ) -> None: - user_data = dpg.get_item_user_data(app_data[1]) + user_data = dpg_get_item_user_data(app_data[1]) + if user_data is None: + return + self._remember_clicked_row(user_data) if item_click_callback is not None: item_click_callback(sender, app_data, user_data=user_data) @@ -587,7 +640,10 @@ def double_click_callback( sender: Sender, app_data: Tuple[int, int], ) -> None: - user_data = dpg.get_item_user_data(app_data[1]) + user_data = dpg_get_item_user_data(app_data[1]) + if user_data is None: + return + if item_double_click_callback is not None: item_double_click_callback( sender, @@ -750,6 +806,11 @@ def _node_header_color(self, node: TreeNode) -> BaseColor: return self._colors.node + def _node_label(self, node: TreeNode) -> str: + """The text a node's row reads, which is the node's name; a panel marking some of its rows + adds the mark here, which keeps the row's tag and its remembered expansion on the name.""" + return node.name + def _resolve_node_name_font(self, node: TreeNode) -> Font: """Select the label font for a node: monospace for the rows stating a configuration. @@ -757,10 +818,11 @@ def _resolve_node_name_font(self, node: TreeNode) -> Font: carries: the fields a reconstruction directory encodes, the stretch of them a heading gathers several directories under, the name a library goes by. A panel that sets ``_MONOSPACE_CONFIG_NODES`` renders every one of those rows in the fixed-width font, so a - column of them reads field under field and a heading reads as the row below it does. Every - other row keeps the panel's ``_NAME_FONT``. + column of them reads field under field and a heading reads as the row below it does. A row + whose name has gathered a name somebody chose reads as that plain name, and every other row + keeps the panel's ``_NAME_FONT``. """ - if self._MONOSPACE_CONFIG_NODES and isinstance(node, (ConfigNode, ConfigGroupNode, LibraryNode)): + if self._MONOSPACE_CONFIG_NODES and node.states_configuration: return self._CONFIG_FONT return self._NAME_FONT @@ -788,6 +850,10 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: return [] + def _node_detail_recordings(self, node: TreeNode) -> Tuple[DetailSwatch, ...]: # pylint: disable=unused-argument + """The recordings a row's document is made of, which a browser reading documents answers.""" + return () + def _library_detail_items( self, key: InstructionLibraryKey, @@ -811,7 +877,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 +898,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.""" @@ -1193,6 +1247,8 @@ def _resolve_file_theme_tag( def _resolve_other_theme_tag(self, node: TreeNode) -> str: match node.node_type: case NodeType.LIBRARY: + if isinstance(node, LibraryNode) and node.outdated: + return TAG_INSTRUCTIONS_LIBRARY_THEME_OUTDATED return TAG_INSTRUCTIONS_LIBRARY_THEME case NodeType.GENERATOR: return TAG_INSTRUCTIONS_LIBRARY_THEME_GENERATOR diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index 4a832b97e..83ec74ca9 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -1,15 +1,18 @@ from abc import ABC, abstractmethod from contextlib import contextmanager +from functools import partial from typing import Any, Iterator, Optional import dearpygui.dearpygui as dpg +from sampletones_application.layout.primitives import DialogGeometry 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, viewport_center from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item -from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_application.utils.placement import centered_position from sampletones_shared.types.callback import VoidCallback @@ -23,33 +26,61 @@ class GUIWindow(GUIPanel, ABC): down. Each rebuild binds the elevated dialog-window theme so the window floats above the app with an accent border and title bar. + A window claims the screen while it stands, so the reader answers it before going on. One + reporting work already under way clears ``_claims_the_screen`` instead, which leaves the rest + of the interface live beside it. DearPyGui shows one modal window at a time, so a window + claiming the screen opens through :class:`ModalQueue`: at once where the screen is free, and + once the conversation holding it has ended otherwise. + A dialog that raises another modal — a prompt, a countdown — hands the screen - over with ``yield_to`` and takes it back with ``resume``, which is what keeps - the two from competing for the one modal DearPyGui carries at a time. + over with ``yield_to`` and takes it back with ``resume``. The position a window was + placed at is its own from then on, so the return brings it back where it stood. + + A dialog that closes on its answer leaves with ``_leave_then``: it goes off + screen, and the answer runs a frame later. Whatever the answer raises — a + question of its own, an error — then opens alone and holds the keyboard. """ - def center(self) -> None: - center_item(self.tag) + _claims_the_screen: bool = True + + def __init__(self, tag: str, geometry: DialogGeometry) -> None: + self._geometry = geometry + super().__init__(tag, geometry.width, geometry.minimum_size[1]) def yield_to(self, raise_modal: VoidCallback) -> None: """Steps off screen and runs ``raise_modal`` a frame later, so what it raises can open. - DearPyGui carries one modal at a time: a modal built while another one is still on - screen opens as a hidden window nobody can reach. This window goes off screen first - and the frame it was drawn in finishes, leaving the new modal alone on screen. The - widget tree stays where it is, so whatever is being edited here survives the visit - and :meth:`resume` brings it back untouched. + The window keeps the screen while it stands aside, so the modal it raises opens ahead of + any waiting in line. The widget tree stays where it is, so whatever is being edited here + survives the visit and :meth:`resume` brings it back untouched. """ dpg_configure_item(self.tag, show=False) - FrameCallbackManager.set_frame_callback(raise_modal) + ModalQueue.step_aside(self.tag) + ModalQueue.hand_off(raise_modal) def resume(self) -> None: """Comes back on screen once the modal this window yielded to is gone. - The return waits a frame for the same reason the hand-off does: the modal being - dismissed still holds the screen for the frame it is dismissed in. + The modal being dismissed still holds the screen for the frame it is dismissed in, so the + return comes on a later frame. + """ + ModalQueue.come_back(self.tag, lambda: dpg_configure_item(self.tag, show=True)) + + def _leave_then(self, answer: VoidCallback) -> None: + """Deletes this appearance and runs ``answer`` a frame later, once the screen is clear. + + The tree goes first, and with it the keyboard claim of this appearance, so a prompt the + answer raises is the only modal on screen and the one the keyboard reaches. The answer + carries on the conversation this window held the screen for, so what it raises opens ahead + of any modal waiting in line. What the answer reads from the window, such as a ticked box + or a form field, is read before this call. Only the first answer given while the window + stands runs: a second click reaches a window that has already left. """ - FrameCallbackManager.set_frame_callback(lambda: dpg_configure_item(self.tag, show=True)) + if not dpg.does_item_exist(self.tag): + return + + self.hide() + ModalQueue.hand_off(answer) @contextmanager def dialog_window( @@ -60,11 +91,11 @@ 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 opens at the size its geometry states and grows in height to hold more than + that, so a prompt whose text wraps over several lines and a form that unfolds a group + after opening both show the whole of what they hold. Its width is the one its geometry + states, held as the largest the window may take as well as the smallest, so an item + stretching across the window measures against a width that stands. 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. @@ -72,27 +103,55 @@ def dialog_window( with dpg.window( tag=self.tag, label=label, - width=self.width, - height=self.height, + width=self._geometry.width, + min_size=list(self._geometry.minimum_size), + max_size=list(self._geometry.maximum_size), + autosize=True, no_resize=True, no_collapse=True, no_close=on_close is None, on_close=on_close, - modal=True, + modal=self._claims_the_screen, ): yield def show(self, *args: Any, **kwargs: Any) -> None: + """Raises this window: at once, or once the screen is free for it where it claims the screen. + + Showing a window again puts the newer appearance in place of the one standing or waiting. + """ self.hide() + appear = partial(self._appear, *args, **kwargs) + if self._claims_the_screen: + ModalQueue.open(self.tag, appear) + else: + appear() + + def _appear(self, *args: Any, **kwargs: Any) -> None: + """Builds this appearance's tree, places it, and holds it centered as it takes its size. + + A window stating a height is placed before it is ever drawn, so the first frame carrying + it already shows it centered — which is what keeps it clear of the spot DearPyGui opens + an unplaced modal at. A window whose height its content settles has none to place from + and is centered on the frame that settles it. The drawn size is known only after a frame + has carried it, so the correction waits for those frames to arrive on their own: waiting + for one 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 stands in the way of. + """ self.prepare(*args, **kwargs) self.create_window() ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG_WINDOW).bind_to_item(self.tag) - dpg.split_frame() - self.center() + if self._geometry.height is not None: + dpg.set_item_pos(self.tag, list(centered_position(viewport_center(), *self._geometry.minimum_size))) + + center_when_settled(self.tag) def hide(self) -> None: + """Takes this window off the screen, or out of the line where it still waits for the screen.""" self._teardown() dpg_delete_item(self.tag) + ModalQueue.leave(self.tag) def _teardown(self) -> None: """Releases resources tied to the current appearance before its tree is deleted. diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 0ef75f5d0..74f737998 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, ...]] = ( @@ -128,11 +134,13 @@ def __init__( player_glyphs: PlayerGlyphs, player_layout: PlayerLayout, language_manager: LanguageManager, + frame_rate_shown: bool, 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 @@ -141,6 +149,7 @@ def __init__( self._player_glyphs = player_glyphs self._player_layout = player_layout self._language_manager = language_manager + self._frame_rate_shown = frame_rate_shown self._build_edit_actions = build_edit_actions self._on_play_from_start = on_play_from_start self._on_pause_or_resume = on_pause_or_resume @@ -154,12 +163,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 +185,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 +259,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 +270,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 +300,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 +390,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 +529,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( @@ -613,6 +635,7 @@ def _create_fps_indicator(self) -> None: tag=TAG_GLOBAL_TEXT_MENU_FPS, width=-1, enabled=False, + show=self._frame_rate_shown, ) self._fps_theme.bind_to_item(TAG_GLOBAL_TEXT_MENU_FPS) FontRegistry.bind_to_item(TAG_GLOBAL_TEXT_MENU_FPS, Font.MONO_SMALL) @@ -648,6 +671,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 +744,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, @@ -734,16 +773,25 @@ def _update_player_toolbar(self, state: MenuBarViewModel) -> None: ) dpg_set_value(self._pause_tooltip_tag, self._lbl_pause) + def show_frame_rate(self, shown: bool) -> None: + """Puts the frame-rate reading on the bar when ``shown`` is true, and takes it off otherwise.""" + self._frame_rate_shown = shown + dpg_configure_item(TAG_GLOBAL_TEXT_MENU_FPS, show=shown) + def update_fps(self, fps: float) -> None: + """Writes the rate reached into the reading while it stands on the bar.""" + if not self._frame_rate_shown: + return + dpg_configure_item( TAG_GLOBAL_TEXT_MENU_FPS, label=self._tpl_fps.format(fps=fps), ) @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/__init__.py b/src/sampletones_application/ui/panels/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index c45b76d67..88919be75 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -1,3 +1,4 @@ +from functools import partial from typing import Any, Callable, Dict, List, Optional import dearpygui.dearpygui as dpg @@ -68,8 +69,7 @@ def __init__( super().__init__( tag=TAG_SETTINGS_AUDIO_WINDOW, - width=layout.audio.window.width, - height=layout.audio.window.height, + geometry=layout.audio.window, key_router=key_router, shortcut_source=shortcut_source, ) @@ -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) @@ -273,8 +273,17 @@ def _refresh_devices(self) -> None: self.call(self.on_refresh_devices) def _commit(self) -> None: - device = self._devices_by_label[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE)] + """Reports the chosen device, rate and buffer once the window has left the screen. + + Applying them can fail with a playback error, and that error opens alone once the window + is gone, so the choices are read while the combos still stand. With no device picked, as on + a machine offering none, the window leaves and the settings stay as they are. + """ + device = self._devices_by_label.get(dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE)) + if device is None: + self.hide() + return + sample_rate = self._sample_rates_by_label[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_SAMPLE_RATE)] buffer_size = BUFFER_SIZE_ITEMS[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_BUFFER_SIZE)] - self.call(self.on_commit, device.device_index, sample_rate, buffer_size) - self.hide() + self._leave_then(partial(self.call, self.on_commit, device.device_index, sample_rate, buffer_size)) diff --git a/src/sampletones_application/ui/panels/dialogs/countdown.py b/src/sampletones_application/ui/panels/dialogs/countdown.py index 593c25e6d..ace6c0a0c 100644 --- a/src/sampletones_application/ui/panels/dialogs/countdown.py +++ b/src/sampletones_application/ui/panels/dialogs/countdown.py @@ -2,7 +2,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.layout.primitives import Dimensions +from sampletones_application.layout.primitives import DialogGeometry from sampletones_application.tags.settings import ( TAG_SETTINGS_DISPLAY_BUTTON_KEEP, TAG_SETTINGS_DISPLAY_BUTTON_REVERT, @@ -37,7 +37,7 @@ class GUICountdownWindow(GUIDialogWindow): def __init__( self, *, - layout: Dimensions, + layout: DialogGeometry, title: str, message: str, remaining_format: str, @@ -58,8 +58,7 @@ def __init__( super().__init__( tag=TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN, - width=layout.width, - height=layout.height, + geometry=layout, key_router=key_router, shortcut_source=shortcut_source, ) diff --git a/src/sampletones_application/ui/panels/dialogs/display_settings.py b/src/sampletones_application/ui/panels/dialogs/display_settings.py index e6aefb219..c0e716587 100644 --- a/src/sampletones_application/ui/panels/dialogs/display_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/display_settings.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Optional +from typing import Callable, Optional import dearpygui.dearpygui as dpg @@ -9,6 +9,7 @@ TAG_SETTINGS_DISPLAY_BUTTON_OK, TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN, + TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE, TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE, TAG_SETTINGS_DISPLAY_COMBO_PALETTE, @@ -16,8 +17,8 @@ TAG_SETTINGS_DISPLAY_WINDOW, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.dialog import GUIDialogWindow from sampletones_application.ui.elements.field import labeled_field, subheader +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value @@ -34,7 +35,7 @@ SettingsCallback = Callable[[DisplaySettings], None] -class GUIDisplaySettingsWindow(GUIDialogWindow): +class GUIDisplaySettingsWindow(GUISeededDialogWindow[DisplaySettingsViewModel]): """Modal form over how the application presents itself: its window, its pacing and its theme. Every control reports the whole edited state through ``on_settings_changed`` the moment it @@ -53,7 +54,6 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout - self._view_model: Optional[DisplaySettingsViewModel] = None self.on_settings_changed: Optional[SettingsCallback] = None self.on_commit: Optional[VoidCallback] = None @@ -62,26 +62,13 @@ def __init__( self._lbl_unlimited = language_manager["settings.display.label.unlimited_frame_rate"] super().__init__( + subject="display settings", tag=TAG_SETTINGS_DISPLAY_WINDOW, - width=layout.display.window.width, - height=layout.display.window.height, + geometry=layout.display.window, key_router=key_router, shortcut_source=shortcut_source, ) - def open(self, view_model: DisplaySettingsViewModel) -> None: - """Shows the window seeded with the given display settings.""" - self._view_model = view_model - self.show() - - def prepare(self, *_args: Any, **_kwargs: Any) -> None: - """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" - - def update_view(self, view_model: DisplaySettingsViewModel) -> None: - """Re-seeds the controls of the open window from the given display settings.""" - self._view_model = view_model - self._render() - def create_window(self) -> None: with self.dialog_window( label=self._language_manager["settings.display.title.window_title"], @@ -109,6 +96,7 @@ def create_window(self) -> None: FocusStop.field(TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN), FocusStop.field(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC), FocusStop.field(TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE), + FocusStop.field(TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE), FocusStop.field(TAG_SETTINGS_DISPLAY_COMBO_PALETTE), FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, self._request_cancel), FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_OK, self._request_commit), @@ -117,7 +105,7 @@ def create_window(self) -> None: ) def _create_window_section(self) -> None: - view_model = self._require_view_model() + view_model = self.view_model subheader(self._language_manager["settings.display.title.section_window"]) with labeled_field( self._language_manager["settings.display.label.resolution"], @@ -145,7 +133,7 @@ def _create_window_section(self) -> None: ) def _create_pacing_section(self) -> None: - view_model = self._require_view_model() + view_model = self.view_model subheader(self._language_manager["settings.display.title.section_pacing"]) dpg.add_checkbox( tag=TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, @@ -165,8 +153,15 @@ def _create_pacing_section(self) -> None: callback=self._on_frame_rate_changed, ) + dpg.add_checkbox( + tag=TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE, + label=self._language_manager["settings.display.label.show_frame_rate"], + default_value=view_model.settings.show_frame_rate, + callback=self._on_show_frame_rate_changed, + ) + def _create_appearance_section(self) -> None: - view_model = self._require_view_model() + view_model = self.view_model subheader(self._language_manager["settings.display.title.section_appearance"]) with labeled_field( self._language_manager["settings.display.label.theme"], @@ -197,7 +192,7 @@ def _create_action_buttons(self) -> None: def _render(self) -> None: """Shows the standing selection, offering the size and frame controls while they apply.""" - view_model = self._require_view_model() + view_model = self.view_model dpg_configure_item( TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, items=list(view_model.resolution_items), @@ -219,54 +214,48 @@ def _render(self) -> None: TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE, view_model.current_frame_rate_item(self._lbl_unlimited), ) + dpg_set_value(TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE, view_model.settings.show_frame_rate) dpg_configure_item(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, items=list(view_model.palettes)) dpg_set_value(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, view_model.settings.palette) def _on_resolution_changed(self, _sender: Sender, app_data: str) -> None: - view_model = self._require_view_model() + view_model = self.view_model resolution = view_model.resolution_for_item(app_data) self._emit_window(view_model.settings.window.with_resolution(resolution)) def _on_borderless_changed(self, _sender: Sender, app_data: bool) -> None: - window = self._require_view_model().settings.window + window = self.view_model.settings.window self._emit_window(window.with_borderless(bool(app_data))) def _on_fullscreen_changed(self, _sender: Sender, app_data: bool) -> None: - window = self._require_view_model().settings.window + window = self.view_model.settings.window self._emit_window(window.with_fullscreen(bool(app_data))) def _on_vsync_changed(self, _sender: Sender, app_data: bool) -> None: - settings = self._require_view_model().settings + settings = self.view_model.settings self._emit(settings.with_vsync(bool(app_data))) def _on_frame_rate_changed(self, _sender: Sender, app_data: str) -> None: - view_model = self._require_view_model() + view_model = self.view_model frame_rate = view_model.frame_rate_for_item(app_data, self._lbl_unlimited) self._emit(view_model.settings.with_frame_rate(frame_rate)) + def _on_show_frame_rate_changed(self, _sender: Sender, app_data: bool) -> None: + settings = self.view_model.settings + self._emit(settings.with_show_frame_rate(bool(app_data))) + def _on_palette_changed(self, _sender: Sender, app_data: str) -> None: - settings = self._require_view_model().settings + settings = self.view_model.settings self._emit(settings.with_palette(app_data)) def _emit(self, settings: DisplaySettings) -> None: self.call(self.on_settings_changed, settings) def _emit_window(self, window: WindowMode) -> None: - self._emit(self._require_view_model().settings.with_window(window)) + self._emit(self.view_model.settings.with_window(window)) def _request_commit(self) -> None: self.call(self.on_commit) def _request_cancel(self) -> None: self.call(self.on_cancel) - - def _require_view_model(self) -> DisplaySettingsViewModel: - """The settings on screen. - - Raises: - SystemError: when the window is drawn before :meth:`open` seeds it. - """ - if self._view_model is None: - raise SystemError("The display settings window is drawn from a view model it was opened with") - - return self._view_model 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..44b1ee513 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/export.py @@ -0,0 +1,218 @@ +from typing import Dict, Final, 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.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, +) +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.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.layout.centered import centered +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow +from sampletones_application.ui.themes.registry import ThemeRegistry +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 + +RING_STYLE: Final[int] = 1 + + +class GUIExportWindow(GUISeededDialogWindow[SongExportViewModel]): + """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. + + Canceling is offered for as long as the run can still answer one. + """ + + 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._indicator = layout.export.indicator + + 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, + geometry=layout.export.window, + subject="export", + key_router=key_router, + shortcut_source=shortcut_source, + ) + + 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, + base_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON, + focused_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, + ) + ], + 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. The pair + stands centered in the window, and both are drawn in the figure's font, which is the font + the ring takes its size from, so the ring sits level with the line beside it. + """ + with ( + dpg.group( + tag=TAG_SETTINGS_EXPORT_GROUP_WORKING, + show=False, + ), + centered(), + dpg.group(horizontal=True), + ): + dpg.add_loading_indicator( + style=RING_STYLE, + radius=self._indicator.radius, + thickness=self._indicator.thickness, + ) + dpg.add_text("", tag=TAG_SETTINGS_EXPORT_TEXT_FIGURE) + + FontRegistry.bind_to_item( + TAG_SETTINGS_EXPORT_GROUP_WORKING, + 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, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), + ) + + 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_TEXT_FIGURE, + show=view_model.figure_visible, + ) + 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..82fdbe92e 100644 --- a/src/sampletones_application/ui/panels/dialogs/keybindings.py +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Optional +from typing import Callable, Optional import dearpygui.dearpygui as dpg @@ -25,10 +25,10 @@ TAG_SETTINGS_KEYBINDINGS_WINDOW, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.dialog import GUIDialogWindow from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value @@ -47,13 +47,15 @@ CombinationCallback = Callable[[KeyCombination], None] -class GUIKeybindingsWindow(GUIDialogWindow): +class GUIKeybindingsWindow(GUISeededDialogWindow[KeybindingsViewModel]): """Modal form over the keys each action answers to, one row per action grouped by its scope. A row is given keys either way round: clicking its shortcut cell listens for the press to assign, and the entry box below writes a combination out for the actions a press cannot reach. Both report through their own hook, so the owner decides what an assignment means and this - window shows what it decided. + window shows what it decided. A row reads as listening exactly while the capture holds the + keyboard: the capture ends with the press it reads, and the owner listens again where the + reader declines to assign it. The action set is fixed, so the rows are built once per appearance and every later view re-reads their labels; the filter reaches the same rows through their visibility, which keeps a keystroke @@ -71,7 +73,6 @@ def __init__( self._language_manager = language_manager self._layout = layout self._capture: Optional[KeyCapture] = None - self._view_model: Optional[KeybindingsViewModel] = None self._filter = "" self.on_scheme_selected: Optional[StringCallback] = None @@ -88,25 +89,16 @@ def __init__( super().__init__( tag=TAG_SETTINGS_KEYBINDINGS_WINDOW, - width=layout.keybindings.window.width, - height=layout.keybindings.window.height, + geometry=layout.keybindings.window, + subject="keybindings", key_router=key_router, shortcut_source=shortcut_source, ) def open(self, view_model: KeybindingsViewModel) -> None: - """Shows the window listing the actions of the draft being edited.""" - self._view_model = view_model + """Shows the window listing the actions of the draft being edited, over every scope.""" self._filter = "" - self.show() - - def prepare(self, *_args: Any, **_kwargs: Any) -> None: - """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" - - def update_view(self, view_model: KeybindingsViewModel) -> None: - """Re-reads the rows of the open window from the draft as it now stands.""" - self._view_model = view_model - self._render() + super().open(view_model) def create_window(self) -> None: with self.dialog_window( @@ -144,7 +136,7 @@ def create_window(self) -> None: ) def _create_scheme_field(self) -> None: - view_model = self._require_view_model() + view_model = self.view_model with labeled_field( self._label(KeybindingsElements.SCHEME), self._layout.label_width, @@ -189,7 +181,7 @@ def _create_action_list(self) -> None: init_width_or_weight=self._layout.keybindings.action_width, ) dpg.add_table_column(label=self._label(KeybindingsElements.SHORTCUT)) - for group in self._require_view_model().groups: + for group in self.view_model.groups: self._create_group(group) def _create_group(self, group: KeybindingGroup) -> None: @@ -211,7 +203,7 @@ def _create_row(self, row: KeybindingRow) -> None: ) dpg.add_selectable( tag=compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_SHORTCUT), - label=row.combination, + label=row.keys, user_data=row.action, callback=self._on_shortcut_clicked, ) @@ -255,14 +247,23 @@ def _create_action_buttons(self) -> None: width=-1, ) + def listen_again(self) -> None: + """Comes back on screen with the selected row listening for the next press. + + A press the reader declined to assign leaves the row waiting for the one they meant. + """ + self.resume() + self._require_capture().start() + self._render() + 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.""" @@ -274,9 +275,9 @@ def _teardown(self) -> None: def _render(self) -> None: """Shows each action's keys, the standing selection, and what the filter leaves listed.""" - view_model = self._require_view_model() + view_model = self.view_model dpg_set_value(TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME, view_model.scheme) - dpg_set_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, view_model.combination) + dpg_set_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, view_model.keys) dpg_set_value(TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE, view_model.message) for group in view_model.groups: self._render_group(group, view_model.selected) @@ -319,7 +320,7 @@ def _shortcut_label(self, row: KeybindingRow, *, is_selected: bool) -> str: if is_selected and self._capture is not None and self._capture.is_listening: return self._msg_capturing - return row.combination if row.combination else self._lbl_unbound + return row.keys if row.keys else self._lbl_unbound def _on_scheme_changed(self, _sender: Sender, app_data: str) -> None: self.call(self.on_scheme_selected, app_data) @@ -353,11 +354,17 @@ def _on_shortcut_typed(self, _sender: Sender, app_data: str) -> None: self.call(self.on_combination_typed, app_data) def _report_captured(self, combination: KeyCombination) -> None: + """Shows the row done listening, then hands the press to the owner to decide what it means.""" + self._render() self.call(self.on_combination_captured, combination) def _stop_capture(self) -> None: - if self._capture is not None: - self._capture.stop() + """Ends a capture that listens, and shows the row's keys in place of the prompt.""" + if self._capture is None or not self._capture.is_listening: + return + + self._capture.stop() + self._render() def _request_clear(self) -> None: self._stop_capture() @@ -409,14 +416,3 @@ def _require_capture(self) -> KeyCapture: raise SystemError("The keybindings window listens for a press only while it is open") return self._capture - - def _require_view_model(self) -> KeybindingsViewModel: - """The actions on screen. - - Raises: - SystemError: when the window is drawn before :meth:`open` seeds it. - """ - if self._view_model is None: - raise SystemError("The keybindings window is drawn from a view model it was opened with") - - return self._view_model diff --git a/src/sampletones_application/ui/panels/dialogs/nsf.py b/src/sampletones_application/ui/panels/dialogs/nsf.py new file mode 100644 index 000000000..99eb69336 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/nsf.py @@ -0,0 +1,530 @@ +from dataclasses import dataclass +from typing import Callable, Dict, Final, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.colors.text import TextColors +from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.tags.settings import ( + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_GROUP_DESTINATION, + TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_ARTIST, + TAG_SETTINGS_NSF_INPUT_COPYRIGHT, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_PATH_DESTINATION, + TAG_SETTINGS_NSF_TEXT_ARTIST_SIZE, + TAG_SETTINGS_NSF_TEXT_COPYRIGHT_SIZE, + TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, + TAG_SETTINGS_NSF_TEXT_LENGTH, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + TAG_SETTINGS_NSF_TEXT_TITLE_SIZE, + TAG_SETTINGS_NSF_WINDOW, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.field import labeled_field, subheader +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIDestinationPathText +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_delete_item, + dpg_set_value, +) +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.keyboard.focus.tree import is_item_active +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + +HEXADECIMAL_BASE: Final[int] = 16 + +ChoicesCallback = Callable[[NSFExportChoices], None] + + +@dataclass(frozen=True) +class HeaderField: + """One of the header's text fields, as the dialog lays it out and edits it. + + Attributes: + input_tag: The field the text is typed into. + size_tag: The line stating the bytes the text takes. + label: What the field is known by. + read: The field's text, out of the header information. + edit: The choices holding a typed text in the field. + """ + + input_tag: str + size_tag: str + label: str + read: Callable[[NSFInformation], str] + edit: Callable[[NSFExportChoices, str], NSFExportChoices] + + +class GUINSFExportWindow(GUISeededDialogWindow[NSFExportViewModel]): + """Modal form over writing a project or a reconstruction as an NSF program. + + The dialog sets the export up and hands it over: the program's text, the channels it sounds, + how it repeats and how hard it is compressed are chosen here along with the file, and the + export's own window reports the run that follows. + + Every control reports the whole edited choices through ``on_choices_changed``, which the owner + reconciles and hands back, so the dialog shows the choices as they end up. A field being typed + into keeps the text the reader types, and takes the text the header holds once it is left. + """ + + def __init__( + self, + *, + layout: SettingsLayout, + path_colors: PathColors, + text_colors: TextColors, + language_manager: LanguageManager, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._layout = layout + self._path_colors = path_colors + self._text_colors = text_colors + self._status_bar = status_bar + self._destination_text: Optional[GUIDestinationPathText] = None + self._handler_tag = compose_tag(TAG_SETTINGS_NSF_WINDOW, SUF_HANDLER_REGISTRY) + + self.on_choices_changed: Optional[ChoicesCallback] = None + self.on_browse: Optional[VoidCallback] = None + self.on_export: Optional[VoidCallback] = None + self.on_close: Optional[VoidCallback] = None + + self._repeats_by_label: Dict[str, NSFRepeat] = {} + self._schemes_by_label: Dict[str, CompressionScheme] = {} + + self._fmt_text_size = language_manager["settings.nsf.template.text_size"] + self._fmt_frame = language_manager["settings.nsf.template.frame"] + self._fmt_frame_count = language_manager["settings.nsf.template.frame_count"] + self._fmt_length = language_manager["settings.nsf.template.length"] + self._msg_destination = language_manager["global.status.message.destination"] + self._fields: Tuple[HeaderField, ...] = ( + HeaderField( + input_tag=TAG_SETTINGS_NSF_INPUT_TITLE, + size_tag=TAG_SETTINGS_NSF_TEXT_TITLE_SIZE, + label=language_manager["settings.nsf.label.title"], + read=lambda information: information.title, + edit=NSFExportChoices.with_title, + ), + HeaderField( + input_tag=TAG_SETTINGS_NSF_INPUT_ARTIST, + size_tag=TAG_SETTINGS_NSF_TEXT_ARTIST_SIZE, + label=language_manager["settings.nsf.label.artist"], + read=lambda information: information.artist, + edit=NSFExportChoices.with_artist, + ), + HeaderField( + input_tag=TAG_SETTINGS_NSF_INPUT_COPYRIGHT, + size_tag=TAG_SETTINGS_NSF_TEXT_COPYRIGHT_SIZE, + label=language_manager["settings.nsf.label.copyright"], + read=lambda information: information.copyright, + edit=NSFExportChoices.with_copyright, + ), + ) + self._repeat_labels: Dict[NSFRepeat, str] = { + NSFRepeat.ONCE: language_manager["settings.nsf.label.repeat_once"], + NSFRepeat.FROM_START: language_manager["settings.nsf.label.repeat_from_start"], + NSFRepeat.FROM_FRAME: language_manager["settings.nsf.label.repeat_from_frame"], + } + self._scheme_labels: Dict[CompressionScheme, str] = { + CompressionScheme.NONE: language_manager["settings.nsf.label.scheme_none"], + CompressionScheme.RUNS: language_manager["settings.nsf.label.scheme_runs"], + CompressionScheme.INSTRUMENTS: language_manager["settings.nsf.label.scheme_instruments"], + CompressionScheme.SEARCH: language_manager["settings.nsf.label.scheme_search"], + } + self._scheme_descriptions: Dict[CompressionScheme, str] = { + CompressionScheme.NONE: language_manager["settings.nsf.message.scheme_none"], + CompressionScheme.RUNS: language_manager["settings.nsf.message.scheme_runs"], + CompressionScheme.INSTRUMENTS: language_manager["settings.nsf.message.scheme_instruments"], + CompressionScheme.SEARCH: language_manager["settings.nsf.message.scheme_search"], + } + + super().__init__( + subject="NSF export", + tag=TAG_SETTINGS_NSF_WINDOW, + geometry=layout.nsf.window, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def create_window(self) -> None: + with self.dialog_window( + label=self._language_manager["settings.nsf.title.window_title"], + on_close=self._request_close, + ): + self._create_program_section() + dpg.add_separator() + self._create_channels_section() + dpg.add_separator() + self._create_playback_section() + dpg.add_separator() + self._create_compression_section() + dpg.add_separator() + self._create_destination() + dpg.add_separator() + self._create_buttons() + + self._create_field_handlers() + self._bind_dialog_theme( + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_INPUT_ARTIST, + TAG_SETTINGS_NSF_INPUT_COPYRIGHT, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_COMBO_SCHEME, + ) + + self._render() + self._install_navigation( + [ + *(FocusStop.field(field.input_tag) for field in self._fields), + *(FocusStop.field(self._channel_tag(channel)) for channel in ChannelName.items()), + FocusStop.field(TAG_SETTINGS_NSF_COMBO_REPEAT), + FocusStop.field(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME), + FocusStop.field(TAG_SETTINGS_NSF_COMBO_SCHEME), + FocusStop.button(TAG_SETTINGS_NSF_BUTTON_BROWSE, self._request_destination), + FocusStop.button(TAG_SETTINGS_NSF_BUTTON_CANCEL, self._request_close), + FocusStop.button(TAG_SETTINGS_NSF_BUTTON_EXPORT, self._request_export), + ], + on_escape=self._request_close, + ) + + def _create_program_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_program"]) + for field in self._fields: + with labeled_field(field.label, self._layout.label_width): + dpg.add_input_text( + tag=field.input_tag, + width=self._layout.nsf.text_width, + callback=self._on_text_changed, + user_data=field, + ) + dpg.add_text("", tag=field.size_tag) + + FontRegistry.bind_to_item(field.size_tag, Font.MONO_SMALL) + dpg_set_palette_color(field.size_tag, self._text_colors.disabled) + + def _create_channels_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_channels"]) + self._create_channel_checkboxes() + dpg.add_text( + self._language_manager["settings.nsf.message.no_channel"], + tag=TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + ) + dpg_set_palette_color(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, self._text_colors.highlight) + + @table_wrapper(columns=len(ChannelName.items()), height=0) + def _create_channel_checkboxes(self) -> None: + """Lays the channels out in one row, each tinted in its own color where the source sounds it.""" + view_model = self.view_model + for channel in ChannelName.items(): + tag = self._channel_tag(channel) + dpg.add_checkbox( + tag=tag, + label=channel_label(self._language_manager, channel), + callback=self._on_channel_changed, + user_data=channel, + ) + if view_model.channel_offered(channel): + ThemeRegistry.get(CHANNEL_THEME_TAGS[channel]).bind_to_item(tag) + + def _create_playback_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_playback"]) + with labeled_field( + self._language_manager["settings.nsf.label.repeat"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_NSF_COMBO_REPEAT, + items=[], + width=self._layout.combo_width, + callback=self._on_repeat_changed, + ) + + with ( + dpg.group(tag=TAG_SETTINGS_NSF_GROUP_LOOP_FRAME), + labeled_field( + self._language_manager["settings.nsf.label.loop_frame"], + self._layout.label_width, + ), + ): + dpg.add_input_text( + tag=TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + width=self._layout.nsf.frame_width, + hexadecimal=True, + uppercase=True, + no_spaces=True, + callback=self._on_loop_frame_changed, + ) + dpg.add_text("", tag=TAG_SETTINGS_NSF_TEXT_FRAME_COUNT) + + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, Font.MONO) + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, Font.MONO) + with labeled_field( + self._language_manager["settings.nsf.label.length"], + self._layout.label_width, + ): + dpg.add_text("", tag=TAG_SETTINGS_NSF_TEXT_LENGTH) + FontRegistry.bind_to_item(TAG_SETTINGS_NSF_TEXT_LENGTH, Font.MONO) + + def _create_compression_section(self) -> None: + subheader(self._language_manager["settings.nsf.title.section_compression"]) + with labeled_field( + self._language_manager["settings.nsf.label.scheme"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_NSF_COMBO_SCHEME, + items=[], + width=self._layout.combo_width, + callback=self._on_scheme_changed, + ) + + dpg.add_text( + "", + tag=TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + indent=self._layout.label_width, + wrap=0, + ) + FontRegistry.bind_to_item( + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + Font.REGULAR_SMALL, + ) + dpg_set_palette_color( + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + self._text_colors.disabled, + ) + + def _create_destination(self) -> None: + """Lays out the file the export writes, with the browse button leading the path it stands at.""" + with labeled_field( + self._language_manager["settings.nsf.label.destination"], + self._layout.label_width, + ): + dpg.add_group( + horizontal=True, + tag=TAG_SETTINGS_NSF_GROUP_DESTINATION, + ) + + GUIButton( + tag=TAG_SETTINGS_NSF_BUTTON_BROWSE, + label=self._language_manager["settings.nsf.label.browse_button"], + parent=TAG_SETTINGS_NSF_GROUP_DESTINATION, + callback=self._request_destination, + ) + self._destination_text = GUIDestinationPathText( + tag=TAG_SETTINGS_NSF_PATH_DESTINATION, + path=self.view_model.destination, + parent=TAG_SETTINGS_NSF_GROUP_DESTINATION, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_destination, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + + @table_wrapper(columns=2) + def _create_buttons(self) -> None: + GUIButton( + tag=TAG_SETTINGS_NSF_BUTTON_CANCEL, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_close, + width=-1, + ) + GUIButton( + tag=TAG_SETTINGS_NSF_BUTTON_EXPORT, + label=self._language_manager["settings.nsf.label.export_button"], + callback=self._request_export, + width=-1, + ) + + def _create_field_handlers(self) -> None: + """Draws a typed field again once it is left, so it shows the text the header holds.""" + with dpg.item_handler_registry(tag=self._handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._render) + + for tag in (*(field.input_tag for field in self._fields), TAG_SETTINGS_NSF_INPUT_LOOP_FRAME): + dpg.bind_item_handler_registry(tag, self._handler_tag) + + def _render(self) -> None: + """Draws the choices as they reconciled, around whatever field is being typed into.""" + view_model = self.view_model + self._render_fields(view_model) + self._render_channels(view_model) + self._render_repeat(view_model) + self._render_scheme(view_model) + if self._destination_text is not None: + self._destination_text.set_path(view_model.destination) + + dpg_configure_item( + TAG_SETTINGS_NSF_BUTTON_EXPORT, + enabled=view_model.export_enabled, + ) + + def _render_fields(self, view_model: NSFExportViewModel) -> None: + for field in self._fields: + text = field.read(view_model.choices.information) + self._settle_input(field.input_tag, text) + dpg_set_value(field.size_tag, view_model.text_size_label(text, self._fmt_text_size)) + + def _render_channels(self, view_model: NSFExportViewModel) -> None: + for channel in ChannelName.items(): + tag = self._channel_tag(channel) + dpg_configure_item(tag, enabled=view_model.channel_offered(channel)) + dpg_set_value(tag, view_model.channel_sounded(channel)) + + dpg_configure_item( + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + show=not view_model.export_enabled, + ) + + def _render_repeat(self, view_model: NSFExportViewModel) -> None: + self._repeats_by_label = {self._repeat_labels[repeat]: repeat for repeat in view_model.repeats} + dpg_configure_item( + TAG_SETTINGS_NSF_COMBO_REPEAT, + items=list(self._repeats_by_label), + ) + dpg_set_value( + TAG_SETTINGS_NSF_COMBO_REPEAT, + self._repeat_labels[view_model.choices.repeat], + ) + dpg_configure_item( + TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, + show=view_model.loop_frame_visible, + ) + dpg_configure_item( + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + enabled=view_model.loop_frame_visible, + ) + self._settle_input( + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + view_model.loop_frame_label(self._fmt_frame), + ) + dpg_set_value( + TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, + view_model.frame_count_label(self._fmt_frame_count), + ) + dpg_set_value( + TAG_SETTINGS_NSF_TEXT_LENGTH, + view_model.length_label(self._fmt_length), + ) + + def _render_scheme(self, view_model: NSFExportViewModel) -> None: + scheme = view_model.choices.scheme + self._schemes_by_label = {self._scheme_labels[offered]: offered for offered in view_model.schemes} + dpg_configure_item( + TAG_SETTINGS_NSF_COMBO_SCHEME, + items=list(self._schemes_by_label), + ) + dpg_set_value( + TAG_SETTINGS_NSF_COMBO_SCHEME, + self._scheme_labels[scheme], + ) + dpg_set_value( + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + self._scheme_descriptions[scheme], + ) + + @staticmethod + def _settle_input(tag: str, text: str) -> None: + """Puts ``text`` in a field the reader has left. + + DearPyGui reports a field's typed text again on every frame once its value is set while it + is being typed into, so the field being edited keeps its own text until it is left. + """ + if dpg.does_item_exist(tag) and not is_item_active(tag): + dpg_set_value(tag, text) + + def _on_text_changed( + self, + _sender: Sender, + app_data: str, + field: HeaderField, + ) -> None: + self._emit(field.edit(self._choices(), app_data)) + + def _on_channel_changed( + self, + _sender: Sender, + app_data: bool, + channel: ChannelName, + ) -> None: + view_model = self.view_model + self._emit(view_model.choices.with_channel(channel, bool(app_data), view_model.offer)) + + def _on_repeat_changed(self, _sender: Sender, app_data: str) -> None: + view_model = self.view_model + self._emit(view_model.choices.with_repeat(self._repeats_by_label[app_data], view_model.offer)) + + def _on_loop_frame_changed(self, _sender: Sender, app_data: str) -> None: + """Takes the frame typed in hexadecimal, once the field holds a number to read.""" + try: + frame = int(app_data, HEXADECIMAL_BASE) + except ValueError: + return + + view_model = self.view_model + self._emit(view_model.choices.with_loop_frame(frame, view_model.offer)) + + def _on_scheme_changed(self, _sender: Sender, app_data: str) -> None: + view_model = self.view_model + self._emit( + view_model.choices.with_scheme(self._schemes_by_label[app_data], view_model.offer), + ) + + def _emit(self, choices: NSFExportChoices) -> None: + self.call(self.on_choices_changed, choices) + + def _request_destination(self) -> None: + self.call(self.on_browse) + + def _request_export(self) -> None: + if self.view_model.export_enabled: + self.call(self.on_export) + + def _request_close(self) -> None: + self.call(self.on_close) + + def _teardown(self) -> None: + """Releases the keyboard claim and the field handlers of the appearance being torn down.""" + super()._teardown() + dpg_delete_item(self._handler_tag) + + def _choices(self) -> NSFExportChoices: + return self.view_model.choices + + @staticmethod + def _channel_tag(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index 1254a6666..ab63ae7fe 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -1,3 +1,4 @@ +from functools import partial from typing import Any, Callable, Optional import dearpygui.dearpygui as dpg @@ -44,7 +45,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 @@ -105,8 +106,7 @@ def __init__( super().__init__( tag=TAG_SETTINGS_PROPERTIES_WINDOW, - width=layout.window.width, - height=layout.window.height, + geometry=layout.window, key_router=key_router, shortcut_source=shortcut_source, ) @@ -254,15 +254,18 @@ def _create_action_buttons(self) -> None: ) def _commit(self) -> None: - self.call( - self.on_commit, - dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE)[:MAX_PROJECT_TITLE_LENGTH], - dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR)[:MAX_PROJECT_AUTHOR_LENGTH], - dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT)[:MAX_PROJECT_COMMENT_LENGTH], - int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT)), - int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT)), + """Reports the edited form once the window has left the screen, reading the fields first.""" + self._leave_then( + partial( + self.call, + self.on_commit, + dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE)[:MAX_PROJECT_TITLE_LENGTH], + dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR)[:MAX_PROJECT_AUTHOR_LENGTH], + dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT)[:MAX_PROJECT_COMMENT_LENGTH], + int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT)), + int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT)), + ) ) - self.hide() @staticmethod def _label( diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py index 459494d26..3ee79680e 100644 --- a/src/sampletones_application/ui/panels/dialogs/render.py +++ b/src/sampletones_application/ui/panels/dialogs/render.py @@ -1,10 +1,14 @@ -from typing import Any, Callable, Dict, Optional +from typing import Callable, Dict, Optional import dearpygui.dearpygui as dpg from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.path import PathColors from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, +) from sampletones_application.tags.settings import ( TAG_SETTINGS_RENDER_BUTTON_BROWSE, TAG_SETTINGS_RENDER_BUTTON_CANCEL, @@ -27,12 +31,13 @@ TAG_SETTINGS_RENDER_WINDOW, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.dialog import GUIDialogWindow from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.path import GUIDestinationPathText +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value @@ -49,7 +54,7 @@ SettingsCallback = Callable[[SongRenderSettings], None] -class GUIRenderWindow(GUIDialogWindow): +class GUIRenderWindow(GUISeededDialogWindow[SongRenderViewModel]): """Modal form over writing the open song to an audio file. The dialog has two faces and shows one at a time: the setup, where the file is described and @@ -76,7 +81,6 @@ def __init__( self._layout = layout self._path_colors = path_colors self._status_bar = status_bar - self._view_model: Optional[SongRenderViewModel] = None self._destination_text: Optional[GUIDestinationPathText] = None self.on_settings_changed: Optional[SettingsCallback] = None @@ -106,26 +110,13 @@ def __init__( } super().__init__( + subject="render", tag=TAG_SETTINGS_RENDER_WINDOW, - width=layout.render.window.width, - height=layout.render.window.height, + geometry=layout.render.window, key_router=key_router, shortcut_source=shortcut_source, ) - def open(self, view_model: SongRenderViewModel) -> None: - """Shows the window seeded with the render being set up.""" - self._view_model = view_model - self.show() - - def prepare(self, *_args: Any, **_kwargs: Any) -> None: - """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" - - def update_view(self, view_model: SongRenderViewModel) -> None: - """Re-seeds the controls of the open window from where the render stands.""" - self._view_model = view_model - self._render() - def create_window(self) -> None: with self.dialog_window( label=self._language_manager["settings.render.title.window_title"], @@ -152,7 +143,12 @@ def create_window(self) -> None: FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_BROWSE, self._request_destination), FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CLOSE, self._request_close), FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_START, self._request_render), - FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CANCEL, self._request_cancel), + FocusStop.button( + TAG_SETTINGS_RENDER_BUTTON_CANCEL, + self._request_cancel, + base_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON, + focused_theme_tag=TAG_GLOBAL_THEME_DANGER_BUTTON_FOCUSED, + ), ], on_escape=self._request_close, ) @@ -259,7 +255,7 @@ def _create_destination(self) -> None: ) self._destination_text = GUIDestinationPathText( tag=TAG_SETTINGS_RENDER_PATH_DESTINATION, - path=self._require_view_model().destination, + path=self.view_model.destination, parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, color=self._path_colors.default, hover_color=self._path_colors.hover, @@ -299,11 +295,12 @@ def _create_progress(self) -> None: label=self._language_manager["global.dialog.label.cancel"], callback=self._request_cancel, width=-1, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), ) def _render(self) -> None: """Shows the face the phase calls for, with each choice standing at what it reconciled to.""" - view_model = self._require_view_model() + view_model = self.view_model self._render_setup(view_model) self._render_progress(view_model) @@ -429,22 +426,11 @@ def _request_close(self) -> None: A render already stopping, and one that has reported its outcome, answer neither — what they are waiting for is the service, which arrives on its own. """ - view_model = self._require_view_model() + view_model = self.view_model if view_model.cancel_enabled: self._request_cancel() elif view_model.setup_visible: self.call(self.on_close) def _settings(self) -> SongRenderSettings: - return self._require_view_model().settings - - def _require_view_model(self) -> SongRenderViewModel: - """The render on screen. - - Raises: - SystemError: when the window is drawn before :meth:`open` seeds it. - """ - if self._view_model is None: - raise SystemError("The render window is drawn from a view model it was opened with") - - return self._view_model + return self.view_model.settings diff --git a/src/sampletones_application/ui/panels/dialogs/scanning.py b/src/sampletones_application/ui/panels/dialogs/scanning.py new file mode 100644 index 000000000..648465c16 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/scanning.py @@ -0,0 +1,128 @@ +from pathlib import Path +from typing import Any, Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.tabs.main.converter import ConverterLayout +from sampletones_application.tags.general import TAG_GLOBAL_THEME_DANGER_BUTTON +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + TAG_MAIN_CONVERTER_PROGRESS_SCAN, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_WINDOW_SCAN, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.window import GUIWindow +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_shared.types.callback import VoidCallback + +ROLL_STEP: Final[float] = 0.02 +ROLL_WIDTH: Final[float] = 0.25 +FULL: Final[float] = 1.0 +NOTHING: Final[float] = 0.0 + + +class GUIScanWindow(GUIWindow): + """What the reader is waiting for while a folder is being read. + + Reading a folder of thousands takes seconds, so the wait is put on screen: the folder being + read, how many recordings have turned up so far, an indicator that keeps moving while the walk + does, and a **Stop**. The window stands beside the interface rather than over it, so the reader + carries on with everything else while it reads. + + The indicator rolls rather than filling, since how much of a tree is left is not known until + the walk reaches the end of it. + """ + + _claims_the_screen = False + + def __init__( + self, + *, + layout: ConverterLayout, + language_manager: LanguageManager, + ) -> None: + self._title = language_manager["main.converter.title.scan_dialog"] + self._opening = language_manager["main.converter.message.scan_opening"] + self._progress = language_manager["main.converter.template.scan_progress"] + self._stop_label = language_manager["main.converter.label.stop_scan_button"] + self._root = Path() + self._rolling = False + self._position = NOTHING + + self.on_stop: Optional[VoidCallback] = None + + super().__init__( + tag=TAG_MAIN_CONVERTER_WINDOW_SCAN, + geometry=layout.scan, + ) + + def open(self, root: Path) -> None: + """Says which folder is being read, and starts the indicator moving.""" + self._root = root + self._position = NOTHING + self.show() + self._roll_on() + + def close(self) -> None: + """Takes the window away, which is what a walk ending or giving up leaves behind.""" + self._rolling = False + self.hide() + + def report(self, count: int) -> None: + """Says how many recordings the walk has met so far.""" + dpg_set_value( + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + self._progress.format(count=count, name=self._root.name), + ) + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The folder is named by :meth:`open` before the tree is built.""" + + def create_window(self) -> None: + with self.dialog_window(label=self._title, on_close=None): + dpg.add_text( + self._opening.format(name=self._root.name), + tag=TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + wrap=self.width, + ) + dpg.add_progress_bar( + tag=TAG_MAIN_CONVERTER_PROGRESS_SCAN, + default_value=NOTHING, + width=-1, + ) + dpg.add_separator() + GUIButton( + tag=TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + label=self._stop_label, + callback=self._stop, + width=-1, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), + ) + + def _stop(self) -> None: + """Takes the window away and asks the walk to give up, which is what **Stop** means here. + + The window answers the gesture itself rather than the walk's next word, so pressing + **Stop** is what closes it however the reading ends. A walk that reaches the end of its + tree finds the window already gone and takes it away again, which leaves it where it is. + """ + self.close() + self.call(self.on_stop) + + def _roll_on(self) -> None: + """Keeps the indicator moving for as long as the walk it stands for runs.""" + self._rolling = True + FrameCallbackManager.set_frame_callback(self._roll) + + def _roll(self) -> None: + if not self._rolling or not dpg.does_item_exist(TAG_MAIN_CONVERTER_PROGRESS_SCAN): + return + + self._position = (self._position + ROLL_STEP) % (FULL + ROLL_WIDTH) + dpg_set_value(TAG_MAIN_CONVERTER_PROGRESS_SCAN, min(self._position, FULL)) + dpg_configure_item(TAG_MAIN_CONVERTER_PROGRESS_SCAN, overlay="") + FrameCallbackManager.set_frame_callback(self._roll) 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..1acc28212 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -0,0 +1,220 @@ +from functools import partial +from pathlib import Path +from typing import Any, Callable, Final, FrozenSet, List, Optional, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.layout.tabs.main.converter import ConverterLayout +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.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import PICKED_SOURCES +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_shared.types.callback import PathCallback + +Answer = Callable[[List[Path]], None] + +ADD_FOCUS_STOP: Final[int] = 1 +NO_PICK: Final[int] = 0 + + +class GUIStemSelectionWindow(GUIDialogWindow): + """The question of which recordings a mix is built from, drawn the way the converter's list is. + + A mix reaches a fixed number of recordings, so a longer list is put to the reader as the same + rows the card shows — folders standing as folders, opening onto what they hold, each row + picked by the box beside it. As many as the mix has room for arrive picked, and any row moves, + so swapping the eighth for the ninth is a click each. The line above reads what stands picked + against the room, and the mix is settled once the pick fits. + + One layout answers both places a mix runs out of room: turning the output switch on a longer + list, and gathering a folder that overflows what is left. Both put the same question — which + recordings the mix is built from — so a folder is offered beside what the mix already stands + on and the answer names the whole of it. + + A double-click sounds the recording it lands on, the way the converter's own list does, so the + reader hears what a row stands for before deciding whether it belongs in the mix. + """ + + def __init__( + self, + *, + layout: ConverterLayout, + stems_layout: StemsListLayout, + glyphs: CommonGlyphs, + stem_colors: StemColors, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + 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._footer_height = layout.stem_selection_footer + self._rows: Tuple[StemRowViewModel, ...] = () + self._picked: FrozenSet[str] = frozenset() + self._room = 0 + self._list = GUIStemsList( + prefix=PRE_MAIN_CONVERTER_CANDIDATE, + layout=stems_layout, + ceiling=layout.stem_selection_list, + glyphs=glyphs, + stem_colors=stem_colors, + language_manager=language_manager, + status_bar=status_bar, + offer=PICKED_SOURCES, + ) + self.on_source_played: Optional[PathCallback] = None + self._answer: Optional[Answer] = None + + self._list.on_row_picked = self._on_picked + self._list.on_row_opened = lambda key: self.call(self.on_source_played, Path(key)) + + super().__init__( + tag=TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, + geometry=layout.stem_selection, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open( + self, + rows: Sequence[StemRowViewModel], + room: int, + answer: Answer, + ) -> None: + """Shows the rows offered, picking as many recordings as the mix has room for. + + ``answer`` is what the pick reaches, which is the question this opening puts. + """ + self._rows = tuple(rows) + self._room = room + self._answer = answer + self._picked = frozenset(recording.key for recording in self._view().recordings[:room]) + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The rows 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._list.create(TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION) + + dpg.add_separator() + self._create_action_buttons() + + self._render() + 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, + ) + + @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, + enabled=self._fits, + ) + + def _view(self) -> StemsListViewModel: + """The rows as the question draws them: one plain run, with what stands picked.""" + return StemsListViewModel( + rows=self._rows, + channels_in_play=(), + muted_channels=frozenset(), + picked_keys=self._picked, + picking_room=self._room, + live=True, + collapse_levels=True, + selected_key=None, + ) + + def _render(self) -> None: + """Draw what stands picked: the rows, the line counting them, and whether the mix fits.""" + self._list.update_view(self._view()) + dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, self._limit_text()) + dpg_configure_item(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, enabled=self._fits) + + def _limit_text(self) -> str: + return self._limit_template.format( + picked=len(self._picked), + total=len(self._view().recordings), + room=self._room, + ) + + def _on_picked(self, key: str) -> None: + """Picks the recordings a row stands for, or lets them go where they all stand picked. + + A mix is built from a fixed number of recordings, so a row takes as many as the room left + reaches and a full pick waits for something to leave. + """ + view_model = self._view() + row = view_model.row(key) + if row is None: + return + + self._picked = view_model.picking_settled(row) + self._render() + + @property + def _fits(self) -> bool: + """The pick is one a mix can be built from: at least one recording, and no more than fit.""" + return NO_PICK < len(self._picked) <= self._room + + def _add(self) -> None: + """Answers with the pick once the question has left the screen, so what follows opens alone.""" + if not self._fits: + return + + self._leave_then(partial(self.call, self._answer, list(self._view().picked_paths))) diff --git a/src/sampletones_application/ui/panels/instruction/__init__.py b/src/sampletones_application/ui/panels/instruction/__init__.py new file mode 100644 index 000000000..e69de29bb 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..e6c546024 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() @@ -262,7 +262,7 @@ def update_view(self, view_model: LibraryPanelViewModel) -> None: ) def set_tree_enabled(self, enabled: bool) -> None: - """Locks the tree and the control reading it again, leaving a running generation cancellable.""" + """Locks the tree and the control reading it again, leaving a running generation cancelable.""" dpg_configure_item( self._tags.group_tree, enabled=enabled, @@ -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: @@ -318,12 +318,11 @@ def _build_tree_node( is_current = isinstance(node, LibraryNode) and self._is_current_library_node(node) should_expand = is_current or self._should_expand_node(node) - leaf = isinstance(node, GeneratorNode) self._append_spec( node, node_tag, state.parent, - leaf=leaf, + leaf=not node.children, should_expand=should_expand, open_on_double_click=True, ) @@ -434,6 +433,14 @@ def _add_context_menu_generator_node(self, node: GeneratorNode) -> None: user_data=node, ) + def _node_label(self, node: TreeNode) -> str: + if isinstance(node, LibraryNode) and node.outdated: + return self._language_manager["instructions.library.template.library_node_outdated_template"].format( + node.name + ) + + return node.name + def _is_current_library_node(self, node: TreeNode) -> bool: if not isinstance(node, LibraryNode): return False 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..31252938c 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 typing import Any, Callable, Optional 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,14 +19,17 @@ 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 + self.on_position_clicked: Optional[Callable[[int], None]] = None super().__init__( tag=TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM, @@ -46,9 +51,14 @@ def create_panel(self, parent: str) -> None: tag=TAG_INSTRUCTIONS_INSTRUCTION_PANEL_INSTRUCTION_WAVEFORM, parent=self._body_container, layout=self._layout, + channel_colors=self._channel_colors, language_manager=self._language_manager, status_bar=self._status_bar, ) + self.display.on_position_clicked = self._on_position_clicked + + def _on_position_clicked(self, position: int) -> None: + self.call(self.on_position_clicked, position) def set_display_height(self, height: int) -> None: self.display.set_height(height) @@ -57,7 +67,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/__init__.py b/src/sampletones_application/ui/panels/main/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/main/advanced.py b/src/sampletones_application/ui/panels/main/advanced.py index 97becf51e..e1fcdd9f9 100644 --- a/src/sampletones_application/ui/panels/main/advanced.py +++ b/src/sampletones_application/ui/panels/main/advanced.py @@ -61,8 +61,6 @@ def __init__( self.on_advanced_settings_changed: Optional[Callable[[AdvancedSettingsUpdate], None]] = None self.on_select_library_directory: Optional[VoidCallback] = None self.on_select_output_directory: Optional[VoidCallback] = None - self.on_update_library_directory: Optional[VoidCallback] = None - self.on_update_output_directory: Optional[VoidCallback] = None self._library_directory: Path = initial_view.library_directory self._output_directory: Path = initial_view.reconstructions_directory @@ -270,8 +268,6 @@ def change_library_directory(self, directory_path: Path) -> None: if self.library_path_text: self.library_path_text.set_path(directory_path) - self.call(self.on_update_library_directory) - def _on_select_output_directory(self) -> None: self.call(self.on_select_output_directory) @@ -282,8 +278,6 @@ def change_reconstructions_directory(self, directory_path: Path) -> None: if self.output_path_text is not None: self.output_path_text.set_path(directory_path) - self.call(self.on_update_output_directory) - @property def library_directory(self) -> Path: return self._library_directory diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py deleted file mode 100644 index a5de9f920..000000000 --- a/src/sampletones_application/ui/panels/main/converter.py +++ /dev/null @@ -1,236 +0,0 @@ -from typing import Any, Optional - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.general.colors.path import PathColors -from sampletones_application.layout.tabs.main.converter import ConverterLayout -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_DANGER_BUTTON, - TAG_GLOBAL_THEME_PANEL_EMPHASIS, - TAG_GLOBAL_THEME_PRIMARY_BUTTON, -) -from sampletones_application.tags.main import ( - TAG_MAIN_CONVERTER_BUTTON_ACTION, - TAG_MAIN_CONVERTER_GROUP, - TAG_MAIN_CONVERTER_GROUP_CONVERT, - TAG_MAIN_CONVERTER_GROUP_SUMMARY, - 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_SUMMARY_HINT, - TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, - TAG_MAIN_CONVERTER_WINDOW_SUMMARY, -) -from sampletones_application.ui.elements.button import GUIButton -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.themes.registry import ThemeRegistry -from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.gui.dpg import ( - dpg_configure_item, - dpg_set_item_callback, - dpg_set_value, -) -from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip -from sampletones_application.view_model.main.converter import ( - ConverterAction, - ConverterViewModel, -) -from sampletones_shared.types.callback import VoidCallback - - -class GUIConverterPanel(GUIPanel): - def __init__( - self, - *, - layout: ConverterLayout, - path_colors: PathColors, - initial_collapsed: bool = False, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - ) -> None: - self._language_manager = language_manager - self.input_path_text: Optional[GUIPathText] = None - self.output_path_text: Optional[GUIDestinationPathText] = None - self._status_bar = status_bar - self._action_button: Optional[GUIButton] = None - self._theme_convert: Optional[Theme] = None - self._theme_cancel: Optional[Theme] = None - - self.on_convert_requested: Optional[VoidCallback] = None - self.on_cancel_requested: Optional[VoidCallback] = None - - self._layout = layout - 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._enable_vertical_collapse(initial_collapsed=initial_collapsed) - - def create_panel(self, parent: str) -> None: - with self._collapsible_card( - parent, - self._language_manager["main.converter.label.section"], - glyph=self._glyphs.headers.converter, - width=self.width, - card_theme=TAG_GLOBAL_THEME_PANEL_EMPHASIS, - ): - self._create_action_button() - dpg.add_separator() - self._create_summary() - self._create_conversion_status() - - 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) - - 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) - - def _update_status(self, view_model: ConverterViewModel) -> None: - dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STATUS, view_model.status_text) - dpg_set_value(TAG_MAIN_CONVERTER_PROGRESS, view_model.progress) - dpg_configure_item(TAG_MAIN_CONVERTER_PROGRESS, overlay=view_model.progress_overlay) - - def _update_paths(self, view_model: ConverterViewModel) -> None: - if self.input_path_text is not None and view_model.input_path is not None: - self.input_path_text.set_path(view_model.input_path) - if self.output_path_text is not None and view_model.output_path is not None: - self.output_path_text.set_path(view_model.output_path) - - def _update_controls(self, view_model: ConverterViewModel) -> None: - match view_model.primary_action: - case ConverterAction.CANCEL: - callback: VoidCallback = self._on_cancel_clicked - theme = self._theme_cancel - self._status_action_message = self._language_manager["main.converter.message.status_cancel"] - case ConverterAction.CONVERT: - callback = self._on_convert_clicked - theme = self._theme_convert - self._status_action_message = self._msg_status_convert - - dpg_configure_item( - TAG_MAIN_CONVERTER_BUTTON_ACTION, - label=view_model.action_label, - enabled=view_model.primary_action_enabled, - ) - dpg_set_item_callback(TAG_MAIN_CONVERTER_BUTTON_ACTION, callback) - if self._action_button is not None and theme is not None: - self._action_button.set_theme(theme) - dpg_configure_item( - TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, - show=view_model.other_operation_active and view_model.primary_action == ConverterAction.CONVERT, - ) - - 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) - with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONVERT): - self._action_button = GUIButton( - label=self._language_manager["main.converter.label.convert_sample_button"], - tag=TAG_MAIN_CONVERTER_BUTTON_ACTION, - width=self._layout.width, - height=self._layout.button_height, - font=Font.BOLD_LARGE, - enabled=False, - callback=self._on_convert_clicked, - theme=self._theme_convert, - ) - attach_disabled_tooltip( - TAG_MAIN_CONVERTER_GROUP_CONVERT, - self._language_manager["global.dialog.message.operation_in_progress"], - tag=TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, - ) - self._status_bar.bind_to_item( - TAG_MAIN_CONVERTER_BUTTON_ACTION, - self._action_status_message, - ) - - 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, - ) - 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( - tag=TAG_MAIN_CONVERTER_GROUP, - show=False, - ): - dpg.add_text( - self._language_manager["main.converter.message.status_waiting"], - tag=TAG_MAIN_CONVERTER_TEXT_STATUS, - parent=TAG_MAIN_CONVERTER_GROUP, - ) - FontRegistry.bind_to_item( - TAG_MAIN_CONVERTER_TEXT_STATUS, - Font.MONO_SMALL, - ) - dpg.add_progress_bar( - tag=TAG_MAIN_CONVERTER_PROGRESS, - parent=TAG_MAIN_CONVERTER_GROUP, - default_value=0.0, - width=-1, - overlay="0%", - ) - FontRegistry.bind_to_item(TAG_MAIN_CONVERTER_PROGRESS, Font.MONO) - - def _on_convert_clicked(self) -> None: - self.call(self.on_convert_requested) - - def _on_cancel_clicked(self) -> None: - self.call(self.on_cancel_requested) diff --git a/src/sampletones_application/ui/panels/main/converter/__init__.py b/src/sampletones_application/ui/panels/main/converter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/main/converter/action.py b/src/sampletones_application/ui/panels/main/converter/action.py new file mode 100644 index 000000000..db949a135 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/action.py @@ -0,0 +1,111 @@ +from typing import Any, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.tabs.main.converter import ConverterLayout +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_PRIMARY_BUTTON, +) +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_GROUP_CONVERT, + TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_item_callback +from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip +from sampletones_application.view_model.main.converter import ConverterAction, ConverterViewModel +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +class ConverterActionButton(CallbackMixin): + """The one button a run is started and stopped from. + + Its label says what the run writes, so the switch above it and the button read as one sentence; + while a conversion holds resources it cancels instead, and takes the tone that says so. + """ + + def __init__( + self, + *, + layout: ConverterLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._layout = layout + self._language_manager = language_manager + self._status_bar = status_bar + self._button: Optional[GUIButton] = None + self._theme_convert: Optional[Theme] = None + self._theme_cancel: Optional[Theme] = None + self._msg_convert = language_manager["main.converter.message.status_convert"] + self._msg_cancel = language_manager["main.converter.message.status_cancel"] + self._status_message = self._msg_convert + + self.on_convert_requested: Optional[VoidCallback] = None + self.on_cancel_requested: Optional[VoidCallback] = None + + def create(self) -> None: + """Build the button, opening on the label a converter with nothing gathered carries.""" + self._theme_convert = ThemeRegistry.get(TAG_GLOBAL_THEME_PRIMARY_BUTTON) + self._theme_cancel = ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON) + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONVERT): + self._button = GUIButton( + label=self._language_manager["main.converter.label.convert_button"], + tag=TAG_MAIN_CONVERTER_BUTTON_ACTION, + width=self._layout.width, + height=self._layout.button_height, + font=Font.BOLD_LARGE, + enabled=False, + callback=self._on_convert_clicked, + theme=self._theme_convert, + ) + + attach_disabled_tooltip( + TAG_MAIN_CONVERTER_GROUP_CONVERT, + self._language_manager["global.dialog.message.operation_in_progress"], + tag=TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, + ) + self._status_bar.bind_to_item(TAG_MAIN_CONVERTER_BUTTON_ACTION, self._explanation) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Take up what the run now is: the label it writes, and whether the button starts or stops.""" + match view_model.primary_action: + case ConverterAction.CANCEL: + callback: VoidCallback = self._on_cancel_clicked + theme = self._theme_cancel + self._status_message = self._msg_cancel + case ConverterAction.CONVERT: + callback = self._on_convert_clicked + theme = self._theme_convert + self._status_message = self._msg_convert + + dpg_configure_item( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + label=view_model.action_label, + enabled=view_model.primary_action_enabled, + ) + dpg_set_item_callback(TAG_MAIN_CONVERTER_BUTTON_ACTION, callback) + if self._button is not None and theme is not None: + self._button.set_theme(theme) + + dpg_configure_item( + TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, + show=view_model.other_operation_active and view_model.primary_action == ConverterAction.CONVERT, + ) + + def _explanation(self, *_args: Any, **_kwargs: Any) -> str: + return self._status_message + + def _on_convert_clicked(self) -> None: + self.call(self.on_convert_requested) + + def _on_cancel_clicked(self) -> None: + self.call(self.on_cancel_requested) diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py new file mode 100644 index 000000000..680ed7492 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -0,0 +1,193 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_STEMS, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_STEMS, +) +from sampletones_application.ui.elements.fonts.font import Font +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.elements.stems.offer import GATHERED_SOURCES +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, + panel_scope_active, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import PathCallback, StringCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +ChannelsCallback = Callable[[Path, FrozenSet[ChannelName]], None] +ChannelCallback = Callable[[Path, ChannelName], None] +RowCallback = Callable[[Path, SourceKind], None] +PathPairCallback = Callable[[Path, Path], None] +PathOffsetCallback = Callable[[Path, int], None] + + +class ConverterListing(CallbackMixin): + """What a run converts: the gathered recordings, and the hint standing where none are. + + The list reports its gestures by the key a row is drawn under, and this is where that key + becomes the path the logic answers for — including the two a folder answers differently: + removing one takes everything it holds, and its box settles every recording under it. + + A row picked out puts the list on the keyboard: while the Main tab is in front, while the card + stands open and while no field holds the keys, the list answers the presses its own category + names and yields every other, so a press it has no action for still reaches the application's + shortcuts. + """ + + def __init__( + self, + *, + stems_layout: StemsListLayout, + glyphs: CommonGlyphs, + stem_colors: StemColors, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, + card_open: ActivePredicate, + ) -> None: + self._language_manager = language_manager + self._router = key_router + self._shortcuts = shortcut_source + self._tab_active = tab_active + self._card_open = card_open + self._stems_list = GUIStemsList( + prefix=PRE_MAIN_CONVERTER_STEMS, + layout=stems_layout, + ceiling=stems_layout.well_ceiling, + glyphs=glyphs, + stem_colors=stem_colors, + language_manager=language_manager, + status_bar=status_bar, + offer=GATHERED_SOURCES, + ) + + self.on_source_channels_changed: Optional[ChannelsCallback] = None + self.on_folder_channel_toggled: Optional[ChannelCallback] = None + self.on_row_selected: Optional[RowCallback] = None + self.on_source_removed: Optional[PathCallback] = None + self.on_folder_removed: Optional[PathCallback] = None + self.on_source_played: Optional[PathCallback] = None + self.on_source_dropped_on_source: Optional[PathPairCallback] = None + self.on_source_dropped_on_level: Optional[PathOffsetCallback] = None + self.on_menu_requested: Optional[StringCallback] = None + self.on_selection_cleared: Optional[VoidCallback] = None + + self._router.register(self._on_key_pressed, priority=PRIORITY_PANEL, active=self._keys_active) + + @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 create(self) -> None: + """Build the hint and the list below it, and take up the gestures the list reports.""" + with dpg.group(tag=TAG_MAIN_CONVERTER_WINDOW_STEMS): + 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_channels_changed + self._stems_list.on_channel_toggled = self._on_folder_channel_toggled + self._stems_list.on_remove_requested = self._on_removed + self._stems_list.on_row_activated = self._on_selected + self._stems_list.on_menu_requested = lambda key: self.call(self.on_menu_requested, key) + self._stems_list.on_row_opened = lambda key: self.call(self.on_source_played, Path(key)) + self._stems_list.on_dropped_on_row = self._on_dropped_on_row + self._stems_list.on_dropped_on_level = self._on_dropped_on_level + self._stems_list.on_selection_cleared = lambda: self.call(self.on_selection_cleared) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Draw the gathered recordings, with the hint standing while none are. + + A list holding nothing stands its heading and its rules over empty room, so the hint takes + the whole of that room instead and the list comes back with the first recording gathered. + """ + dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=not view_model.listed) + dpg_configure_item(self._stems_list.tag, show=view_model.listed) + self._stems_list.update_view(view_model.stems_list) + + def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: + self.call(self.on_source_channels_changed, Path(key), channels) + + def _on_folder_channel_toggled(self, key: str, channel_name: ChannelName) -> None: + """A folder's box moves every recording it stands for, whichever way they were standing.""" + self.call(self.on_folder_channel_toggled, Path(key), channel_name) + + def _on_selected(self, key: str) -> None: + """A clicked row is the one the settings card inspects.""" + row = self._stems_list.row(key) + if row is not None: + self.call(self.on_row_selected, Path(key), row.kind) + + @property + def keys_active(self) -> bool: + """Whether a key reaching the row picked out answers, which the channel keys ask as well.""" + return self._keys_active() + + def _keys_active(self) -> bool: + """Whether the list owns the next key, which the row it holds picked out is what decides.""" + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._stems_list.picked_key is not None, + card_open=self._card_open, + ) + + def _on_key_pressed(self, event: KeyEvent) -> bool: + """Act on the row picked out, reporting whether the list consumed the press. + + The scheme says which press each of the list's actions answers to; a press its category + leaves unnamed goes on to the application's shortcuts, as does one naming a move the list + is standing inert against. + """ + key = self._stems_list.picked_key + if key is None: + return False + + match self._shortcuts.action(ShortcutCategory.SOURCES, event): + case ShortcutId.SOURCES_REMOVE_SOURCE if self._stems_list.lets_a_row_go: + self._on_removed(key) + case _: + return False + + return True + + def _on_removed(self, key: str) -> None: + """Taking a folder out takes everything it holds, which is a move of its own.""" + row = self._stems_list.row(key) + if row is not None and row.stands_for_a_folder: + self.call(self.on_folder_removed, Path(key)) + return + + self.call(self.on_source_removed, Path(key)) + + def _on_dropped_on_row(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) diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py new file mode 100644 index 000000000..2fdce6544 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -0,0 +1,179 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.main import ( + ConverterFolderElements, + ConverterStemMoveElements, +) +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_path_menu_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.stems.list import GUIStemsList +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +LEVEL_ABOVE: Final[int] = -1 +LEVEL_BELOW: Final[int] = 1 +POSITION_EARLIER: Final[int] = -1 +POSITION_LATER: Final[int] = 1 + +PathOffsetCallback = Callable[[Path, int], None] + + +class ConverterMenus(CallbackMixin): + """What a right-click on the list offers, which follows the row it landed on. + + A recording is played wherever it is met, so its menu leads with the item the file browser + leads with, and offers the moves that rearrange a mix while one is being built. A folder + stands for everything gathered below it, so its menu reaches all of them at once and leaves + the recordings inside it to their own menus. + """ + + def __init__( + self, + *, + stems_list: GUIStemsList, + language_manager: LanguageManager, + shortcut_source: ShortcutSource, + ) -> None: + self._stems_list = stems_list + self._language_manager = language_manager + self._shortcuts = shortcut_source + self._lbl_play = language_manager["global.context.label.play"] + + self.on_source_played: Optional[PathCallback] = 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_folder_removed: Optional[PathCallback] = None + self.on_folder_toggled: Optional[PathCallback] = None + + def show(self, key: str) -> None: + """Offer what the row a gesture landed on can do, reading the kind of row it is.""" + row = self._stems_list.row(key) + if row is None: + return + + if row.stands_for_a_folder: + self._show_folder(row) + return + + self._show_row(row) + + def _show_row(self, row: StemRowViewModel) -> None: + with context_menu(): + self._header(row.name) + add_play_menu_item( + self._lbl_play, + lambda: self.call(self.on_source_played, row.path), + enabled=row.available, + ) + dpg.add_separator() + for element, enabled, callback in self._moves(row): + dpg.add_menu_item( + label=self._label(element), + enabled=enabled, + callback=callback, + ) + + self._create_removal( + self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM), + lambda: self.call(self.on_source_removed, row.path), + ) + if row.path is not None: + add_path_menu_items(self._language_manager, row.path) + + def _show_folder(self, row: StemRowViewModel) -> None: + opened = self._stems_list.stands_open(row.key) + with context_menu(): + self._header(row.name) + dpg.add_menu_item( + label=self._folder_label( + ConverterFolderElements.CONTEXT_CLOSE_FOLDER + if opened + else ConverterFolderElements.CONTEXT_OPEN_FOLDER + ), + callback=lambda: self.call(self.on_folder_toggled, row.path), + ) + self._create_removal( + self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), + lambda: self.call(self.on_folder_removed, row.path), + ) + if row.path is not None: + add_path_menu_items(self._language_manager, row.path) + + def _moves(self, row: StemRowViewModel) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: + """The moves the row can make, which are the ones the list is drawing the levels for. + + A drag makes these same moves, so the menu names them wherever a drag rearranges the list + and stands with removal alone wherever the recordings are one flat run. + """ + path = row.path + if not self._stems_list.rearranges: + return [] + + 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), + ), + ] + + def _create_removal(self, label: str, callback: VoidCallback) -> None: + """The item taking a row out, printing the key that does the same thing. + + The key and the item are one action, so the item reads whatever combination the scheme + gives it and a rebind reaches the menu without another edit. + """ + dpg.add_menu_item( + label=label, + shortcut=self._shortcuts.display(ShortcutId.SOURCES_REMOVE_SOURCE), + enabled=self._stems_list.lets_a_row_go, + callback=callback, + ) + + @staticmethod + def _header(name: str) -> None: + """What the menu names above its items: whatever the gesture landed on.""" + header = dpg.add_text(name) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + + def _label(self, element: ConverterStemMoveElements) -> str: + return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] + + def _folder_label(self, element: ConverterFolderElements) -> str: + return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] diff --git a/src/sampletones_application/ui/panels/main/converter/panel.py b/src/sampletones_application/ui/panels/main/converter/panel.py new file mode 100644 index 000000000..a718c1597 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/panel.py @@ -0,0 +1,191 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SourceKind +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.colors.stem import StemColors +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.general import TAG_GLOBAL_THEME_PANEL_EMPHASIS +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +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.panels.main.converter.action import ConverterActionButton +from sampletones_application.ui.panels.main.converter.listing import ConverterListing +from sampletones_application.ui.panels.main.converter.menus import ConverterMenus +from sampletones_application.ui.panels.main.converter.setup import ConverterSetup +from sampletones_application.ui.panels.main.converter.summary import ConverterSummary +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_shared.types.callback import PathCallback, VoidCallback + +PathOffsetCallback = Callable[[Path, int], None] + + +class GUIConverterPanel(GUIPanel): + """The card a conversion is set up on: what it writes, what it converts, and how far it is. + + The card reads top to bottom as one sentence. The output switch says what a run writes, the + button below repeats it in the words of what is listed, and the list itself is what the run + converts. The order a mix picks in stands under the list, since it answers for what the list + holds, and the destination stands under it. + """ + + def __init__( + self, + *, + layout: ConverterLayout, + stems_layout: StemsListLayout, + inputs: InputsLayout, + path_colors: PathColors, + stem_colors: StemColors, + initial_collapsed: bool = False, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, + ) -> None: + self._language_manager = language_manager + self._setup = ConverterSetup(inputs=inputs, language_manager=language_manager) + self._listing = ConverterListing( + stems_layout=stems_layout, + glyphs=self._glyphs.common, + stem_colors=stem_colors, + language_manager=language_manager, + status_bar=status_bar, + key_router=key_router, + shortcut_source=shortcut_source, + tab_active=tab_active, + card_open=self.card_open, + ) + self._menus = ConverterMenus( + stems_list=self._listing.stems_list, + language_manager=language_manager, + shortcut_source=shortcut_source, + ) + self._action = ConverterActionButton( + layout=layout, + language_manager=language_manager, + status_bar=status_bar, + ) + self._summary = ConverterSummary( + path_colors=path_colors, + language_manager=language_manager, + status_bar=status_bar, + ) + self.on_convert_requested: Optional[VoidCallback] = None + self.on_cancel_requested: Optional[VoidCallback] = None + self.on_output_changed: Optional[Callable[[OutputKind], 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_folder_channel_toggled: Optional[Callable[[Path, ChannelName], None]] = None + self.on_row_selected: Optional[Callable[[Path, SourceKind], None]] = None + self.on_selection_cleared: Optional[VoidCallback] = None + self.on_source_removed: Optional[PathCallback] = None + self.on_folder_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[PathOffsetCallback] = None + self.on_source_played: Optional[PathCallback] = None + + super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) + self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) + self._wire() + + def create_panel(self, parent: str) -> None: + 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._setup.create_output() + self._action.create() + dpg.add_separator() + self._listing.create() + self._setup.create_controls() + self._summary.create_paths() + dpg.add_separator() + self._summary.create_status() + + @property + def stems_list(self) -> GUIStemsList: + """The list the gathered recordings are drawn in, which is what addresses their widgets.""" + return self._listing.stems_list + + @property + def keys_active(self) -> bool: + """Whether a key reaching the row picked out answers: the tab in front, the card open.""" + return self._listing.keys_active + + @property + def input_path_text(self) -> Optional[GUIPathText]: + """The line naming the recording a running conversion is on.""" + return self._summary.input_path_text + + @property + def output_path_text(self) -> Optional[GUIDestinationPathText]: + """The line naming where a run writes.""" + return self._summary.output_path_text + + def is_visible(self) -> bool: + return bool(dpg.get_item_configuration(self.tag)["show"]) + + def update_view(self, view_model: ConverterViewModel) -> None: + self._action.update_view(view_model) + self._summary.update_view(view_model) + self._setup.update_view(view_model) + self._listing.update_view(view_model) + + def _wire(self) -> None: + """Hand each section's reports on to the card's own hooks, which the coordinator wires.""" + self._setup.on_output_changed = lambda output: self.call(self.on_output_changed, output) + self._setup.on_hierarchy_mode_changed = lambda mode: self.call(self.on_hierarchy_mode_changed, mode) + + self._action.on_convert_requested = lambda: self.call(self.on_convert_requested) + self._action.on_cancel_requested = lambda: self.call(self.on_cancel_requested) + + self._listing.on_source_channels_changed = lambda path, channels: self.call( + self.on_source_channels_changed, path, channels + ) + self._listing.on_folder_channel_toggled = lambda path, channel: self.call( + self.on_folder_channel_toggled, path, channel + ) + self._listing.on_row_selected = lambda path, kind: self.call(self.on_row_selected, path, kind) + self._listing.on_selection_cleared = lambda: self.call(self.on_selection_cleared) + self._listing.on_source_removed = lambda path: self.call(self.on_source_removed, path) + self._listing.on_folder_removed = lambda path: self.call(self.on_folder_removed, path) + self._listing.on_source_played = lambda path: self.call(self.on_source_played, path) + self._listing.on_source_dropped_on_source = lambda path, target: self.call( + self.on_source_dropped_on_source, path, target + ) + self._listing.on_source_dropped_on_level = lambda path, position: self.call( + self.on_source_dropped_on_level, path, position + ) + self._listing.on_menu_requested = self._menus.show + + self._menus.on_source_played = lambda path: self.call(self.on_source_played, path) + self._menus.on_source_removed = lambda path: self.call(self.on_source_removed, path) + self._menus.on_source_moved = lambda path, offset: self.call(self.on_source_moved, path, offset) + self._menus.on_source_level_joined = lambda path, offset: self.call(self.on_source_level_joined, path, offset) + self._menus.on_source_isolated = lambda path: self.call(self.on_source_isolated, path) + self._menus.on_folder_removed = lambda path: self.call(self.on_folder_removed, path) + self._menus.on_folder_toggled = self._toggle_folder + + def _toggle_folder(self, root: Path) -> None: + """Whether a folder stands open is the list's own memory, so the menu asks the list.""" + self.stems_list.toggle_folder(str(root)) diff --git a/src/sampletones_application/ui/panels/main/converter/setup.py b/src/sampletones_application/ui/panels/main/converter/setup.py new file mode 100644 index 000000000..c3fcdbdb1 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/setup.py @@ -0,0 +1,120 @@ +from typing import Callable, Dict, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_RADIO_MODE, + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_TOOLTIP_MODE, +) +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.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.tooltip import set_tooltip_visible, show_tooltip +from sampletones_application.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import HierarchyMode +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.callbacks import CallbackMixin + + +class ConverterSetup(CallbackMixin): + """What a run is set up as: the output it writes, and the order a mix picks in. + + The output switch stands at the head of the card and names the run in words, so the button + below it and the switch above read as one sentence. The order answers for a mix of several + recordings, and stands under the list that holds them. + """ + + def __init__( + self, + *, + inputs: InputsLayout, + language_manager: LanguageManager, + ) -> None: + self._language_manager = language_manager + self._input_width = inputs.default_width + self._label_width = inputs.label_width + self._mode_labels: Dict[OutputKind, str] = { + OutputKind.PER_RECORDING: language_manager["main.converter.label.mode_each"], + OutputKind.MIXED: language_manager["main.converter.label.mode_mixed"], + } + 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.on_output_changed: Optional[Callable[[OutputKind], None]] = None + self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None + + def create_output(self) -> None: + """The switch naming what the run writes, which the card opens on.""" + with labeled_field(self._language_manager["main.converter.label.mode"], self._label_width): + mode = dpg.add_radio_button( + items=list(self._mode_labels.values()), + tag=TAG_MAIN_CONVERTER_RADIO_MODE, + horizontal=True, + default_value=self._mode_labels[OutputKind.PER_RECORDING], + callback=self._on_mode_changed, + ) + FontRegistry.bind_to_item(mode, Font.REGULAR_SMALL) + + show_tooltip( + TAG_MAIN_CONVERTER_RADIO_MODE, + self._language_manager["main.converter.message.mode_tooltip"], + tag=TAG_MAIN_CONVERTER_TOOLTIP_MODE, + ) + + def create_controls(self) -> None: + """The order a mix of several recordings picks in, which stands below the list.""" + with ( + dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_ORDER, show=False), + 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[DEFAULT_STEMS_HIERARCHY_MODE], + callback=self._on_hierarchy_mode_edited, + ) + + show_tooltip( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + self._language_manager["main.converter.message.hierarchy_mode_tooltip"], + tag=TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + ) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Draw the setup the view names onto the widgets standing for it.""" + dpg_set_value(TAG_MAIN_CONVERTER_RADIO_MODE, self._mode_labels[view_model.output]) + dpg_configure_item(TAG_MAIN_CONVERTER_RADIO_MODE, enabled=view_model.live) + dpg_configure_item(TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, enabled=view_model.live) + dpg_set_value( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + self._hierarchy_labels[view_model.hierarchy_mode], + ) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_ORDER, show=view_model.mixes_several) + set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.mixes_several) + + def _on_mode_changed(self, _sender: Sender, value: str) -> None: + """The switch names what the run writes, which the reader states in words.""" + for output, label in self._mode_labels.items(): + if label == value: + self.call(self.on_output_changed, output) + return + + def _on_hierarchy_mode_edited(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 diff --git a/src/sampletones_application/ui/panels/main/converter/summary.py b/src/sampletones_application/ui/panels/main/converter/summary.py new file mode 100644 index 000000000..08e1b5a4c --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/summary.py @@ -0,0 +1,105 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_GROUP, + TAG_MAIN_CONVERTER_GROUP_INPUT, + TAG_MAIN_CONVERTER_GROUP_SUMMARY, + TAG_MAIN_CONVERTER_PATH_INPUT_PATH, + TAG_MAIN_CONVERTER_PROGRESS, + TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, + TAG_MAIN_CONVERTER_TEXT_STATUS, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.view_model.main.converter import ConverterViewModel + + +class ConverterSummary: + """Where a run writes, and how far it has come. + + The destination stands whatever the list holds, so a reader always knows where a run would + land. The input line names the recording a running conversion is on, and stands while it is + reporting one. + """ + + def __init__( + self, + *, + path_colors: PathColors, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._path_colors = path_colors + self._status_bar = status_bar + self._msg_path = language_manager["global.status.message.path"] + self._msg_destination = language_manager["global.status.message.destination"] + self.input_path_text: Optional[GUIPathText] = None + self.output_path_text: Optional[GUIDestinationPathText] = None + + def create_paths(self) -> None: + """The recording a run is on, and the place its result lands.""" + dpg.add_separator() + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY): + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_INPUT, 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_INPUT, + 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_status(self) -> None: + """What the running conversion says it is doing, and how far along it is.""" + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP, show=False): + status = dpg.add_text( + self._language_manager["main.converter.message.status_waiting"], + tag=TAG_MAIN_CONVERTER_TEXT_STATUS, + parent=TAG_MAIN_CONVERTER_GROUP, + ) + FontRegistry.bind_to_item(status, Font.MONO_SMALL) + dpg.add_progress_bar( + tag=TAG_MAIN_CONVERTER_PROGRESS, + parent=TAG_MAIN_CONVERTER_GROUP, + default_value=0.0, + width=-1, + overlay="0%", + ) + FontRegistry.bind_to_item(TAG_MAIN_CONVERTER_PROGRESS, Font.MONO) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Name where the run writes, and say where it has got to.""" + if self.input_path_text is not None and view_model.input_path is not None: + self.input_path_text.set_path(view_model.input_path) + if self.output_path_text is not None and view_model.output_path is not None: + self.output_path_text.set_path(view_model.output_path) + + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_INPUT, show=view_model.shows_input) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) + dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STATUS, view_model.status_text) + dpg_set_value(TAG_MAIN_CONVERTER_PROGRESS, view_model.progress) + dpg_configure_item(TAG_MAIN_CONVERTER_PROGRESS, overlay=view_model.progress_overlay) diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 45da542df..c705e0fab 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -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, @@ -54,15 +55,15 @@ def refresh_tree(self) -> None: ... def collapse_all(self) -> None: ... - def expand_directory(self, node: FileSystemNode) -> None: ... + def expand_directory(self, directory_node: FileSystemNode) -> None: ... - def has_loaded_children(self, filepath: Path) -> bool: ... + def has_loaded_children(self, directory_path: Path) -> bool: ... - def is_directory_open(self, filepath: Path) -> bool: ... + def is_directory_open(self, directory_path: Path) -> bool: ... - def set_directory_open(self, filepath: Path, is_open: bool) -> None: ... + def set_directory_open(self, directory_path: Path, is_open: bool) -> None: ... - def has_relevant_content(self, filepath: Path) -> bool: ... + def has_relevant_content(self, directory_path: Path) -> bool: ... class GUIExplorerPanel(GUIFileBrowserPanel): @@ -91,8 +92,8 @@ def __init__( self._language_manager = language_manager self._explorer_logic = explorer_logic - 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.on_reconstruct_directory: Optional[PathCallback] = None self.on_reconstruct_file: Optional[PathCallback] = None self.on_load_reconstruction: Optional[PathCallback] = None @@ -148,7 +149,7 @@ def _refresh_model(self) -> None: def _on_collapse_all_clicked(self) -> None: """Folds every folder away and drops the children it had loaded, so opening one reads it again. - The rows fold while the model still states them, and the folders the model held go afterwards, + The rows fold while the model still states them, and the folders the model held go afterward, which is what makes a later open list the folder as it stands on disk. """ super()._on_collapse_all_clicked() @@ -164,6 +165,7 @@ def _rebuild_node_subtree( lambda: None, lambda: self._collect_subtree_specs(node, node_tag), root_tag=node_tag, + retry=None, ) def _collect_subtree_specs( @@ -174,6 +176,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 +274,31 @@ 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, and a plain click plays it. + + A plain click previews the recording and leaves the conversion as it stands, so walking the + browser to hear what a file holds costs the run nothing. Ctrl is the gathering gesture + throughout the browser, so it reaches a recording the same way it reaches a folder and asks + what **Add as stem** asks, which the converter answers. + """ + if Modifier.CTRL in capture_modifiers(): + self._gather_audio_node(node) + return + + self._logic.request_autoplay(node) + + def _gather_audio_node(self, node: FileSystemNode) -> None: + """Asks the converter to gather a recording, as **Add as stem** does.""" + self.call(self.on_file_add_requested, node.filepath) + def _on_file_node_double_clicked( self, _sender: Sender, @@ -292,8 +312,7 @@ def _on_file_node_double_clicked( case extensions.EXT_FILE_RECONSTRUCTION: self._load_reconstruction(node) case suffix if suffix in extensions.EXT_FILES_AUDIO: - self._logic.cancel_autoplay() - return self._reconstruct_file(node) + self._gather_audio_node(node) case extensions.EXT_FILE_LIBRARY: return self._load_library(node) @@ -331,12 +350,23 @@ def _directory_node_clicked( node: FileSystemNode, node_tag: str, ) -> None: + """Answers a click on a folder: Ctrl gathers its recordings, and a plain click opens it. + + Gathering a folder reads every recording below it, so it waits for the reader to ask for + it. Ctrl asks what **Add folder** asks, so the + folder joins the conversion without the reader leaving the row, and the converter answers + for whether its list takes it. A plain click opens the folder and leaves the conversion as + it stands. + """ has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: return + if Modifier.CTRL in capture_modifiers(): + 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) def _load_reconstruction(self, node: FileSystemNode) -> None: filepath = node.filepath @@ -354,12 +384,6 @@ def _has_relevant_content(self, node: TreeNode) -> bool: return True - def _reconstruct_file(self, node: FileSystemNode) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return - - self.call(self.on_reconstruct_file, node.filepath) - def _toggle_directory_expansion( self, node: FileSystemNode, @@ -403,6 +427,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 +452,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 deleted file mode 100644 index 398f686a3..000000000 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ /dev/null @@ -1,165 +0,0 @@ -from typing import Any, Callable, List, Optional, Tuple - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.context import channel_label -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.general.inputs import InputsLayout -from sampletones_application.layout.tabs.main.reconstructor import ReconstructorLayout -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, - TAG_MAIN_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, -) -from sampletones_application.ui.elements.field import labeled_field, subheader -from sampletones_application.ui.elements.fonts.font import Font -from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.panel import GUIPanel -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_set_value -from sampletones_application.utils.gui.tooltip import show_tooltip -from sampletones_application.utils.gui.widgets import clamp_widget_value -from sampletones_application.view_model.main.reconstructor import ( - ReconstructorPanelViewModel, -) -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_shared.types.application import Sender - - -class GUIReconstructorPanel(GUIPanel): - def __init__( - self, - initial_view: ReconstructorPanelViewModel, - *, - layout: ReconstructorLayout, - inputs: InputsLayout, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - initial_collapsed: bool = False, - ) -> None: - self._language_manager = language_manager - self._view = initial_view - self._layout = layout - self._input_width = inputs.default_width - self._label_width = inputs.label_width - self._status_bar = status_bar - self.on_generation_settings_changed: Optional[Callable[[GenerationSettingsUpdate], None]] = None - self._item_handler_tag = compose_tag(TAG_MAIN_RECONSTRUCTOR_PANEL, SUF_HANDLER_REGISTRY) - - super().__init__( - tag=TAG_MAIN_RECONSTRUCTOR_PANEL, - height=layout.height, - ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed) - - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with self._collapsible_card( - parent, - self._language_manager["main.reconstructor.label.section_settings"], - glyph=self._glyphs.headers.reconstruction, - width=self.width, - ): - self._create_generator_selection() - dpg.add_separator() - self._create_drive_slider() - self._create_tooltips() - - def _setup_handlers(self) -> None: - with dpg.item_handler_registry(tag=self._item_handler_tag): - dpg.add_item_deactivated_handler(callback=self._on_parameter_change) - dpg.add_item_deactivated_after_edit_handler(callback=self._on_parameter_change) - 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"]) - - with dpg.group(): - for generator, label, theme_tag in self._generator_chips(): - checkbox_tag = self._get_generator_checkbox_tag(generator) - dpg.add_checkbox( - label=label, - default_value=generator in self._view.generators, - 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]]: - return [ - ( - generator_name, - channel_label(self._language_manager, generator_name), - CHANNEL_THEME_TAGS[generator_name], - ) - for generator_name in GeneratorName.items() - ] - - def _create_drive_slider(self) -> None: - with labeled_field(self._language_manager["main.reconstructor.label.slider_drive"], self._label_width): - dpg.add_slider_float( - tag=TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - min_value=0.0, - max_value=MAX_DRIVE, - default_value=self._view.drive, - width=self._input_width, - format=self._layout.drive_format, - ) - - dpg.bind_item_handler_registry( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - self._item_handler_tag, - ) - self._status_bar.bind_to_item( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - self._language_manager["global.status.message.input"], - ) - FontRegistry.bind_to_item( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - Font.MONO, - ) - - def _create_tooltips(self) -> None: - show_tooltip( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - 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. - - This is the gesture a click on the generator'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) - dpg_set_value(checkbox_tag, not dpg.get_value(checkbox_tag)) - self._report_generation_settings() - - 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)) - ] - generation_update = GenerationSettingsUpdate( - drive=float(clamp_widget_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE)), - generators=generators, - ) - 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) - - @staticmethod - def _get_generator_checkbox_tag(generator: GeneratorName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_GENERATOR, generator.value) diff --git a/src/sampletones_application/ui/panels/main/source/__init__.py b/src/sampletones_application/ui/panels/main/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/main/source/panel.py b/src/sampletones_application/ui/panels/main/source/panel.py new file mode 100644 index 000000000..3f5ba812a --- /dev/null +++ b/src/sampletones_application/ui/panels/main/source/panel.py @@ -0,0 +1,120 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SettingsField +from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.layout.tabs.main.source import SourceSettingsLayout +from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECTION_HEADER +from sampletones_application.tags.main import ( + TAG_MAIN_SOURCE_PANEL, + TAG_MAIN_SOURCE_TEXT_SUBJECT, + TAG_MAIN_SOURCE_TOOLTIP_SUBJECT, +) +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.main.source.rows import ChannelSettingsRows, DriveCallback, SlotCallback +from sampletones_application.ui.panels.main.source.steps import ChannelCapSteps, StepCallback +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_set_value +from sampletones_application.utils.gui.tooltip import set_tooltip_visible, show_tooltip +from sampletones_application.view_model.main.source import SourceSettingsPanelViewModel +from sampletones_core.constants.enums import ChannelName + + +class GUISourceSettingsPanel(GUIPanel): + """The source settings card: what one recording, one folder, or every new recording converts with. + + The card names what it edits — the row picked out of the converter's list, or the recordings a + reader adds next — and lays each channel out as a line of its own, with how many of them a + recording may sound at once below. It settles nothing itself: every gesture names the choice + and hands it on, and the card draws whatever reading it is given back. + """ + + def __init__( + self, + initial_view: SourceSettingsPanelViewModel, + *, + layout: SourceSettingsLayout, + inputs: InputsLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + initial_collapsed: bool = False, + ) -> None: + self._language_manager = language_manager + self._view = initial_view + self._rows = ChannelSettingsRows(layout=layout, language_manager=language_manager, status_bar=status_bar) + self._steps = ChannelCapSteps( + layout=layout, + inputs=inputs, + language_manager=language_manager, + status_bar=status_bar, + ) + self._lbl_new_recordings = language_manager["main.source.label.new_recordings"] + self._msg_new_recordings = language_manager["main.source.tooltip.tooltip_new_recordings"] + self._tpl_folder = language_manager["global.stems.template.folder_row"] + + self.on_slot_toggled: Optional[SlotCallback] = None + self.on_drive_changed: Optional[DriveCallback] = None + self.on_channel_cap_changed: Optional[StepCallback] = None + + super().__init__(tag=TAG_MAIN_SOURCE_PANEL) + self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) + self._wire() + + def create_panel(self, parent: str) -> None: + with self._collapsible_card( + parent, + self._language_manager["main.source.label.section_settings"], + glyph=self._glyphs.headers.reconstruction, + width=self.width, + no_scrollbar=True, + ): + self._create_subject_line() + self._rows.create(self._view) + self._steps.create() + + self.update_view(self._view) + + def update_view(self, view_model: SourceSettingsPanelViewModel) -> None: + """Take up what the card now edits and draw every choice the way the recordings read.""" + self._view = view_model + dpg_set_value(TAG_MAIN_SOURCE_TEXT_SUBJECT, self._subject_text(view_model)) + set_tooltip_visible(TAG_MAIN_SOURCE_TOOLTIP_SUBJECT, view_model.edits_new_recordings) + self._rows.render(view_model) + self._steps.render(view_model) + + def _create_subject_line(self) -> None: + """What the card edits, named the way the list names a row.""" + text = dpg.add_text(self._subject_text(self._view), tag=TAG_MAIN_SOURCE_TEXT_SUBJECT) + FontRegistry.bind_to_item(text, Font.BOLD) + ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(text) + show_tooltip(TAG_MAIN_SOURCE_TEXT_SUBJECT, self._msg_new_recordings, tag=TAG_MAIN_SOURCE_TOOLTIP_SUBJECT) + + def _subject_text(self, view_model: SourceSettingsPanelViewModel) -> str: + subject = view_model.subject + if subject is None: + return self._lbl_new_recordings + + if subject.stands_for_a_folder: + return self._tpl_folder.format(name=subject.name, count=subject.holds) + + return subject.name + + def _wire(self) -> None: + """Hand each section's reports on to the card's own hooks, which the coordinator wires.""" + self._rows.on_slot_toggled = self._on_slot_toggled + self._rows.on_drive_changed = self._on_drive_changed + self._steps.on_channel_cap_changed = self._on_channel_cap_changed + + def _on_slot_toggled(self, field: SettingsField, channel_name: ChannelName) -> None: + self.call(self.on_slot_toggled, field, channel_name) + + def _on_drive_changed(self, channel_name: ChannelName, drive: float) -> None: + self.call(self.on_drive_changed, channel_name, drive) + + def _on_channel_cap_changed(self, channel_cap: int) -> None: + self.call(self.on_channel_cap_changed, channel_cap) diff --git a/src/sampletones_application/ui/panels/main/source/rows.py b/src/sampletones_application/ui/panels/main/source/rows.py new file mode 100644 index 000000000..741656d7e --- /dev/null +++ b/src/sampletones_application/ui/panels/main/source/rows.py @@ -0,0 +1,233 @@ +from typing import Callable, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SettingsField +from sampletones_application.layout.tabs.main.source import SourceSettingsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_CHECKBOX, + SUF_HANDLER_REGISTRY, + SUF_HEADING, + SUF_SLIDER, + SUF_TEXT, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_STEMS_SLOT_LABEL, +) +from sampletones_application.tags.main import PRE_MAIN_SOURCE_CHANNEL, TAG_MAIN_SOURCE_TABLE_CHANNELS +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS, PARTIAL_CHANNEL_THEME_TAGS +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_get_item_user_data, + dpg_set_value, +) +from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.view_model.main.source import ChannelSettingsViewModel, SourceSettingsPanelViewModel +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.algorithm import MAX_DRIVE, MIN_DRIVE +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.callbacks import CallbackMixin + +SlotCallback = Callable[[SettingsField, ChannelName], None] +DriveCallback = Callable[[ChannelName, float], None] + + +class ChannelSettingsRows(CallbackMixin): + """The channels a recording is converted on, one line apiece. + + A line names its channel in the channel's color and holds what the recording says about it: + the box using the channel, the box bending it where the hardware reads a bend, and the slider + driving it. A line whose channel no inspected recording uses reads muted, and its bend and + drive take no gesture. A drive is reported once its slider is let go, so a drag is one edit, + and a slider under the reader's hand keeps the value they are setting whatever the card is + told meanwhile. + """ + + def __init__( + self, + *, + layout: SourceSettingsLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._layout = layout + self._language_manager = language_manager + self._status_bar = status_bar + self._lbl_on = language_manager["global.stems.label.channel_on"] + self._lbl_bend = language_manager["global.stems.label.channel_bend"] + self._lbl_drive = language_manager["main.source.label.drive"] + self._lbl_mixed = language_manager["main.source.label.drive_mixed"] + self._msg_bend = language_manager["global.stems.message.bend_tooltip"] + self._msg_drive = language_manager["main.source.tooltip.tooltip_drive"] + self._msg_input = language_manager["global.status.message.input"] + self._handler_tag = compose_tag(TAG_MAIN_SOURCE_TABLE_CHANNELS, SUF_HANDLER_REGISTRY) + + self.on_slot_toggled: Optional[SlotCallback] = None + self.on_drive_changed: Optional[DriveCallback] = None + + @staticmethod + def name_tag(channel_name: ChannelName) -> str: + return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel_name, SUF_TEXT) + + @staticmethod + def box_tag(channel_name: ChannelName, field: SettingsField) -> str: + return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel_name, field, SUF_CHECKBOX) + + @staticmethod + def slider_tag(channel_name: ChannelName) -> str: + return compose_tag(PRE_MAIN_SOURCE_CHANNEL, channel_name, SUF_SLIDER) + + def create(self, view_model: SourceSettingsPanelViewModel) -> None: + """Build the heading naming each column, then one line per channel under it.""" + with dpg.item_handler_registry(tag=self._handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._on_drive_released) + + with dpg.table( + tag=TAG_MAIN_SOURCE_TABLE_CHANNELS, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + ): + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.name_column_width) + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.choice_column_width) + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.choice_column_width) + dpg.add_table_column(width_stretch=True) + self._create_heading() + for channel in view_model.channels: + self._create_line(channel) + + def render(self, view_model: SourceSettingsPanelViewModel) -> None: + """Draw what the inspected recordings say about each channel onto the line standing for it.""" + for channel in view_model.channels: + self._render_line(channel, live=view_model.live) + + def _create_heading(self) -> None: + """The column names above the lines, set small so the channels below carry the card.""" + with dpg.table_row(): + dpg.add_spacer(width=0) + self._create_column_name(self._lbl_on) + bend = self._create_column_name(self._lbl_bend) + show_tooltip( + bend, self._msg_bend, tag=compose_tag(TAG_MAIN_SOURCE_TABLE_CHANNELS, SUF_HEADING, SUF_TOOLTIP) + ) + self._create_column_name(self._lbl_drive) + + @staticmethod + def _create_column_name(label: str) -> int: + text = dpg.add_text(label) + FontRegistry.bind_to_item(text, Font.REGULAR_SMALL) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_SLOT_LABEL).bind_to_item(text) + return int(text) + + def _create_line(self, channel: ChannelSettingsViewModel) -> None: + channel_name = channel.channel + with dpg.table_row(): + name = dpg.add_text(channel_label(self._language_manager, channel_name), tag=self.name_tag(channel_name)) + FontRegistry.bind_to_item(name, Font.BOLD_SMALL) + self._create_box(channel_name, SettingsField.CHANNELS) + if channel.bendable: + self._create_box(channel_name, SettingsField.BENDS) + else: + dpg.add_spacer(width=0) + + self._create_slider(channel_name) + + self._render_line(channel, live=True) + + def _create_box(self, channel_name: ChannelName, field: SettingsField) -> None: + dpg.add_checkbox( + tag=self.box_tag(channel_name, field), + user_data=(field, channel_name), + callback=self._on_box, + ) + + def _create_slider(self, channel_name: ChannelName) -> None: + slider_tag = self.slider_tag(channel_name) + dpg.add_slider_float( + tag=slider_tag, + min_value=MIN_DRIVE, + max_value=MAX_DRIVE, + clamped=True, + width=-1, + format=self._layout.drive_format, + user_data=channel_name, + ) + FontRegistry.bind_to_item(slider_tag, Font.MONO_SMALL) + dpg.bind_item_handler_registry(slider_tag, self._handler_tag) + self._status_bar.bind_to_item(slider_tag, self._msg_input) + show_tooltip(slider_tag, self._msg_drive, tag=compose_tag(slider_tag, SUF_TOOLTIP)) + + def _render_line(self, channel: ChannelSettingsViewModel, *, live: bool) -> None: + """Draw one channel's line: its name, its boxes and its drive, in the tone its reading takes.""" + channel_name = channel.channel + ThemeRegistry.get(self._line_theme(channel, Agreement.ALL)).bind_to_item(self.name_tag(channel_name)) + self._render_box(channel, SettingsField.CHANNELS, channel.use, enabled=live) + if channel.bendable: + self._render_box(channel, SettingsField.BENDS, channel.bend, enabled=live and channel.bend_offered) + + self._render_slider(channel, enabled=live and channel.used) + + def _render_box( + self, + channel: ChannelSettingsViewModel, + field: SettingsField, + agreement: Agreement, + *, + enabled: bool, + ) -> None: + box_tag = self.box_tag(channel.channel, field) + dpg_set_value(box_tag, agreement.reads_held) + dpg_configure_item(box_tag, enabled=enabled) + ThemeRegistry.get(self._line_theme(channel, agreement)).bind_to_item(box_tag) + + def _render_slider(self, channel: ChannelSettingsViewModel, *, enabled: bool) -> None: + """Draw the channel's drive, leaving a slider the reader is holding as they hold it. + + A reading the recordings differ on states so in place of a value, with the grab resting at + the calibrated level, and the first drag settles every recording on the value it reaches. + """ + slider_tag = self.slider_tag(channel.channel) + if not dpg.is_item_active(slider_tag): + dpg_set_value(slider_tag, channel.drive_shown) + + drive_format = self._lbl_mixed if channel.drive_mixed else self._layout.drive_format + dpg_configure_item(slider_tag, enabled=enabled, format=drive_format) + theme = self._line_theme(channel, Agreement.SOME if channel.drive_mixed else Agreement.ALL) + ThemeRegistry.get(theme).bind_to_item(slider_tag) + + @staticmethod + def _line_theme(channel: ChannelSettingsViewModel, agreement: Agreement) -> str: + """The tone a control on a line takes: muted where no recording uses the channel, softened + where the recordings differ, and the channel's own color otherwise.""" + if not channel.used: + return TAG_GLOBAL_THEME_CHANNEL_MUTED + + if agreement is Agreement.SOME: + return PARTIAL_CHANNEL_THEME_TAGS[channel.channel] + + return CHANNEL_THEME_TAGS[channel.channel] + + def _on_box(self, _sender: Sender, _value: bool, user_data: Tuple[SettingsField, ChannelName]) -> None: + field, channel_name = user_data + self.call(self.on_slot_toggled, field, channel_name) + + def _on_drive_released(self, _sender: Sender, slider: Sender) -> None: + """Report the drive a released slider holds, to the decimals the slider prints. + + The slider's clamp holds a typed value within the bounds a drive has, and rounding sets + aside the single-precision noise the widget carries its value in. + """ + channel_name = dpg_get_item_user_data(slider) + if not isinstance(channel_name, ChannelName): + return + + drive = round(float(dpg.get_value(slider)), self._layout.drive_decimals) + self.call(self.on_drive_changed, channel_name, drive) diff --git a/src/sampletones_application/ui/panels/main/source/steps.py b/src/sampletones_application/ui/panels/main/source/steps.py new file mode 100644 index 000000000..719e25821 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/source/steps.py @@ -0,0 +1,105 @@ +from typing import Callable, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.layout.tabs.main.source import SourceSettingsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_STEP_BUTTON, + TAG_GLOBAL_THEME_STEP_BUTTON_DIM, + TAG_GLOBAL_THEME_STEP_BUTTON_LIT, + TAG_GLOBAL_THEME_STEP_BUTTON_PARTIAL, +) +from sampletones_application.tags.main import PRE_MAIN_SOURCE_STEP, TAG_MAIN_SOURCE_GROUP_STEPS +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.status import GUIStatusBar +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.view_model.main.source import CHANNEL_CAP_STEPS, SourceSettingsPanelViewModel +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.callbacks import CallbackMixin + +StepCallback = Callable[[int], None] + + +class ChannelCapSteps(CallbackMixin): + """How many channels a recording may sound at once, as a row of steps from one to them all. + + The count a recording holds lights its step, and a folder whose recordings differ half-lights + every count present. A step past the channels any inspected recording uses reads dim, since + a run sounds at most the channels a recording holds; it still takes the gesture, so a count + set ahead of the channels stands once they are added. + """ + + def __init__( + self, + *, + layout: SourceSettingsLayout, + inputs: InputsLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._layout = layout + self._label_width = inputs.label_width + self._status_bar = status_bar + self._lbl_channel_cap = language_manager["main.source.label.channel_cap"] + self._msg_channel_cap = language_manager["main.source.tooltip.tooltip_channel_cap"] + self._tpl_status = language_manager["main.source.message.status_channel_cap"] + + self.on_channel_cap_changed: Optional[StepCallback] = None + + @staticmethod + def step_tag(step: int) -> str: + return compose_tag(PRE_MAIN_SOURCE_STEP, str(step), SUF_BUTTON) + + def create(self) -> None: + """Build the steps beside the name of the choice they make.""" + with ( + labeled_field(self._lbl_channel_cap, self._label_width), + dpg.group(tag=TAG_MAIN_SOURCE_GROUP_STEPS, horizontal=True), + ): + for step in CHANNEL_CAP_STEPS: + self._create_step(step) + + def render(self, view_model: SourceSettingsPanelViewModel) -> None: + """Light the counts the inspected recordings hold, dimming the ones their channels leave unreached.""" + for step in CHANNEL_CAP_STEPS: + step_tag = self.step_tag(step) + dpg_configure_item(step_tag, enabled=view_model.live) + ThemeRegistry.get(self._step_theme(view_model, step)).bind_to_item(step_tag) + + def _create_step(self, step: int) -> None: + step_tag = self.step_tag(step) + dpg.add_button( + label=str(step), + tag=step_tag, + width=self._layout.step_button_width, + user_data=step, + callback=self._on_step, + ) + FontRegistry.bind_to_item(step_tag, Font.MONO_SMALL) + self._status_bar.bind_to_item(step_tag, self._tpl_status.format(count=step)) + show_tooltip(step_tag, self._msg_channel_cap, tag=compose_tag(step_tag, SUF_TOOLTIP)) + + @staticmethod + def _step_theme(view_model: SourceSettingsPanelViewModel, step: int) -> str: + match view_model.step_agreement(step): + case Agreement.ALL: + return TAG_GLOBAL_THEME_STEP_BUTTON_LIT + case Agreement.SOME: + return TAG_GLOBAL_THEME_STEP_BUTTON_PARTIAL + case _: + return ( + TAG_GLOBAL_THEME_STEP_BUTTON if view_model.step_reaches(step) else TAG_GLOBAL_THEME_STEP_BUTTON_DIM + ) + + def _on_step(self, _sender: Sender, _app_data: None, step: int) -> None: + self.call(self.on_channel_cap_changed, step) diff --git a/src/sampletones_application/ui/panels/reconstruction/__init__.py b/src/sampletones_application/ui/panels/reconstruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 59a65183d..71002f969 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -4,12 +4,18 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, 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_LOCKED, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -17,17 +23,34 @@ from sampletones_application.ui.elements.path import GUIPathText 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.gui.tooltip import attach_disabled_tooltip, set_tooltip_visible, show_tooltip +from sampletones_application.utils.gui.widgets import clamp_widget_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.rate import RateLock +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) from sampletones_core.constants.enums import AudioSourceType +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY 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 takes 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. A reconstruction that is a + sample of a project follows the project's rate, and one with no file keeps its rate until it + is saved to one, so the field is locked for both, with a hint naming which. + """ + def __init__( self, *, @@ -41,11 +64,15 @@ def __init__( self._status_bar = status_bar self._path_colors = path_colors self._path_status_color = path_status_color + self._nes_frequency_handler_tag = compose_tag( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + SUF_HANDLER_REGISTRY, + ) self._reconstruction_file_path: GUIPathText - self._original_audio_path: GUIPathText self.on_audio_source_changed: Optional[Callable[[AudioSourceType], None]] = None + self.on_nes_frequency_changed: Optional[Callable[[int], None]] = None self._load_path_text(language_manager) self._lbl_original_audio_radio = language_manager["reconstructions.reconstruction.label.original_audio_radio"] @@ -73,16 +100,16 @@ def create_panel(self, parent: str) -> None: self._create_audio_source_radio_buttons() dpg.add_separator() self._create_path_display() + self._create_frequency_input() 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) 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 +151,93 @@ 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, + self._reconstruction_file_path.set_status("", self._path_status_color) + + def _create_frequency_input(self) -> None: + """Draws the engine rate as a field beside its label, monospaced as a figure. + + The rate is committed once editing finishes, since a new rate re-times the whole + reconstruction and a change per keystroke would redo that work for every digit. A locked + field shows no tooltip of its own, so the group around it explains why it is locked. + """ + with dpg.group( + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, + horizontal=True, 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, + ): + label = dpg.add_text(self._language_manager["reconstructions.reconstruction.label.nes_frequency_label"]) + dpg.add_input_int( + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + min_value=MIN_NES_FREQUENCY, + max_value=MAX_NES_FREQUENCY, + min_clamped=True, + max_clamped=True, + width=-1, + enabled=False, + show=False, + ) + + FontRegistry.bind_to_item(label, Font.REGULAR_SMALL) + FontRegistry.bind_to_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, Font.MONO) + with dpg.item_handler_registry(tag=self._nes_frequency_handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._on_nes_frequency_input) + + dpg.bind_item_handler_registry( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + self._nes_frequency_handler_tag, + ) + show_tooltip( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + self._language_manager["main.config.tooltip.tooltip_nes_frequency"], + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + ) + attach_disabled_tooltip( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, + self._rate_lock_words(RateLock.PROJECT_SAMPLE), + tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, + text_tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, ) - self._reconstruction_file_path.set_status("", self._path_status_color) - self._original_audio_path.set_status("", self._path_status_color) + def _render_frequency(self, view_model: ReconstructionViewModel) -> None: + """States the rate a loaded reconstruction runs at, and stands blank for an empty tab. + + A field being typed into keeps what the reader has typed until the edit is committed. + """ + nes_frequency = view_model.nes_frequency + dpg_configure_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + show=nes_frequency is not None, + enabled=view_model.nes_frequency_editable, + ) + set_tooltip_visible( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + view_model.nes_frequency_editable, + ) + set_tooltip_visible( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, + view_model.show_nes_frequency_hint, + ) + if view_model.rate_lock is not None: + dpg_set_value( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, + self._rate_lock_words(view_model.rate_lock), + ) + if nes_frequency is not None and not dpg.is_item_active(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY): + dpg_set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, nes_frequency) + + def _rate_lock_words(self, rate_lock: RateLock) -> str: + """What the locked rate's hint says, which names the reason the rate is kept.""" + match rate_lock: + case RateLock.PROJECT_SAMPLE: + return self._language_manager["reconstructions.reconstruction.tooltip.nes_frequency_locked"] + case RateLock.NO_FILE: + return self._language_manager["reconstructions.reconstruction.tooltip.nes_frequency_no_file"] + + def _on_nes_frequency_input(self, _sender: Sender, _app_data: int) -> None: + self.call( + self.on_nes_frequency_changed, + int(clamp_widget_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)), + ) def _create_audio_source_radio_buttons(self) -> None: with dpg.group( @@ -160,7 +260,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/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 8f618d03e..244de4c36 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -7,6 +7,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, @@ -48,6 +49,7 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, + stem_colors: StemColors, initial_collapsed: bool, initial_favorites_only: bool, initial_expanded_rows: AbstractSet[str], @@ -61,6 +63,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, + stem_colors=stem_colors, initial_collapsed=initial_collapsed, initial_favorites_only=initial_favorites_only, initial_expanded_rows=initial_expanded_rows, 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..17d1bbd9e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -1,15 +1,25 @@ from functools import partial -from typing import Any, Callable, Dict, List, Optional, Tuple, cast +from typing import Any, Callable, Dict, Final, 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.general.colors.stem import StemColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( @@ -35,6 +45,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, ) @@ -43,6 +54,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.graphs.bar import GUIBarGraph +from sampletones_application.ui.elements.graphs.ownership import OwnershipRuns from sampletones_application.ui.elements.graphs.utils import extend_y_range from sampletones_application.ui.elements.layout.card import card from sampletones_application.ui.elements.layout.collapse import CollapseAxis @@ -57,27 +69,45 @@ make_feature_plot_configs, ) from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.clipboard import copy_to_clipboard +from sampletones_application.utils.gui.clipboard.copy_button import copy_to_clipboard from sampletones_application.utils.gui.dpg import ( dpg_configure_item, dpg_set_value, ) +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, + panel_scope_active, +) +from sampletones_application.utils.gui.keyboard.piano import semitone_of 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.envelopes import ( + ChannelEnvelopesViewModel, +) 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_application.view_model.shared.ownership import OwnershipLaneViewModel 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.exports.ceilings import FormatShortening, format_shortenings +from sampletones_core.exports.format import ExportFormat +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.utils.pitch_kind import ( PERIOD_VALUE_KIND, PITCH_VALUE_KIND, @@ -88,10 +118,18 @@ from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.arrays import clamp -OnInstrumentExportCallback = Callable[[GeneratorName], None] +ONE_SLOT_PER_FRAME: Final[float] = 1.0 + +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, @@ -99,29 +137,38 @@ def __init__( pitch_stepper_style: PitchStepperStyle, copy_width: int, feature_colors: FeatureColors, + stem_colors: StemColors, 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 + self._ownership = OwnershipRuns(stem_colors) self._feature_plot_configs = make_feature_plot_configs( feature_colors, language_manager, @@ -134,9 +181,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 +195,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 +228,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( @@ -212,10 +268,10 @@ def _create_size_field( ) -> None: """Draws a read-only byte figure, styled as the pitch stepper's readout is. - 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 - names the export the figure measures, since the formats spend differently. + The figure names the raw size of the sound's data, 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 color and font. A tooltip says the figure is measured + before compression, which an NSF export applies. """ with labeled_field( label, @@ -232,13 +288,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 +303,84 @@ 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_text_tag( + self, + channel_name: ChannelName, + feature_key: FeatureKey, + ) -> str: + return compose_tag(self._get_feature_text_group_tag(channel_name, feature_key), 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 +389,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 +397,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 +442,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 +473,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 +484,177 @@ 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, - feature_key, + dpg_set_value( + self._get_feature_text_tag(channel_name, feature_key), + format_envelope(envelope), ) - 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)) + 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 a sounding 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 an instrument 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 an instrument 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, + ), + 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 an instrument 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, + so an open audition is what the panel holds them for. + """ + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._audition_open, + card_open=self.card_open, + ) + + def _on_key_pressed(self, event: KeyEvent) -> bool: + """Sounds the open instrument at the note a plain piano key names, reporting whether it did. + + A combination ending in a note key passes on to the shortcuts, so Undo and Save answer + while an instrument is open. + """ + semitone = semitone_of(event) + 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 +663,97 @@ def _format_size(self, byte_count: int) -> str: def update_feature_data( self, - generators: Optional[Dict[GeneratorName, Features]], + envelopes: Optional[ChannelEnvelopesViewModel], ) -> None: - if generators is None: + if envelopes is None: return - for generator_name in GeneratorName.items(): - generator_features = generators.get(generator_name) + for channel_name in ChannelName.items(): + generator_features = envelopes.channels.get(channel_name) if generator_features is None: continue self._update_generator_feature_data( - generator_name, + channel_name, generator_features, + envelopes.lane(channel_name), ) def _update_generator_feature_data( self, - generator_name: GeneratorName, + channel_name: ChannelName, generator_features: Features, + lane: OwnershipLaneViewModel, ) -> 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, + lane, ) def _update_generator_feature_display( self, - generator_name: GeneratorName, + channel_name: ChannelName, generator_features: Features, feature_key: FeatureKey, + lane: OwnershipLaneViewModel, ) -> 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) + items = _plotted_items(envelope) + self._update_generator_plot(channel_name, feature_key, items) + self._update_raw_data_text(channel_name, feature_key, envelope) + self._paint_ownership(channel_name, feature_key, lane, len(items)) + + def _paint_ownership( + self, + channel_name: ChannelName, + feature_key: FeatureKey, + lane: OwnershipLaneViewModel, + frame_count: int, + ) -> None: + """Paints the recording behind each frame in a band beneath that dimension's bars. + + The band stands under the frames the bars draw, so the two read column for column + whatever the dimension's own values reach. A dimension writing nothing, and a document + answering to one recording, have nothing to tell apart and give the band to the bars. + """ + plot = self.channel_plots.get(channel_name, {}).get(feature_key) + if plot is None: + return - def _feature_array( + runs = lane.up_to(frame_count) + share = self._layout_graphs.bar_plot.ownership_band if runs else 0.0 + self._ownership.paint( + plot.y_axis_tag, + runs, + frame_span=ONE_SLOT_PER_FRAME, + band=plot.reserve_band(share), + ) + + 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 +777,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 +801,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 @@ -636,11 +829,17 @@ def _calculate_plot_limits( config: FeaturePlotConfig, data: np.ndarray, ) -> Tuple[int, int, Optional[Tuple[int, ...]]]: + """The bounds and the ticks one dimension is drawn between. + + A dimension reading around zero takes bounds from the widest value it writes, so an + envelope describing no frame stands at the floor the empty reading gives it and the + plot keeps a grid to draw on. + """ y_min = config.y_min y_max = config.y_max y_ticks = config.y_ticks if y_min == -1.0 and y_max == -1.0: - max_abs_value = float(np.max(np.abs(data))) + max_abs_value = float(np.max(np.abs(data))) if data.size else 0.0 y_min = -max_abs_value y_max = max_abs_value @@ -656,10 +855,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 +876,7 @@ def _add_bar_plot( self._configure_plot_data( plot, - generator_name, + channel_name, feature_key, config, data, @@ -688,32 +887,30 @@ 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, ), on_bar_point_hovered=self._on_bar_point_hovered, ) def _on_bar_point_clicked( self, - generator_name: GeneratorName, + channel_name: ChannelName, feature_key: FeatureKey, data: np.ndarray, - plot_tag: str, ) -> None: - 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) + envelope = self._standing_sequence(channel_name, feature_key).with_items(tuple(int(value) for value in data)) + self._update_raw_data_text(channel_name, feature_key, envelope) + self.call(self.on_envelope_changed, channel_name, feature_key, envelope) def _on_bar_point_hovered( self, @@ -731,18 +928,18 @@ 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_tag = self._get_feature_text_tag(text_group_tag) + raw_data_text = format_envelope(envelope) + raw_data_tag = self._get_feature_text_tag(channel_name, feature_key) copy_button_tag = compose_tag(text_group_tag, SUF_BUTTON_COPY) with dpg.group(tag=text_group_tag, parent=parent, horizontal=True): @@ -750,8 +947,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 +963,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 +977,119 @@ 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 each tracker export keeps of an over-long one.""" + envelope = self._standing_sequence(channel_name, feature_key) + shortenings = format_shortenings(feature_key, envelope) + if shortenings: + separator = self._language_manager["reconstructions.instruments.template.kept_separator"] 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), + kept=separator.join(self._kept_by(shortening) for shortening in shortenings), ) return self._language_manager["reconstructions.instruments.message.status_sequence"].format( instrument_feature=feature_key.capitalized ) - def _apply_input_theme( + def _kept_by(self, shortening: FormatShortening) -> str: + """Names one tracker export beside the items it keeps of a dimension.""" + match shortening.export_format: + case ExportFormat.FAMITRACKER: + template = self._language_manager["reconstructions.instruments.template.kept_famitracker"] + case ExportFormat.BITPHASE: + template = self._language_manager["reconstructions.instruments.template.kept_bitphase"] + case ExportFormat.BITPHASE_PRESET: + template = self._language_manager["reconstructions.instruments.template.kept_bitphase_preset"] + + return template.format(limit=shortening.kept) + + 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 the tracker exports treat 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 any tracker export holds only part of carries the warning color, so which + dimensions reach every 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) - 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 + self._sequences[(channel_name, feature_key)] = envelope + raw_data_tag = self._get_feature_text_tag(channel_name, feature_key) + theme = self.warning_input_theme if format_shortenings(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 +1097,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..de9babb3d 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -4,17 +4,20 @@ 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.general.colors.stem import StemColors 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, ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.graphs.ribbon import GUIOwnershipRibbon 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 @@ -25,8 +28,12 @@ from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.reconstruction.waveform import ( + InstrumentWaveformViewModel, +) +from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel 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 +43,25 @@ def __init__( self, *, layout_graphs: GraphsLayout, + channel_colors: ChannelColors, + stem_colors: StemColors, language_manager: LanguageManager, status_bar: GUIStatusBar, initial_collapsed: bool = False, ) -> None: self._layout_graphs = layout_graphs + self._channel_colors = channel_colors + self._stem_colors = stem_colors self._status_bar = status_bar self._language_manager = language_manager self.waveform_display: GUIWaveformGraph + self.ownership_ribbon: GUIOwnershipRibbon + self._ownership: OwnershipRibbonViewModel = OwnershipRibbonViewModel.empty() 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.on_position_clicked: Optional[Callable[[int], None]] = None self.autoscale_tag = compose_tag( TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_PLOT, SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE @@ -71,7 +85,8 @@ def create_panel(self, parent: str) -> None: ): self._create_autoscale_checkbox() self._create_waveform_display() - self._create_generator_checkboxes() + self._create_ownership_ribbon() + self._create_channel_checkboxes() self._create_tooltips() def update_view(self, view_model: ReconstructionViewModel) -> None: @@ -80,10 +95,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 +106,45 @@ 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 an instrument'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: + self._draw_ownership(self._ownership) + return + + self._draw_ownership(OwnershipRibbonViewModel.empty()) + 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,13 +152,16 @@ def load_waveform_data( def update_waveform_data( self, waveform_data: WaveformData, - generators: List[GeneratorName], + generators: List[ChannelName], + *, + refit: bool = False, ) -> None: - self.waveform_display.update_waveform_data(waveform_data, generators) + self.waveform_display.update_waveform_data(waveform_data, generators, refit=refit) def clear_waveform(self) -> None: self._frame_length = None self.waveform_display.clear() + self.update_ownership(OwnershipRibbonViewModel.empty()) def set_waveform_top_source(self, audio_source: AudioSourceType) -> None: self.waveform_display.set_top_source(audio_source) @@ -150,23 +199,47 @@ def _create_waveform_display(self) -> None: tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_RECONSTRUCTION_WAVEFORM, parent=self._body_container, layout=self._layout_graphs, + channel_colors=self._channel_colors, language_manager=self._language_manager, status_bar=self._status_bar, ) + self.waveform_display.on_position_clicked = self._on_position_clicked + + def _create_ownership_ribbon(self) -> None: + """The lanes naming the recording behind each stretch, painted in the waveform's own row.""" + self.ownership_ribbon = GUIOwnershipRibbon( + plot_tags=self.waveform_display.lane_plot_tags, + y_axis_tags=self.waveform_display.lane_y_axis_tags, + layout=self._layout_graphs, + stem_colors=self._stem_colors, + ) + self.ownership_ribbon.bind_theme() + + def update_ownership(self, ribbon: OwnershipRibbonViewModel) -> None: + """Takes the lanes the open document answers for and paints them under its waveform.""" + self._ownership = ribbon + self._draw_ownership(ribbon) + + def _draw_ownership(self, ribbon: OwnershipRibbonViewModel) -> None: + """Paints the lanes and gives them the room they need under the waveform.""" + self.ownership_ribbon.update_view(ribbon) + self.waveform_display.set_lane_heights(self.ownership_ribbon.lane_heights) + + def _on_position_clicked(self, position: int) -> None: + self.call(self.on_position_clicked, position) - 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 +250,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 +261,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 +284,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 +310,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..dbf631698 --- /dev/null +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -0,0 +1,223 @@ +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.stem import StemColors +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.ui.elements.stems.offer import RECORDED_ASSIGNMENT +from sampletones_application.ui.panels.reconstruction.stems_menu import StemsMenu +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 StemRowViewModel, 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. Each row leads with the color its recording is + painted in, so a stretch of the ribbon under the waveform answers to a name here at a glance. + A double-click on a row shows the recording where it sits on disk, a right-click offers to + mute or solo it beside the file items, 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, + stem_colors: StemColors, + 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._lbl_edits = language_manager["global.stems.label.edits"] + 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, + ceiling=stems_layout.well_ceiling, + glyphs=self._glyphs.common, + stem_colors=stem_colors, + language_manager=language_manager, + status_bar=status_bar, + offer=RECORDED_ASSIGNMENT, + ) + self._menu = StemsMenu( + stems_list=self._stems_list, + language_manager=language_manager, + ) + + self.on_stem_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None + self.on_stem_solo_requested: Optional[Callable[[int], 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.stems, + 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_revealed = self._on_row_revealed + self._stems_list.on_remove_requested = self._on_remove_requested + self._stems_list.on_menu_requested = self._menu.show + self._menu.on_channels_changed = lambda stem_id, channels: self.call( + self.on_stem_channels_changed, stem_id, channels + ) + self._menu.on_solo_requested = lambda stem_id: self.call(self.on_stem_solo_requested, stem_id) + + 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: the reader's banding, and the edits row named. + + A row standing for the frames the reader wrote answers to no recording, so it takes its + name here rather than from the document. + """ + collapsed = bool(dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_CHECKBOX_COLLAPSE_LEVELS)) + return stems.model_copy( + update={ + "collapse_levels": collapsed, + "rows": tuple(self._named(row) for row in stems.rows), + } + ) + + def _named(self, row: StemRowViewModel) -> StemRowViewModel: + """The row under the name it reads as, which the edits row takes from the language file.""" + if not row.stands_for_edits: + return row + + return row.model_copy(update={"name": self._lbl_edits}) + + 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), + ) + + 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_revealed(self, key: str) -> None: + """A double-clicked row shows its recording where it sits on disk. + + Leaving the file browser to a deliberate gesture keeps a click free to pick a row, which + is what a reader does far more often. + """ + row = self._stems_list.row(key) + if row is not None and row.available and row.path is not None: + open_path_in_explorer(row.path) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems_menu.py b/src/sampletones_application/ui/panels/reconstruction/stems_menu.py new file mode 100644 index 000000000..6222b20a9 --- /dev/null +++ b/src/sampletones_application/ui/panels/reconstruction/stems_menu.py @@ -0,0 +1,82 @@ +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.context_menu import ( + add_path_menu_items, + 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.stems.list import GUIStemsList +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.utils.callbacks import CallbackMixin + + +class StemsMenu(CallbackMixin): + """What a right-click on a recording of the open reconstruction offers. + + The recordings are heard the way the boxes on their rows leave them, so the menu names the + same choices in one place: switching the recording off or back on, and hearing it alone. A + recording standing on disk adds the file items every file listing offers. + """ + + def __init__( + self, + *, + stems_list: GUIStemsList, + language_manager: LanguageManager, + ) -> None: + self._stems_list = stems_list + self._language_manager = language_manager + self._lbl_mute = language_manager["reconstructions.reconstruction.label.stem_mute"] + self._lbl_unmute = language_manager["reconstructions.reconstruction.label.stem_unmute"] + self._lbl_solo = language_manager["reconstructions.reconstruction.label.stem_solo"] + self._lbl_unsolo = language_manager["reconstructions.reconstruction.label.stem_unsolo"] + + self.on_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None + self.on_solo_requested: Optional[Callable[[int], None]] = None + + def show(self, key: str) -> None: + """Offer what the row a gesture landed on can do.""" + row = self._stems_list.row(key) + if row is None: + return + + stem_id = int(key) + with context_menu(): + self._header(row.name) + self._create_mute(row, stem_id) + self._create_solo(row, stem_id) + if row.path is not None: + add_path_menu_items(self._language_manager, row.path) + + def _create_mute(self, row: StemRowViewModel, stem_id: int) -> None: + """The item switching the recording off, or back on across every channel it offers.""" + heard = row.takes_part + dpg.add_menu_item( + label=self._lbl_mute if heard else self._lbl_unmute, + enabled=row.offers_channels, + callback=lambda: self.call( + self.on_channels_changed, + stem_id, + frozenset() if heard else row.offered_channels, + ), + ) + + def _create_solo(self, row: StemRowViewModel, stem_id: int) -> None: + """The item hearing the recording alone, or returning to the mix it replaced.""" + dpg.add_menu_item( + label=self._lbl_unsolo if self._stems_list.soloed(row.key) else self._lbl_solo, + enabled=row.offers_channels and self._stems_list.holds_several_rows, + callback=lambda: self.call(self.on_solo_requested, stem_id), + ) + + @staticmethod + def _header(name: str) -> None: + """What the menu names above its items: the recording the gesture landed on.""" + header = dpg.add_text(name) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() diff --git a/src/sampletones_application/ui/panels/sequencer/__init__.py b/src/sampletones_application/ui/panels/sequencer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 9bfe14e39..306509b00 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,9 +1,4 @@ -from typing import AbstractSet - from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.behavior.scheduling.scheduling import ( - SchedulingBehavior, -) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, @@ -12,19 +7,17 @@ TAG_SEQUENCER_BROWSER_TREE, TAG_SEQUENCER_BROWSER_WINDOW_TREE, ) -from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.panels.shared.browser import ( GUIReconstructionBrowserPanel, ) -from sampletones_core.structures.tree import FileSystemNode, Tree +from sampletones_core.structures.tree import FileSystemNode class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): """The Sequencer tab's browser, whose reconstructions become the song's samples.""" + _language_manager: LanguageManager _tags: FileBrowserTags = FileBrowserTags( panel=TAG_SEQUENCER_BROWSER_PANEL, tree=TAG_SEQUENCER_BROWSER_TREE, @@ -34,33 +27,6 @@ class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, ) - def __init__( - self, - tree: Tree, - tree_logic: TreeLogicProtocol, - *, - scheduling: SchedulingBehavior, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - colors: TreeColors, - initial_collapsed: bool, - initial_favorites_only: bool, - initial_expanded_rows: AbstractSet[str], - ) -> None: - self._language_manager = language_manager - - super().__init__( - tree=tree, - tree_logic=tree_logic, - scheduling=scheduling, - language_manager=language_manager, - status_bar=status_bar, - colors=colors, - initial_collapsed=initial_collapsed, - initial_favorites_only=initial_favorites_only, - initial_expanded_rows=initial_expanded_rows, - ) - @property def refresh_button_label(self) -> str: return self._language_manager["sequencer.browser.label.refresh_button"] 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..619cf6d68 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,17 +21,7 @@ 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) - - -def tracker_table_row(row_index: int) -> int: - """Maps a pattern row to its DPG table row index. - - The clickable header row occupies the table's first row, so a pattern row sits one - slot further down than its index. Every highlight keyed by table row goes through - here, keeping the cursor, hover, and playback cues on the row the user sees. - """ - return row_index + HEADER_TABLE_ROWS + return _FIRST_CHANNEL_TABLE_COLUMN + ChannelName.items().index(channel) diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index 4b99693ac..820795270 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -1,20 +1,27 @@ -from typing import Dict, Final, Optional, Tuple +from typing import Dict, Final, Literal, Optional, Tuple from sampletones_application.ui.elements.table.cells import pending_label from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_application.view_model.sequencer.slot import TrackerSlot 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_core.utils.display import display_id, display_transpose, display_volume - -CellKey = Tuple[int, Optional[GeneratorName], SubColumn] +from sampletones_application.view_model.sequencer.tracker import ( + SequencerCellViewModel, + SequencerRowViewModel, +) +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_pitch, display_volume + +CellKey = Tuple[int, Optional[ChannelName], SubColumn] CellValues = Dict[CellKey, str] +CellKinds = Dict[CellKey, Optional[VoiceKind]] +NumberSubColumn = Literal[SubColumn.VOICE, SubColumn.VOLUME] CELL_TITLE_SEPARATOR: Final[str] = " | " _DEFAULT_LABELS: Final[Dict[SubColumn, str]] = { - SubColumn.INSTRUMENT: display_id(None), - SubColumn.TRANSPOSE: display_transpose(None), + SubColumn.VOICE: display_id(None), + SubColumn.TRANSPOSE: display_pitch(None), SubColumn.VOLUME: display_volume(None), } @@ -36,39 +43,64 @@ 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: return cell_view_model.volume -def format_committed(subcolumn: SubColumn, value: Optional[int]) -> str: - """Format an integer value as the display string stored in the optimistic cell cache.""" +def row_values(row: SequencerRowViewModel) -> Dict[TrackerSlot, str]: + """What every slot of one tracker row reads, the Sample column's summaries among them.""" + values: Dict[TrackerSlot, str] = { + TrackerSlot(None, SubColumn.VOICE): row.sample, + TrackerSlot(None, SubColumn.TRANSPOSE): row.transpose, + TrackerSlot(None, SubColumn.VOLUME): row.volume, + } + for channel in ChannelName.items(): + for subcolumn in SubColumn: + values[TrackerSlot(channel, subcolumn)] = cell_display(row.cells[channel], subcolumn) + + return values + + +def row_kinds(row: SequencerRowViewModel) -> Dict[TrackerSlot, Optional[VoiceKind]]: + """The kind of voice each voice slot of one tracker row names, which is the color that slot wears. + + Only the voice slot reports a kind, so the map covers those slots alone. + """ + kinds: Dict[TrackerSlot, Optional[VoiceKind]] = {TrackerSlot(None, SubColumn.VOICE): row.sample_kind} + for channel in ChannelName.items(): + kinds[TrackerSlot(channel, SubColumn.VOICE)] = row.cells[channel].kind + + return kinds + + +def format_committed(subcolumn: NumberSubColumn, value: Optional[int]) -> str: + """Format a number as the display string stored in the optimistic cell cache. + + A pitch prints through :func:`display_pitch`, since the cell holds a note or a step. + """ match subcolumn: - case SubColumn.INSTRUMENT: + case SubColumn.VOICE: return display_id(value) - case SubColumn.TRANSPOSE: - return display_transpose(value) case SubColumn.VOLUME: return display_volume(value) 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/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py index 19d5c84d4..2853dbb2d 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py +++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py @@ -1,5 +1,6 @@ from typing import Callable, Generic, Optional, Protocol, TypeVar +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin RegionT = TypeVar("RegionT") @@ -33,6 +34,7 @@ class BlockGrid(Protocol[RegionT, CellT]): on_delete_block: Optional[Callable[[RegionT], None]] on_paste_block: Optional[Callable[[CellT], None]] can_paste_block: Optional[Callable[[], bool]] + refresh_paste_block: Optional[Callable[[VoidCallback], None]] class BlockGestures(CallbackMixin, Generic[RegionT, CellT]): @@ -47,9 +49,13 @@ def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None: self._grid = grid def can_paste(self) -> bool: - """Whether a block stands ready for a paste to write.""" + """Whether a block stands ready for a paste to write, as the clipboard last answered.""" return self.query(self._grid.can_paste_block, default=False) + def refresh_paste(self, then: VoidCallback) -> None: + """Asks the clipboard again, running ``then`` once :meth:`can_paste` reads the fresh answer.""" + self.call(self._grid.refresh_paste_block, then) + def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None: """Takes what a target covers, leaving the grid as it stands.""" self.call(self._grid.on_copy_block, target.region) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py index 56aeb8386..239ee43c4 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from functools import partial from typing import Dict, Final, Generic, Mapping, Tuple, TypeVar import dearpygui.dearpygui as dpg @@ -7,8 +8,10 @@ from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.manager import LanguageManager from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_shared.types.application import Sender RegionT = TypeVar("RegionT") CellT = TypeVar("CellT") @@ -64,8 +67,10 @@ def labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: def add_items(self, target: BlockTarget[RegionT, CellT]) -> None: """Builds the four items, acting on the block the actions were raised on. - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. + Paste is offered on the block the clipboard last answered with, and the opening asks the + clipboard again, so the item follows the fresh answer once it lands. It anchors at the + target's own cell, so the cell menu lands a block where the pointer is while the keys land + it under the cursor. """ dpg.add_menu_item( label=self._labels[ContextElements.COPY], @@ -77,13 +82,18 @@ def add_items(self, target: BlockTarget[RegionT, CellT]) -> None: shortcut=self._shortcuts.display(self._block_shortcuts.cut), callback=lambda: self._blocks.cut_at(target), ) - dpg.add_menu_item( + paste = dpg.add_menu_item( label=self._labels[ContextElements.PASTE], shortcut=self._shortcuts.display(self._block_shortcuts.paste), enabled=self._blocks.can_paste(), callback=lambda: self._blocks.paste_at(target), ) + self._blocks.refresh_paste(partial(self._offer_paste, paste)) dpg.add_menu_item( label=self._labels[ContextElements.DELETE], callback=lambda: self._blocks.delete_at(target), ) + + def _offer_paste(self, item: Sender) -> None: + """Sets the Paste item by the block the clipboard's fresh answer leaves in hand.""" + dpg_configure_item(item, enabled=self._blocks.can_paste()) diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 9a340473c..d6b279afe 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 @@ -173,7 +173,7 @@ def _window(self, view_model: HistoryViewModel) -> EntryWindow: Rendering is capped at ``max_rendered_entries`` so a full-budget history keeps the panel responsive; the window tracks the cursor, keeping the current entry visible and clickable. Entries beyond the window are - reached by stepping the cursor towards them. + reached by stepping the cursor toward them. """ limit = self._layout.history.max_rendered_entries entries = view_model.entries @@ -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..8b4291a19 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/edit.py +++ b/src/sampletones_application/ui/panels/sequencer/input/edit.py @@ -3,15 +3,16 @@ 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 +from sampletones_core.project.patterns.pitch import RowPitch @dataclass(frozen=True) class EditAction: row: int - generator: Optional[GeneratorName] + channel: Optional[ChannelName] sample_index: Optional[int] - transpose: Optional[int] + pitch: Optional[RowPitch] volume: Optional[int] note_off: bool = False @@ -19,5 +20,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..179659d4b 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -18,12 +18,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 from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.project.patterns.pitch import clamped_step +from sampletones_shared.constants.general import DECIMAL_BASE, 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 +34,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), - transpose=None, + channel=cursor.channel, + sample_index=int(pending, HEXADECIMAL_BASE), + pitch=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), + pitch=None, + volume=min(int(pending, HEXADECIMAL_BASE), MAX_VOLUME), ) case SubColumn.TRANSPOSE: sign = -1 if pending.startswith(MINUS) else 1 @@ -63,9 +65,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), + pitch=clamped_step(sign * int(magnitude, DECIMAL_BASE)), volume=None, ) except ValueError: @@ -86,8 +88,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 +98,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 +114,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 +123,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 +139,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 +158,7 @@ def extend_row( return self.extend_to( TrackerCursor( new_row, - self.cursor.generator, + self.cursor.channel, self.cursor.subcolumn, ) ) @@ -171,14 +173,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 +199,7 @@ def navigate_row( return TrackerInputState( cursor=TrackerCursor( new_row, - self.cursor.generator, + self.cursor.channel, self.cursor.subcolumn, ), pending="", @@ -211,7 +213,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 +224,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 +248,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 +266,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,9 +286,9 @@ 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, + pitch=None, volume=None, note_off=True, ) @@ -295,17 +297,21 @@ def _type_transpose_char( self, char: str, ) -> Tuple[TrackerInputState, Optional[EditAction]]: - """Drives the signed transpose field: ``[±][H][H]``. + """Drives the signed step field: ``[±][D][D]``, in decimal digits. The first slot is reserved for the sign. A leading sign sets it; a leading digit implies ``+``. A sign key pressed later flips the sign in place, keeping any digits already entered. The field commits once both magnitude - digits are in. + digits are in. A key naming no decimal digit and no sign leaves the field + as it stands. """ if self.cursor is None: return self, None is_sign = char in SIGNS + if not is_sign and not char.isdecimal(): + return self, None + if not self.pending: pending = char if is_sign else f"{PLUS}{char}" elif is_sign: @@ -340,14 +346,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/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py deleted file mode 100644 index 0be02c15a..000000000 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ /dev/null @@ -1,1556 +0,0 @@ -from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple - -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.constants.sequencer import CHANNEL_AXIS -from sampletones_application.layout.general.plus_minus_buttons import ( - PlusMinusButtonsLayout, -) -from sampletones_application.layout.tabs.sequencer import SequencerLayout -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_HANDLER_DRAG, - SUF_HANDLER_HEADER, - SUF_HANDLER_REGISTRY, -) -from sampletones_application.tags.sequencer import ( - TAG_SEQUENCER_ORDER_BUTTON_PAIR, - TAG_SEQUENCER_ORDER_PANEL, - TAG_SEQUENCER_ORDER_TABLE, - TAG_SEQUENCER_ORDER_WINDOW, - 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 -from sampletones_application.ui.elements.plus_minus_buttons import ( - GUIPlusMinusButtons, - PlusMinusOrder, -) -from sampletones_application.ui.elements.table.caret import CaretOverlay -from sampletones_application.ui.elements.table.cells import EditableCells, pending_label -from sampletones_application.ui.elements.table.selection import TableSelection -from sampletones_application.ui.panels.sequencer.channels import ( - ChannelMenuLabels, - 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, -) -from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand -from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel -from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( - BlockShortcuts, - ClipboardItems, -) -from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( - GridEditSurface, -) -from sampletones_application.ui.panels.sequencer.input.order import ( - INDEX_DIGITS, - OrderCursor, - OrderInputState, -) -from sampletones_application.ui.panels.sequencer.input.target import OrderTarget -from sampletones_application.ui.themes.inline import ( - create_header_selectable_theme, - create_selectable_text_theme, -) -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item -from sampletones_application.utils.gui.keyboard import ( - PRIORITY_PANEL, - ActivePredicate, - KeyEvent, - KeyRouter, -) -from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import ( - Modifier, - capture_modifiers, -) -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.faded import FadedColor -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.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 - -FROZEN_LABEL_COLUMNS: Final[int] = 1 - -_EMPTY_LABEL = display_id(None) - - -class GUISequencerOrderPanel(GUIPanel): - """Editable, transposed view of the arrangement: positions are columns, channels rows. - - A single edit cursor moves over the cells; its position is the active frame, so - moving it horizontally drives the tracker grid (``on_frame_selected``). The - master row (``None``) broadcasts a typed index to every channel and shows ``?`` - when they disagree — the horizontal analog of the tracker's sample column. - """ - - def __init__( - self, - *, - layout: SequencerLayout, - plus_minus_layout: PlusMinusButtonsLayout, - language_manager: LanguageManager, - key_router: KeyRouter, - tab_active: ActivePredicate, - shortcut_source: ShortcutSource, - initial_collapsed: bool = False, - ) -> None: - self._layout = layout - self._plus_minus_layout = plus_minus_layout - self._router = key_router - self._tab_active = tab_active - self._shortcuts = shortcut_source - self._buttons: Optional[GUIPlusMinusButtons] = None - self._position_count: int = 0 - self._order: EditableCells[OrderKey] = EditableCells() - self._input_state: OrderInputState = OrderInputState() - self._selection: TableSelection[OrderKey] = TableSelection( - cells=self._order, - cell_at=self._cell_at, - covered=self._selected_cells, - ) - self._travel: DragTravel = DragTravel( - axis=HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE), - band=self._travel_band, - elapsed=dpg.get_delta_time, - ) - self._highlighted: Optional[OrderCursor] = None - self._highlighted_column: Optional[int] = None - self._current_position: Optional[int] = None - self._playing_position: Optional[int] = None - 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._entry_theme: int = 0 - self._muted_entry_theme: int = 0 - self._label_theme: int = 0 - self._muted_label_theme: int = 0 - self._current_channels: Optional[SequencerChannelsViewModel] = None - self._table_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_ORDER) - - self.on_frame_selected: Optional[OnFrameSelectedCallback] = None - self.on_remove_requested: Optional[OnRemoveCallback] = None - self.on_duplicate_requested: Optional[OnFrameActionCallback] = None - self.on_clone_requested: Optional[OnFrameActionCallback] = None - self.on_insert_requested: Optional[OnFrameActionCallback] = None - self.on_clear_requested: Optional[OnFrameActionCallback] = None - self.on_play_from_requested: Optional[OnFrameActionCallback] = None - self.on_move_requested: Optional[OnMoveCallback] = None - self.on_set_order_entry: Optional[OnSetOrderEntryCallback] = None - self.on_set_master_entry: Optional[OnSetMasterEntryCallback] = None - self.on_cell_selected: Optional[VoidCallback] = None - self.on_copy_block: Optional[OnBlockRegionCallback] = None - self.on_cut_block: Optional[OnBlockRegionCallback] = None - self.on_delete_block: Optional[OnBlockRegionCallback] = None - self.on_paste_block: Optional[OnPasteBlockCallback] = None - self.can_paste_block: Optional[CanPasteBlockQuery] = None - self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None - self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None - self.on_channels_toggled: Optional[VoidCallback] = None - self.on_channels_muted: Optional[VoidCallback] = None - self.on_channels_unmuted: Optional[VoidCallback] = None - - self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) - self._surface: OrderEditSurface = GridEditSurface.build( - grid=self, - blocks=self._blocks, - target=OrderTarget, - shortcuts=shortcut_source, - block_shortcuts=BlockShortcuts( - copy=ShortcutId.ORDER_COPY_BLOCK, - cut=ShortcutId.ORDER_CUT_BLOCK, - paste=ShortcutId.ORDER_PASTE_BLOCK, - ), - labels=ClipboardItems.labels(language_manager), - ) - 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) - - super().__init__( - tag=TAG_SEQUENCER_ORDER_PANEL, - ) - self._enable_vertical_collapse( - initial_collapsed=initial_collapsed, - auto_height=True, - card_tag=TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, - ) - - @staticmethod - def _label(language_manager: LanguageManager, element: SequencerOrderElements) -> str: - return language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element] - - 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] = { - 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), - } - - 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.""" - - def tooltip(element: SequencerOrderElements) -> str: - return language_manager[Page.SEQUENCER, Panel.ORDER, TextType.TOOLTIP, element] - - self._tooltip_label_channel = channel_tooltip(tooltip(SequencerOrderElements.LABEL_CHANNEL)) - self._tooltip_label_master = tooltip(SequencerOrderElements.LABEL_MASTER) - - def _create_channel_switch(self, language_manager: LanguageManager) -> None: - """Builds the switch a row label's click and menu act through. - - The hooks are read at call time, so the switch is built here while they are still unset and - the coordinator wires them once the panel exists. - """ - labels = ChannelMenuLabels( - mute=self._label(language_manager, SequencerOrderElements.CONTEXT_MUTE), - unmute=self._label(language_manager, SequencerOrderElements.CONTEXT_UNMUTE), - solo=self._label(language_manager, SequencerOrderElements.CONTEXT_SOLO), - unsolo=self._label(language_manager, SequencerOrderElements.CONTEXT_UNSOLO), - mute_all=self._label(language_manager, SequencerOrderElements.CONTEXT_MUTE_ALL), - unmute_all=self._label(language_manager, SequencerOrderElements.CONTEXT_UNMUTE_ALL), - ) - 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_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), - ) - - def create_panel(self, parent: str) -> None: - self._create_entry_themes() - with ( - self._collapsible_card( - parent, - self._lbl_order, - glyph=self._glyphs.headers.order, - ), - dpg.group(tag=self.tag), - ): - self._create_button_row() - self._create_order_window() - 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. - - 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 - 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. - """ - colors = self._layout.colors - self._entry_theme = create_selectable_text_theme(colors.text.order) - self._muted_entry_theme = create_selectable_text_theme( - FadedColor( - color=colors.text.order, - fraction=self._layout.tracker.muted_text_fraction, - ), - ) - self._label_theme = create_header_selectable_theme( - colors.label, - colors.header.hovered, - colors.header.active, - ) - self._muted_label_theme = create_header_selectable_theme( - colors.muted.text, - colors.header.hovered, - colors.header.active, - ) - - def _create_button_row(self) -> None: - buttons = GUIPlusMinusButtons( - tag=TAG_SEQUENCER_ORDER_BUTTON_PAIR, - parent=self.tag, - layout=self._plus_minus_layout, - order=PlusMinusOrder.PLUS_FIRST, - hold_repeat=False, - decrement_enabled=False, - ) - buttons.on_increment = self._on_add_clicked - buttons.on_decrement = self._on_remove_clicked - self._buttons = buttons - - def _create_order_window(self) -> None: - dpg.add_child_window( - tag=TAG_SEQUENCER_ORDER_WINDOW, - parent=self.tag, - height=self._layout.order.height, - width=0, - border=False, - ) - - def _register_handlers(self) -> None: - self._router.register( - self._on_key_pressed, - priority=PRIORITY_PANEL, - active=self._keys_active, - ) - - with dpg.item_handler_registry(tag=self._cell_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) - dpg.add_item_active_handler(callback=self._on_cell_held) - - with dpg.item_handler_registry(tag=self._label_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_label_right_clicked) - - with dpg.handler_registry(tag=self._drag_handler_tag): - dpg.add_mouse_click_handler( - button=dpg.mvMouseButton_Left, - callback=self._on_pointer_pressed, - ) - - def update_order(self, view_model: SequencerOrderTrackerViewModel) -> None: - """Reconciles the order table; rebuilds only when the position count changes.""" - cell_values = self._compute_cell_values(view_model) - if view_model.position_count != self._position_count: - self._rebuild_table(view_model, cell_values) - else: - self._order.reconcile(cell_values, self._render_cell) - - self._refresh_remove_enabled() - - def select_position(self, frame: int) -> None: - """Follows the tracker frame: always updates the unfocused column highlight; - also moves the edit cursor when one is active. - """ - self._current_position = frame - self._follow_frame(frame) - self._refresh_remove_enabled() - - def _follow_frame(self, frame: int) -> None: - """Carries the edit cursor to the frame, or shifts the unfocused column highlight to it - while no cursor is set.""" - cursor = self._input_state.cursor - if cursor is not None: - if cursor.position == frame: - return - - new_state = OrderInputState( - cursor=OrderCursor(cursor.generator, frame), - ) - if 0 <= frame < self._position_count: - self._apply_state(new_state, notify=False) - else: - self._clear_cursor_highlight() - self._clear_column_highlight() - self._input_state = new_state - self._update_caret() - else: - if self._highlighted_column == frame: - return - - self._clear_column_highlight() - if 0 <= frame < self._position_count: - self._apply_column_highlight(frame, focused=False) - - def deselect_cell(self) -> None: - """Drops the edit cursor (e.g. when focus moves to the tracker grid).""" - cursor = self._input_state.cursor - self._clear_cursor_highlight() - self._clear_column_highlight() - self._input_state = OrderInputState() - self._selection.repaint() - self._update_caret() - - if cursor is not None: - self._update_cell_display(cursor) - - if self._current_position is not None and 0 <= self._current_position < self._position_count: - self._apply_column_highlight(self._current_position, focused=False) - - self._refresh_remove_enabled() - - def set_enabled(self, enabled: bool) -> None: - dpg_configure_item(TAG_SEQUENCER_ORDER_PANEL, enabled=enabled) - - def update_channels(self, view_model: SequencerChannelsViewModel) -> None: - """Shows which channels the song player silences. - - The model is kept so a rebuilt table takes the cue again, the way the row tints do, and so - a table still waiting for its rows picks it up once they arrive. - """ - self._current_channels = view_model - self._apply_channel_cues() - - def _apply_channel_cues(self) -> None: - """Marks each silenced channel along its whole row: label, background, and entry text. - - The three cues land together because they read as one: the row recedes as a unit while its - frames stay legible, so the channel is visibly out of the mix and still open for editing. - The tracker grid carries the same cue down each column, so a channel looks the same in both. - """ - if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE): - return - - self._tint_channel_rows() - self._bind_label_themes() - for generator in GeneratorName.items(): - self._bind_channel_entry_themes(generator) - - def _bind_label_themes(self) -> None: - for label, generator in self._label_rows.items(): - muted = generator is not None and self._is_muted(generator) - 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 - for position in range(self._position_count): - widget = self._order.widget((generator, 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 _compute_cell_values( - self, - view_model: SequencerOrderTrackerViewModel, - ) -> Dict[OrderKey, str]: - 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, - position, - ) - - return cell_values - - def _rebuild_table( - self, - view_model: SequencerOrderTrackerViewModel, - cell_values: Dict[OrderKey, str], - ) -> None: - """Recreates the whole table when the position count changes. - - Deleting and re-adding a live table's *columns* leaves DPG's per-column - state (and any highlight keyed by column index) dangling, which corrupted - the heap. Replacing the table item wholesale sidesteps that: the cursor and - column highlights die with the old table, so nothing references freed - columns. The selected cells go with them, and :meth:`_restore_cursor` brings the - cursor back on its own — a table of another width is a table a region no longer - describes. - """ - dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE) - self._highlighted = None - self._highlighted_column = None - self._selection.reset() - self._travel.rest() - self._order.reset(cell_values) - self._position_count = view_model.position_count - self._build_table(view_model.position_count) - self._restore_cursor() - - def _build_table(self, position_count: int) -> None: - dpg.add_table( - tag=TAG_SEQUENCER_ORDER_TABLE, - parent=TAG_SEQUENCER_ORDER_WINDOW, - header_row=True, - resizable=False, - borders_innerH=True, - borders_innerV=True, - borders_outerH=True, - borders_outerV=True, - scrollX=True, - scrollY=False, - freeze_columns=FROZEN_LABEL_COLUMNS, - policy=dpg.mvTable_SizingFixedFit, - ) - FontRegistry.bind_to_item(TAG_SEQUENCER_ORDER_TABLE, Font.MONO_BOLD) - self._table_theme.bind_to_item(TAG_SEQUENCER_ORDER_TABLE) - - dpg.add_table_column( - label="", - parent=TAG_SEQUENCER_ORDER_TABLE, - width_fixed=True, - init_width_or_weight=self._layout.table_cells.generator, - ) - for position in range(position_count): - dpg.add_table_column( - label=display_id(position), - parent=TAG_SEQUENCER_ORDER_TABLE, - width_fixed=True, - init_width_or_weight=self._layout.order.position_column_width, - ) - - self._label_rows = {} - for generator in CHANNEL_AXIS: - self._build_row(generator, position_count) - if generator is None: - self._build_divider_row(position_count) - - self._apply_column_backgrounds() - self._highlight_master_row(position_count) - self._apply_channel_cues() - - 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. - """ - if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE): - return - - self._apply_column_backgrounds() - self._highlight_master_row(self._position_count) - self._apply_channel_cues() - self._repaint_highlights() - - def _repaint_highlights(self) -> None: - if self._highlighted_column is not None: - position = self._highlighted_column - focused = self._input_state.cursor is not None and self._input_state.cursor.position == position - self._apply_column_highlight(position, focused=focused) - - if self._highlighted is not None: - self._apply_cursor_highlight(self._highlighted) - - def _apply_column_backgrounds(self) -> None: - """Tints the label column like the header row. - - The position columns carry no static shade, so the per-channel row tints read - cleanly beneath the position and cursor highlights — the transposed analog of - the tracker grid, whose channel column tints sit beneath its cursor. - """ - dpg.highlight_table_column( - TAG_SEQUENCER_ORDER_TABLE, - 0, - self._layout.colors.order.label.rgba, - ) - - def _highlight_master_row(self, position_count: int) -> None: - """Tints the master row and the rule below it, matching the tracker grid's - sample column and sample divider so the two tables read consistently. - - The tint is applied per cell so it overrides the per-column shades and reads - consistently across the whole row. - """ - for column in range(position_count + 1): - self._highlight_master_cell_at(column) - dpg.highlight_table_cell( - TAG_SEQUENCER_ORDER_TABLE, - DIVIDER_TABLE_ROW, - column, - color=self._layout.colors.order.master_divider.rgba, - ) - - def _highlight_master_cell_at(self, column: int) -> None: - dpg.highlight_table_cell( - TAG_SEQUENCER_ORDER_TABLE, - MASTER_TABLE_ROW, - column, - color=self._layout.colors.order.master.rgba, - ) - - def _tint_channel_rows(self) -> None: - """Washes each channel row with a light tint of its identity colour. - - 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 - tint. This is the transposed analog of the tracker grid's per-channel column - 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(): - dpg.highlight_table_row( - TAG_SEQUENCER_ORDER_TABLE, - self._table_row(generator), - self._channel_row_tint(generator), - ) - - def _channel_row_tint(self, generator: GeneratorName) -> ColorRGBA: - if self._is_muted(generator): - return self._layout.colors.muted.background.rgba - - channel = channel_color(self._layout.colors.channels, generator) - return FadedColor( - color=channel, - fraction=self._layout.tracker.channel_column_tint, - ).rgba - - def _apply_column_highlight(self, position: int, *, focused: bool) -> None: - if focused: - color = self._layout.colors.pattern_highlight - elif position == self._playing_position: - color = self._layout.colors.order.column_playing - else: - color = self._layout.colors.order.column_current - - dpg.highlight_table_column(TAG_SEQUENCER_ORDER_TABLE, position + 1, color.rgba) - self._highlighted_column = position - - def set_playing_position(self, position: Optional[int]) -> None: - previous_position = self._playing_position - self._playing_position = position - if ( - previous_position is not None - and previous_position != position - and previous_position == self._highlighted_column - ): - focused = self._input_state.cursor is not None and self._input_state.cursor.position == previous_position - self._apply_column_highlight(previous_position, focused=focused) - - def _clear_column_highlight(self) -> None: - if self._highlighted_column is not None: - dpg.unhighlight_table_column( - TAG_SEQUENCER_ORDER_TABLE, - self._highlighted_column + 1, - ) - self._highlighted_column = None - - def _build_row( - self, - generator: Optional[GeneratorName], - position_count: int, - ) -> None: - font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL - row_id = dpg.add_table_row(parent=TAG_SEQUENCER_ORDER_TABLE) - self._add_row_label(row_id, generator) - for position in range(position_count): - cell = dpg.add_table_cell(parent=row_id) - key: OrderKey = (generator, position) - selectable = dpg.add_selectable( - parent=cell, - label=self._render_cell(key), - user_data=key, - callback=self._on_cell_clicked, - ) - FontRegistry.bind_to_item(selectable, font) - dpg.bind_item_theme(selectable, self._entry_theme) - dpg.bind_item_handler_registry(selectable, self._cell_handler_tag) - self._order.register(key, selectable) - - def _add_row_label( - self, - row_id: Sender, - generator: Optional[GeneratorName], - ) -> None: - """Places one clickable row label: a channel's mute target, or the master target. - - The label spans its frozen cell, which is what makes the whole name a click target, and it - carries its channel so the click knows which row it landed on. A tooltip names the gestures - the label answers to, and the right-click registry opens the same actions as a menu. - """ - label_cell = dpg.add_table_cell(parent=row_id) - label = dpg.add_selectable( - parent=label_cell, - label=self._row_labels[generator], - user_data=generator, - 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._label_rows[label] = generator - - def _build_divider_row(self, position_count: int) -> None: - """Inserts the thin rule that sets the master row apart from the channel - rows — the transposed analog of the tracker grid's sample-divider column. - """ - row_id = dpg.add_table_row(parent=TAG_SEQUENCER_ORDER_TABLE) - spacer_cell = dpg.add_table_cell(parent=row_id) - dpg.add_spacer( - parent=spacer_cell, - height=self._layout.order.master_divider_height, - ) - for _ in range(position_count): - dpg.add_table_cell(parent=row_id) - - 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: - return pending_label( - self._input_state.pending, - stored, - INDEX_DIGITS, - ) - - return stored - - def _table_row(self, generator: Optional[GeneratorName]) -> int: - if generator is None: - return MASTER_TABLE_ROW - return CHANNEL_AXIS.index(generator) + 1 - - def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: - dpg.highlight_table_cell( - TAG_SEQUENCER_ORDER_TABLE, - self._table_row(cursor.generator), - cursor.position + 1, - color=self._layout.colors.cell_cursor.rgba, - ) - self._highlighted = cursor - - def _selected_cells(self) -> FrozenSet[OrderKey]: - """Every cell the selection covers, clipped to the positions the table holds.""" - region = self._input_state.region - if region is None: - return frozenset() - - keys: Set[OrderKey] = set() - for generator in region.generators: - for position in region.positions: - if position < self._position_count: - keys.add((generator, position)) - - return frozenset(keys) - - def _clear_cursor_highlight(self) -> None: - if self._highlighted is None: - return - - cursor = self._highlighted - self._highlighted = None - if cursor.generator is None: - self._highlight_master_cell_at(cursor.position + 1) - else: - dpg.unhighlight_table_cell( - TAG_SEQUENCER_ORDER_TABLE, - self._table_row(cursor.generator), - cursor.position + 1, - ) - - def _restore_cursor(self) -> None: - cursor = self._input_state.cursor - if cursor is None or self._position_count == 0: - self._input_state = OrderInputState() - if self._current_position is not None and 0 <= self._current_position < self._position_count: - self._apply_column_highlight( - self._current_position, - focused=False, - ) - - self._update_caret() - return - - position = min(cursor.position, self._position_count - 1) - clamped = OrderCursor(cursor.generator, position) - self._input_state = OrderInputState(cursor=clamped) - self._apply_cursor_highlight(clamped) - self._apply_column_highlight(clamped.position, focused=True) - self._update_caret() - - def _apply_state( - self, - new_state: OrderInputState, - notify: bool = True, - ) -> None: - old = self._input_state.cursor - new = new_state.cursor - - self._clear_cursor_highlight() - self._input_state = new_state - - if old is not None: - self._update_cell_display(old) - - if new is not None: - self._apply_cursor_highlight(new) - self._update_cell_display(new) - - old_position = old.position if old is not None else None - new_position = new.position if new is not None else None - if old_position != new_position: - self._clear_column_highlight() - if new_position is not None: - self._current_position = new_position - self._apply_column_highlight(new_position, focused=True) - - if notify and new is not None: - self.call(self.on_cell_selected) - if old is None or old.position != new.position: - self.call(self.on_frame_selected, new.position) - - self._selection.repaint() - self._update_caret() - self._refresh_remove_enabled() - - def _update_cell_display(self, cursor: OrderCursor) -> None: - key: OrderKey = (cursor.generator, cursor.position) - widget = self._order.widget(key) - if widget is not None: - dpg.configure_item(widget, label=self._render_cell(key)) - - def _update_caret(self) -> None: - """Arms (or clears) the shared caret box on the active order cell.""" - cursor = self._input_state.cursor - if cursor is 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 - CaretOverlay.set_target( - owner=TAG_SEQUENCER_ORDER_TABLE, - widget=self._order.widget(key), - caret_index=len(self._input_state.pending), - font=font, - clip_widget=TAG_SEQUENCER_ORDER_WINDOW, - ) - - def _on_cell_clicked( - self, - sender: Sender, - _app_data: bool, - user_data: OrderKey, - ) -> None: - """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. - - A drag that comes back to the cell it started from ends on a click, and the selection takes - that click as the end of the drag, so the range dragged out stands and the cursor with it. - """ - if self._selection.claims_click(sender, user_data): - return - - state = self._committed_state() - generator, position = user_data - cursor = OrderCursor(generator, position) - if Modifier.SHIFT in capture_modifiers(): - self._apply_state(state.extend_to(cursor)) - return - - self._apply_state(OrderInputState(cursor=cursor)) - - 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. - - The gesture states how far the pointer has carried: a plain drag anchors at the cell the - press landed on, and one whose press held Shift carries the selection already standing. - - A pointer held past an edge travels the table first, so the reach that follows reads the - positions the travel has brought into view. - """ - self._travel.advance() - reach = self._selection.hold(app_data) - if reach is None: - return - - state = self._committed_state() - if not reach.extends: - state = OrderInputState(cursor=OrderCursor(*reach.origin)) - - self._apply_state(state.extend_to(OrderCursor(*reach.reached))) - - def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: - """Drops the gesture a finished drag left behind, so this press selects on its own. - - A press is where a gesture ends rather than the release before it, because the release - reaches this panel ahead of the click the cell itself reports: a drag that comes back to - the cell it started from would otherwise have its selection taken down by its own click. - """ - self._selection.drop_gesture() - self._travel.rest() - - def _travel_band(self) -> Optional[TravelBand]: - """Where the order's positions stand, which is the band a drag held beside them travels across. - - Two positions state the pitch the columns are laid out at, so an order holding one of them - travels nowhere — there is nothing beside it to reach. - """ - first = self._cell_left(0) - following = self._cell_left(1) - if first is None or following is None: - return None - - return TravelBand( - first_edge=first, - cell_extent=following - first, - cell_count=self._position_count, - ) - - def _cell_at(self) -> Optional[OrderKey]: - """The cell the pointer stands on, clamped to the table the order lays out. - - A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond - the last channel or the last position selects up to it rather than stopping there. - """ - left, top = dpg.get_mouse_pos(local=False) - position = self._position_at(left) - if position is None: - return None - - return (self._generator_at(top), position) - - def _generator_at(self, top: float) -> Optional[GeneratorName]: - """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)) - 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_AXIS[-1] - - def _position_at(self, left: float) -> Optional[int]: - """Which position stands at a width, counted from the first cell's left edge. - - Every position column is the same width, so the count is arithmetic once two of them - state the pitch; an order of a single position holds every width there is. - """ - first = self._cell_left(0) - if first is None: - return None - - following = self._cell_left(1) - if following is None: - return 0 - - position = int((left - first) // (following - first)) - return max(0, min(position, self._position_count - 1)) - - def _cell_left(self, position: int) -> Optional[float]: - """Where a position column's cells begin, in the coordinates the viewport is drawn in.""" - widget = self._order.widget((None, position)) - if widget is None: - return None - - cell_left, _ = dpg.get_item_rect_min(widget) - return float(cell_left) - - def _on_cell_right_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - ) -> None: - """Opens the frame-operations menu for the right-clicked cell. - - The menu acts on the clicked cell and its frame directly, and leaves the edit cursor (and, - while following playback, the playhead) where it is — right-clicking should not seek. - """ - mouse_button, clicked_item = app_data - if mouse_button != dpg.mvMouseButton_Right: - return - - key = dpg.get_item_user_data(clicked_item) - if key is None: - return - - generator, position = key - self._show_context_menu(generator, position) - - def _on_label_clicked( - self, - sender: Sender, - _app_data: bool, - user_data: Optional[GeneratorName], - ) -> None: - self._channel_switch.click(sender, user_data) - - def _on_label_right_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - ) -> None: - """Opens the channel menu for the right-clicked row label. - - The registry reaches the row labels alone, so a click on one of them names its row through - the map the rows filled in; a label replaced by a rebuild is absent from it. - """ - mouse_button, clicked_item = app_data - if mouse_button != dpg.mvMouseButton_Right: - return - - 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) - - @property - def edit_surface(self) -> OrderEditSurface: - """This table as the menu bar's Edit menu reaches it.""" - return self._surface - - def input_state(self) -> OrderInputState: - """Where the cursor stands and what it has selected, which a target is resolved from.""" - return self._input_state - - 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, - ), - ) - - 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), - ) - - 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, - ) - - 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. - - 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 _keys_active(self) -> bool: - """Whether the order table owns the next key: its tab is in front, its cursor is set, and - no field holds the keyboard. - - The table keeps its cursor while another tab is worked on, so the tab in front is what - decides whether a press reaches it. A focused field keeps the keyboard, so the table stands - down while the user types into an input. A modal dialog claims keys at a higher priority in - the router, so the table carries no modal check of its own. - """ - return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused - - def _on_key_pressed(self, event: KeyEvent) -> bool: - """Applies an order key to the active cell, reporting whether the table consumed it. - - The scheme says which press each order action answers to; a press the order category - leaves unnamed goes to cell entry, which keeps the hex digits and hands the rest to the - application's global shortcuts. - """ - cursor = self._input_state.cursor - if cursor is None: - return False - - shortcut_id = self._shortcuts.action(ShortcutCategory.ORDER, event) - if shortcut_id is None: - return self._type_character(event) - - if self._move_cursor(shortcut_id): - return True - - if self._extend_selection(shortcut_id): - return True - - if self._select_shape(shortcut_id, cursor): - return True - - if self._block_action(shortcut_id): - return True - - if self._edit_cell(shortcut_id): - return True - - return self._act_on_frame(shortcut_id, cursor.position) - - def _move_cursor(self, shortcut_id: ShortcutId) -> bool: - """Moves the edit cursor over the table, reporting whether the action was one of its moves.""" - match shortcut_id: - case ShortcutId.ORDER_PREVIOUS_POSITION: - self._move_position(-1) - case ShortcutId.ORDER_NEXT_POSITION: - self._move_position(1) - case ShortcutId.ORDER_PREVIOUS_CHANNEL: - self._move_channel(-1) - case ShortcutId.ORDER_NEXT_CHANNEL: - self._move_channel(1) - case ShortcutId.ORDER_FIRST_POSITION: - self._jump_position(0) - case ShortcutId.ORDER_LAST_POSITION: - self._jump_position(self._position_count - 1) - case _: - return False - - return True - - def _extend_selection(self, shortcut_id: ShortcutId) -> bool: - """Grows or shrinks the selected block, reporting whether the action was one of its reaches. - - Each reach moves the end the cursor holds while the anchor stays where the selection began, - so the same keys that move the cursor select with Shift held. - """ - match shortcut_id: - case ShortcutId.ORDER_EXTEND_SELECTION_UP: - self._extend_channel(-1) - case ShortcutId.ORDER_EXTEND_SELECTION_DOWN: - self._extend_channel(1) - case ShortcutId.ORDER_EXTEND_SELECTION_LEFT: - self._extend_position(-1) - case ShortcutId.ORDER_EXTEND_SELECTION_RIGHT: - self._extend_position(1) - case ShortcutId.ORDER_EXTEND_SELECTION_TO_FIRST_POSITION: - self._extend_to_position(0) - case ShortcutId.ORDER_EXTEND_SELECTION_TO_LAST_POSITION: - self._extend_to_position(self._position_count - 1) - case _: - return False - - return True - - def _select_shape( - self, - shortcut_id: ShortcutId, - cell: OrderCursor, - ) -> bool: - """Selects a rectangle of the table, reporting whether the action was one of its shapes. - - A press names its shape from the cell the cursor stands on, which is the cell the menu - items name as well, so a key and an item select the same block. - """ - match shortcut_id: - case ShortcutId.ORDER_SELECT_ALL: - self._select_all() - case ShortcutId.ORDER_SELECT_ROW: - self._select_row(cell) - case _: - return False - - return True - - def _select_all(self) -> None: - self._apply_state( - self._committed_state().select_all( - self._position_count, - ) - ) - - def _select_row(self, cell: OrderCursor) -> None: - self._apply_state( - self._committed_state().select_row( - cell, - self._position_count, - ) - ) - - def _block_action(self, shortcut_id: ShortcutId) -> bool: - """Acts on the selected block, reporting whether the action was one of its gestures. - - Delete is a block gesture only while a selection stands: with one it empties every cell the - selection covers and keeps it, and with none it falls through to clearing the cell under - the cursor, the meaning that key already carries. - """ - match shortcut_id: - case ShortcutId.ORDER_COPY_BLOCK: - self._surface.copy() - case ShortcutId.ORDER_CUT_BLOCK: - self._surface.cut() - case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: - self._surface.delete() - case ShortcutId.ORDER_PASTE_BLOCK: - self._surface.paste() - case _: - return False - - return True - - def _edit_cell(self, shortcut_id: ShortcutId) -> bool: - """Empties the cell under the cursor or drops a partial entry, reporting whether the action - was one of the cell edits. - - A cancel with nothing typed and nothing selected leaves the press to the application, so - Escape stops playback while the table holds a cursor. - """ - match shortcut_id: - case ShortcutId.ORDER_CLEAR_CELL: - self._clear_cell() - self._move_position(1) - case ShortcutId.ORDER_CLEAR_PREVIOUS_CELL: - self._clear_cell() - self._move_position(-1) - case ShortcutId.ORDER_CANCEL_ENTRY: - if not self._input_state.pending and self._input_state.anchor is None: - return False - - self._apply_state(self._input_state.cancel()) - case _: - return False - - return True - - def _act_on_frame(self, shortcut_id: ShortcutId, position: int) -> bool: - """Adds, removes or moves a whole frame, reporting whether the action was one of them. - - A move with nowhere to go still counts as consumed, so a press at either boundary stays - out of the global shortcuts. - """ - direction = MOVE_DIRECTIONS.get(shortcut_id) - if direction is not None: - target = direction.target(position, self._position_count) - if target is not None: - self.call(self.on_move_requested, position, target) - - return True - - match shortcut_id: - case ShortcutId.ORDER_ADD_FRAME: - self._on_add_clicked() - case ShortcutId.ORDER_INSERT_FRAME: - self.call(self.on_insert_requested, position) - case ShortcutId.ORDER_REMOVE_FRAME: - self._on_remove_clicked() - case ShortcutId.ORDER_DUPLICATE_FRAME: - self.call(self.on_duplicate_requested, position) - case ShortcutId.ORDER_CLONE_FRAME: - self.call(self.on_clone_requested, position) - case ShortcutId.ORDER_CLEAR_FRAME: - self.call(self.on_clear_requested, position) - case _: - return False - - return True - - def _move_position(self, delta: int) -> None: - self._apply_state( - self._committed_state().navigate_position( - delta, - self._position_count, - ), - ) - - def _jump_position(self, index: int) -> None: - self._apply_state( - self._committed_state().navigate_position( - index, - self._position_count, - absolute=True, - ), - ) - - def _move_channel(self, delta: int) -> None: - self._apply_state(self._committed_state().navigate_channel(delta)) - - def _extend_position(self, delta: int) -> None: - self._apply_state( - self._committed_state().extend_position( - delta, - self._position_count, - ), - ) - - def _extend_to_position(self, index: int) -> None: - self._apply_state( - self._committed_state().extend_position( - index, - self._position_count, - absolute=True, - ), - ) - - def _extend_channel(self, delta: int) -> None: - self._apply_state(self._committed_state().extend_channel(delta)) - - def _committed_state(self) -> OrderInputState: - state, index = self._input_state.commit_partial() - if index is not None: - self._emit(self._input_state.cursor, index) - - return state - - def commit_entry(self) -> None: - """Writes the entry being typed into the cell the cursor stands on. - - A block gesture takes this first, so what it lifts out carries the index the reader has - just finished typing. - """ - self._apply_state(self._committed_state()) - - def _type_character(self, event: KeyEvent) -> bool: - """Types a hex digit into the cell under the cursor, reporting whether the press was one. - - A press holding Ctrl or Alt is an application gesture, so cell entry reads the plain keys - and leaves the rest to the global shortcuts. - """ - if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers: - return False - - char = HEX_KEYS.get(event.key) - if char is None: - return False - - new_state, index = self._input_state.type_char(char) - if index is not None: - self._emit(self._input_state.cursor, index) - new_state = new_state.navigate_position(1, self._position_count) - - self._apply_state(new_state) - return True - - def _clear_cell(self) -> None: - """Empties the cell under the cursor (sets its slot to ``None``). - - Mirrors the tracker's clear: a channel cell empties just that channel's - slot; the master row empties every channel at that position. - """ - cursor = self._input_state.cursor - if cursor is None: - return - - self._emit(cursor, None) - self._apply_state(self._input_state.cancel()) - - def _emit( - self, - cursor: Optional[OrderCursor], - index: Optional[int], - ) -> None: - if cursor is None: - return - - if cursor.generator is None: - self.call(self.on_set_master_entry, cursor.position, index) - else: - self.call( - self.on_set_order_entry, - cursor.generator, - cursor.position, - index, - ) - - def _get_cursor_position(self) -> Optional[int]: - cursor = self._input_state.cursor - return cursor.position if cursor is not None else self._current_position - - def _get_removable_position(self) -> Optional[int]: - """The frame ``[-]`` acts on: the cursor's frame, or the followed tracker frame while no - cursor is set, resolved while it addresses a live frame. - """ - position = self._get_cursor_position() - if position is not None and 0 <= position < self._position_count: - return position - - return None - - def _refresh_remove_enabled(self) -> None: - """Enables ``[-]`` while a press would remove a frame, so a greyed-out button tells the - user that removal awaits a selected frame. - """ - if self._buttons is not None: - self._buttons.set_decrement_enabled(self._get_removable_position() is not None) - - def _on_add_clicked(self) -> None: - """Inserts an empty frame after the current one (appends when at or past the end).""" - position = self._get_cursor_position() - if position is None or not 0 <= position < self._position_count: - position = self._position_count - 1 - - self.call(self.on_insert_requested, position) - - def _on_remove_clicked(self) -> None: - position = self._get_removable_position() - if position is not None: - self.call(self.on_remove_requested, position) diff --git a/src/sampletones_application/ui/panels/sequencer/order/__init__.py b/src/sampletones_application/ui/panels/sequencer/order/__init__.py new file mode 100644 index 000000000..e69de29bb 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..f3cd7dc17 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py @@ -0,0 +1,25 @@ +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 +from sampletones_shared.types.callback import VoidCallback + +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] +RefreshPasteBlockRequest = Callable[[VoidCallback], None] + +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/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py new file mode 100644 index 000000000..4996e37ca --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -0,0 +1,1417 @@ +from typing import Dict, Final, FrozenSet, Optional, Set, Tuple + +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.constants.sequencer import CHANNEL_AXIS +from sampletones_application.layout.general.colors.channel import ChannelColors +from sampletones_application.layout.general.plus_minus_buttons import ( + PlusMinusButtonsLayout, +) +from sampletones_application.layout.tabs.sequencer import SequencerLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_HANDLER_DRAG, + SUF_HANDLER_HEADER, + SUF_HANDLER_REGISTRY, +) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_ORDER_BUTTON_PAIR, + TAG_SEQUENCER_ORDER_PANEL, + TAG_SEQUENCER_ORDER_TABLE, + TAG_SEQUENCER_ORDER_WINDOW, + TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, + TAG_SEQUENCER_THEME_TABLE_ORDER, +) +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.plus_minus_buttons import ( + GUIPlusMinusButtons, + PlusMinusOrder, +) +from sampletones_application.ui.elements.table.caret import CaretOverlay +from sampletones_application.ui.elements.table.cells import EditableCells, pending_label +from sampletones_application.ui.elements.table.selection import TableSelection +from sampletones_application.ui.panels.sequencer.channels import ( + ChannelMenuLabels, + ChannelSwitch, + channel_tooltip, +) +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, +) +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( + BlockShortcuts, + ClipboardItems, +) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( + GridEditSurface, +) +from sampletones_application.ui.panels.sequencer.input.order import ( + INDEX_DIGITS, + OrderCursor, + 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, + RefreshPasteBlockRequest, +) +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, +) +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_delete_item, + dpg_get_item_user_data, +) +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, + panel_scope_active, +) +from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS +from sampletones_application.utils.gui.keyboard.modifiers import ( + Modifier, + capture_modifiers, +) +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.faded import FadedColor +from sampletones_application.view_model.sequencer.channels import ( + SequencerChannelsViewModel, +) +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 ChannelName +from sampletones_core.utils.display import display_id +from sampletones_shared.types.application import ColorRGBA, Sender +from sampletones_shared.types.callback import VoidCallback + +MASTER_TABLE_ROW: Final[int] = 0 +DIVIDER_TABLE_ROW: Final[int] = 1 + +FROZEN_LABEL_COLUMNS: Final[int] = 1 + +_EMPTY_LABEL = display_id(None) + + +class GUISequencerOrderPanel(GUIPanel): + """Editable, transposed view of the arrangement: positions are columns, channels rows. + + A single edit cursor moves over the cells; its position is the active frame, so + moving it horizontally drives the tracker grid (``on_frame_selected``). The + master row (``None``) broadcasts a typed index to every channel and shows ``?`` + when they disagree — the horizontal analog of the tracker's sample column. + """ + + def __init__( + self, + *, + layout: SequencerLayout, + channel_colors: ChannelColors, + plus_minus_layout: PlusMinusButtonsLayout, + language_manager: LanguageManager, + key_router: KeyRouter, + tab_active: ActivePredicate, + shortcut_source: ShortcutSource, + 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 + self._shortcuts = shortcut_source + self._buttons: Optional[GUIPlusMinusButtons] = None + self._position_count: int = 0 + self._order: EditableCells[OrderKey] = EditableCells() + self._input_state: OrderInputState = OrderInputState() + self._selection: TableSelection[OrderKey] = TableSelection( + cells=self._order, + cell_at=self._cell_at, + covered=self._selected_cells, + ) + self._travel: DragTravel = DragTravel( + axis=HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE), + band=self._travel_band, + elapsed=dpg.get_delta_time, + ) + self._highlighted: Optional[OrderCursor] = None + self._highlighted_column: Optional[int] = None + self._current_position: Optional[int] = None + self._playing_position: Optional[int] = None + 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[ChannelName]] = {} + self._entry_theme: int = 0 + self._muted_entry_theme: int = 0 + self._label_theme: int = 0 + self._muted_label_theme: int = 0 + self._current_channels: Optional[SequencerChannelsViewModel] = None + self._table_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_ORDER) + + self.on_frame_selected: Optional[OnFrameSelectedCallback] = None + self.on_remove_requested: Optional[OnRemoveCallback] = None + self.on_duplicate_requested: Optional[OnFrameActionCallback] = None + self.on_clone_requested: Optional[OnFrameActionCallback] = None + self.on_insert_requested: Optional[OnFrameActionCallback] = None + self.on_clear_requested: Optional[OnFrameActionCallback] = None + self.on_play_from_requested: Optional[OnFrameActionCallback] = None + self.on_move_requested: Optional[OnMoveCallback] = None + self.on_set_order_entry: Optional[OnSetOrderEntryCallback] = None + self.on_set_master_entry: Optional[OnSetMasterEntryCallback] = None + self.on_cell_selected: Optional[VoidCallback] = None + self.on_copy_block: Optional[OnBlockRegionCallback] = None + self.on_cut_block: Optional[OnBlockRegionCallback] = None + self.on_delete_block: Optional[OnBlockRegionCallback] = None + self.on_paste_block: Optional[OnPasteBlockCallback] = None + self.can_paste_block: Optional[CanPasteBlockQuery] = None + self.refresh_paste_block: Optional[RefreshPasteBlockRequest] = None + self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None + self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None + self.on_channels_toggled: Optional[VoidCallback] = None + self.on_channels_muted: Optional[VoidCallback] = None + self.on_channels_unmuted: Optional[VoidCallback] = None + + self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) + self._surface: OrderEditSurface = GridEditSurface.build( + grid=self, + blocks=self._blocks, + target=OrderTarget, + shortcuts=shortcut_source, + block_shortcuts=BlockShortcuts( + copy=ShortcutId.ORDER_COPY_BLOCK, + cut=ShortcutId.ORDER_CUT_BLOCK, + paste=ShortcutId.ORDER_PASTE_BLOCK, + ), + labels=ClipboardItems.labels(language_manager), + ) + self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) + self._load_row_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, + ) + self._enable_vertical_collapse( + initial_collapsed=initial_collapsed, + auto_height=True, + card_tag=TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD, + ) + + @staticmethod + def _label(language_manager: LanguageManager, element: SequencerOrderElements) -> str: + return language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element] + + 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[ChannelName], str] = { + None: self._label(language_manager, SequencerOrderElements.ROW_MASTER), + 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_label_tooltips(self, language_manager: LanguageManager) -> None: + """Reads the row-label tooltips, which name the click gestures the labels carry.""" + + def tooltip(element: SequencerOrderElements) -> str: + return language_manager[Page.SEQUENCER, Panel.ORDER, TextType.TOOLTIP, element] + + self._tooltip_label_channel = channel_tooltip(tooltip(SequencerOrderElements.LABEL_CHANNEL)) + self._tooltip_label_master = tooltip(SequencerOrderElements.LABEL_MASTER) + + def _create_channel_switch(self, language_manager: LanguageManager) -> None: + """Builds the switch a row label's click and menu act through. + + The hooks are read at call time, so the switch is built here while they are still unset and + the coordinator wires them once the panel exists. + """ + labels = ChannelMenuLabels( + mute=self._label(language_manager, SequencerOrderElements.CONTEXT_MUTE), + unmute=self._label(language_manager, SequencerOrderElements.CONTEXT_UNMUTE), + solo=self._label(language_manager, SequencerOrderElements.CONTEXT_SOLO), + unsolo=self._label(language_manager, SequencerOrderElements.CONTEXT_UNSOLO), + mute_all=self._label(language_manager, SequencerOrderElements.CONTEXT_MUTE_ALL), + unmute_all=self._label(language_manager, SequencerOrderElements.CONTEXT_UNMUTE_ALL), + ) + self._channel_switch = ChannelSwitch( + labels=labels, + 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), + ) + + def create_panel(self, parent: str) -> None: + self._create_entry_themes() + with ( + self._collapsible_card( + parent, + self._lbl_order, + glyph=self._glyphs.headers.order, + ), + dpg.group(tag=self.tag), + ): + self._create_button_row() + self._create_order_window() + self._register_handlers() + + def _create_entry_themes(self) -> None: + """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 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. + """ + colors = self._layout.colors + self._entry_theme = create_selectable_text_theme(colors.text.order) + self._muted_entry_theme = create_selectable_text_theme( + FadedColor( + color=colors.text.order, + fraction=self._layout.tracker.muted_text_fraction, + ), + ) + self._label_theme = create_header_selectable_theme( + colors.label, + colors.header.hovered, + colors.header.active, + ) + self._muted_label_theme = create_header_selectable_theme( + colors.muted.text, + colors.header.hovered, + colors.header.active, + ) + + def _create_button_row(self) -> None: + buttons = GUIPlusMinusButtons( + tag=TAG_SEQUENCER_ORDER_BUTTON_PAIR, + parent=self.tag, + layout=self._plus_minus_layout, + order=PlusMinusOrder.PLUS_FIRST, + hold_repeat=False, + decrement_enabled=False, + ) + buttons.on_increment = self._on_add_clicked + buttons.on_decrement = self._on_remove_clicked + self._buttons = buttons + + def _create_order_window(self) -> None: + dpg.add_child_window( + tag=TAG_SEQUENCER_ORDER_WINDOW, + parent=self.tag, + height=self._layout.order.height, + width=0, + border=False, + ) + + def _register_handlers(self) -> None: + self._router.register( + self._on_key_pressed, + priority=PRIORITY_PANEL, + active=self._keys_active, + ) + + with dpg.item_handler_registry(tag=self._cell_handler_tag): + dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) + dpg.add_item_active_handler(callback=self._on_cell_held) + + with dpg.item_handler_registry(tag=self._label_handler_tag): + dpg.add_item_clicked_handler(callback=self._on_label_right_clicked) + + with dpg.handler_registry(tag=self._drag_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_pointer_pressed, + ) + + def update_order(self, view_model: SequencerOrderTrackerViewModel) -> None: + """Reconciles the order table; rebuilds only when the position count changes.""" + cell_values = self._compute_cell_values(view_model) + if view_model.position_count != self._position_count: + self._rebuild_table(view_model, cell_values) + else: + self._order.reconcile(cell_values, self._render_cell) + + self._refresh_remove_enabled() + + def select_position(self, frame: int) -> None: + """Follows the tracker frame: always updates the unfocused column highlight; + also moves the edit cursor when one is active. + """ + self._current_position = frame + self._follow_frame(frame) + self._refresh_remove_enabled() + + def _follow_frame(self, frame: int) -> None: + """Carries the edit cursor to the frame, or shifts the unfocused column highlight to it + while no cursor is set.""" + cursor = self._input_state.cursor + if cursor is not None: + if cursor.position == frame: + return + + new_state = OrderInputState( + cursor=OrderCursor(cursor.channel, frame), + ) + if 0 <= frame < self._position_count: + self._apply_state(new_state, notify=False) + else: + self._clear_cursor_highlight() + self._clear_column_highlight() + self._input_state = new_state + self._update_caret() + else: + if self._highlighted_column == frame: + return + + self._clear_column_highlight() + if 0 <= frame < self._position_count: + self._apply_column_highlight(frame, focused=False) + + def deselect_cell(self) -> None: + """Drops the edit cursor (e.g. when focus moves to the tracker grid).""" + cursor = self._input_state.cursor + self._clear_cursor_highlight() + self._clear_column_highlight() + self._input_state = OrderInputState() + self._selection.repaint() + self._update_caret() + + if cursor is not None: + self._update_cell_display(cursor) + + if self._current_position is not None and 0 <= self._current_position < self._position_count: + self._apply_column_highlight(self._current_position, focused=False) + + self._refresh_remove_enabled() + + def set_enabled(self, enabled: bool) -> None: + dpg_configure_item(TAG_SEQUENCER_ORDER_PANEL, enabled=enabled) + + def update_channels(self, view_model: SequencerChannelsViewModel) -> None: + """Shows which channels the song player silences. + + The model is kept so a rebuilt table takes the cue again, the way the row tints do, and so + a table still waiting for its rows picks it up once they arrive. + """ + self._current_channels = view_model + self._apply_channel_cues() + + def _apply_channel_cues(self) -> None: + """Marks each silenced channel along its whole row: label, background, and entry text. + + The three cues land together because they read as one: the row recedes as a unit while its + frames stay legible, so the channel is visibly out of the mix and still open for editing. + The tracker grid carries the same cue down each column, so a channel looks the same in both. + """ + if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE): + return + + self._tint_channel_rows() + self._bind_label_themes() + for channel in ChannelName.items(): + self._bind_channel_entry_themes(channel) + + def _bind_label_themes(self) -> None: + 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, 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((channel, position)) + if widget is not None: + dpg.bind_item_theme(widget, theme) + + 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, + view_model: SequencerOrderTrackerViewModel, + ) -> Dict[OrderKey, str]: + cell_values: Dict[OrderKey, str] = {} + for position in range(view_model.position_count): + cell_values[(None, position)] = view_model.master_label(position) + for channel in ChannelName.items(): + cell_values[(channel, position)] = view_model.entry_label( + channel, + position, + ) + + return cell_values + + def _rebuild_table( + self, + view_model: SequencerOrderTrackerViewModel, + cell_values: Dict[OrderKey, str], + ) -> None: + """Recreates the whole table when the position count changes. + + Deleting and re-adding a live table's *columns* leaves DPG's per-column + state (and any highlight keyed by column index) dangling, which corrupted + the heap. Replacing the table item wholesale sidesteps that: the cursor and + column highlights die with the old table, so nothing references freed + columns. The selected cells go with them, and :meth:`_restore_cursor` brings the + cursor back on its own — a table of another width is a table a region no longer + describes. + """ + dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE) + self._highlighted = None + self._highlighted_column = None + self._selection.reset() + self._travel.rest() + self._order.reset(cell_values) + self._position_count = view_model.position_count + self._build_table(view_model.position_count) + self._restore_cursor() + + def _build_table(self, position_count: int) -> None: + dpg.add_table( + tag=TAG_SEQUENCER_ORDER_TABLE, + parent=TAG_SEQUENCER_ORDER_WINDOW, + header_row=True, + resizable=False, + borders_innerH=True, + borders_innerV=True, + borders_outerH=True, + borders_outerV=True, + scrollX=True, + scrollY=False, + freeze_columns=FROZEN_LABEL_COLUMNS, + policy=dpg.mvTable_SizingFixedFit, + ) + FontRegistry.bind_to_item(TAG_SEQUENCER_ORDER_TABLE, Font.MONO_BOLD) + self._table_theme.bind_to_item(TAG_SEQUENCER_ORDER_TABLE) + + dpg.add_table_column( + label="", + parent=TAG_SEQUENCER_ORDER_TABLE, + width_fixed=True, + init_width_or_weight=self._layout.table_cells.channel, + ) + for position in range(position_count): + dpg.add_table_column( + label=display_id(position), + parent=TAG_SEQUENCER_ORDER_TABLE, + width_fixed=True, + init_width_or_weight=self._layout.order.position_column_width, + ) + + self._label_rows = {} + 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() + self._highlight_master_row(position_count) + self._apply_channel_cues() + + 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 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 + + self._apply_column_backgrounds() + self._highlight_master_row(self._position_count) + self._apply_channel_cues() + self._repaint_highlights() + + def _repaint_highlights(self) -> None: + if self._highlighted_column is not None: + position = self._highlighted_column + focused = self._input_state.cursor is not None and self._input_state.cursor.position == position + self._apply_column_highlight(position, focused=focused) + + if self._highlighted is not None: + self._apply_cursor_highlight(self._highlighted) + + def _apply_column_backgrounds(self) -> None: + """Tints the label column like the header row. + + The position columns carry no static shade, so the per-channel row tints read + cleanly beneath the position and cursor highlights — the transposed analog of + the tracker grid, whose channel column tints sit beneath its cursor. + """ + dpg.highlight_table_column( + TAG_SEQUENCER_ORDER_TABLE, + 0, + self._layout.colors.order.label.rgba, + ) + + def _highlight_master_row(self, position_count: int) -> None: + """Tints the master row and the rule below it, matching the tracker grid's + sample column and sample divider so the two tables read consistently. + + The tint is applied per cell so it overrides the per-column shades and reads + consistently across the whole row. + """ + for column in range(position_count + 1): + self._highlight_master_cell_at(column) + dpg.highlight_table_cell( + TAG_SEQUENCER_ORDER_TABLE, + DIVIDER_TABLE_ROW, + column, + color=self._layout.colors.order.master_divider.rgba, + ) + + def _highlight_master_cell_at(self, column: int) -> None: + dpg.highlight_table_cell( + TAG_SEQUENCER_ORDER_TABLE, + MASTER_TABLE_ROW, + column, + color=self._layout.colors.order.master.rgba, + ) + + def _tint_channel_rows(self) -> None: + """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 + tint. This is the transposed analog of the tracker grid's per-channel column + tint, sharing the fraction. A silenced channel trades that identity for a + neutral dark shade, so its row recedes as a whole. + """ + for channel in ChannelName.items(): + dpg.highlight_table_row( + TAG_SEQUENCER_ORDER_TABLE, + self._table_row(channel), + self._channel_row_tint(channel), + ) + + def _channel_row_tint(self, channel: ChannelName) -> ColorRGBA: + if self._is_muted(channel): + return self._layout.colors.muted.background.rgba + + tint_color = self._channel_colors.for_channel(channel) + return FadedColor( + color=tint_color, + fraction=self._layout.tracker.channel_column_tint, + ).rgba + + def _apply_column_highlight(self, position: int, *, focused: bool) -> None: + if focused: + color = self._layout.colors.pattern_highlight + elif position == self._playing_position: + color = self._layout.colors.order.column_playing + else: + color = self._layout.colors.order.column_current + + dpg.highlight_table_column(TAG_SEQUENCER_ORDER_TABLE, position + 1, color.rgba) + self._highlighted_column = position + + def set_playing_position(self, position: Optional[int]) -> None: + previous_position = self._playing_position + self._playing_position = position + if ( + previous_position is not None + and previous_position != position + and previous_position == self._highlighted_column + ): + focused = self._input_state.cursor is not None and self._input_state.cursor.position == previous_position + self._apply_column_highlight(previous_position, focused=focused) + + def _clear_column_highlight(self) -> None: + if self._highlighted_column is not None: + dpg.unhighlight_table_column( + TAG_SEQUENCER_ORDER_TABLE, + self._highlighted_column + 1, + ) + self._highlighted_column = None + + def _build_row( + self, + channel: Optional[ChannelName], + position_count: int, + ) -> None: + 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, channel) + for position in range(position_count): + cell = dpg.add_table_cell(parent=row_id) + key: OrderKey = (channel, position) + selectable = dpg.add_selectable( + parent=cell, + label=self._render_cell(key), + user_data=key, + callback=self._on_cell_clicked, + ) + FontRegistry.bind_to_item(selectable, font) + dpg.bind_item_theme(selectable, self._entry_theme) + dpg.bind_item_handler_registry(selectable, self._cell_handler_tag) + self._order.register(key, selectable) + + def _add_row_label( + self, + row_id: Sender, + channel: Optional[ChannelName], + ) -> None: + """Places one clickable row label: a channel's mute target, or the master target. + + The label spans its frozen cell, which is what makes the whole name a click target, and it + carries its channel so the click knows which row it landed on. A tooltip names the gestures + the label answers to, and the right-click registry opens the same actions as a menu. + """ + label_cell = dpg.add_table_cell(parent=row_id) + label = dpg.add_selectable( + parent=label_cell, + 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 channel is None else self._tooltip_label_channel, + ) + 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 + rows — the transposed analog of the tracker grid's sample-divider column. + """ + row_id = dpg.add_table_row(parent=TAG_SEQUENCER_ORDER_TABLE) + spacer_cell = dpg.add_table_cell(parent=row_id) + dpg.add_spacer( + parent=spacer_cell, + height=self._layout.order.master_divider_height, + ) + for _ in range(position_count): + dpg.add_table_cell(parent=row_id) + + 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.channel, cursor.position) == key: + return pending_label( + self._input_state.pending, + stored, + INDEX_DIGITS, + ) + + return stored + + def _table_row(self, channel: Optional[ChannelName]) -> int: + if channel is None: + return MASTER_TABLE_ROW + 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.channel), + cursor.position + 1, + color=self._layout.colors.cell_cursor.rgba, + ) + self._highlighted = cursor + + def _selected_cells(self) -> FrozenSet[OrderKey]: + """Every cell the selection covers, clipped to the positions the table holds.""" + region = self._input_state.region + if region is None: + return frozenset() + + keys: Set[OrderKey] = set() + for channel in region.channels: + for position in region.positions: + if position < self._position_count: + keys.add((channel, position)) + + return frozenset(keys) + + def _clear_cursor_highlight(self) -> None: + if self._highlighted is None: + return + + cursor = self._highlighted + self._highlighted = 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.channel), + cursor.position + 1, + ) + + def _restore_cursor(self) -> None: + cursor = self._input_state.cursor + if cursor is None or self._position_count == 0: + self._input_state = OrderInputState() + if self._current_position is not None and 0 <= self._current_position < self._position_count: + self._apply_column_highlight( + self._current_position, + focused=False, + ) + + self._update_caret() + return + + position = min(cursor.position, self._position_count - 1) + clamped = OrderCursor(cursor.channel, position) + self._input_state = OrderInputState(cursor=clamped) + self._apply_cursor_highlight(clamped) + self._apply_column_highlight(clamped.position, focused=True) + self._update_caret() + + def _apply_state( + self, + new_state: OrderInputState, + notify: bool = True, + ) -> None: + old = self._input_state.cursor + new = new_state.cursor + + self._clear_cursor_highlight() + self._input_state = new_state + + if old is not None: + self._update_cell_display(old) + + if new is not None: + self._apply_cursor_highlight(new) + self._update_cell_display(new) + + old_position = old.position if old is not None else None + new_position = new.position if new is not None else None + if old_position != new_position: + self._clear_column_highlight() + if new_position is not None: + self._current_position = new_position + self._apply_column_highlight(new_position, focused=True) + + if notify and new is not None: + self.call(self.on_cell_selected) + if old is None or old.position != new.position: + self.call(self.on_frame_selected, new.position) + + self._selection.repaint() + self._update_caret() + self._refresh_remove_enabled() + + def _update_cell_display(self, cursor: OrderCursor) -> None: + 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)) + + def _update_caret(self) -> None: + """Arms (or clears) the shared caret box on the active order cell.""" + cursor = self._input_state.cursor + if cursor is None: + CaretOverlay.clear(TAG_SEQUENCER_ORDER_TABLE) + return + + 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), + caret_index=len(self._input_state.pending), + font=font, + clip_widget=TAG_SEQUENCER_ORDER_WINDOW, + ) + + def _on_cell_clicked( + self, + sender: Sender, + _app_data: bool, + user_data: OrderKey, + ) -> None: + """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. + + A drag that comes back to the cell it started from ends on a click, and the selection takes + that click as the end of the drag, so the range dragged out stands and the cursor with it. + """ + if self._selection.claims_click(sender, user_data): + return + + state = self._committed_state() + 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. + + The gesture states how far the pointer has carried: a plain drag anchors at the cell the + press landed on, and one whose press held Shift carries the selection already standing. + + A pointer held past an edge travels the table first, so the reach that follows reads the + positions the travel has brought into view. + """ + self._travel.advance() + reach = self._selection.hold(app_data) + if reach is None: + return + + state = self._committed_state() + if not reach.extends: + state = OrderInputState(cursor=OrderCursor(*reach.origin)) + + self._apply_state(state.extend_to(OrderCursor(*reach.reached))) + + def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: + """Drops the gesture a finished drag left behind, so this press selects on its own. + + A press is where a gesture ends rather than the release before it, because the release + reaches this panel ahead of the click the cell itself reports: a drag that comes back to + the cell it started from would otherwise have its selection taken down by its own click. + """ + self._selection.drop_gesture() + self._travel.rest() + + def _travel_band(self) -> Optional[TravelBand]: + """Where the order's positions stand, which is the band a drag held beside them travels across. + + Two positions state the pitch the columns are laid out at, so an order holding one of them + travels nowhere — there is nothing beside it to reach. + """ + first = self._cell_left(0) + following = self._cell_left(1) + if first is None or following is None: + return None + + return TravelBand( + first_edge=first, + cell_extent=following - first, + cell_count=self._position_count, + ) + + def _cell_at(self) -> Optional[OrderKey]: + """The cell the pointer stands on, clamped to the table the order lays out. + + A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond + the last channel or the last position selects up to it rather than stopping there. + """ + left, top = dpg.get_mouse_pos(local=False) + position = self._position_at(left) + if position is None: + return None + + return (self._generator_at(top), position) + + 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 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 channel + + return CHANNEL_AXIS[-1] + + def _position_at(self, left: float) -> Optional[int]: + """Which position stands at a width, counted from the first cell's left edge. + + Every position column is the same width, so the count is arithmetic once two of them + state the pitch; an order of a single position holds every width there is. + """ + first = self._cell_left(0) + if first is None: + return None + + following = self._cell_left(1) + if following is None: + return 0 + + position = int((left - first) // (following - first)) + return max(0, min(position, self._position_count - 1)) + + def _cell_left(self, position: int) -> Optional[float]: + """Where a position column's cells begin, in the coordinates the viewport is drawn in.""" + widget = self._order.widget((None, position)) + if widget is None: + return None + + cell_left, _ = dpg.get_item_rect_min(widget) + return float(cell_left) + + def _on_cell_right_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + ) -> None: + """Opens the frame-operations menu for the right-clicked cell. + + The menu acts on the clicked cell and its frame directly, and leaves the edit cursor (and, + while following playback, the playhead) where it is — right-clicking should not seek. + """ + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + key = dpg_get_item_user_data(clicked_item) + if key is None: + return + + channel, position = key + self._menu.show_for_cell(channel, position) + + def _on_label_clicked( + self, + sender: Sender, + _app_data: bool, + user_data: Optional[ChannelName], + ) -> None: + self._channel_switch.click(sender, user_data) + + def _on_label_right_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + ) -> None: + """Opens the channel menu for the right-clicked row label. + + The registry reaches the row labels alone, so a click on one of them names its row through + the map the rows filled in; a label replaced by a rebuild is absent from it. + """ + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + if clicked_item not in self._label_rows: + return + + 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() + + @property + def channel_switch(self) -> ChannelSwitch: + """The switch a row label's click and menu act through.""" + return self._channel_switch + + @property + def channels(self) -> Optional[SequencerChannelsViewModel]: + """Which channels stand silenced, as the last view the panel was given states it.""" + return self._current_channels + + @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 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] + + 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, which the cell its cursor is set on decides.""" + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._input_state.cursor is not None, + card_open=self.card_open, + ) + + # 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. + + The scheme says which press each order action answers to; a press the order category + leaves unnamed goes to cell entry, which keeps the hex digits and hands the rest to the + application's global shortcuts. + """ + cursor = self._input_state.cursor + if cursor is None: + return False + + shortcut_id = self._shortcuts.action(ShortcutCategory.ORDER, event) + if shortcut_id is None: + return self._type_character(event) + + if self._move_cursor(shortcut_id): + return True + + if self._extend_selection(shortcut_id): + return True + + if self.select_shape(shortcut_id, cursor): + return True + + if self._block_action(shortcut_id): + return True + + if self._edit_cell(shortcut_id): + return True + + return self._act_on_frame(shortcut_id, cursor.position) + + def _move_cursor(self, shortcut_id: ShortcutId) -> bool: + """Moves the edit cursor over the table, reporting whether the action was one of its moves.""" + match shortcut_id: + case ShortcutId.ORDER_PREVIOUS_POSITION: + self._move_position(-1) + case ShortcutId.ORDER_NEXT_POSITION: + self._move_position(1) + case ShortcutId.ORDER_PREVIOUS_CHANNEL: + self._move_channel(-1) + case ShortcutId.ORDER_NEXT_CHANNEL: + self._move_channel(1) + case ShortcutId.ORDER_FIRST_POSITION: + self._jump_position(0) + case ShortcutId.ORDER_LAST_POSITION: + self._jump_position(self._position_count - 1) + case _: + return False + + return True + + def _extend_selection(self, shortcut_id: ShortcutId) -> bool: + """Grows or shrinks the selected block, reporting whether the action was one of its reaches. + + Each reach moves the end the cursor holds while the anchor stays where the selection began, + so the same keys that move the cursor select with Shift held. + """ + match shortcut_id: + case ShortcutId.ORDER_EXTEND_SELECTION_UP: + self._extend_channel(-1) + case ShortcutId.ORDER_EXTEND_SELECTION_DOWN: + self._extend_channel(1) + case ShortcutId.ORDER_EXTEND_SELECTION_LEFT: + self._extend_position(-1) + case ShortcutId.ORDER_EXTEND_SELECTION_RIGHT: + self._extend_position(1) + case ShortcutId.ORDER_EXTEND_SELECTION_TO_FIRST_POSITION: + self._extend_to_position(0) + case ShortcutId.ORDER_EXTEND_SELECTION_TO_LAST_POSITION: + self._extend_to_position(self._position_count - 1) + case _: + return False + + return True + + def select_shape( + self, + shortcut_id: ShortcutId, + cell: OrderCursor, + ) -> bool: + """Selects a rectangle of the table, reporting whether the action was one of its shapes. + + A press names its shape from the cell the cursor stands on, which is the cell the menu + items name as well, so a key and an item select the same block. + """ + match shortcut_id: + case ShortcutId.ORDER_SELECT_ALL: + self._select_all() + case ShortcutId.ORDER_SELECT_ROW: + self._select_row(cell) + case _: + return False + + return True + + def _select_all(self) -> None: + self._apply_state( + self._committed_state().select_all( + self._position_count, + ) + ) + + def _select_row(self, cell: OrderCursor) -> None: + self._apply_state( + self._committed_state().select_row( + cell, + self._position_count, + ) + ) + + def _block_action(self, shortcut_id: ShortcutId) -> bool: + """Acts on the selected block, reporting whether the action was one of its gestures. + + Delete is a block gesture only while a selection stands: with one it empties every cell the + selection covers and keeps it, and with none it falls through to clearing the cell under + the cursor, the meaning that key already carries. + """ + match shortcut_id: + case ShortcutId.ORDER_COPY_BLOCK: + self._surface.copy() + case ShortcutId.ORDER_CUT_BLOCK: + self._surface.cut() + case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: + self._surface.delete() + case ShortcutId.ORDER_PASTE_BLOCK: + self._surface.paste() + case _: + return False + + return True + + def _edit_cell(self, shortcut_id: ShortcutId) -> bool: + """Empties the cell under the cursor or drops a partial entry, reporting whether the action + was one of the cell edits. + + A cancel with nothing typed and nothing selected leaves the press to the application, so + Escape stops playback while the table holds a cursor. + """ + match shortcut_id: + case ShortcutId.ORDER_CLEAR_CELL: + self._clear_cell() + self._move_position(1) + case ShortcutId.ORDER_CLEAR_PREVIOUS_CELL: + self._clear_cell() + self._move_position(-1) + case ShortcutId.ORDER_CANCEL_ENTRY: + if not self._input_state.pending and self._input_state.anchor is None: + return False + + self._apply_state(self._input_state.cancel()) + case _: + return False + + return True + + def _act_on_frame(self, shortcut_id: ShortcutId, position: int) -> bool: + """Adds, removes or moves a whole frame, reporting whether the action was one of them. + + A move with nowhere to go still counts as consumed, so a press at either boundary stays + out of the global shortcuts. + """ + direction = MOVE_DIRECTIONS.get(shortcut_id) + if direction is not None: + target = direction.target(position, self._position_count) + if target is not None: + self.call(self.on_move_requested, position, target) + + return True + + match shortcut_id: + case ShortcutId.ORDER_ADD_FRAME: + self._on_add_clicked() + case ShortcutId.ORDER_INSERT_FRAME: + self.call(self.on_insert_requested, position) + case ShortcutId.ORDER_REMOVE_FRAME: + self._on_remove_clicked() + case ShortcutId.ORDER_DUPLICATE_FRAME: + self.call(self.on_duplicate_requested, position) + case ShortcutId.ORDER_CLONE_FRAME: + self.call(self.on_clone_requested, position) + case ShortcutId.ORDER_CLEAR_FRAME: + self.call(self.on_clear_requested, position) + case _: + return False + + return True + + def _move_position(self, delta: int) -> None: + self._apply_state( + self._committed_state().navigate_position( + delta, + self._position_count, + ), + ) + + def _jump_position(self, index: int) -> None: + self._apply_state( + self._committed_state().navigate_position( + index, + self._position_count, + absolute=True, + ), + ) + + def _move_channel(self, delta: int) -> None: + self._apply_state(self._committed_state().navigate_channel(delta)) + + def _extend_position(self, delta: int) -> None: + self._apply_state( + self._committed_state().extend_position( + delta, + self._position_count, + ), + ) + + def _extend_to_position(self, index: int) -> None: + self._apply_state( + self._committed_state().extend_position( + index, + self._position_count, + absolute=True, + ), + ) + + def _extend_channel(self, delta: int) -> None: + self._apply_state(self._committed_state().extend_channel(delta)) + + def _committed_state(self) -> OrderInputState: + state, index = self._input_state.commit_partial() + if index is not None: + self._emit(self._input_state.cursor, index) + + return state + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on. + + A block gesture takes this first, so what it lifts out carries the index the reader has + just finished typing. + """ + self._apply_state(self._committed_state()) + + def _type_character(self, event: KeyEvent) -> bool: + """Types a hex digit into the cell under the cursor, reporting whether the press was one. + + A press holding Ctrl, Alt or Super is an application gesture, so cell entry reads the plain + keys and leaves the rest to the global shortcuts. + """ + if not event.is_plain: + return False + + char = HEX_KEYS.get(event.key) + if char is None: + return False + + new_state, index = self._input_state.type_char(char) + if index is not None: + self._emit(self._input_state.cursor, index) + new_state = new_state.navigate_position(1, self._position_count) + + self._apply_state(new_state) + return True + + def _clear_cell(self) -> None: + """Empties the cell under the cursor (sets its slot to ``None``). + + Mirrors the tracker's clear: a channel cell empties just that channel's + slot; the master row empties every channel at that position. + """ + cursor = self._input_state.cursor + if cursor is None: + return + + self._emit(cursor, None) + self._apply_state(self._input_state.cancel()) + + def _emit( + self, + cursor: Optional[OrderCursor], + index: Optional[int], + ) -> None: + if cursor is None: + return + + if cursor.channel is None: + self.call(self.on_set_master_entry, cursor.position, index) + else: + self.call( + self.on_set_order_entry, + cursor.channel, + cursor.position, + index, + ) + + def _get_cursor_position(self) -> Optional[int]: + cursor = self._input_state.cursor + return cursor.position if cursor is not None else self._current_position + + def _get_removable_position(self) -> Optional[int]: + """The frame ``[-]`` acts on: the cursor's frame, or the followed tracker frame while no + cursor is set, resolved while it addresses a live frame. + """ + position = self._get_cursor_position() + if position is not None and 0 <= position < self._position_count: + return position + + return None + + def _refresh_remove_enabled(self) -> None: + """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: + self._buttons.set_decrement_enabled(self._get_removable_position() is not None) + + def _on_add_clicked(self) -> None: + """Inserts an empty frame after the current one (appends when at or past the end).""" + position = self._get_cursor_position() + if position is None or not 0 <= position < self._position_count: + position = self._position_count - 1 + + self.call(self.on_insert_requested, position) + + def _on_remove_clicked(self) -> None: + position = self._get_removable_position() + if position is not None: + self.call(self.on_remove_requested, position) 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.py b/src/sampletones_application/ui/panels/sequencer/tracker.py deleted file mode 100644 index 8427560d8..000000000 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ /dev/null @@ -1,1988 +0,0 @@ -from typing import Callable, Dict, Final, FrozenSet, Optional, Set, 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.layout.tabs.sequencer import SequencerLayout -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_HANDLER_DRAG, - SUF_HANDLER_HEADER, - SUF_HANDLER_REGISTRY, -) -from sampletones_application.tags.sequencer import ( - TAG_SEQUENCER_THEME_TABLE_PATTERN, - TAG_SEQUENCER_TRACKER_GROUP, - 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 -from sampletones_application.ui.elements.table.caret import CaretOverlay -from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.selection import TableSelection -from sampletones_application.ui.panels.sequencer import display as tracker_display -from sampletones_application.ui.panels.sequencer.channels import ( - ChannelMenuLabels, - ChannelSwitch, - channel_tooltip, -) -from sampletones_application.ui.panels.sequencer.columns import ( - DIVIDER_TABLE_COLUMN, - HEADER_TABLE_ROW, - 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.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 -from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel -from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( - BlockShortcuts, - ClipboardItems, -) -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.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.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.keyboard.keys import HEX_KEYS, SIGN_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers -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.utils.palette.colors.faded import FadedColor -from sampletones_application.utils.palette.colors.layered import LayeredColor -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.settings import ( - SequencerSettingsViewModel, -) -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_application.view_model.sequencer.tracker import ( - SequencerRowViewModel, - SequencerTrackerViewModel, -) -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.constants.general import MAX_VOLUME -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.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__( - self, - initial_settings: SequencerSettingsViewModel, - *, - layout: SequencerLayout, - language_manager: LanguageManager, - key_router: KeyRouter, - tab_active: ActivePredicate, - shortcut_source: ShortcutSource, - initial_collapsed: bool = False, - ) -> None: - self._layout = layout - self._settings = initial_settings - self._language_manager = language_manager - self._router = key_router - self._tab_active = tab_active - self._shortcuts = shortcut_source - - widths = layout.tracker.subcolumn_widths - self._subcolumn_widths: Dict[SubColumn, int] = { - SubColumn.INSTRUMENT: widths.instrument, - SubColumn.TRANSPOSE: widths.transpose, - SubColumn.VOLUME: widths.volume, - } - - self._item_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_PANEL, SUF_HANDLER_REGISTRY) - self._cell_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_REGISTRY) - self._header_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_HEADER) - 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._editable_cells: EditableCells[CellKey] = EditableCells() - self._current_row_count: int = 0 - self._highlighted_row: Optional[int] = None - self._displayed_frame: Optional[int] = None - self._playing_frame: Optional[int] = None - self._playing_row: Optional[int] = None - self._painted_row: Optional[int] = None - self._follows_playing_row: bool = False - self._input_state: TrackerInputState = TrackerInputState() - self._selection: TableSelection[CellKey] = TableSelection( - cells=self._editable_cells, - cell_at=self._cell_at, - covered=self._selected_cells, - ) - self._travel: DragTravel = DragTravel( - axis=VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE), - 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._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_cell_selected: Optional[OnCellSelectedCallback] = None - self.on_play_from_row: Optional[OnPlayFromRowCallback] = None - self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None - self.on_adjust_transpose: Optional[OnAdjustCallback] = None - self.on_adjust_volume: Optional[OnAdjustCallback] = None - self.on_copy_block: Optional[OnBlockRegionCallback] = None - self.on_cut_block: Optional[OnBlockRegionCallback] = None - self.on_delete_block: Optional[OnBlockRegionCallback] = None - self.on_paste_block: Optional[OnPasteBlockCallback] = None - self.can_paste_block: Optional[CanPasteBlockQuery] = None - self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None - self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None - self.on_channels_toggled: Optional[VoidCallback] = None - self.on_channels_muted: Optional[VoidCallback] = None - self.on_channels_unmuted: Optional[VoidCallback] = None - - self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) - self._surface: TrackerEditSurface = GridEditSurface.build( - grid=self, - blocks=self._blocks, - target=TrackerTarget, - shortcuts=shortcut_source, - block_shortcuts=BlockShortcuts( - copy=ShortcutId.TRACKER_COPY_BLOCK, - cut=ShortcutId.TRACKER_CUT_BLOCK, - paste=ShortcutId.TRACKER_PASTE_BLOCK, - ), - labels=ClipboardItems.labels(language_manager), - ) - self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) - - 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) - - super().__init__( - tag=TAG_SEQUENCER_TRACKER_PANEL, - height=-1, - ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed) - - 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] = { - 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), - } - - @staticmethod - def _label( - language_manager: LanguageManager, - element: SequencerTrackerElements, - ) -> str: - return language_manager[ - Page.SEQUENCER, - Panel.TRACKER, - TextType.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.""" - - def tooltip(element: SequencerTrackerElements) -> str: - return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.TOOLTIP, element] - - self._tooltip_header_channel = channel_tooltip(tooltip(SequencerTrackerElements.HEADER_CHANNEL)) - self._tooltip_header_sample = tooltip(SequencerTrackerElements.HEADER_SAMPLE) - - def _create_channel_switch(self, language_manager: LanguageManager) -> None: - """Builds the switch a column header's click and menu act through. - - The hooks are read at call time, so the switch is built here while they are still unset and - the coordinator wires them once the panel exists. - """ - labels = ChannelMenuLabels( - mute=self._label(language_manager, SequencerTrackerElements.CONTEXT_MUTE), - unmute=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNMUTE), - solo=self._label(language_manager, SequencerTrackerElements.CONTEXT_SOLO), - unsolo=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNSOLO), - mute_all=self._label(language_manager, SequencerTrackerElements.CONTEXT_MUTE_ALL), - unmute_all=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNMUTE_ALL), - ) - 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_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), - ) - - def create_panel(self, parent: str) -> None: - self._setup_handlers() - self._create_themes() - self._create_tracker_view(parent) - - def _setup_handlers(self) -> None: - with dpg.item_handler_registry(tag=self._item_handler_tag): - dpg.add_item_hover_handler( - parent=self._item_handler_tag, - callback=self._on_row_hovered, - ) - - with dpg.item_handler_registry(tag=self._cell_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) - dpg.add_item_active_handler(callback=self._on_cell_held) - - with dpg.item_handler_registry(tag=self._header_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_header_right_clicked) - - with dpg.handler_registry(tag=self._drag_handler_tag): - dpg.add_mouse_click_handler( - button=dpg.mvMouseButton_Left, - callback=self._on_pointer_pressed, - ) - - self._router.register( - self._on_key_pressed, - priority=PRIORITY_PANEL, - active=self._keys_active, - ) - - 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, - ), - ) - - 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 _create_tracker_view(self, parent: str) -> None: - """Builds the tracker card and the empty table its rows are filled into. - - The column labels are carried by a row of widgets (see :meth:`_build_header_row`) that - ``freeze_rows`` pins at the top, which makes each channel's label a click target for - muting. ``no_clip`` lets a label wider than its column draw across the boundary the way - a table header does, so the header keeps the size and position it has always had. - - The pattern stands on one even ground: the tracker's own theme - (``sequencer.theme.table_pattern``) gives ``TableRowBg`` and ``TableRowBgAlt`` the same - shade, leaving the row background free to carry the beat and bar grouping that tells a - tracker's rows apart (see :meth:`_row_background`). - """ - with self._collapsible_card( - parent, - self._lbl_tracker, - glyph=self._glyphs.headers.tracker, - ): - dpg.add_group(tag=TAG_SEQUENCER_TRACKER_GROUP) - with ( - dpg.child_window( - tag=TAG_SEQUENCER_TRACKER_WINDOW, - parent=TAG_SEQUENCER_TRACKER_GROUP, - border=False, - width=0, - height=-1, - ), - dpg.table( - tag=TAG_SEQUENCER_TRACKER_TABLE, - width=0, - header_row=False, - resizable=False, - borders_innerH=False, - borders_innerV=True, - borders_outerH=True, - borders_outerV=True, - scrollX=False, - scrollY=True, - freeze_rows=HEADER_TABLE_ROWS, - row_background=True, - policy=dpg.mvTable_SizingFixedFit, - ), - ): - FontRegistry.bind_to_item(dpg.last_item(), Font.MONO_BOLD) - dpg.add_table_column(width_stretch=True) - dpg.add_table_column( - width_fixed=True, - init_width_or_weight=self._layout.table_cells.row, - no_clip=True, - ) - dpg.add_table_column( - width_fixed=True, - init_width_or_weight=self._layout.table_cells.sample, - no_clip=True, - ) - dpg.add_table_column( - width_fixed=True, - init_width_or_weight=self._layout.table_cells.divider, - ) - for _ in GeneratorName.items(): - dpg.add_table_column( - width_fixed=True, - init_width_or_weight=self._layout.table_cells.generator, - no_clip=True, - ) - dpg.add_table_column(width_stretch=True) - - self.pattern_theme.bind_to_item(TAG_SEQUENCER_TRACKER_TABLE) - - def update_tracker(self, view_model: SequencerTrackerViewModel) -> None: - """Reconciles the tracker body with the visible order frame. - - The grid is only torn down and rebuilt when the row count changes; for the - common in-place edit the changed cell labels are reconfigured one by one. - Reusing the existing widgets preserves scroll position, the hover row, and - the edit cursor that a full rebuild would otherwise discard. - """ - cell_values = self._compute_cell_values(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) - else: - self._editable_cells.reconcile(cell_values, self._render_cell) - - def _show_frame(self, frame_index: int) -> None: - """Records the order frame the grid stands on, and settles the playhead's mark for it. - - The mark reads as the sounding row of the pattern on screen, so it belongs to the frame the - playhead sounds: a frame arriving at the grid takes the mark while playback stands on it, - and hands it back as the reader moves on to another frame. - """ - if frame_index == self._displayed_frame: - return - - self._displayed_frame = frame_index - self._paint_playhead() - - def _rebuild_table( - self, - view_model: SequencerTrackerViewModel, - cell_values: CellValues, - ) -> None: - """Replaces the table body, and re-reveals the sounding row once the new body has laid out. - - A table repopulated this frame reports the scroll extent of the body it replaced, so the - reveal is repeated a frame later, when DearPyGui has measured the rows now in it. - - The frame the grid stands on has a row count of its own, so a selection is taken down to - its cursor: the cells it covered belong to the body being replaced. - """ - dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) - self._input_state = self._input_state.collapse() - self._selection.reset() - self._travel.rest() - self._editable_cells.reset(cell_values) - self._build_table(view_model) - self.repaint() - FrameCallbackManager.set_frame_callback(self._reveal_playing_row) - - 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. - """ - if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): - return - - self._highlight_sample_column() - self._highlight_header_row() - self._apply_channel_cues() - self._apply_row_backgrounds() - self._update_cursor() - - def update_settings(self, view_model: SequencerSettingsViewModel) -> None: - """Takes the metre 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.""" - cursor = self._input_state.cursor - return row_background( - row_index, - self._settings, - self._layout.colors, - RowCues( - cursor=cursor.row if cursor is not None else None, - playing=self._painted_row, - ), - ) - - def _draw_row( - self, - row_index: int, - color: Optional[BaseColor], - ) -> None: - """Gives one pattern row the background colour 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; - checking the live row count keeps a stale index from reaching DearPyGui. - """ - if not 0 <= row_index < self._live_row_count(): - return - - table_row = tracker_table_row(row_index) - if color is None: - dpg.unhighlight_table_row( - TAG_SEQUENCER_TRACKER_TABLE, - table_row, - ) - else: - dpg.highlight_table_row( - TAG_SEQUENCER_TRACKER_TABLE, - table_row, - color=color.rgba, - ) - - def _paint_row(self, row_index: int) -> None: - """Draws a row in the colour 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: - """Draws a row with the hover shade over the background it already carries.""" - background = self._row_background(row_index) - highlight = self._layout.colors.pattern_highlight - self._draw_row( - row_index, - highlight if background is None else LayeredColor(base=background, overlay=highlight), - ) - - def _apply_row_backgrounds(self) -> None: - """Draws every live pattern row, which is how the beat and bar grouping reaches the table.""" - for row_index in range(self._live_row_count()): - self._paint_row(row_index) - - def _render_cell(self, key: CellKey) -> str: - row, generator, subcolumn = key - return tracker_display.subcolumn_label( - row, - generator, - subcolumn, - cursor=self._input_state.cursor, - pending=self._input_state.pending, - cell_values=self._editable_cells.values, - ) - - def _highlight_sample_column(self) -> None: - """Tints the sample column and the rule that separates it from the channels. - - These column highlights are static decoration, distinct from the cursor's - cell/row highlight; reapplying them after each rebuild keeps them in place - once the rows are replaced. - """ - dpg.highlight_table_column( - TAG_SEQUENCER_TRACKER_TABLE, - SAMPLE_TABLE_COLUMN, - self._layout.colors.sample.column.rgba, - ) - dpg.highlight_table_column( - TAG_SEQUENCER_TRACKER_TABLE, - DIVIDER_TABLE_COLUMN, - self._layout.colors.sample.divider.rgba, - ) - - def _highlight_header_row(self) -> None: - """Gives the widget header row the background a table header carries. - - The shade is laid cell by cell so it covers the sample and channel column washes, - which DearPyGui draws over a row highlight; the header then reads as one band with - the column tints beginning below it. - """ - for column in range(TRACKER_TABLE_COLUMNS): - dpg.highlight_table_cell( - TAG_SEQUENCER_TRACKER_TABLE, - HEADER_TABLE_ROW, - column, - color=self._layout.colors.header.background.rgba, - ) - - def _tint_channel_columns(self) -> None: - """Washes each channel's column with a faint tint of its identity colour. - - 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(): - dpg.highlight_table_column( - TAG_SEQUENCER_TRACKER_TABLE, - tracker_table_column(generator), - self._channel_column_tint(generator), - ) - - def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA: - if self._is_muted(generator): - return self._layout.colors.muted.background.rgba - - channel = channel_color(self._layout.colors.channels, generator) - return FadedColor( - color=channel, - fraction=self._layout.tracker.channel_column_tint, - ).rgba - - def _compute_cell_values( - self, - view_model: SequencerTrackerViewModel, - ) -> 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] - for subcolumn in SubColumn: - cell_values[ - ( - row.index, - generator, - subcolumn, - ) - ] = tracker_display.cell_display( - cell, - subcolumn, - ) - - return cell_values - - def _build_table(self, view_model: SequencerTrackerViewModel) -> None: - self._rows = {} - self._current_row_count = len(view_model.rows) - self._build_header_row() - for row in view_model.rows: - self._build_table_row(row) - - def _build_header_row(self) -> None: - """Builds the header as the table's first row, with each channel label a click target. - - A rebuild replaces every row of the table, so the header is raised here, ahead of the - pattern rows, and lands on the row ``freeze_rows`` pins in place. The cells are - positional like a pattern row's, so the labels line up with the columns they name. - """ - self._header_columns = {} - row_id = dpg.add_table_row(parent=TAG_SEQUENCER_TRACKER_TABLE) - self._add_empty_cell(row_id) - 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) - - 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. - - It is laid out as a selectable like the labels beside it, so it takes the header's - height and sits on their line; its own theme leaves it reading as text. - """ - label_cell = dpg.add_table_cell(parent=row_id) - label = dpg.add_selectable( - parent=label_cell, - label=self._lbl_col_row, - height=self._layout.tracker.header_height, - ) - dpg.bind_item_theme(label, self._column_label_theme) - - def _add_header_selectable( - self, - row_id: Sender, - generator: Optional[GeneratorName], - ) -> None: - """Places one clickable column label: a channel's mute target, or the master target. - - The selectable takes its width from its label, which is what lets a label wider than - its column draw in full, and it carries its channel so the click knows which column - it landed on. A tooltip names the gestures the label answers to, and the right-click - registry opens the same actions as a menu. - """ - header_cell = dpg.add_table_cell(parent=row_id) - selectable = dpg.add_selectable( - parent=header_cell, - label=self._column_labels[generator], - height=self._layout.tracker.header_height, - user_data=generator, - 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._header_columns[selectable] = generator - - def _build_table_row(self, row: SequencerRowViewModel) -> None: - """Builds one tracker row. - - The cells are positional, so the empty divider cell after the sample column - keeps the channel cells aligned with their (shifted) table columns. - """ - row_id = dpg.add_table_row( - parent=TAG_SEQUENCER_TRACKER_TABLE, - user_data=row.index, - ) - self._add_empty_cell(row_id) - 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) - - def _add_empty_cell(self, row_id: Sender) -> None: - empty_cell = dpg.add_table_cell(parent=row_id) - if dpg.does_item_exist(empty_cell): - dpg.add_spacer(parent=empty_cell, width=0) - - def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None: - number_cell = dpg.add_table_cell(parent=row_id) - selectable = dpg.add_selectable( - parent=number_cell, - label=display_id(row_index), - height=self._layout.tracker.row_height, - user_data=row_index, - 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_handler_registry(selectable, self._item_handler_tag) - self._rows[row_index] = selectable - - def _add_column_cell( - self, - row_id: Sender, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: - font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL - cell = dpg.add_table_cell(parent=row_id) - group = dpg.add_group( - horizontal=True, - horizontal_spacing=0, - parent=cell, - ) - for subcolumn in SubColumn: - self._add_subcolumn_selectable( - group, - row_index, - generator, - subcolumn, - font, - ) - - def _add_subcolumn_selectable( - self, - group: Sender, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - font: Font, - ) -> None: - key = (row_index, generator, subcolumn) - selectable = dpg.add_selectable( - parent=group, - label=self._render_cell(key), - width=self._subcolumn_widths[subcolumn], - height=self._layout.tracker.row_height, - user_data=key, - callback=self._on_cell_clicked, - ) - FontRegistry.bind_to_item(selectable, font) - dpg.bind_item_theme(selectable, self._subcolumn_themes[subcolumn]) - dpg.bind_item_handler_registry(selectable, self._cell_handler_tag) - self._editable_cells.register(key, selectable) - - 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) - else: - self._input_state = TrackerInputState() - - self._selection.repaint() - self._update_caret() - - 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._selection.repaint() - - self._update_caret() - - 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 - - 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) - - if old_cursor is not None: - self._update_cell_display(old_cursor.row, old_cursor.generator) - - 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) - - if new_pos != old_pos and new_cursor is not None: - self.call(self.on_cell_selected) - - self._selection.repaint() - self._update_caret() - - def update_samples(self, view_model: SequencerSamplesViewModel) -> None: - self._current_samples = view_model - - def update_channels(self, view_model: SequencerChannelsViewModel) -> None: - """Shows which channels the song player silences. - - The model is kept so a rebuilt table takes the cue again, the way the column tints do, - and so a table still waiting for its rows picks it up once they arrive. - """ - self._current_channels = view_model - self._apply_channel_cues() - - def _apply_channel_cues(self) -> None: - """Marks each silenced channel down its whole column: label, background, and cell text. - - The three cues land together because they read as one: the column recedes as a unit - while its values stay legible, so the channel is visibly out of the mix and still open - for editing. - """ - if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): - return - - self._tint_channel_columns() - self._bind_header_themes() - for generator in GeneratorName.items(): - self._bind_channel_cell_themes(generator) - - 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, - ) - - def _bind_channel_cell_themes(self, generator: GeneratorName) -> None: - themes = self._muted_subcolumn_themes if self._is_muted(generator) else self._subcolumn_themes - 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]) - - def _is_muted(self, generator: GeneratorName) -> bool: - return self._current_channels is not None and self._current_channels.is_muted(generator) - - def set_enabled(self, enabled: bool) -> None: - dpg.configure_item(TAG_SEQUENCER_TRACKER_GROUP, enabled=enabled) - - def _update_cell_display( - self, - row: int, - generator: Optional[GeneratorName], - ) -> None: - for subcolumn in SubColumn: - key = (row, generator, subcolumn) - cell_id = self._editable_cells.widget(key) - if cell_id is not None: - dpg.configure_item(cell_id, label=self._render_cell(key)) - - def _update_caret(self) -> None: - """Arms (or clears) the shared caret box on the active subcolumn cell.""" - cursor = self._input_state.cursor - if cursor is 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 - CaretOverlay.set_target( - owner=TAG_SEQUENCER_TRACKER_TABLE, - widget=self._editable_cells.widget(key), - caret_index=len(self._input_state.pending), - font=font, - clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, - ) - - def _resolve_sample_id( - self, - sample_index: int, - ) -> Optional[Tuple[int, str]]: - if not self._current_samples or not self._current_samples.samples: - 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 - - def _handle_edit_action(self, action: EditAction) -> None: - """Commits a single-subcolumn edit. - - An :class:`EditAction` only ever carries the subcolumn under the cursor; - 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 - - if action.note_off: - self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = NOTE_OFF - self.call(self.on_set_note_off, row, generator) - return - - sample_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, - ) - - if action.transpose is not None: - self._editable_cells.values[(row, generator, 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( - SubColumn.VOLUME, - action.volume, - ) - - self.call( - self.on_set_row, - row, - generator, - sample_id, - action.transpose, - action.volume, - ) - - 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), - None, - ) - self.call(self.on_clear_row, action.row, action.generator) - else: - self._editable_cells.values.pop( - (action.row, action.generator, action.subcolumn), - None, - ) - self.call( - self.on_clear_subcolumn, - action.row, - action.generator, - action.subcolumn, - ) - - def _apply_cell_highlight( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> 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), - color=self._layout.colors.cell_cursor.rgba, - ) - - def _selected_cells(self) -> FrozenSet[CellKey]: - """Every cell the selection covers, clipped to the rows the shown frame holds. - - A region names rows of the grid rather than widgets, so a row past the end of a shorter - frame is left out: the selection reaches as far as the pattern does. - """ - region = self._input_state.region - if region is None: - return frozenset() - - keys: Set[CellKey] = set() - for row_index in region.rows: - if row_index >= self._current_row_count: - continue - - for slot in region.slots: - keys.add((row_index, slot.generator, slot.subcolumn)) - - return frozenset(keys) - - def _remove_cell_highlight( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: - """Clears the cursor cell and returns its row to the background the row itself carries. - - The input state names the row the cursor stands on, so it is updated before this runs - and the row resolves to what it looks like once the cursor has left. - """ - dpg.unhighlight_table_cell( - TAG_SEQUENCER_TRACKER_TABLE, - tracker_table_row(row_index), - tracker_table_column(generator), - ) - self._paint_row(row_index) - - def _on_cell_clicked( - self, - sender: Sender, - _app_data: bool, - user_data: Tuple[int, Optional[GeneratorName], SubColumn], - ) -> None: - """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. - - A drag that comes back to the cell it started from ends on a click, and the selection takes - that click as the end of the drag, so the range dragged out stands and the cursor with it. - """ - if self._selection.claims_click(sender, user_data): - return - - state = self._committed_state() - row_index, generator, subcolumn = user_data - cursor = TrackerCursor(row_index, generator, subcolumn) - if Modifier.SHIFT in capture_modifiers(): - self._apply_state(state.extend_to(cursor)) - return - - self._apply_state(TrackerInputState(cursor=cursor, pending="")) - - 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. - - The gesture states how far the pointer has carried: a plain drag anchors at the cell the - press landed on, and one whose press held Shift carries the selection already standing. - - A pointer held past an edge travels the grid first, so the reach that follows reads the rows - the travel has brought into view. - """ - self._travel.advance() - reach = self._selection.hold(app_data) - if reach is None: - return - - state = self._committed_state() - if not reach.extends: - state = TrackerInputState(cursor=TrackerCursor(*reach.origin)) - - self._apply_state(state.extend_to(TrackerCursor(*reach.reached))) - - def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: - """Drops the gesture a finished drag left behind, so this press selects on its own. - - A press is where a gesture ends rather than the release before it, because the release - reaches this panel ahead of the click the cell itself reports: a drag that comes back to - the cell it started from would otherwise have its selection taken down by its own click. - """ - self._selection.drop_gesture() - self._travel.rest() - - def _travel_band(self) -> Optional[TravelBand]: - """Where the frame's rows stand, which is the band a drag held below them travels across.""" - first = self._row_top(0) - if first is None or self._current_row_count == 0: - return None - - return TravelBand( - first_edge=first, - cell_extent=self._layout.tracker.row_height, - cell_count=self._current_row_count, - ) - - def _cell_at(self) -> Optional[CellKey]: - """The cell the pointer stands on, clamped to the grid the shown frame lays out. - - A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond - the last row or the last column selects up to it rather than stopping where the grid ends. - """ - left, top = dpg.get_mouse_pos(local=False) - row_index = self._row_at(top) - slot = self._slot_at(left) - if row_index is None or slot is None: - return None - - return (row_index, slot.generator, 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. - - Every row is the height the layout states, so the count is arithmetic: the rows the - grid holds are evenly pitched whether or not they are scrolled into view. - """ - first = self._row_top(0) - if first is None or self._current_row_count == 0: - return None - - row_index = int((top - first) // self._layout.tracker.row_height) - return max(0, min(row_index, self._current_row_count - 1)) - - def _slot_at(self, left: float) -> Optional[TrackerSlot]: - """Which subcolumn stands at a width, taken from where the first row's cells are drawn. - - The subcolumns differ in width and the columns stand apart, so the walk asks each cell - where it was drawn and takes the first one reaching past the pointer. - """ - if self._current_row_count == 0: - return None - - for index in range(SLOT_COUNT): - slot = slot_from_flat(index) - widget = self._editable_cells.widget((0, slot.generator, slot.subcolumn)) - if widget is None: - return None - - cell_left, _ = dpg.get_item_rect_min(widget) - cell_width, _ = dpg.get_item_rect_size(widget) - if left < cell_left + cell_width: - return slot - - return slot_from_flat(SLOT_COUNT - 1) - - def _on_header_clicked( - self, - sender: Sender, - _app_data: bool, - user_data: Optional[GeneratorName], - ) -> None: - self._channel_switch.click(sender, user_data) - - def _on_header_right_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - ) -> None: - """Opens the channel menu for the right-clicked column header. - - The registry reaches the header labels alone, so a click on one of them names its column - through the map the header row filled in; a label replaced by a rebuild is absent from it. - """ - mouse_button, clicked_item = app_data - if mouse_button != dpg.mvMouseButton_Right: - return - - 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) - - def _on_cell_right_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - ) -> None: - """Opens the cell-operations menu for the right-clicked subcolumn. - - The menu targets the clicked cell directly and leaves the edit cursor where it is, - so a right-click inspects a cell while the caret stays put. - """ - mouse_button, clicked_item = app_data - if mouse_button != dpg.mvMouseButton_Right: - return - - key = dpg.get_item_user_data(clicked_item) - 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) - - @property - def edit_surface(self) -> TrackerEditSurface: - """This grid as the menu bar's Edit menu reaches it.""" - return self._surface - - def input_state(self) -> TrackerInputState: - """Where the cursor stands and what it has selected, which a target is resolved from.""" - return self._input_state - - 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) - - 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) - - 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) - - 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_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 _keys_active(self) -> bool: - """Whether the grid owns the next key: its tab is in front, its cursor is set, and no - field holds the keyboard. - - The grid keeps its cursor while another tab is worked on, so the tab in front is what - decides whether a press reaches it. A focused field keeps the keyboard, so the grid stands - down while the user types into an input. A modal dialog claims keys at a higher priority in - the router, so the grid carries no modal check of its own. - """ - return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused - - def _on_key_pressed(self, event: KeyEvent) -> bool: - """Applies a tracker key to the active cell, reporting whether the grid consumed it. - - The scheme says which press each tracker action answers to; a press the tracker category - leaves unnamed goes to cell entry, which keeps the note and hex keys and hands the rest to - the application's global shortcuts. - """ - cursor = self._input_state.cursor - if cursor is None: - return False - - shortcut_id = self._shortcuts.action(ShortcutCategory.TRACKER, event) - if shortcut_id is None: - return self._type_character(event) - - if shortcut_id is ShortcutId.TRACKER_PLAY_FROM_ROW: - self.call(self.on_play_from_row, cursor.row) - return True - - if self._move_cursor(shortcut_id): - return True - - if self._extend_selection(shortcut_id): - return True - - if self._select_shape(shortcut_id, cursor): - return True - - if self._block_action(shortcut_id): - return True - - if self._adjust_action(shortcut_id): - return True - - return self._edit_row(shortcut_id) - - def _move_cursor(self, shortcut_id: ShortcutId) -> bool: - """Moves the edit cursor over the grid, reporting whether the action was one of its moves.""" - match shortcut_id: - case ShortcutId.TRACKER_PREVIOUS_ROW: - self._move_row(-1) - case ShortcutId.TRACKER_NEXT_ROW: - self._move_row(1) - case ShortcutId.TRACKER_PREVIOUS_SUBCOLUMN: - self._move_subcolumn(-1) - case ShortcutId.TRACKER_NEXT_SUBCOLUMN: - self._move_subcolumn(1) - case ShortcutId.TRACKER_PREVIOUS_COLUMN: - self._move_column(-1) - case ShortcutId.TRACKER_NEXT_COLUMN: - self._move_column(1) - case ShortcutId.TRACKER_FIRST_ROW: - self._jump_to_row(0) - case ShortcutId.TRACKER_LAST_ROW: - self._jump_to_row(self._current_row_count - 1) - case ShortcutId.TRACKER_PAGE_UP: - self._page(-self._layout.tracker.page_size) - case ShortcutId.TRACKER_PAGE_DOWN: - self._page(self._layout.tracker.page_size) - case _: - return False - - return True - - def _extend_selection(self, shortcut_id: ShortcutId) -> bool: - """Grows or shrinks the selected block, reporting whether the action was one of its reaches. - - Each reach moves the end the cursor holds while the anchor stays where the selection began, - so the same keys that move the cursor select with Shift held. - """ - match shortcut_id: - case ShortcutId.TRACKER_EXTEND_SELECTION_UP: - self._extend_row(-1) - case ShortcutId.TRACKER_EXTEND_SELECTION_DOWN: - self._extend_row(1) - case ShortcutId.TRACKER_EXTEND_SELECTION_LEFT: - self._extend_slot(-1) - case ShortcutId.TRACKER_EXTEND_SELECTION_RIGHT: - self._extend_slot(1) - case ShortcutId.TRACKER_EXTEND_SELECTION_TO_FIRST_ROW: - self._extend_to_row(0) - case ShortcutId.TRACKER_EXTEND_SELECTION_TO_LAST_ROW: - self._extend_to_row(self._current_row_count - 1) - case _: - return False - - return True - - def _select_shape( - self, - shortcut_id: ShortcutId, - cell: TrackerCursor, - ) -> bool: - """Selects a rectangle of the grid, reporting whether the action was one of its shapes. - - A press names its shape from the cell the cursor stands on, which is the cell the menu - items name as well, so a key and an item select the same block. - """ - match shortcut_id: - case ShortcutId.TRACKER_SELECT_ALL: - self._select_all() - case ShortcutId.TRACKER_SELECT_COLUMN: - self._select_column(cell) - case ShortcutId.TRACKER_SELECT_SUBCOLUMN: - self._select_subcolumn(cell) - case _: - return False - - return True - - def _select_all(self) -> None: - self._select(self._committed_state().select_all(self._current_row_count)) - - def _select_column(self, cell: TrackerCursor) -> None: - self._select(self._committed_state().select_column(cell, self._current_row_count)) - - def _select_subcolumn(self, cell: TrackerCursor) -> None: - self._select(self._committed_state().select_subcolumn(cell, self._current_row_count)) - - def _select(self, new_state: TrackerInputState) -> None: - """Stands a selected shape, revealing the row its cursor landed on. - - A shape ends at the frame's last row, so the reveal carries the grid to the end the cursor - now holds — the same landing a Shift+End reach makes. - """ - self._apply_state(new_state) - self._scroll_cursor_into_view() - - def _block_action(self, shortcut_id: ShortcutId) -> bool: - """Acts on the selected block, reporting whether the action was one of its gestures. - - Delete is a block gesture only while a selection stands: with one it empties what the - selection covers and keeps it, and with none it falls through to clearing the cell under - the cursor, the meaning that key already carries. - """ - match shortcut_id: - case ShortcutId.TRACKER_COPY_BLOCK: - self._surface.copy() - case ShortcutId.TRACKER_CUT_BLOCK: - self._surface.cut() - case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: - self._surface.delete() - case ShortcutId.TRACKER_PASTE_BLOCK: - self._surface.paste() - case _: - return False - - return True - - def _adjust_action(self, shortcut_id: ShortcutId) -> bool: - """Shifts the covered cells' transpose or volume, reporting whether the action was one of - the two axes. - - A press acts on the block the cursor stands in, which is the selection while one covers it - and the cursor's own cell otherwise — the target the menus resolve as well, so a key and a - menu item reach the same cells. - """ - transpose_step = TRANSPOSE_STEPS.get(shortcut_id) - if transpose_step is not None: - self._adjust_at_cursor(self.on_adjust_transpose, transpose_step) - return True - - volume_step = VOLUME_STEPS.get(shortcut_id) - if volume_step is not None: - self._adjust_at_cursor(self.on_adjust_volume, volume_step) - return True - - return False - - def _adjust_at_cursor( - self, - hook: Optional[OnAdjustCallback], - delta: int, - ) -> None: - """Raises an adjustment on the block the cursor stands in, the entry being typed landing first. - - Committing ahead of the shift is what lets a nudge carry the value the reader has just - finished typing, the rule the block gestures follow as well. - """ - self.commit_entry() - target = self._surface.cursor_target() - if target is not None: - self.call(hook, target.region, delta) - - def _edit_row(self, shortcut_id: ShortcutId) -> bool: - """Empties the cell under the cursor or drops a partial entry, reporting whether the action - was one of the cell edits. - - A cancel with nothing typed and nothing selected leaves the press to the application, so - Escape stops playback while the grid holds a cursor. - """ - match shortcut_id: - case ShortcutId.TRACKER_CLEAR_ROW: - self._clear_row() - self._move_row(1) - case ShortcutId.TRACKER_CLEAR_PREVIOUS_ROW: - self._clear_row() - self._move_row(-1) - case ShortcutId.TRACKER_CANCEL_ENTRY: - if not self._input_state.pending and self._input_state.anchor is None: - return False - - self._apply_state(self._input_state.cancel()) - case _: - return False - - return True - - def _move_row(self, delta: int) -> None: - self._apply_state( - self._committed_state().navigate_row( - delta, - self._current_row_count, - ) - ) - - def _page(self, delta: int) -> None: - """Moves the cursor a page of rows, then scrolls it back into view.""" - self._move_row(delta) - self._scroll_cursor_into_view() - - def _jump_to_row(self, index: int) -> None: - self._apply_state( - self._committed_state().navigate_row( - index, - self._current_row_count, - absolute=True, - ) - ) - self._scroll_cursor_into_view() - - def _extend_row(self, delta: int) -> None: - self._apply_state( - self._committed_state().extend_row( - delta, - self._current_row_count, - ) - ) - - def _extend_to_row(self, index: int) -> None: - self._apply_state( - self._committed_state().extend_row( - index, - self._current_row_count, - absolute=True, - ) - ) - self._scroll_cursor_into_view() - - def _extend_slot(self, delta: int) -> None: - self._apply_state(self._committed_state().extend_slot(delta)) - - def _move_subcolumn(self, delta: int) -> None: - self._apply_state(self._committed_state().navigate_subcolumn(delta)) - - def _move_column(self, delta: int) -> None: - self._apply_state(self._committed_state().navigate_column_by(delta)) - - def _scroll_cursor_into_view(self) -> None: - """Scrolls the tracker so the cursor's row stays on screen after a page or Home/End jump.""" - cursor = self._input_state.cursor - if cursor is not None: - self._scroll_row_into_view(cursor.row) - - def _scroll_row_into_view(self, row_index: int) -> None: - """Scrolls the tracker so the given row rests within the visible band. - - The frame's rows all live in one scrolling table, so a row outside the band is reached by - setting the scroll from that row's position within the frame: the first row rests at the - top of the band, the last at the bottom, and the rows between drift across it. This is how - a jump of the edit cursor lands. - """ - scroll_max = self._scroll_extent() - if scroll_max is None: - return - - fraction = row_index / (self._current_row_count - 1) - dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, fraction * scroll_max) - - def _scroll_row_to_band_top(self, row_index: int) -> None: - """Scrolls the tracker so the given row heads the visible band. - - A playhead read from one place is a playhead that stays easy to read, so the sounding row - is carried to the top of the band by the height of the rows above it, and the rows it is - about to reach fill the band beneath it. The rows closing a frame have nothing behind them - left to scroll into place: there the grid rests at its end and the playhead walks down the - band to meet it. - """ - scroll_max = self._scroll_extent() - offset = self._row_offset(row_index) - if scroll_max is None or offset is None: - return - - dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, min(offset, scroll_max)) - - def _scroll_extent(self) -> Optional[float]: - """How far the grid scrolls, once there is a built table with a frame too tall to fit it.""" - if self._current_row_count <= 1: - return None - - if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): - return None - - scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_TRACKER_TABLE) - return scroll_max if scroll_max > 0 else None - - def _row_offset(self, row_index: int) -> Optional[float]: - """How far down the frame a row stands, measured from the first row to it. - - The rows report where they were last drawn, so the distance between two of them is the - scroll that brings the lower one to where the upper one stands, and the distance from the - first row is the scroll that carries a row to the head of the band. Reading it off the rows - holds whatever height they take and however tall the header above them stands. A grid - awaiting its first layout measures nothing, and its rows are placed by the report that - follows. - """ - first = self._row_top(0) - row = self._row_top(row_index) - if first is None or row is None: - return None - - return row - first - - def _row_top(self, row_index: int) -> Optional[float]: - """Where a pattern row's top edge stands, in the coordinates the viewport is drawn in.""" - row = self._rows.get(row_index) - if row is None or not dpg.does_item_exist(row): - return None - - _, top = dpg.get_item_rect_min(row) - return float(top) - - def _clear_row(self) -> None: - state, clear_action = self._input_state.clear() - self._handle_clear_action(clear_action) - self._apply_state(state) - - def _committed_state(self) -> TrackerInputState: - state, edit_action = self._input_state.commit_partial() - if edit_action is not None: - self._handle_edit_action(edit_action) - - return state - - def commit_entry(self) -> None: - """Writes the entry being typed into the cell the cursor stands on. - - A block gesture takes this first, so what it lifts out carries the value the reader has - just finished typing. - """ - self._apply_state(self._committed_state()) - - def _type_character(self, event: KeyEvent) -> bool: - """Types a note, digit or sign into the cell under the cursor, reporting whether the press - was one. - - A press holding Ctrl or Alt is an application gesture, so cell entry reads the plain keys - and leaves the rest to the global shortcuts. - """ - if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers: - return False - - char = HEX_KEYS.get(event.key) or SIGN_KEYS.get(event.key) - if char is None: - return False - - new_state, edit_action = self._input_state.type_char(char) - if edit_action is not None: - self._handle_edit_action(edit_action) - new_state = new_state.navigate_row(1, self._current_row_count) - - self._apply_state(new_state) - return True - - def _on_row_number_clicked( - self, - sender: Sender, - _app_data: bool, - user_data: int, - ) -> 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 - self._apply_state( - TrackerInputState( - cursor=TrackerCursor( - user_data, - generator, - subcolumn, - ), - pending="", - ) - ) - - def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: - if not dpg.does_item_exist(app_data): - return - - row_index = dpg.get_item_user_data(app_data) - if row_index is not None: - self._highlighted_row = row_index - - def highlight_row(self, row_index: Optional[int] = None) -> None: - """Marks the row the pointer rests on, over the background that row already carries.""" - self.unhighlight_row(self._highlighted_row) - self._highlighted_row = row_index - if row_index is None: - return - - self._paint_hovered_row(row_index) - - def unhighlight_row(self, row_index: Optional[int] = None) -> None: - """Returns a hovered row to the background its group and the marks on it give it.""" - if row_index is None: - return - - self._highlighted_row = None - self._paint_row(row_index) - - def set_row_following(self, following: bool) -> None: - """Whether the grid keeps the sounding row within the visible band as playback advances.""" - self._follows_playing_row = following - - def set_playing_position(self, position: Optional[SongPosition]) -> None: - """Moves the playhead to the position playback reached, mark and grid arriving together. - - The position carries the order frame with the row, which is what tells the grid whether the - row it would mark belongs to the pattern it shows. A row's mark is drawn on the very next - frame while the grid answers a scroll on the frame after that, so a mark drawn as the row is - reported stands a row clear of the band's head until the grid catches up — a step down and - back on every row. Holding the mark until the frame its scroll lands on carries the two as - one. - """ - self._playing_frame = position.order_position if position is not None else None - self._playing_row = position.row_index if position is not None else None - self._reveal_playing_row() - FrameCallbackManager.set_frame_callback(self._paint_playhead, PLAYHEAD_PAINT_FRAMES) - - @property - def _playhead_row(self) -> Optional[int]: - """The row the mark stands on: the sounding row, while the grid shows the frame it sounds.""" - if self._playing_frame == self._displayed_frame: - return self._playing_row - - return None - - def _paint_playhead(self) -> None: - """Draws the mark on the row the playhead has reached, clearing the row it came from.""" - previous = self._painted_row - self._painted_row = self._playhead_row - if previous is not None and previous != self._painted_row: - self._paint_row(previous) - - if self._painted_row is not None: - self._paint_row(self._painted_row) - - def _reveal_playing_row(self) -> None: - """Carries the sounding row to the head of the band while the grid follows the playhead.""" - row_index = self._playhead_row - if self._follows_playing_row and row_index is not None: - self._scroll_row_to_band_top(row_index) - - def _live_row_count(self) -> int: - """The table's current pattern-row count, read live from DearPyGui. - - The cached ``_current_row_count`` reflects the last build on this thread; a concurrent - rebuild on another thread can leave it stale, so row-index-bounded DearPyGui calls read the - actual children directly. The count covers the pattern rows that follow the header row, - so it compares against a pattern row index. - """ - if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): - return 0 - - rows = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) - return len(rows) - HEADER_TABLE_ROWS if rows else 0 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/band.py b/src/sampletones_application/ui/panels/sequencer/tracker/band.py new file mode 100644 index 000000000..389148a27 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/band.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from math import ceil +from typing import Final + +from sampletones_application.ui.panels.sequencer.columns import HEADER_TABLE_ROWS + +UNMEASURED_BAND: Final[float] = 0.0 + + +@dataclass(frozen=True) +class TrackerRows: + """Where each row of the tracker table stands: the header, the frame, and the song either side. + + The table holds the header, then ``reach`` rows the song plays before the frame, then the + frame's own rows, then ``reach`` rows the song plays after it. A pattern row is named by its + index in the frame wherever it is drawn, so every highlight, lookup and reading of a row goes + through here to find the table row it stands on. + """ + + reach: int + frame_rows: int + + @property + def body_rows(self) -> int: + """How many rows scroll below the header: the frame's and those standing either side of it.""" + return 2 * self.reach + self.frame_rows + + def table_row(self, frame_row: int) -> int: + """The table row a pattern row of the frame stands on.""" + return HEADER_TABLE_ROWS + self.body_row(frame_row) + + def body_row(self, frame_row: int) -> int: + """How many rows scroll above a pattern row of the frame, the rows before the frame among them.""" + return self.reach + frame_row + + def lead_table_row(self, slot: int) -> int: + """The table row the ``slot``-th row standing before the frame takes, counted from the top.""" + return HEADER_TABLE_ROWS + slot + + def trail_table_row(self, slot: int) -> int: + """The table row the ``slot``-th row standing after the frame takes, counted from the frame.""" + return HEADER_TABLE_ROWS + self.reach + self.frame_rows + slot + + +@dataclass(frozen=True) +class TrackerBand: + """The part of the tracker grid standing in view below its header, and the room it centers a row in. + + The band shows the row it follows at its center, so it needs as much room above and below a + frame as stands above its own center row. Every row is ``row_height`` tall, which keeps both the + room and the scroll that centers a row arithmetic. + + DearPyGui reports a child window's height and no rectangle of its own, so the band is measured + as the window's height less the part of it the header row covers: a group around the window + states where the window begins, and the header's cells where the header ends. Measured on a + probe grid, the reading matches the band the table's own scroll extent leaves, and an item + resize handler on the window reports every change to it — a resized viewport, a card above it + collapsed or grown — on the frame it happens. + """ + + height: float + row_height: float + + @property + def reach(self) -> int: + """How many rows the band holds above its center row, which is the room each side of a frame takes.""" + room = max(0.0, self.height - self.row_height) / 2 + return ceil(room / self.row_height) + + def centering(self, body_row: int) -> float: + """The scroll that stands a row at the band's center, the row named by how many rows scroll above it.""" + return max(0.0, body_row * self.row_height + (self.row_height - self.height) / 2) 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..611a4c25e --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py @@ -0,0 +1,30 @@ +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, VoiceSelection +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.patterns.pitch import RowPitch +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[RowPitch], Optional[int]], None] +OnSetNoteOffCallback = Callable[[int, Optional[ChannelName]], 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] +MarkedVoiceQuery = Callable[[], Optional[VoiceSelection]] +RefreshPasteBlockRequest = Callable[[VoidCallback], None] + +TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] +ThemeKey = Tuple[SubColumn, Optional[VoiceKind]] diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/context.py b/src/sampletones_application/ui/panels/sequencer/tracker/context.py new file mode 100644 index 000000000..9782fdd8c --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/context.py @@ -0,0 +1,213 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.tabs.sequencer import SequencerLayout +from sampletones_application.tags.sequencer import TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.sequencer.display import row_kinds, row_values +from sampletones_application.ui.panels.sequencer.rows import group_color +from sampletones_application.ui.panels.sequencer.tracker.band import TrackerRows +from sampletones_application.ui.panels.sequencer.tracker.row import ( + add_empty_cell, + add_slot_group, + slot_font, +) +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.blended import BlendedColor +from sampletones_application.utils.palette.colors.faded import FadedColor +from sampletones_application.utils.palette.colors.layered import LayeredColor +from sampletones_application.view_model.sequencer.settings import SequencerSettingsViewModel +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.tracker import SequencerContextRowViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import display_id +from sampletones_shared.types.application import Sender + +BLANK_LABEL: Final[str] = "" +NO_OPACITY: Final[float] = 0.0 + +SongRow = Tuple[int, int] +Entries = Sequence[SequencerContextRowViewModel] +Placed = List[Optional[SequencerContextRowViewModel]] + + +@dataclass +class _StandingRow: + """A table row standing beyond the frame: the widgets it draws through and the song row it shows.""" + + number: Sender + slots: Dict[TrackerSlot, Sender] + entry: Optional[SequencerContextRowViewModel] + + +class ContextRows: + """The rows of the song standing before and after the shown frame, drawn dimmed and read-only. + + The tracker centers the row it follows, so the table holds as many rows on each side of the + frame as stand above the band's center. They show the song's neighboring rows, each read as + its own frame reads it, and where the song ends they stand blank, so the room is there whatever + frame is shown. Every cell wears a label theme and carries no gesture: a row of another frame + is edited by showing that frame. + + A background follows the beat and bar grouping by the row's own index, dimmed as its text is, + and the playhead's mark stands on a row here while playback sounds it. A group's wash is already + translucent, so it is dimmed at a fraction of its own strength. + """ + + def __init__( + self, + *, + layout: SequencerLayout, + themes: TrackerThemes, + subcolumn_widths: Mapping[SubColumn, int], + ) -> None: + self._layout = layout + self._themes = themes + self._subcolumn_widths = subcolumn_widths + self._lead: List[_StandingRow] = [] + self._trail: List[_StandingRow] = [] + + def build_lead(self, reach: int, entries: Entries) -> None: + """Appends the rows standing before the frame to the table, the song's nearest last.""" + self._lead = [self._build_row(entry) for entry in self._placed_before(reach, entries)] + + def build_trail(self, reach: int, entries: Entries) -> None: + """Appends the rows standing after the frame to the table, the song's nearest first.""" + self._trail = [self._build_row(entry) for entry in self._placed_after(reach, entries)] + + def fill(self, lead: Entries, trail: Entries) -> None: + """Shows the song rows now standing either side of the frame, rewriting the rows that changed.""" + for row, entry in zip(self._lead, self._placed_before(len(self._lead), lead)): + self._show(row, entry) + + for row, entry in zip(self._trail, self._placed_after(len(self._trail), trail)): + self._show(row, entry) + + def paint( + self, + rows: TrackerRows, + settings: SequencerSettingsViewModel, + playing: Optional[SongRow], + ) -> None: + """Gives every row either side of the frame its background, the playhead's mark among them. + + Args: + rows: Where the table's rows stand. + settings: The meter the grouping is counted by. + playing: The frame and row playback sounds, or ``None`` while nothing plays. + """ + for slot, row in enumerate(self._lead): + self._draw(rows.lead_table_row(slot), self._background(row.entry, settings, playing)) + + for slot, row in enumerate(self._trail): + self._draw(rows.trail_table_row(slot), self._background(row.entry, settings, playing)) + + @staticmethod + def _placed_before(reach: int, entries: Entries) -> Placed: + """The song row each of ``reach`` rows above the frame shows, blank above where the song begins.""" + taken: Placed = list(entries[max(0, len(entries) - reach) :]) + return [None] * (reach - len(taken)) + taken + + @staticmethod + def _placed_after(reach: int, entries: Entries) -> Placed: + """The song row each of ``reach`` rows below the frame shows, blank below where the song ends.""" + taken: Placed = list(entries[:reach]) + return taken + [None] * (reach - len(taken)) + + def _build_row(self, entry: Optional[SequencerContextRowViewModel]) -> _StandingRow: + """Appends one row to the table, laid out cell for cell as a row of the frame is.""" + row_id = dpg.add_table_row(parent=TAG_SEQUENCER_TRACKER_TABLE) + add_empty_cell(row_id) + number = self._add_number(row_id) + slots: Dict[TrackerSlot, Sender] = {} + self._add_column(row_id, None, slots) + add_empty_cell(row_id) + for channel in ChannelName.items(): + self._add_column(row_id, channel, slots) + + row = _StandingRow(number=number, slots=slots, entry=None) + self._show(row, entry) + return row + + def _add_number(self, row_id: Sender) -> Sender: + cell = dpg.add_table_cell(parent=row_id) + number: Sender = dpg.add_selectable( + parent=cell, + label=BLANK_LABEL, + height=self._layout.tracker.row_height, + ) + FontRegistry.bind_to_item(number, Font.MONO_SMALL) + dpg.bind_item_theme(number, self._themes.context_row_number) + return number + + def _add_column( + self, + row_id: Sender, + channel: Optional[ChannelName], + slots: Dict[TrackerSlot, Sender], + ) -> None: + group = add_slot_group(row_id) + for subcolumn in SubColumn: + selectable = dpg.add_selectable( + parent=group, + label=BLANK_LABEL, + width=self._subcolumn_widths[subcolumn], + height=self._layout.tracker.row_height, + ) + FontRegistry.bind_to_item(selectable, slot_font(channel)) + dpg.bind_item_theme(selectable, self._themes.context_cell(subcolumn, None)) + slots[TrackerSlot(channel, subcolumn)] = selectable + + def _show(self, row: _StandingRow, entry: Optional[SequencerContextRowViewModel]) -> None: + """Rewrites a row to read as the song row it now stands for, or blank beyond the song.""" + if entry == row.entry: + return + + row.entry = entry + dpg.configure_item(row.number, label=display_id(entry.row.index) if entry is not None else BLANK_LABEL) + values = row_values(entry.row) if entry is not None else {} + kinds = row_kinds(entry.row) if entry is not None else {} + for slot, selectable in row.slots.items(): + dpg.configure_item(selectable, label=values.get(slot, BLANK_LABEL)) + dpg.bind_item_theme(selectable, self._themes.context_cell(slot.subcolumn, kinds.get(slot))) + + def _background( + self, + entry: Optional[SequencerContextRowViewModel], + settings: SequencerSettingsViewModel, + playing: Optional[SongRow], + ) -> Optional[BaseColor]: + """The shade a row beside the frame carries: its group dimmed, and the mark while it sounds.""" + if entry is None: + return None + + colors = self._layout.colors + group = group_color(entry.row.index, settings, colors) + dimmed = self._dimmed(group) if group is not None else None + if playing != (entry.frame_index, entry.row.index): + return dimmed + + if dimmed is None: + return colors.playback_row + + return LayeredColor(base=dimmed, overlay=colors.playback_row) + + def _dimmed(self, wash: BaseColor) -> BaseColor: + """A wash at a fraction of its own strength, its hue kept.""" + return BlendedColor( + start=FadedColor(color=wash, fraction=NO_OPACITY), + end=wash, + fraction=self._layout.tracker.muted_text_fraction, + ) + + @staticmethod + def _draw(table_row: int, color: Optional[BaseColor]) -> None: + if color is None: + dpg.unhighlight_table_row(TAG_SEQUENCER_TRACKER_TABLE, table_row) + else: + dpg.highlight_table_row(TAG_SEQUENCER_TRACKER_TABLE, table_row, color=color.rgba) 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/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py new file mode 100644 index 000000000..71010bc97 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -0,0 +1,2025 @@ +from typing import Callable, Dict, Final, FrozenSet, Optional, Set, 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.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 ( + SUF_HANDLER_DRAG, + SUF_HANDLER_HEADER, + SUF_HANDLER_REGISTRY, +) +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.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.table.caret import CaretOverlay +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.selection import TableSelection +from sampletones_application.ui.panels.sequencer import display as tracker_display +from sampletones_application.ui.panels.sequencer.channels import ( + ChannelMenuLabels, + ChannelSwitch, + channel_tooltip, +) +from sampletones_application.ui.panels.sequencer.columns import ( + DIVIDER_TABLE_COLUMN, + HEADER_TABLE_ROW, + HEADER_TABLE_ROWS, + SAMPLE_TABLE_COLUMN, + TRACKER_TABLE_COLUMNS, + tracker_table_column, +) +from sampletones_application.ui.panels.sequencer.display import ( + CellKey, + CellKinds, + CellValues, + row_kinds, + row_values, +) +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 +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( + BlockShortcuts, + ClipboardItems, +) +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.rows import RowCues, row_background +from sampletones_application.ui.panels.sequencer.tracker.adjust import ( + TRANSPOSE_STEPS, + VOLUME_STEPS, +) +from sampletones_application.ui.panels.sequencer.tracker.band import ( + UNMEASURED_BAND, + TrackerBand, + TrackerRows, +) +from sampletones_application.ui.panels.sequencer.tracker.callbacks import ( + CanPasteBlockQuery, + MarkedVoiceQuery, + OnAdjustCallback, + OnBlockRegionCallback, + OnCellSelectedCallback, + OnChannelMuteToggledCallback, + OnChannelSoloedCallback, + OnClearRowCallback, + OnClearSubcolumnCallback, + OnPasteBlockCallback, + OnPlayFromFrameCallback, + OnPlayFromRowCallback, + OnSetNoteOffCallback, + OnSetRowCallback, + RefreshPasteBlockRequest, + TrackerEditSurface, +) +from sampletones_application.ui.panels.sequencer.tracker.context import ContextRows +from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu +from sampletones_application.ui.panels.sequencer.tracker.row import ( + add_empty_cell, + add_slot_group, + slot_font, +) +from sampletones_application.ui.panels.sequencer.tracker.status import CellStatusText +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, + dpg_get_item_user_data, +) +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, + panel_scope_active, +) +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.piano import semitone_of +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.utils.palette.colors.faded import FadedColor +from sampletones_application.utils.palette.colors.layered import LayeredColor +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 ( + TrackerCell, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.settings import ( + SequencerSettingsViewModel, +) +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_application.view_model.sequencer.tracker import ( + NO_REACH, + SequencerRowViewModel, + SequencerTrackerViewModel, +) +from sampletones_application.view_model.sequencer.voices import ( + SequencerVoicesViewModel, + VoiceEntryViewModel, + VoiceKind, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.patterns.pitch import Note, note_for_channel, played_note +from sampletones_core.project.song_position import SongPosition +from sampletones_core.utils.display import NOTE_OFF, display_id, display_pitch +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 + +PLAYHEAD_PAINT_FRAMES: Final[int] = 1 + + +class GUISequencerTrackerPanel(GUIPanel): + def __init__( + self, + initial_settings: SequencerSettingsViewModel, + *, + layout: SequencerLayout, + channel_colors: ChannelColors, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + 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._status_bar = status_bar + self._status_text = CellStatusText(language_manager) + self._router = key_router + self._tab_active = tab_active + self._shortcuts = shortcut_source + + widths = layout.tracker.subcolumn_widths + self._subcolumn_widths: Dict[SubColumn, int] = { + SubColumn.VOICE: widths.voice, + SubColumn.TRANSPOSE: widths.transpose, + SubColumn.VOLUME: widths.volume, + } + + self._item_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_PANEL, SUF_HANDLER_REGISTRY) + self._cell_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_REGISTRY) + self._header_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_HEADER) + self._drag_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_DRAG) + self._band_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_WINDOW, SUF_HANDLER_REGISTRY) + + self._rows: Dict[Optional[int], Sender] = {} + self._header_columns: Dict[Sender, Optional[ChannelName]] = {} + self._header_label: Optional[Sender] = None + self._editable_cells: EditableCells[CellKey] = EditableCells() + self._rows_layout: TrackerRows = TrackerRows(reach=NO_REACH, frame_rows=0) + self._band: TrackerBand = TrackerBand(height=UNMEASURED_BAND, row_height=layout.tracker.row_height) + self._current_rows: Dict[int, SequencerRowViewModel] = {} + self._rows_taking_offsets: FrozenSet[int] = frozenset() + self._highlighted_row: Optional[int] = None + self._displayed_frame: Optional[int] = None + self._playing_frame: Optional[int] = None + self._playing_row: Optional[int] = None + self._painted_row: Optional[int] = None + self._follows_playing_row: bool = False + self._input_state: TrackerInputState = TrackerInputState() + self._selection: TableSelection[CellKey] = TableSelection( + cells=self._editable_cells, + cell_at=self._cell_at, + covered=self._selected_cells, + ) + self._travel: DragTravel = DragTravel( + axis=VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE), + band=self._travel_band, + elapsed=dpg.get_delta_time, + ) + self._cell_kinds: CellKinds = {} + self._themes = TrackerThemes(layout) + self._context = ContextRows( + layout=layout, + themes=self._themes, + subcolumn_widths=self._subcolumn_widths, + ) + 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_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 + self.on_adjust_transpose: Optional[OnAdjustCallback] = None + self.on_adjust_volume: Optional[OnAdjustCallback] = None + self.on_copy_block: Optional[OnBlockRegionCallback] = None + self.on_cut_block: Optional[OnBlockRegionCallback] = None + self.on_delete_block: Optional[OnBlockRegionCallback] = None + self.on_paste_block: Optional[OnPasteBlockCallback] = None + self.can_paste_block: Optional[CanPasteBlockQuery] = None + self.marked_voice: Optional[MarkedVoiceQuery] = None + self.refresh_paste_block: Optional[RefreshPasteBlockRequest] = None + self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None + self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None + self.on_channels_toggled: Optional[VoidCallback] = None + self.on_channels_muted: Optional[VoidCallback] = None + self.on_channels_unmuted: Optional[VoidCallback] = None + self.on_reach_changed: Optional[Callable[[int], None]] = None + + self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) + self._surface: TrackerEditSurface = GridEditSurface.build( + grid=self, + blocks=self._blocks, + target=TrackerTarget, + shortcuts=shortcut_source, + block_shortcuts=BlockShortcuts( + copy=ShortcutId.TRACKER_COPY_BLOCK, + cut=ShortcutId.TRACKER_CUT_BLOCK, + paste=ShortcutId.TRACKER_PASTE_BLOCK, + ), + labels=ClipboardItems.labels(language_manager), + ) + 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_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, + height=-1, + ) + self._enable_vertical_collapse(initial_collapsed=initial_collapsed) + + 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[ChannelName], str] = { + None: self._label(language_manager, SequencerTrackerElements.COLUMN_SAMPLE), + 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 + def _label( + language_manager: LanguageManager, + element: SequencerTrackerElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.TRACKER, + TextType.LABEL, + element, + ] + + def _load_header_tooltips(self, language_manager: LanguageManager) -> None: + """Reads the header tooltips, which name the click gestures the labels carry.""" + + def tooltip(element: SequencerTrackerElements) -> str: + return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.TOOLTIP, element] + + self._tooltip_header_channel = channel_tooltip(tooltip(SequencerTrackerElements.HEADER_CHANNEL)) + self._tooltip_header_sample = tooltip(SequencerTrackerElements.HEADER_SAMPLE) + + def _create_channel_switch(self, language_manager: LanguageManager) -> None: + """Builds the switch a column header's click and menu act through. + + The hooks are read at call time, so the switch is built here while they are still unset and + the coordinator wires them once the panel exists. + """ + labels = ChannelMenuLabels( + mute=self._label(language_manager, SequencerTrackerElements.CONTEXT_MUTE), + unmute=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNMUTE), + solo=self._label(language_manager, SequencerTrackerElements.CONTEXT_SOLO), + unsolo=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNSOLO), + mute_all=self._label(language_manager, SequencerTrackerElements.CONTEXT_MUTE_ALL), + unmute_all=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNMUTE_ALL), + ) + self._channel_switch = ChannelSwitch( + labels=labels, + 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), + ) + + def create_panel(self, parent: str) -> None: + self._setup_handlers() + self._create_themes() + self._create_tracker_view(parent) + + def _setup_handlers(self) -> None: + with dpg.item_handler_registry(tag=self._item_handler_tag): + dpg.add_item_hover_handler( + parent=self._item_handler_tag, + callback=self._on_row_hovered, + ) + + with dpg.item_handler_registry(tag=self._cell_handler_tag): + dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) + dpg.add_item_active_handler(callback=self._on_cell_held) + dpg.add_item_hover_handler(callback=self._on_cell_hovered) + + with dpg.item_handler_registry(tag=self._header_handler_tag): + dpg.add_item_clicked_handler(callback=self._on_header_right_clicked) + + with dpg.handler_registry(tag=self._drag_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_pointer_pressed, + ) + + with dpg.item_handler_registry(tag=self._band_handler_tag): + dpg.add_item_resize_handler(callback=self._on_band_resized) + + self._router.register( + self._on_key_pressed, + priority=PRIORITY_PANEL, + active=self._keys_active, + ) + + def _create_themes(self) -> None: + 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 _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. + + The column labels are carried by a row of widgets (see :meth:`_build_header_row`) that + ``freeze_rows`` pins at the top, which makes each channel's label a click target for + muting. ``no_clip`` lets a label wider than its column draw across the boundary the way + a table header does, so the header keeps the size and position it has always had. + + The pattern stands on one even ground: the tracker's own theme + (``sequencer.theme.table_pattern``) gives ``TableRowBg`` and ``TableRowBgAlt`` the same + shade, leaving the row background free to carry the beat and bar grouping that tells a + tracker's rows apart (see :meth:`_row_background`). + """ + with self._collapsible_card( + parent, + self._lbl_tracker, + glyph=self._glyphs.headers.tracker, + ): + self._create_octave_control() + dpg.add_group(tag=TAG_SEQUENCER_TRACKER_GROUP) + with ( + dpg.child_window( + tag=TAG_SEQUENCER_TRACKER_WINDOW, + parent=TAG_SEQUENCER_TRACKER_GROUP, + border=False, + width=0, + height=-1, + ), + dpg.table( + tag=TAG_SEQUENCER_TRACKER_TABLE, + width=0, + header_row=False, + resizable=False, + borders_innerH=False, + borders_innerV=True, + borders_outerH=True, + borders_outerV=True, + scrollX=False, + scrollY=True, + freeze_rows=HEADER_TABLE_ROWS, + row_background=True, + policy=dpg.mvTable_SizingFixedFit, + ), + ): + FontRegistry.bind_to_item(dpg.last_item(), Font.MONO_BOLD) + dpg.add_table_column(width_stretch=True) + dpg.add_table_column( + width_fixed=True, + init_width_or_weight=self._layout.table_cells.row, + no_clip=True, + ) + dpg.add_table_column( + width_fixed=True, + init_width_or_weight=self._layout.table_cells.sample, + no_clip=True, + ) + dpg.add_table_column( + width_fixed=True, + init_width_or_weight=self._layout.table_cells.divider, + ) + for _ in ChannelName.items(): + dpg.add_table_column( + width_fixed=True, + init_width_or_weight=self._layout.table_cells.channel, + no_clip=True, + ) + dpg.add_table_column(width_stretch=True) + + self.pattern_theme.bind_to_item(TAG_SEQUENCER_TRACKER_TABLE) + dpg.bind_item_handler_registry(TAG_SEQUENCER_TRACKER_WINDOW, self._band_handler_tag) + + @property + def _current_row_count(self) -> int: + """How many rows the shown frame holds in the table.""" + return self._rows_layout.frame_rows + + def _on_band_resized(self, _sender: Sender, _app_data: Sender) -> None: + self._measure_band() + + def _measure_band(self) -> None: + """Reads how tall the band stands, and asks for the rows either side of the frame it now reaches. + + The band is what the window holds below the header row, so it is read off the window's + height, the group it opens in and the header's own cells. A grid awaiting its first layout + reports no height, and the reach it asked for stands until one arrives. + """ + header = self._header_label + if header is None or not dpg.does_item_exist(header): + return + + _, window_height = dpg.get_item_rect_size(TAG_SEQUENCER_TRACKER_WINDOW) + _, window_top = dpg.get_item_rect_min(TAG_SEQUENCER_TRACKER_GROUP) + _, header_top = dpg.get_item_rect_min(header) + _, header_height = dpg.get_item_rect_size(header) + if window_height <= 0 or header_height <= 0: + return + + reach = self._band.reach + self._band = TrackerBand( + height=window_top + window_height - (header_top + header_height), + row_height=self._layout.tracker.row_height, + ) + if self._band.reach != reach: + self.call(self.on_reach_changed, self._band.reach) + + def update_tracker(self, view_model: SequencerTrackerViewModel) -> None: + """Reconciles the tracker body with the visible order frame and the song around it. + + The grid is only torn down and rebuilt when the frame's row count or the band's reach + changes; for the common in-place edit the changed cell labels are reconfigured one by one. + Reusing the existing widgets preserves scroll position, the hover row, and + 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._current_rows = {row.index: row for row in view_model.rows} + self._rows_taking_offsets = frozenset(row.index for row in view_model.rows if row.takes_offsets) + self._show_frame(view_model.frame_index) + layout = TrackerRows(reach=self._band.reach, frame_rows=len(view_model.rows)) + if layout != self._rows_layout: + self._rebuild_table(view_model, layout, cell_values, cell_kinds) + else: + self._reconcile_cell_kinds(cell_kinds) + self._editable_cells.reconcile(cell_values, self._render_cell) + self._context.fill(view_model.lead, view_model.trail) + self._paint_context() + + def _show_frame(self, frame_index: int) -> None: + """Records the order frame the grid stands on, and settles the playhead's mark for it. + + The mark reads as the sounding row of the pattern on screen, so it belongs to the frame the + playhead sounds: a frame arriving at the grid takes the mark while playback stands on it, + and hands it back as the reader moves on to another frame. + """ + if frame_index == self._displayed_frame: + return + + self._displayed_frame = frame_index + self._paint_playhead() + + def _rebuild_table( + self, + view_model: SequencerTrackerViewModel, + layout: TrackerRows, + cell_values: CellValues, + cell_kinds: CellKinds, + ) -> None: + """Replaces the table body, then measures the band and re-reveals the sounding row once it has laid out. + + A table repopulated this frame reports the scroll extent of the body it replaced, so the + reveal is repeated a frame later, when DearPyGui has measured the rows now in it. The band + is read then too, since a header raised in this frame states nothing of where it ends. + + The frame the grid stands on has a row count of its own, so a selection is taken down to + its cursor: the cells it covered belong to the body being replaced. + """ + dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) + self._input_state = self._input_state.collapse() + self._selection.reset() + self._travel.rest() + self._editable_cells.reset(cell_values) + self._cell_kinds = dict(cell_kinds) + self._rows_layout = layout + self._build_table(view_model) + self.repaint() + FrameCallbackManager.set_frame_callback(self._settle_rebuilt_table) + + def _settle_rebuilt_table(self) -> None: + """Reads the band a rebuilt table stands in, then brings the row it follows back to its center.""" + self._measure_band() + self._center_followed_row() + + 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 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 + + self._highlight_sample_column() + self._highlight_header_row() + self._apply_channel_cues() + self._apply_row_backgrounds() + self._update_cursor() + + def update_settings(self, view_model: SequencerSettingsViewModel) -> None: + """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 color a pattern row's background carries under the marks standing on it now.""" + cursor = self._input_state.cursor + return row_background( + row_index, + self._settings, + self._layout.colors, + RowCues( + cursor=cursor.row if cursor is not None else None, + playing=self._painted_row, + ), + ) + + def _draw_row( + self, + row_index: int, + color: Optional[BaseColor], + ) -> None: + """Gives one pattern row the background color it resolved to. + + A mark can name a row of a frame taller than the one the table now holds (a rows-per-pattern + change in between), so the row is checked against the rows the table holds before its index + reaches DearPyGui. + """ + if not 0 <= row_index < self._live_row_count(): + return + + table_row = self._rows_layout.table_row(row_index) + if color is None: + dpg.unhighlight_table_row( + TAG_SEQUENCER_TRACKER_TABLE, + table_row, + ) + else: + dpg.highlight_table_row( + TAG_SEQUENCER_TRACKER_TABLE, + table_row, + color=color.rgba, + ) + + def _paint_row(self, row_index: int) -> None: + """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: + """Draws a row with the hover shade over the background it already carries.""" + background = self._row_background(row_index) + highlight = self._layout.colors.pattern_highlight + self._draw_row( + row_index, + highlight if background is None else LayeredColor(base=background, overlay=highlight), + ) + + def _apply_row_backgrounds(self) -> None: + """Draws every row the table holds, which is how the beat and bar grouping reaches it.""" + for row_index in range(self._live_row_count()): + self._paint_row(row_index) + + self._paint_context() + + def _paint_context(self) -> None: + """Draws the rows standing either side of the frame, the playhead's mark among them.""" + playing = ( + (self._playing_frame, self._playing_row) + if self._playing_frame is not None and self._playing_row is not None + else None + ) + self._context.paint(self._rows_layout, self._settings, playing) + + def _render_cell(self, key: CellKey) -> str: + row, channel, subcolumn = key + return tracker_display.subcolumn_label( + row, + channel, + subcolumn, + cursor=self._input_state.cursor, + pending=self._input_state.pending, + cell_values=self._editable_cells.values, + ) + + def _highlight_sample_column(self) -> None: + """Tints the sample column and the rule that separates it from the channels. + + These column highlights are static decoration, distinct from the cursor's + cell/row highlight; reapplying them after each rebuild keeps them in place + once the rows are replaced. + """ + dpg.highlight_table_column( + TAG_SEQUENCER_TRACKER_TABLE, + SAMPLE_TABLE_COLUMN, + self._layout.colors.sample.column.rgba, + ) + dpg.highlight_table_column( + TAG_SEQUENCER_TRACKER_TABLE, + DIVIDER_TABLE_COLUMN, + self._layout.colors.sample.divider.rgba, + ) + + def _highlight_header_row(self) -> None: + """Gives the widget header row the background a table header carries. + + The shade is laid cell by cell so it covers the sample and channel column washes, + which DearPyGui draws over a row highlight; the header then reads as one band with + the column tints beginning below it. + """ + for column in range(TRACKER_TABLE_COLUMNS): + dpg.highlight_table_cell( + TAG_SEQUENCER_TRACKER_TABLE, + HEADER_TABLE_ROW, + column, + color=self._layout.colors.header.background.rgba, + ) + + def _tint_channel_columns(self) -> None: + """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 channel in ChannelName.items(): + dpg.highlight_table_column( + TAG_SEQUENCER_TRACKER_TABLE, + tracker_table_column(channel), + self._channel_column_tint(channel), + ) + + def _channel_column_tint(self, channel: ChannelName) -> ColorRGBA: + if self._is_muted(channel): + return self._layout.colors.muted.background.rgba + + tint_color = self._channel_colors.for_channel(channel) + return FadedColor( + color=tint_color, + fraction=self._layout.tracker.channel_column_tint, + ).rgba + + @staticmethod + def _compute_cell_values(view_model: SequencerTrackerViewModel) -> CellValues: + """What every slot of the frame reads, keyed by its row and slot.""" + return { + (row.index, slot.channel, slot.subcolumn): value + for row in view_model.rows + for slot, value in row_values(row).items() + } + + @staticmethod + def _compute_cell_kinds(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. + """ + return { + (row.index, slot.channel, slot.subcolumn): kind + for row in view_model.rows + for slot, kind in row_kinds(row).items() + } + + 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: + """Raises the header, the rows standing before the frame, the frame's own, and those after it.""" + self._rows = {} + self._build_header_row() + self._context.build_lead(self._rows_layout.reach, view_model.lead) + for row in view_model.rows: + self._build_table_row(row) + + self._context.build_trail(self._rows_layout.reach, view_model.trail) + + def _build_header_row(self) -> None: + """Builds the header as the table's first row, with each channel label a click target. + + A rebuild replaces every row of the table, so the header is raised here, ahead of the + pattern rows, and lands on the row ``freeze_rows`` pins in place. The cells are + positional like a pattern row's, so the labels line up with the columns they name. + """ + self._header_columns = {} + row_id = dpg.add_table_row(parent=TAG_SEQUENCER_TRACKER_TABLE) + add_empty_cell(row_id) + self._add_header_label_cell(row_id) + self._add_header_selectable(row_id, None) + add_empty_cell(row_id) + 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. + + It is laid out as a selectable like the labels beside it, so it takes the header's + height and sits on their line; its own theme leaves it reading as text. The band is + measured from where it ends. + """ + label_cell = dpg.add_table_cell(parent=row_id) + label = dpg.add_selectable( + parent=label_cell, + label=self._lbl_col_row, + height=self._layout.tracker.header_height, + ) + dpg.bind_item_theme(label, self._themes.column_label) + self._header_label = label + + def _add_header_selectable( + self, + row_id: Sender, + channel: Optional[ChannelName], + ) -> None: + """Places one clickable column label: a channel's mute target, or the master target. + + The selectable takes its width from its label, which is what lets a label wider than + its column draw in full, and it carries its channel so the click knows which column + it landed on. A tooltip names the gestures the label answers to, and the right-click + registry opens the same actions as a menu. + """ + header_cell = dpg.add_table_cell(parent=row_id) + selectable = dpg.add_selectable( + parent=header_cell, + label=self._column_labels[channel], + height=self._layout.tracker.header_height, + 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 channel is None else self._tooltip_header_channel, + ) + self._header_columns[selectable] = channel + + def _build_table_row(self, row: SequencerRowViewModel) -> None: + """Builds one tracker row. + + The cells are positional, so the empty divider cell after the sample column + keeps the channel cells aligned with their (shifted) table columns. + """ + row_id = dpg.add_table_row( + parent=TAG_SEQUENCER_TRACKER_TABLE, + user_data=row.index, + ) + add_empty_cell(row_id) + self._add_row_number_cell(row_id, row.index) + self._add_column_cell(row_id, row.index, None) + add_empty_cell(row_id) + for channel in ChannelName.items(): + self._add_column_cell(row_id, row.index, channel) + + def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None: + number_cell = dpg.add_table_cell(parent=row_id) + selectable = dpg.add_selectable( + parent=number_cell, + label=display_id(row_index), + height=self._layout.tracker.row_height, + user_data=row_index, + callback=self._on_row_number_clicked, + ) + FontRegistry.bind_to_item(selectable, Font.MONO_SMALL) + dpg.bind_item_theme(selectable, self._themes.row_number) + dpg.bind_item_handler_registry(selectable, self._item_handler_tag) + self._rows[row_index] = selectable + + def _add_column_cell( + self, + row_id: Sender, + row_index: int, + channel: Optional[ChannelName], + ) -> None: + group = add_slot_group(row_id) + for subcolumn in SubColumn: + self._add_subcolumn_selectable( + group, + row_index, + channel, + subcolumn, + slot_font(channel), + ) + + def _add_subcolumn_selectable( + self, + group: Sender, + row_index: int, + channel: Optional[ChannelName], + subcolumn: SubColumn, + font: Font, + ) -> None: + key = (row_index, channel, subcolumn) + selectable = dpg.add_selectable( + parent=group, + label=self._render_cell(key), + width=self._subcolumn_widths[subcolumn], + height=self._layout.tracker.row_height, + user_data=key, + callback=self._on_cell_clicked, + ) + FontRegistry.bind_to_item(selectable, font) + 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) + + 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.channel) + else: + self._input_state = TrackerInputState() + + self._selection.repaint() + self._update_caret() + + 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.channel) + self._selection.repaint() + + self._update_caret() + + 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.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.channel) + + if old_cursor is not None: + 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.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) + + self._selection.repaint() + self._update_caret() + + def update_samples(self, view_model: SequencerVoicesViewModel) -> None: + self._current_samples = view_model + + def update_channels(self, view_model: SequencerChannelsViewModel) -> None: + """Shows which channels the song player silences. + + The model is kept so a rebuilt table takes the cue again, the way the column tints do, + and so a table still waiting for its rows picks it up once they arrive. + """ + self._current_channels = view_model + self._apply_channel_cues() + + def _apply_channel_cues(self) -> None: + """Marks each silenced channel down its whole column: label, background, and cell text. + + The three cues land together because they read as one: the column recedes as a unit + while its values stay legible, so the channel is visibly out of the mix and still open + for editing. + """ + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + return + + self._tint_channel_columns() + self._bind_header_themes() + for channel in ChannelName.items(): + self._bind_channel_cell_themes(channel) + + def _bind_header_themes(self) -> None: + 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, channel: ChannelName) -> None: + for row_index in range(self._current_row_count): + for subcolumn in 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 + sample from an instrument 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, 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) + + def _update_cell_display( + self, + row: int, + channel: Optional[ChannelName], + ) -> None: + for subcolumn in 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)) + + def _update_caret(self) -> None: + """Arms (or clears) the shared caret box on the active subcolumn cell.""" + cursor = self._input_state.cursor + if cursor is None: + CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE) + return + + key = (cursor.row, cursor.channel, cursor.subcolumn) + CaretOverlay.set_target( + owner=TAG_SEQUENCER_TRACKER_TABLE, + widget=self._editable_cells.widget(key), + caret_index=len(self._input_state.pending), + font=slot_font(cursor.channel), + clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, + ) + + def _resolve_voice( + self, + sample_index: int, + 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 + + return sample_index, voice + + def _handle_edit_action(self, action: EditAction) -> None: + """Commits a single-subcolumn edit. + + An :class:`EditAction` only ever carries the subcolumn under the cursor; + the others are ``None`` meaning "leave unchanged". Forwarding those ``None`` + values lets the downstream partial update preserve the rest of the row. A pitch + typed while the voices list marks a voice carries that voice into the cell too — see + :meth:`_place_marked_voice`. + """ + row, channel = action.row, action.channel + + if action.note_off: + self._show_voice(row, channel, NOTE_OFF, None) + self.call(self.on_set_note_off, row, channel) + return + + voice_id: Optional[str] = None + + if action.sample_index is not None: + 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, + ) + elif action.pitch is not None: + voice_id = self._place_marked_voice(row, channel) + + if voice_id is None and not self._offset_lands(action): + return + + if action.pitch is not None: + self._editable_cells.values[(row, channel, SubColumn.TRANSPOSE)] = display_pitch(action.pitch) + + if action.volume is not None: + self._editable_cells.values[(row, channel, SubColumn.VOLUME)] = tracker_display.format_committed( + SubColumn.VOLUME, + action.volume, + ) + + self.call( + self.on_set_row, + row, + channel, + voice_id, + action.pitch, + action.volume, + ) + + def _place_marked_voice(self, row: int, channel: Optional[ChannelName]) -> Optional[str]: + """The marked voice a typed pitch carries into the cell, shown at once where the column takes it. + + A pitch typed while the voices list marks a voice places that voice too, which is how a + melody is typed without naming the voice on every row. The column decides as it does for a + typed number: a channel column takes either kind, and the sample column takes a sample and + writes the pitch alone for a marked instrument. + """ + mark = self.query(self.marked_voice, default=None) + if mark is None or not column_takes(channel, mark.kind): + return None + + self._show_voice( + row, + channel, + tracker_display.format_committed(SubColumn.VOICE, mark.position), + mark.kind, + ) + return mark.voice_id + + def _offset_lands(self, action: EditAction) -> bool: + """Whether the pitch or volume an edit carries has a channel to reach. + + A channel column writes its own channel. The sample column writes the channels a sample is + playing on at the row, so on a row where none plays the edit is turned away here, and the + cell keeps showing the value it held. + """ + if action.channel is not None: + return True + + if action.pitch is None and action.volume is None: + return True + + return action.row in self._rows_taking_offsets + + 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.channel, subcolumn), + None, + ) + self._forget_voice(action.row, action.channel) + self.call(self.on_clear_row, action.row, action.channel) + else: + 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.channel, + action.subcolumn, + ) + + def _apply_cell_highlight( + self, + row_index: int, + 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, + self._rows_layout.table_row(row_index), + tracker_table_column(channel), + color=self._layout.colors.cell_cursor.rgba, + ) + + def _selected_cells(self) -> FrozenSet[CellKey]: + """Every cell the selection covers, clipped to the rows the shown frame holds. + + A region names rows of the grid rather than widgets, so a row past the end of a shorter + frame is left out: the selection reaches as far as the pattern does. + """ + region = self._input_state.region + if region is None: + return frozenset() + + keys: Set[CellKey] = set() + for row_index in region.rows: + if row_index >= self._current_row_count: + continue + + for slot in region.slots: + keys.add((row_index, slot.channel, slot.subcolumn)) + + return frozenset(keys) + + def _remove_cell_highlight( + self, + row_index: int, + channel: Optional[ChannelName], + ) -> None: + """Clears the cursor cell and returns its row to the background the row itself carries. + + The input state names the row the cursor stands on, so it is updated before this runs + and the row resolves to what it looks like once the cursor has left. + """ + dpg.unhighlight_table_cell( + TAG_SEQUENCER_TRACKER_TABLE, + self._rows_layout.table_row(row_index), + tracker_table_column(channel), + ) + self._paint_row(row_index) + + def _on_cell_clicked( + self, + sender: Sender, + _app_data: bool, + 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. + + A drag that comes back to the cell it started from ends on a click, and the selection takes + that click as the end of the drag, so the range dragged out stands and the cursor with it. + """ + if self._selection.claims_click(sender, user_data): + return + + state = self._committed_state() + 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="")) + + def _on_cell_hovered(self, _sender: Sender, app_data: int) -> None: + """Says what the slot under the pointer holds, read from the row the grid shows there. + + The hover is reported a frame after it happened, by which time a rebuilt grid may have + taken the cell away, so the handler answers for the cells still standing. + """ + key = dpg_get_item_user_data(app_data) + if not isinstance(key, tuple): + return + + row_index, channel, subcolumn = key + row = self._current_rows.get(row_index) + if row is None: + return + + voices = self._current_samples.voices if self._current_samples is not None else () + self._status_bar.set(self._status_text.describe(row, channel, subcolumn, voices)) + + # 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. + + The gesture states how far the pointer has carried: a plain drag anchors at the cell the + press landed on, and one whose press held Shift carries the selection already standing. + + A pointer held past an edge travels the grid first, so the reach that follows reads the rows + the travel has brought into view. + """ + self._travel.advance() + reach = self._selection.hold(app_data) + if reach is None: + return + + state = self._committed_state() + if not reach.extends: + state = TrackerInputState(cursor=TrackerCursor(*reach.origin)) + + self._apply_state(state.extend_to(TrackerCursor(*reach.reached))) + + def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: + """Drops the gesture a finished drag left behind, so this press selects on its own. + + A press is where a gesture ends rather than the release before it, because the release + reaches this panel ahead of the click the cell itself reports: a drag that comes back to + the cell it started from would otherwise have its selection taken down by its own click. + """ + self._selection.drop_gesture() + self._travel.rest() + + def _travel_band(self) -> Optional[TravelBand]: + """Where the rows scrolling below the header stand, which a drag held past them travels across. + + The rows either side of the frame scroll with it, so the band runs from the first of those + above to the last of those below, while a drag still selects up to the frame's own edge. + """ + first = self._row_top(0) + if first is None or self._current_row_count == 0: + return None + + row_height = self._layout.tracker.row_height + return TravelBand( + first_edge=first - self._rows_layout.reach * row_height, + cell_extent=row_height, + cell_count=self._rows_layout.body_rows, + ) + + def _cell_at(self) -> Optional[CellKey]: + """The cell the pointer stands on, clamped to the grid the shown frame lays out. + + A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond + the last row or the last column selects up to it rather than stopping where the grid ends. + """ + left, top = dpg.get_mouse_pos(local=False) + row_index = self._row_at(top) + slot = self._slot_at(left) + if row_index is None or slot is None: + return None + + 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. + + Every row is the height the layout states, so the count is arithmetic: the rows the + grid holds are evenly pitched whether or not they are scrolled into view. + """ + first = self._row_top(0) + if first is None or self._current_row_count == 0: + return None + + row_index = int((top - first) // self._layout.tracker.row_height) + return max(0, min(row_index, self._current_row_count - 1)) + + def _slot_at(self, left: float) -> Optional[TrackerSlot]: + """Which subcolumn stands at a width, taken from where the first row's cells are drawn. + + The subcolumns differ in width and the columns stand apart, so the walk asks each cell + where it was drawn and takes the first one reaching past the pointer. + """ + if self._current_row_count == 0: + return None + + for index in range(SLOT_COUNT): + slot = slot_from_flat(index) + widget = self._editable_cells.widget((0, slot.channel, slot.subcolumn)) + if widget is None: + return None + + cell_left, _ = dpg.get_item_rect_min(widget) + cell_width, _ = dpg.get_item_rect_size(widget) + if left < cell_left + cell_width: + return slot + + return slot_from_flat(SLOT_COUNT - 1) + + def _on_header_clicked( + self, + sender: Sender, + _app_data: bool, + user_data: Optional[ChannelName], + ) -> None: + self._channel_switch.click(sender, user_data) + + def _on_header_right_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + ) -> None: + """Opens the channel menu for the right-clicked column header. + + The registry reaches the header labels alone, so a click on one of them names its column + through the map the header row filled in; a label replaced by a rebuild is absent from it. + """ + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + if clicked_item not in self._header_columns: + return + + self._menu.show_for_header(self._header_columns[clicked_item]) + + def _on_cell_right_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + ) -> None: + """Opens the cell-operations menu for the right-clicked subcolumn. + + The menu targets the clicked cell directly and leaves the edit cursor where it is, + so a right-click inspects a cell while the caret stays put. + """ + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + key = dpg_get_item_user_data(clicked_item) + if key is None: + return + + 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() + + @property + def channel_switch(self) -> ChannelSwitch: + """The switch a column header's click and menu act through.""" + return self._channel_switch + + @property + def channels(self) -> Optional[SequencerChannelsViewModel]: + """Which channels stand silenced, as the last view the panel was given states it.""" + return self._current_channels + + @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 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] + + 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, which the cell its cursor is set on decides.""" + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._input_state.cursor is not None, + card_open=self.card_open, + ) + + # 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. + + The scheme says which press each tracker action answers to; a press the tracker category + leaves unnamed goes to cell entry, which keeps the note and hex keys and hands the rest to + the application's global shortcuts. A key carrying the cursor to another row — a step, a + page, a jump, a reach, or the row a typed entry moves on to — brings that row to the band's + center, so the rows pass under one line while the keys move through them. + """ + cursor = self._input_state.cursor + if cursor is None: + return False + + consumed = self._answer_key(event, cursor) + moved = self._input_state.cursor + if consumed and moved is not None and moved.row != cursor.row: + self._center_followed_row() + + return consumed + + def _answer_key(self, event: KeyEvent, cursor: TrackerCursor) -> bool: + """Runs the tracker action a key names, or types it into the cell, reporting whether it was taken.""" + shortcut_id = self._shortcuts.action(ShortcutCategory.TRACKER, event) + if shortcut_id is None: + return self._type_character(event) + + if shortcut_id is ShortcutId.TRACKER_PLAY_FROM_ROW: + self.call(self.on_play_from_row, cursor.row) + return True + + if self._move_cursor(shortcut_id): + return True + + if self._extend_selection(shortcut_id): + return True + + if self.select_shape(shortcut_id, cursor): + return True + + if self._block_action(shortcut_id): + return True + + if self._adjust_action(shortcut_id): + return True + + return self._edit_row(shortcut_id) + + def _move_cursor(self, shortcut_id: ShortcutId) -> bool: + """Moves the edit cursor over the grid, reporting whether the action was one of its moves.""" + match shortcut_id: + case ShortcutId.TRACKER_PREVIOUS_ROW: + self._move_row(-1) + case ShortcutId.TRACKER_NEXT_ROW: + self._move_row(1) + case ShortcutId.TRACKER_PREVIOUS_SUBCOLUMN: + self._move_subcolumn(-1) + case ShortcutId.TRACKER_NEXT_SUBCOLUMN: + self._move_subcolumn(1) + case ShortcutId.TRACKER_PREVIOUS_COLUMN: + self._move_column(-1) + case ShortcutId.TRACKER_NEXT_COLUMN: + self._move_column(1) + case ShortcutId.TRACKER_FIRST_ROW: + self._jump_to_row(0) + case ShortcutId.TRACKER_LAST_ROW: + self._jump_to_row(self._current_row_count - 1) + case ShortcutId.TRACKER_PAGE_UP: + self._move_row(-self._layout.tracker.page_size) + case ShortcutId.TRACKER_PAGE_DOWN: + self._move_row(self._layout.tracker.page_size) + case _: + return False + + return True + + def _extend_selection(self, shortcut_id: ShortcutId) -> bool: + """Grows or shrinks the selected block, reporting whether the action was one of its reaches. + + Each reach moves the end the cursor holds while the anchor stays where the selection began, + so the same keys that move the cursor select with Shift held. + """ + match shortcut_id: + case ShortcutId.TRACKER_EXTEND_SELECTION_UP: + self._extend_row(-1) + case ShortcutId.TRACKER_EXTEND_SELECTION_DOWN: + self._extend_row(1) + case ShortcutId.TRACKER_EXTEND_SELECTION_LEFT: + self._extend_slot(-1) + case ShortcutId.TRACKER_EXTEND_SELECTION_RIGHT: + self._extend_slot(1) + case ShortcutId.TRACKER_EXTEND_SELECTION_TO_FIRST_ROW: + self._extend_to_row(0) + case ShortcutId.TRACKER_EXTEND_SELECTION_TO_LAST_ROW: + self._extend_to_row(self._current_row_count - 1) + case _: + return False + + return True + + def select_shape( + self, + shortcut_id: ShortcutId, + cell: TrackerCursor, + ) -> bool: + """Selects a rectangle of the grid, reporting whether the action was one of its shapes. + + A press names its shape from the cell the cursor stands on, which is the cell the menu + items name as well, so a key and an item select the same block. + """ + match shortcut_id: + case ShortcutId.TRACKER_SELECT_ALL: + self._select_all() + case ShortcutId.TRACKER_SELECT_COLUMN: + self._select_column(cell) + case ShortcutId.TRACKER_SELECT_SUBCOLUMN: + self._select_subcolumn(cell) + case _: + return False + + return True + + def _select_all(self) -> None: + self._select(self._committed_state().select_all(self._current_row_count)) + + def _select_column(self, cell: TrackerCursor) -> None: + self._select(self._committed_state().select_column(cell, self._current_row_count)) + + def _select_subcolumn(self, cell: TrackerCursor) -> None: + self._select(self._committed_state().select_subcolumn(cell, self._current_row_count)) + + def _select(self, new_state: TrackerInputState) -> None: + """Stands a selected shape, bringing the row its cursor landed on to the band's center. + + A shape ends at the frame's last row, so the band carries the end the cursor now holds — + the same landing a Shift+End reach makes, whether a key or a menu item selected it. + """ + self._apply_state(new_state) + self._center_followed_row() + + def _block_action(self, shortcut_id: ShortcutId) -> bool: + """Acts on the selected block, reporting whether the action was one of its gestures. + + Delete is a block gesture only while a selection stands: with one it empties what the + selection covers and keeps it, and with none it falls through to clearing the cell under + the cursor, the meaning that key already carries. + """ + match shortcut_id: + case ShortcutId.TRACKER_COPY_BLOCK: + self._surface.copy() + case ShortcutId.TRACKER_CUT_BLOCK: + self._surface.cut() + case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: + self._surface.delete() + case ShortcutId.TRACKER_PASTE_BLOCK: + self._surface.paste() + case _: + return False + + return True + + def _adjust_action(self, shortcut_id: ShortcutId) -> bool: + """Shifts the covered cells' transpose or volume, reporting whether the action was one of + the two axes. + + A press acts on the block the cursor stands in, which is the selection while one covers it + and the cursor's own cell otherwise — the target the menus resolve as well, so a key and a + menu item reach the same cells. + """ + transpose_step = TRANSPOSE_STEPS.get(shortcut_id) + if transpose_step is not None: + self._adjust_at_cursor(self.on_adjust_transpose, transpose_step) + return True + + volume_step = VOLUME_STEPS.get(shortcut_id) + if volume_step is not None: + self._adjust_at_cursor(self.on_adjust_volume, volume_step) + return True + + return False + + def _adjust_at_cursor( + self, + hook: Optional[OnAdjustCallback], + delta: int, + ) -> None: + """Raises an adjustment on the block the cursor stands in, the entry being typed landing first. + + Committing ahead of the shift is what lets a nudge carry the value the reader has just + finished typing, the rule the block gestures follow as well. + """ + self.commit_entry() + target = self._surface.cursor_target() + if target is not None: + self.call(hook, target.region, delta) + + def _edit_row(self, shortcut_id: ShortcutId) -> bool: + """Empties the cell under the cursor or drops a partial entry, reporting whether the action + was one of the cell edits. + + A cancel with nothing typed and nothing selected leaves the press to the application, so + Escape stops playback while the grid holds a cursor. + """ + match shortcut_id: + case ShortcutId.TRACKER_CLEAR_ROW: + self._clear_row() + self._move_row(1) + case ShortcutId.TRACKER_CLEAR_PREVIOUS_ROW: + self._clear_row() + self._move_row(-1) + case ShortcutId.TRACKER_CANCEL_ENTRY: + if not self._input_state.pending and self._input_state.anchor is None: + return False + + self._apply_state(self._input_state.cancel()) + case _: + return False + + return True + + def _move_row(self, delta: int) -> None: + self._apply_state( + self._committed_state().navigate_row( + delta, + self._current_row_count, + ) + ) + + def _jump_to_row(self, index: int) -> None: + self._apply_state( + self._committed_state().navigate_row( + index, + self._current_row_count, + absolute=True, + ) + ) + + def _extend_row(self, delta: int) -> None: + self._apply_state( + self._committed_state().extend_row( + delta, + self._current_row_count, + ) + ) + + def _extend_to_row(self, index: int) -> None: + self._apply_state( + self._committed_state().extend_row( + index, + self._current_row_count, + absolute=True, + ) + ) + + def _extend_slot(self, delta: int) -> None: + self._apply_state(self._committed_state().extend_slot(delta)) + + def _move_subcolumn(self, delta: int) -> None: + self._apply_state(self._committed_state().navigate_subcolumn(delta)) + + def _move_column(self, delta: int) -> None: + self._apply_state(self._committed_state().navigate_column_by(delta)) + + @property + def _followed_row(self) -> Optional[int]: + """The row the band keeps at its center: the sounding one while the grid follows playback, else the cursor's. + + The playhead outranks the cursor while the grid follows it on the frame it shows, so the + band moves with the song and a key moving the cursor leaves it there. + """ + if self._follows_playing_row and self._playhead_row is not None: + return self._playhead_row + + cursor = self._input_state.cursor + return cursor.row if cursor is not None else None + + def _center_followed_row(self) -> None: + """Brings the row the band follows to its center, where it follows one.""" + row_index = self._followed_row + if row_index is not None: + self._center_row(row_index) + + def _center_row(self, row_index: int) -> None: + """Scrolls the grid so a pattern row stands at the band's center. + + The table holds as many rows of the song either side of the frame as stand above the + band's center, so every row of the frame has the room to reach it, and the scroll is worked + out from the rows' height. A band awaiting its first measurement, or a table holding no + frame, stays as it stands. + """ + if self._band.height <= UNMEASURED_BAND or self._current_row_count == 0: + return + + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + return + + dpg.set_y_scroll( + TAG_SEQUENCER_TRACKER_TABLE, + self._band.centering(self._rows_layout.body_row(row_index)), + ) + + def _row_top(self, row_index: int) -> Optional[float]: + """Where a pattern row's top edge stands, in the coordinates the viewport is drawn in.""" + row = self._rows.get(row_index) + if row is None or not dpg.does_item_exist(row): + return None + + _, top = dpg.get_item_rect_min(row) + return float(top) + + def _clear_row(self) -> None: + state, clear_action = self._input_state.clear() + self._handle_clear_action(clear_action) + self._apply_state(state) + + def _committed_state(self) -> TrackerInputState: + state, edit_action = self._input_state.commit_partial() + if edit_action is not None: + self._handle_edit_action(edit_action) + + return state + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on. + + A block gesture takes this first, so what it lifts out carries the value the reader has + just finished typing. + """ + self._apply_state(self._committed_state()) + + def _type_character(self, event: KeyEvent) -> bool: + """Types a note, digit or sign into the cell under the cursor, reporting whether the press + was one. + + A press holding Ctrl, Alt or Super is an application gesture, so cell entry reads the plain + keys and leaves the rest to the global shortcuts. + """ + if not event.is_plain: + 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 + + new_state, edit_action = self._input_state.type_char(char) + if edit_action is not None: + self._handle_edit_action(edit_action) + new_state = new_state.navigate_row(1, self._current_row_count) + + self._apply_state(new_state) + return True + + def _type_note(self, event: KeyEvent) -> bool: + """Types the note a piano key names into the pitch cell under the cursor, reporting whether it did. + + The note goes the way a typed step goes, so it lands in every column alike: a channel takes + it as that channel names notes, the noise channel as one of its periods, and the sample + column hands it to the channels still playing a sample, each in its own terms. + """ + cursor = self._input_state.cursor + if cursor is None or cursor.subcolumn is not SubColumn.TRANSPOSE: + return False + + semitone = semitone_of(event) + if semitone is None: + return False + + pitch = (self._octave + OCTAVE_OFFSET) * OCTAVE_SEMITONES + semitone + self._handle_edit_action( + EditAction( + row=cursor.row, + channel=cursor.channel, + sample_index=None, + pitch=self._typed_note(cursor.channel, pitch), + volume=None, + ) + ) + self._apply_state(self._input_state.reset_pending().navigate_row(1, self._current_row_count)) + return True + + @staticmethod + def _typed_note(channel: Optional[ChannelName], pitch: int) -> Note: + """The note a pitch cell takes: the sample column names the pitch, a channel what it makes of it.""" + if channel is None: + return played_note(pitch) + + return note_for_channel(channel, pitch) + + def _on_row_number_clicked( + self, + sender: Sender, + _app_data: bool, + user_data: int, + ) -> None: + dpg.set_value(sender, False) + existing = self._input_state.cursor + 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, + channel, + subcolumn, + ), + pending="", + ) + ) + + def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: + row_index = dpg_get_item_user_data(app_data) + if row_index is not None: + self._highlighted_row = row_index + + def highlight_row(self, row_index: Optional[int] = None) -> None: + """Marks the row the pointer rests on, over the background that row already carries.""" + self.unhighlight_row(self._highlighted_row) + self._highlighted_row = row_index + if row_index is None: + return + + self._paint_hovered_row(row_index) + + def unhighlight_row(self, row_index: Optional[int] = None) -> None: + """Returns a hovered row to the background its group and the marks on it give it.""" + if row_index is None: + return + + self._highlighted_row = None + self._paint_row(row_index) + + def set_row_following(self, following: bool) -> None: + """Whether the grid keeps the sounding row at the band's center as playback advances.""" + self._follows_playing_row = following + + def set_playing_position(self, position: Optional[SongPosition]) -> None: + """Moves the playhead to the position playback reached, mark and grid arriving together. + + The position carries the order frame with the row, which is what tells the grid whether the + row it would mark belongs to the pattern it shows. A row's mark is drawn on the very next + frame while the grid answers a scroll on the frame after that, so a mark drawn as the row is + reported stands a row clear of the band's center until the grid catches up — a step and + back on every row. Holding the mark until the frame its scroll lands on carries the two as + one. + """ + self._playing_frame = position.order_position if position is not None else None + self._playing_row = position.row_index if position is not None else None + self._reveal_playing_row() + FrameCallbackManager.set_frame_callback(self._paint_playhead, PLAYHEAD_PAINT_FRAMES) + + @property + def _playhead_row(self) -> Optional[int]: + """The row the mark stands on: the sounding row, while the grid shows the frame it sounds.""" + if self._playing_frame == self._displayed_frame: + return self._playing_row + + return None + + def _paint_playhead(self) -> None: + """Draws the mark on the row the playhead has reached, clearing the row it came from. + + The rows either side of the frame show other frames, so the mark stands on one of them + while playback sounds it there. + """ + previous = self._painted_row + self._painted_row = self._playhead_row + if previous is not None and previous != self._painted_row: + self._paint_row(previous) + + if self._painted_row is not None: + self._paint_row(self._painted_row) + + self._paint_context() + + def _reveal_playing_row(self) -> None: + """Brings the sounding row to the band's center while the grid follows the playhead.""" + row_index = self._playhead_row + if self._follows_playing_row and row_index is not None: + self._center_row(row_index) + + def _live_row_count(self) -> int: + """How many pattern rows the table holds, read off the rows DearPyGui holds. + + The count leaves out the header and the rows standing either side of the frame, so it + compares against a pattern row index, and a table not yet built holds none. + """ + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + return 0 + + rows = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) + if not rows: + return 0 + + return max(0, len(rows) - HEADER_TABLE_ROWS - 2 * self._rows_layout.reach) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/row.py b/src/sampletones_application/ui/panels/sequencer/tracker/row.py new file mode 100644 index 000000000..8847fcf3c --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/row.py @@ -0,0 +1,30 @@ +from typing import Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender + + +def add_empty_cell(row_id: Sender) -> None: + """Fills a table cell that carries nothing, which keeps the cells after it in their columns.""" + empty_cell = dpg.add_table_cell(parent=row_id) + if dpg.does_item_exist(empty_cell): + dpg.add_spacer(parent=empty_cell, width=0) + + +def add_slot_group(row_id: Sender) -> Sender: + """Opens a column's cell on a row, as the line its three slots are placed side by side in.""" + cell = dpg.add_table_cell(parent=row_id) + group: Sender = dpg.add_group( + horizontal=True, + horizontal_spacing=0, + parent=cell, + ) + return group + + +def slot_font(channel: Optional[ChannelName]) -> Font: + """The font a column's slots are drawn in: the Sample column's in bold, a channel's plain.""" + return Font.MONO_BOLD_SMALL if channel is None else Font.MONO_SMALL diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/status.py b/src/sampletones_application/ui/panels/sequencer/tracker/status.py new file mode 100644 index 000000000..84cd3f45b --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/status.py @@ -0,0 +1,163 @@ +from typing import Optional, Tuple + +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.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.tracker import ( + SequencerCellViewModel, + SequencerRowViewModel, +) +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +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.utils.display import display_pitch, display_voice_label +from sampletones_shared.utils.agreement import Agreement + +Voices = Tuple[VoiceEntryViewModel, ...] + + +def _sample_command(cell: SequencerCellViewModel) -> Optional[NoteCommand]: + """The command the sample column reads from a cell: an instrument reads as none there.""" + if cell.kind is VoiceKind.INSTRUMENT: + return None + + return cell.command + + +class CellStatusText: + """The sentence the status bar prints for the slot the pointer rests on. + + A slot is named by what it holds, in the terms the grid prints it in: a voice by its number + and name, a pitch by the face it was typed in, a volume by its level, and an empty slot by its + name alone. The sample column speaks for the channels its row spans, so a slot whose channels + disagree says so, which is what the ``?`` it shows means. + """ + + def __init__(self, language_manager: LanguageManager) -> None: + def label(element: SequencerTrackerElements) -> str: + return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, element] + + self._lbl_voice = label(SequencerTrackerElements.STATUS_VOICE) + self._lbl_sample = label(SequencerTrackerElements.STATUS_SAMPLE) + self._lbl_pitch = label(SequencerTrackerElements.STATUS_PITCH) + self._lbl_volume = label(SequencerTrackerElements.STATUS_VOLUME) + self._lbl_note_off = label(SequencerTrackerElements.STATUS_NOTE_OFF) + self._lbl_different_voices = label(SequencerTrackerElements.STATUS_DIFFERENT_VOICES) + self._lbl_different_pitches = label(SequencerTrackerElements.STATUS_DIFFERENT_PITCHES) + self._lbl_different_volumes = label(SequencerTrackerElements.STATUS_DIFFERENT_VOLUMES) + self._tpl_voice = language_manager["sequencer.tracker.template.status_voice"] + self._tpl_sample = language_manager["sequencer.tracker.template.status_sample"] + self._tpl_note = language_manager["sequencer.tracker.template.status_note"] + self._tpl_period = language_manager["sequencer.tracker.template.status_period"] + self._tpl_transpose = language_manager["sequencer.tracker.template.status_transpose"] + self._tpl_volume = language_manager["sequencer.tracker.template.status_volume"] + + def describe( + self, + row: SequencerRowViewModel, + channel: Optional[ChannelName], + subcolumn: SubColumn, + voices: Voices, + ) -> str: + """What one slot of a row holds, as the status bar says it. + + Args: + row: The row the slot stands on. + channel: The channel the slot belongs to, ``None`` for the sample column. + subcolumn: Which of the three slots the pointer rests on. + voices: The project's voices, in the order the voices list numbers them. + + Returns: + str: The sentence. + """ + match subcolumn: + case SubColumn.VOICE: + return self._voice(row, channel, voices) + case SubColumn.TRANSPOSE: + return self._pitch(row, channel) + case SubColumn.VOLUME: + return self._volume(row, channel) + + def _voice( + self, + row: SequencerRowViewModel, + channel: Optional[ChannelName], + voices: Voices, + ) -> str: + if channel is not None: + return self._command(row.cells[channel].command, voices, self._tpl_voice, self._lbl_voice) + + commands = Agreement.collapse(_sample_command(row.cells[name]) for name in row.note_channels) + if commands.is_mixed: + return self._lbl_different_voices + + return self._command(commands.resolve(absent=None, mixed=None), voices, self._tpl_sample, self._lbl_sample) + + def _command( + self, + command: Optional[NoteCommand], + voices: Voices, + template: str, + empty: str, + ) -> str: + """A voice slot's reading: the voice it names as the voices list prints it, a cut, or the slot's name.""" + match command: + case NoteOff(): + return self._lbl_note_off + case NoteOn(): + named = self._voice_label(voices, command.voice_id) + return template.format(voice=named) if named is not None else empty + case None: + return empty + + @staticmethod + def _voice_label(voices: Voices, voice_id: str) -> Optional[str]: + """The label the voices list prints a voice under, and ``None`` for a voice the pool lacks.""" + for position, entry in enumerate(voices): + if entry.voice_id == voice_id: + return display_voice_label(position, entry.name) + + return None + + def _pitch(self, row: SequencerRowViewModel, channel: Optional[ChannelName]) -> str: + if channel is not None: + return self._face(row.cells[channel].pitch) + + pitches = Agreement.collapse(row.cells[name].pitch for name in row.offset_channels) + if pitches.is_mixed: + return self._lbl_different_pitches + + return self._face(pitches.resolve(absent=None, mixed=None)) + + def _face(self, pitch: Optional[RowPitch]) -> str: + """A pitch slot's reading in the face the cell shows: a step in decimal, a note by name, a period by number.""" + match pitch: + case Step(): + return self._tpl_transpose.format(step=f"{pitch.value:+d}") + case Note() if pitch.value <= MAX_PERIOD: + return self._tpl_period.format(period=pitch.value) + case Note(): + return self._tpl_note.format(note=display_pitch(pitch)) + case None: + return self._lbl_pitch + + def _volume(self, row: SequencerRowViewModel, channel: Optional[ChannelName]) -> str: + if channel is not None: + return self._level(row.cells[channel].level) + + levels = Agreement.collapse(row.cells[name].level for name in row.offset_channels) + if levels.is_mixed: + return self._lbl_different_volumes + + return self._level(levels.resolve(absent=None, mixed=None)) + + def _level(self, level: Optional[int]) -> str: + if level is None: + return self._lbl_volume + + return self._tpl_volume.format(volume=level) 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..eeddb942c --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py @@ -0,0 +1,137 @@ +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. A row of another frame, standing beside the shown one, wears the dimmed color as a label + that answers no pointer. 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._context_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 + self._context_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() + row = self._layout.colors.text.row + self._row_number = create_selectable_text_theme(row) + self._context_row_number = create_label_selectable_theme(self._dimmed(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, + } + 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(self._dimmed(color)) + self._context_subcolumn[theme_key] = create_label_selectable_theme(self._dimmed(color)) + + def _dimmed(self, color: BaseColor) -> BaseColor: + """The same hue at the reduced alpha a silenced channel and a row of another frame read in.""" + return FadedColor(color=color, fraction=self._layout.tracker.muted_text_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 + sample from an instrument 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 context_cell(self, subcolumn: SubColumn, kind: Optional[VoiceKind]) -> int: + """The theme a cell of another frame's row wears: its slot's color, dimmed, as a plain label.""" + return self._context_subcolumn[(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 + + @property + def context_row_number(self) -> int: + """The theme a row number of another frame's row wears, dimmed as a plain label.""" + return self._context_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..a804693fd --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/voices/menu.py @@ -0,0 +1,338 @@ +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: ... + + @property + def accepts_voices(self) -> bool: ... + + 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. The three answer while a project stands + open to take a voice, as the button above the list does. + """ + accepts_voices = self._panel.accepts_voices + dpg.add_menu_item( + label=self._label(SequencerVoicesElements.NEW_INSTRUMENT), + shortcut=self._shortcuts.display(ShortcutId.NEW_INSTRUMENT), + enabled=accepts_voices, + 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), + enabled=accepts_voices, + 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), + enabled=accepts_voices, + 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..ba3e29ada --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -0,0 +1,812 @@ +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_GROUP_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_INPUT_RENAME, + TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TABLE, + TAG_SEQUENCER_VOICES_THEME_ROW, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, + TAG_SEQUENCER_VOICES_WINDOW, +) +from sampletones_application.ui.elements.context_menu import context_menu_under_pointer +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_configure_item, + dpg_delete_children, + dpg_get_item_user_data, + dpg_pointer_within_window, +) +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, + panel_scope_active, +) +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 attach_disabled_tooltip, set_tooltip_visible, 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._mark_clear_pending = False + self._focused = 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._accepts_voices = False + self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT) + self._tip_new_instrument_no_project = self._tooltip( + language_manager, + SequencerVoicesElements.NEW_INSTRUMENT_NO_PROJECT, + ) + 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. The hover is reported a frame + after it happened, by which time a rebuilt list may have taken the cell away, so the handler + answers for the cells still standing. + """ + 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 sample plays the channels its conversion found and exports an instrument for each of + them; an instrument 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_Left, + callback=self._on_list_left_clicked, + ) + 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 an instrument, which is the one kind no browser brings in. + + A greyed-out button shows no tooltip of its own, so the group around it says what brings + the button back while no project is open. + """ + with dpg.group(tag=TAG_SEQUENCER_VOICES_GROUP_NEW_INSTRUMENT): + button = dpg.add_button( + tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + label=self._label(self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT), + width=-1, + enabled=self._accepts_voices, + callback=lambda: self.call(self.on_new_instrument_requested), + ) + + FontRegistry.bind_to_item(button, Font.REGULAR_SMALL) + show_tooltip(button, self._tip_new_instrument, tag=TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT) + attach_disabled_tooltip( + TAG_SEQUENCER_VOICES_GROUP_NEW_INSTRUMENT, + self._tip_new_instrument_no_project, + tag=TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, + ) + self._show_new_instrument_tooltip() + + def set_enabled(self, enabled: bool) -> None: + """Offers the ways a voice comes in while a project stands open to take one. + + The button above the list, its tooltip and the pool section of every menu the list raises + follow the one answer. + """ + self._accepts_voices = enabled + dpg_configure_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, enabled=enabled) + self._show_new_instrument_tooltip() + + def _show_new_instrument_tooltip(self) -> None: + """Shows the tooltip that fits the button's state: what it adds, or what brings it back.""" + set_tooltip_visible(TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, self._accepts_voices) + set_tooltip_visible(TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, not self._accepts_voices) + + @property + def accepts_voices(self) -> bool: + """Whether a project stands open for a voice to come into.""" + return self._accepts_voices + + 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, position, 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, + position: int, + 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. + The glyph answers a hover and a press as the row's other cells do, so the row reads and + answers as one thing wherever the pointer rests on it. + """ + kind_cell = dpg.add_table_cell(parent=row_id) + mark = dpg.add_text( + parent=kind_cell, + default_value=self._kind_glyph(entry.kind), + user_data=(position, entry.voice_id), + ) + FontRegistry.bind_to_item(mark, Font.ICON) + dpg_set_palette_color(mark, self._kind_color(entry.kind)) + dpg.bind_item_handler_registry(mark, self._row_handler_tag) + 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._focused = True + self._highlight_selected_row(position) + self.call(self.on_voice_selected, voice_id) + + @property + def selection(self) -> Optional[VoiceSelection]: + """The marked voice, or ``None`` while the panel marks none. + + 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 mark without keeping a copy of it: the browser replaces it, and the grid + places it with every pitch typed there. + """ + 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 mark, and the panel's claim on the keyboard with it. + + A voice removed from the pool, or a list rebuilt without it, leaves nothing to mark. A cell + picked in another panel keeps the mark and takes the keyboard alone — see :meth:`blur`. + """ + 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 + self._focused = False + + def blur(self) -> None: + """Yields the keyboard while keeping the mark on the voice. + + The grid, the order and this panel each register a key-router scope active only while it + holds the keyboard, so one holder across the three decides which acts on a keystroke. A + cell picked in the grid or the order takes the keyboard here, and the voice stays marked + for the pitches typed there to carry. + """ + self._focused = False + + def _keys_active(self) -> bool: + """Whether the voices panel owns the next key, which the voice it marks and the keyboard it holds decide. + + A name being edited claims every press on its own, so Escape reaches the rename it would + cancel rather than the field that holds the keyboard. The card put away rests ahead of + that, since a rename hidden with the card is one the reader has stepped away from. + """ + if self._editing_voice_id is not None: + return self._tab_active() and self.card_open() + + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._selected_voice_id is not None and self._focused, + card_open=self.card_open, + ) + + 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 + self._mark_clear_pending = False + 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_left_clicked( + self, + _sender: Sender, + _app_data: int, + ) -> None: + """Clears the mark a frame later, leaving a row that answers the press first. + + The list is offered the press before the row, so the clearing waits a frame: a row landed + on claims the press in the meantime and marks the voice it holds, and the empty stretch + below the rows leaves the claim unmade, which is how the reader stops marking. + """ + if not self._pointer_within_list(): + return + + self._mark_clear_pending = True + FrameCallbackManager.set_frame_callback(self._clear_mark) + + def _clear_mark(self) -> None: + """Drops the mark for a press the list answered.""" + if not self._mark_clear_pending: + return + + self._mark_clear_pending = False + self.deselect() + + 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, with no menu open above it there. + + 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. A press landing on a menu raised over + the list is the menu's. + """ + return not context_menu_under_pointer() and 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/__init__.py b/src/sampletones_application/ui/panels/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 35652a6be..dc2f448d3 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -1,5 +1,6 @@ from abc import abstractmethod -from typing import AbstractSet, Any, Optional, Tuple +from pathlib import Path +from typing import AbstractSet, Any, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -7,6 +8,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FILE_WAVE, @@ -18,7 +20,10 @@ from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.utils.gui.tooltip import DetailSwatch +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel from sampletones_core.structures.tree import ( + ConfigNode, FileSystemNode, NodeType, Tree, @@ -27,13 +32,15 @@ traverse, ) from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.types.callback import PathCallback, VoidCallback + +SEVERAL_RECORDINGS: Final[int] = 2 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. @@ -41,6 +48,10 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild — a refresh, a change of mode — brings the rows back standing as they were left, and so does the next run of the application. + + A row naming a reconstruction asks through ``on_recordings_requested`` what that document is made + of, and lists what comes back in the hover details, each recording marked in the color the entry + it was converted as gives it everywhere else. """ _MONOSPACE_CONFIG_NODES: bool = True @@ -56,12 +67,17 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, + stem_colors: StemColors, initial_collapsed: bool, initial_favorites_only: bool, initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager + self._stem_colors = stem_colors + self._detail_document: Optional[Path] = None + self._detail_recordings: Tuple[NamedRecordingViewModel, ...] = () self.on_refresh_tree: Optional[VoidCallback] = None + self.on_recordings_requested: Optional[PathCallback] = None super().__init__( tree=tree, @@ -111,6 +127,84 @@ def _setup_handlers(self) -> None: super()._setup_handlers() + def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: + """What a row states about the configuration behind it. + + A reconstruction file reads its fields from the configuration directory holding it, since a + configuration states them in a directory name. The same file listed under the audio it + mirrors carries those fields on its own row, so both branches say the same thing about it. + """ + match node: + case ConfigNode(): + return super()._node_detail_items(node) + case FileSystemNode(node_type=NodeType.FILE): + return self._enclosing_configuration_items(node) + + return super()._node_detail_items(node) + + def _enclosing_configuration_items(self, node: FileSystemNode) -> List[Tuple[str, str]]: + """The fields of the nearest configuration directory above ``node``, where one stands there.""" + configuration = next( + (ancestor.config for ancestor in reversed(node.ancestors) if isinstance(ancestor, ConfigNode)), + None, + ) + if configuration is None: + return [] + + return self._reconstruction_detail_items(configuration) + + def _node_detail_recordings(self, node: TreeNode) -> Tuple[DetailSwatch, ...]: + """The recordings the hovered document is made of, asked for as the pointer reaches its row. + + The document holds the answer, so the row asks for it and lists what has come back. A + document standing for one recording says what the row already says, which leaves the list + to the documents holding several. + """ + path = self._document_path(node) + if path is None: + self._forget_hovered_document() + return () + + if path != self._detail_document: + self._forget_hovered_document() + self._detail_document = path + self.call(self.on_recordings_requested, path) + + return self._recording_swatches(self._detail_recordings) + + def update_recordings(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + """Takes what a document names and redraws the details, where they stand on that document.""" + if path != self._detail_document: + return + + self._detail_recordings = recordings + self.refresh_detail_tooltip() + + def _forget_hovered_document(self) -> None: + self._detail_document = None + self._detail_recordings = () + + def _recording_swatches(self, recordings: Tuple[NamedRecordingViewModel, ...]) -> Tuple[DetailSwatch, ...]: + """One mark per recording, colored by the entry it was converted as.""" + if len(recordings) < SEVERAL_RECORDINGS: + return () + + return tuple( + DetailSwatch( + name=recording.name, + color=self._stem_colors.for_recording(recording.stem_id), + ) + for recording in recordings + ) + + @staticmethod + def _document_path(node: TreeNode) -> Optional[Path]: + """Where the reconstruction a row names lives, for the rows that name one.""" + if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE: + return node.filepath + + return None + def _has_relevant_content(self, node: TreeNode) -> bool: if node.node_type == NodeType.FILE: return True @@ -163,10 +257,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/resources/__init__.py b/src/sampletones_application/ui/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/resources/loader.py b/src/sampletones_application/ui/resources/loader.py index 5c61dc3d2..327a1fa7c 100644 --- a/src/sampletones_application/ui/resources/loader.py +++ b/src/sampletones_application/ui/resources/loader.py @@ -1,39 +1,33 @@ -import sys -from importlib.resources import files -from importlib.resources.abc import Traversable from pathlib import Path -from typing import Union +from typing import Final +from sampletones_shared.paths.package import package_directory from sampletones_shared.types.path import Pathlike +ASSETS_PACKAGE: Final[str] = "sampletones_assets" + class ResourceLoader: + """Reads the files of one directory of the assets package, where the package lies in every copy.""" + def __init__(self, resource_directory: Pathlike) -> None: self.resource_directory = Path(resource_directory) - def _get_package_path(self, resource_name: str) -> Union[Path, Traversable]: - if getattr(sys, "frozen", False): - base_path = Path(sys._MEIPASS) # type: ignore[attr-defined] # pylint: disable=protected-access - resource_type = self.resource_directory.name - return base_path / "assets" / resource_type / resource_name - - package_name = f"sampletones_assets.{self.resource_directory.name}" - return files(package_name).joinpath(resource_name) + def _get_package_path(self, resource_name: str) -> Path: + package = f"{ASSETS_PACKAGE}.{self.resource_directory.name}" + return package_directory(package) / resource_name def get_path(self, resource_name: str) -> str: - resource_path = self._get_package_path(resource_name) + """The resource's location, which a widget loading it by path reads. - if isinstance(resource_path, Path): - if not resource_path.exists(): - raise FileNotFoundError(f"Resource not found: '{resource_path}'") - else: - try: - resource_path.read_bytes() - except FileNotFoundError as exception: - raise FileNotFoundError(f"Resource not found: '{resource_path}'") from exception + Raises: + FileNotFoundError: If the directory holds no file of that name. + """ + resource_path = self._get_package_path(resource_name) + if not resource_path.is_file(): + raise FileNotFoundError(f"Resource not found: '{resource_path}'") return str(resource_path) def get_bytes(self, resource_name: str) -> bytes: - resource_path = self._get_package_path(resource_name) - return resource_path.read_bytes() + return self._get_package_path(resource_name).read_bytes() diff --git a/src/sampletones_application/ui/themes/__init__.py b/src/sampletones_application/ui/themes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py index c37865540..6556f0ad5 100644 --- a/src/sampletones_application/ui/themes/channels.py +++ b/src/sampletones_application/ui/themes/channels.py @@ -2,15 +2,26 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_CHANNEL_NOISE, + TAG_GLOBAL_THEME_CHANNEL_NOISE_PARTIAL, TAG_GLOBAL_THEME_CHANNEL_PULSE1, + TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, TAG_GLOBAL_THEME_CHANNEL_PULSE2, + TAG_GLOBAL_THEME_CHANNEL_PULSE2_PARTIAL, TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, + TAG_GLOBAL_THEME_CHANNEL_TRIANGLE_PARTIAL, ) -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, +} + +PARTIAL_CHANNEL_THEME_TAGS: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, + ChannelName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2_PARTIAL, + ChannelName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE_PARTIAL, + ChannelName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE_PARTIAL, } diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index 5eed2b854..80e8a0652 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, @@ -14,6 +16,7 @@ "RadioButton": dpg.mvRadioButton, "Selectable": dpg.mvSelectable, "ShadeSeries": dpg.mvShadeSeries, + "SliderFloat": dpg.mvSliderFloat, "Table": dpg.mvTable, "Text": dpg.mvText, "TreeNode": dpg.mvTreeNode, 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/callbacks/delay.py b/src/sampletones_application/utils/callbacks/delay.py new file mode 100644 index 000000000..9c099bd56 --- /dev/null +++ b/src/sampletones_application/utils/callbacks/delay.py @@ -0,0 +1,26 @@ +import time +from typing import Final + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import VoidCallback + +NEXT_FRAME: Final[int] = 1 + + +def call_after(seconds: float, callback: VoidCallback) -> None: + """Runs ``callback`` on the render thread once ``seconds`` have passed. + + The wait is read against the clock at each frame's drain, so it lasts the same span at any frame + rate, and the callback runs on the thread that owns the widgets it reaches. The wait travels + through the queue, so the shutdown that stops the queue drops a wait still under way. + """ + deadline = time.monotonic() + seconds + CallbackQueue.add(_call_when_due, deadline, callback, delay=NEXT_FRAME) + + +def _call_when_due(deadline: float, callback: VoidCallback) -> None: + if time.monotonic() < deadline: + CallbackQueue.add(_call_when_due, deadline, callback, delay=NEXT_FRAME) + return + + callback() diff --git a/src/sampletones_application/utils/callbacks/failures.py b/src/sampletones_application/utils/callbacks/failures.py new file mode 100644 index 000000000..f38b4384d --- /dev/null +++ b/src/sampletones_application/utils/callbacks/failures.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Callable, ClassVar, Optional + +from sampletones_shared.logger import logger +from sampletones_shared.meta import NonInstantiableMeta + +FailurePresenter = Callable[[Exception], None] +FailurePost = Callable[[FailurePresenter, Exception], None] +ThreadHook = Callable[[threading.ExceptHookArgs], object] + + +@dataclass(frozen=True) +class FailureAttachment: + """What a running interface attached to the channel: the presenter, its post, and the hook it found. + + Attributes: + present: Shows the reader one failure. Runs on the render thread. + post: Hands ``present`` and the failure to the render thread. + saved_thread_hook: The thread hook in place when the channel was attached. + """ + + present: FailurePresenter + post: FailurePost + saved_thread_hook: ThreadHook + + +class UnhandledFailures(metaclass=NonInstantiableMeta): + """The one channel a failure no coordinator recovered from takes to reach the reader. + + Every place where control enters the application hands it what escapes the work it runs: the + callback queue for gestures, frame callbacks and queued deliveries, the runner every executor + worker runs its tasks through, and any other thread through the thread hook installed while a + presenter is attached. A report is logged with its traceback, and the presenter is posted to + the render thread, whichever thread failed. A failure on a worker while the interface is still + being built is therefore presented by the first drain. + + The composition root attaches the presenter and the post that carries it to the render thread, + and the teardown detaches them, so a report outside a running interface is logged alone. A + presenter that fails is logged alone too, which ends a broken report at its own failure. + + Non-instantiable by design: like the callback queue it posts to, it is a process-wide channel. + """ + + _attachment: ClassVar[Optional[FailureAttachment]] = None + + @classmethod + def attach( + cls, + present: FailurePresenter, + *, + post: FailurePost, + ) -> None: + """Shows every report through ``present``, carried to the render thread by ``post``. + + The thread hook in place is kept for what a thread lets escape beyond an ``Exception``, and + put back once the channel is detached. + + Args: + present: Shows the reader one failure. Runs on the render thread. + post: Hands ``present`` and the failure to the render thread. + """ + cls.detach() + cls._attachment = FailureAttachment(present=present, post=post, saved_thread_hook=threading.excepthook) + threading.excepthook = cls._escaped + + @classmethod + def detach(cls) -> None: + """Logs every later report alone, and puts back the thread hook the attachment found.""" + attachment = cls._attachment + if attachment is None: + return + + threading.excepthook = attachment.saved_thread_hook + cls._attachment = None + + @classmethod + def report(cls, exception: Exception, origin: str) -> None: + """Logs ``exception`` as escaping ``origin``, and has the reader shown it. + + Args: + exception: The failure nothing recovered from. + origin: The log line's opening, naming where the failure escaped. + """ + logger.error_with_traceback(exception, origin) + attachment = cls._attachment + if attachment is None: + return + + attachment.post(cls._presented, exception) + + @classmethod + def _presented(cls, exception: Exception) -> None: + """Shows ``exception`` through the presenter attached when it runs. + + The presentation is the channel's own entry point, so a presenter that fails is logged here, + and its failure ends there. + """ + attachment = cls._attachment + if attachment is None: + return + + try: + attachment.present(exception) + except Exception as failure: # pylint: disable=broad-exception-caught + logger.error_with_traceback(failure, f"The report of {type(exception).__name__} could not be shown") + + @classmethod + def _escaped(cls, arguments: threading.ExceptHookArgs) -> None: + """Reports an ``Exception`` a thread let escape, and passes anything else to the saved hook.""" + match arguments.exc_value: + case Exception() as exception: + thread = f"The thread {arguments.thread.name}" if arguments.thread is not None else "A thread" + cls.report(exception, f"{thread} let {type(exception).__name__} escape") + case _ if cls._attachment is not None: + cls._attachment.saved_thread_hook(arguments) diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py new file mode 100644 index 000000000..2c07ec785 --- /dev/null +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -0,0 +1,314 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import partial +from typing import Callable, Generic, Optional, ParamSpec, Sequence, TypeVar + +from sampletones_shared.types.callback import VoidCallback + +Wait = Callable[[VoidCallback], None] +Gate = Callable[[VoidCallback, VoidCallback], None] + +GestureParameters = ParamSpec("GestureParameters") +GestureResult = TypeVar("GestureResult") + + +def waiting(wait: Wait) -> Gate: + """``wait`` as a gate that holds a request until it may go on, and then lets it through. + + Args: + wait: What a request waits on, such as the edits of the open reconstruction. + """ + + def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: + wait(proceed) + + return gate + + +def asking( + unsettled: Callable[[], bool], + question: Gate, + screen: Wait, +) -> Gate: + """A gate that asks ``question`` about something unsettled once ``screen`` is free for the question. + + While ``unsettled`` reads false, the request goes on at once, whatever holds the screen. Otherwise + the gate waits for the screen and reads ``unsettled`` again there, so the question speaks of the + state it shows over. A thing another conversation settled meanwhile, such as a project its answer + closed, lets the request through with no question. The step on the free screen runs from the + line, so one that raises before it hands the request on turns the request away, and the error + goes on up. + + Args: + unsettled: Whether there is something to ask about. + question: The gate that asks, which runs only while the screen is free for it. + screen: The wait for the screen, such as the modal line's turn. + """ + + def gate(proceed: VoidCallback, decline: VoidCallback) -> None: + if not unsettled(): + proceed() + return + + def ask_on_the_screen(go_on: VoidCallback, turn_away: VoidCallback) -> None: + if unsettled(): + question(go_on, turn_away) + else: + go_on() + + screen(_declining_on_failure(ask_on_the_screen, proceed, decline)) + + return gate + + +def waiting_for_the_screen( + unsettled: Callable[[], bool], + screen: Wait, +) -> Gate: + """A gate that holds a chain of questions for the screen while any of them has something to ask. + + With nothing unsettled the request goes on at once, whatever holds the screen. Otherwise it waits + for the screen, and every guard after the gate reads its state there. A dialog standing as the + request was made can change that state, such as Project properties leaving the project unsaved, + so a guard with nothing to ask at the request asks once the dialog has left. + + Args: + unsettled: Whether any guard after the gate has something to ask about. + screen: The wait for the screen, such as the modal line's turn. + """ + + def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: + if unsettled(): + screen(proceed) + else: + proceed() + + return gate + + +def _declining_on_failure( + step: Gate, + proceed: VoidCallback, + decline: VoidCallback, +) -> VoidCallback: + """``step`` as a callback handed ``proceed`` and ``decline``, which calls ``decline`` where it raises before either. + + A step that called one of the two has handed the request on, and a failure after that belongs to + the continuation it called. + """ + + def run() -> None: + settled = False + + def settling(continuation: VoidCallback) -> VoidCallback: + def settle() -> None: + nonlocal settled + settled = True + continuation() + + return settle + + try: + step(settling(proceed), settling(decline)) + settled = True + finally: + if not settled: + decline() + + return run + + +def pass_gates( + gates: Sequence[Gate], + arrive: VoidCallback, + decline: VoidCallback, +) -> None: + """Runs each gate in turn, and calls ``arrive`` once the last one lets the request through. + + Each gate receives a callback that runs the gates after it, and ``decline``. A gate checks what it + guards when it is reached: it calls the first at once, or asks a question whose answer calls one of + the two. An earlier question is therefore answered before a later gate checks, and a state that + cleared meanwhile asks nothing. A request ends in ``arrive`` or in ``decline``, so whoever waits on + it hears how it ended. + + Args: + gates: What stands between a request and ``arrive``, in the order the gates are asked. + arrive: What runs once every gate has let the request through. + decline: What runs once a gate turns the request away. + """ + if not gates: + arrive() + return + + gates[0](partial(pass_gates, gates[1:], arrive, decline), decline) + + +def gated( + wait: Wait, + gesture: Callable[GestureParameters, GestureResult], +) -> Callable[GestureParameters, None]: + """``gesture`` as a callback that waits on ``wait`` before it runs, with the arguments it was called with. + + A menu item, a shortcut or a panel hook takes the callback in the gesture's place, so whatever + reaches the gesture waits first. The callback discards what the gesture returns. + + Args: + wait: What the gesture waits on. + gesture: What runs once the wait lets it through. + """ + + def call(*args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + def run() -> None: + gesture(*args, **kwargs) + + wait(run) + + return call + + +@dataclass(eq=False) +class Ticket: + """One take-off of a gesture, and the request its end lets through. + + Each take-off gets a ticket of its own, so a continuation handed out during one conversation + reaches that conversation alone. + + Attributes: + request: The gesture's arrival the end of the flight lets through. + """ + + request: VoidCallback + + +class SingleFlight(ABC, Generic[GestureParameters]): + """A gesture that holds one conversation at a time, and lets one request through at its end. + + The conversation is the gates the gesture passes. It is in flight from the moment the gesture is + asked for until the gates let it through or turn it away, and a gesture asked for after that asks + again. A gesture asked for while it is in flight asks nothing, and the kind of flight decides which + request its end lets through. A conversation that turns the gesture away drops the request it + holds. A gate that raises, as it is reached or in a step it runs after an answer, a wait or a + turn in the line, ends the flight the same way, so one failure leaves the gesture to be asked for + again. Each take-off has a ticket, and a continuation acts on the flight of its own ticket alone, + so an answer reaching a conversation that has ended leaves the newer one as it stands. + """ + + def __init__(self, arrive: Callable[GestureParameters, GestureResult]) -> None: + self._arrive = arrive + self._ticket: Optional[Ticket] = None + + @property + def in_flight(self) -> bool: + """Whether a conversation of this gesture is under way, from the moment it is asked for to its end.""" + return self._ticket is not None + + @abstractmethod + def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + """Asks for the gesture with these arguments.""" + + def _request(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> VoidCallback: + """The gesture's arrival with these arguments, which discards what the gesture returns.""" + + def arrival() -> None: + self._arrive(*args, **kwargs) + + return arrival + + def _take_off(self, gates: Sequence[Gate], request: VoidCallback) -> None: + """Asks ``gates`` with ``request`` standing, and holds the flight until they let it through or turn it away.""" + ticket = Ticket(request=request) + self._ticket = ticket + conversation = partial( + pass_gates, + [self._guarded_gate(ticket, gate) for gate in gates], + partial(self._land, ticket), + partial(self._turn_away, ticket), + ) + self._guarded(ticket, conversation)() + + def _redirect(self, request: VoidCallback) -> None: + """Lets ``request`` through at the end of the flight under way, in the place of the one standing.""" + assert self._ticket is not None, "A request was redirected with no flight under way" + self._ticket.request = request + + def _guarded_gate(self, ticket: Ticket, gate: Gate) -> Gate: + """``gate`` handed continuations that end the flight of ``ticket`` when they raise.""" + + def guarded(proceed: VoidCallback, decline: VoidCallback) -> None: + gate(self._guarded(ticket, proceed), self._guarded(ticket, decline)) + + return guarded + + def _guarded(self, ticket: Ticket, continuation: VoidCallback) -> VoidCallback: + """``continuation`` turning the flight of ``ticket`` away when it raises, with the error going on up.""" + + def run() -> None: + went_on = False + try: + continuation() + went_on = True + finally: + if not went_on: + self._turn_away(ticket) + + return run + + def _land(self, ticket: Ticket) -> None: + """Ends the flight of ``ticket``, and then lets its request through, so its arrival may ask anew.""" + if self._ticket is not ticket: + return + + self._ticket = None + ticket.request() + + def _turn_away(self, ticket: Ticket) -> None: + if self._ticket is ticket: + self._ticket = None + + +class LatestRequestFlight(SingleFlight[GestureParameters]): + """A gesture whose conversation is the same whatever it carries, letting the latest request through. + + The gates are asked once for the whole flight, and a request made meanwhile takes the place of the + one standing. The answer therefore goes on with what the reader asked for last, such as the second + of two files opened before the question showed. + """ + + def __init__( + self, + gates: Sequence[Gate], + arrive: Callable[GestureParameters, GestureResult], + ) -> None: + super().__init__(arrive) + self._gates = tuple(gates) + + def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + request = self._request(*args, **kwargs) + if self.in_flight: + self._redirect(request) + return + + self._take_off(self._gates, request) + + +class FirstRequestFlight(SingleFlight[GestureParameters]): + """A gesture whose conversation speaks of what it carries, keeping the first request to the end. + + The conversation is built from the request's arguments, so a question can name the file it asks + about, and its answer holds for that request alone. A request made while the question stands is + therefore absorbed, and one made after the answer asks about what it carries. + """ + + def __init__( + self, + conversation: Callable[GestureParameters, Sequence[Gate]], + arrive: Callable[GestureParameters, GestureResult], + ) -> None: + super().__init__(arrive) + self._conversation = conversation + + def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + if self.in_flight: + return + + self._take_off(self._conversation(*args, **kwargs), self._request(*args, **kwargs)) diff --git a/src/sampletones_application/utils/callbacks/queue.py b/src/sampletones_application/utils/callbacks/queue.py index d8c2d6e4c..3f4c7f322 100644 --- a/src/sampletones_application/utils/callbacks/queue.py +++ b/src/sampletones_application/utils/callbacks/queue.py @@ -5,6 +5,7 @@ import time from typing import Any, ClassVar, List +from sampletones_application.utils.callbacks.failures import UnhandledFailures from sampletones_application.utils.callbacks.priority import CallbackPriority from sampletones_application.utils.callbacks.task import CallbackTask from sampletones_shared.exceptions import CallbackQueueStop @@ -27,8 +28,10 @@ class CallbackQueue(metaclass=NonInstantiableMeta): A callback becomes due once the frame counter reaches its target frame; callbacks posted with a delay wait in the heap until then. Ordering within a - frame follows the callback priority and then insertion order. Individual - failures are caught and logged so the remaining callbacks still run. + frame follows the callback priority and then insertion order. :meth:`run` is + where a gesture, a frame callback or a queued delivery enters the application, + so a failure there is reported through :class:`UnhandledFailures` and the + remaining callbacks still run. Non-instantiable by design: it is a shared execution channel reached through its class methods. @@ -61,7 +64,15 @@ def add( delay: int = 0, **kwargs: Any, ) -> None: + """Posts ``callback`` to run on the render thread, ``delay`` frames from now at the earliest. + + A stopped queue lets the work go: the run it was posted to is being taken down, and the + context it would reach goes with it. + """ with cls._lock: + if cls._stopped: + return + frame = cls._frame_counter + delay insertion_order = cls._insertion_counter cls._insertion_counter += 1 @@ -74,6 +85,12 @@ def notify_frame(cls) -> None: with cls._lock: cls._frame_counter += 1 + @classmethod + def current_frame(cls) -> int: + """The frame the counter stands at, which a delay given to :meth:`add` counts from.""" + with cls._lock: + return cls._frame_counter + @classmethod def process(cls, budget_seconds: float) -> None: """Run the callbacks due at the current frame, up to a time budget. @@ -114,6 +131,15 @@ def stop(cls) -> None: cls._stopped = True cls._callbacks.clear() + @classmethod + def clear(cls) -> None: + """Gives the next run an empty, live queue: what waited is let go, and the counters start over.""" + with cls._lock: + cls._callbacks.clear() + cls._frame_counter = 0 + cls._insertion_counter = 0 + cls._stopped = False + @classmethod def run(cls, callback: Callback, *args: Any, **kwargs: Any) -> bool: try: @@ -123,7 +149,7 @@ def run(cls, callback: Callback, *args: Any, **kwargs: Any) -> bool: logger.error(f"Callback queue processing stopped due to the error: {exception}.") return True except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback( + UnhandledFailures.report( exception, f"Error executing callback {getattr(callback, '__name__', str(callback))}", ) diff --git a/src/sampletones_application/utils/file_dialogs/api.py b/src/sampletones_application/utils/file_dialogs/api.py index 2ae187c06..e952ce268 100644 --- a/src/sampletones_application/utils/file_dialogs/api.py +++ b/src/sampletones_application/utils/file_dialogs/api.py @@ -1,3 +1,4 @@ +from functools import partial from itertools import chain from pathlib import Path from typing import Optional, Tuple @@ -7,8 +8,9 @@ from sampletones_application.utils.file_dialogs.selection import ( select_file_dialog_backend, ) +from sampletones_application.utils.gui.render_thread import answered_while_drawing from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.system.paths import ensure_suffix, to_path +from sampletones_shared.utils.system.paths import ensure_suffix, nearest_directory, to_path def open_file_dialog( @@ -17,11 +19,19 @@ def open_file_dialog( initial_directory: Optional[Pathlike] = None, filters: Tuple[FileFilter, ...] = (), ) -> Optional[Path]: + """Asks for a file to open, yielding ``None`` once the dialog is dismissed. + + The dialog stands in front of the interface until the reader answers it, and the frames keep + being drawn behind it meanwhile. + """ backend = select_file_dialog_backend() - return backend.open_file( - title=title, - initial_directory=_optional_path(initial_directory), - filters=filters, + return answered_while_drawing( + partial( + backend.open_file, + title=title, + initial_directory=_standing_directory(initial_directory), + filters=filters, + ) ) @@ -40,11 +50,14 @@ def save_file_dialog( finds one. ``filters`` is ordered, and its first type is the one the dialog opens on. """ backend = select_file_dialog_backend() - destination = backend.save_file( - title=title, - initial_directory=_optional_path(initial_directory), - suggested_name=default_filename, - filters=filters, + destination = answered_while_drawing( + partial( + backend.save_file, + title=title, + initial_directory=_standing_directory(initial_directory), + suggested_name=default_filename, + filters=filters, + ) ) if destination is None: @@ -58,10 +71,18 @@ def select_directory_dialog( title: str, initial_directory: Optional[Pathlike] = None, ) -> Optional[Path]: + """Asks for a directory, yielding ``None`` once the dialog is dismissed. + + The dialog stands in front of the interface until the reader answers it, and the frames keep + being drawn behind it meanwhile. + """ backend = select_file_dialog_backend() - return backend.select_directory( - title=title, - initial_directory=_optional_path(initial_directory), + return answered_while_drawing( + partial( + backend.select_directory, + title=title, + initial_directory=_standing_directory(initial_directory), + ) ) @@ -111,5 +132,14 @@ def _offered_extensions(filters: Tuple[FileFilter, ...]) -> Tuple[str, ...]: return tuple(chain.from_iterable(file_filter.extensions for file_filter in filters)) -def _optional_path(value: Optional[Pathlike]) -> Optional[Path]: - return to_path(value) if value is not None else None +def _standing_directory(value: Optional[Pathlike]) -> Optional[Path]: + """The folder a dialog opens in: the one asked for, or the nearest one above it still on the disk. + + A remembered folder may have gone since it was written down — deleted, or on a drive unplugged + for now — so a dialog opens as close to it as the disk allows and the remembered path stands for + the next time it is there. + """ + if value is None: + return None + + return nearest_directory(to_path(value)) 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..7ae19830d 100644 --- a/src/sampletones_application/utils/gui/align.py +++ b/src/sampletones_application/utils/gui/align.py @@ -1,16 +1,19 @@ -from typing import Any, Callable, Final, Tuple +from functools import partial +from typing import Any, Callable, Optional, Tuple import dearpygui.dearpygui as dpg from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.placement import centered_position -_SETTLE_FRAMES: Final[int] = 2 +def viewport_center() -> Tuple[int, int]: + """The middle of the space a window's position is measured in. -def get_center(width: int, height: int) -> Tuple[int, int]: - x = (dpg.get_viewport_width() - width) / 2 - y = (dpg.get_viewport_height() - height) / 2 - return round(x), round(y) + A position given to DearPyGui stands in the viewport's client area, so the center it is + measured against is read from the same space. + """ + return dpg.get_viewport_client_width() // 2, dpg.get_viewport_client_height() // 2 def center_item(tag: str) -> None: @@ -18,22 +21,32 @@ def center_item(tag: str) -> None: return width, height = dpg.get_item_rect_size(tag) - x, y = get_center(width, height) - dpg.set_item_pos(tag, [x, y]) + dpg.set_item_pos(tag, list(centered_position(viewport_center(), width, height))) def center_when_settled(tag: str) -> None: - """Centres an autosizing window once its content has reached its final size. + """Holds a window centered over the frames it takes its size in, and lets go once it settles. - 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 - first appearance the same way a reopened one does from its remembered size. + A window that takes the size its content asks for reaches it over the frames it is drawn + in: a form measures its fields on the second, and the keybindings list widens over a dozen + more. Centering the window against every size it is read at keeps it centered the whole way + there, and two readings that agree end the pass: a dialog can be dragged, and one that kept + measuring would drag it back. """ - FrameCallbackManager.set_frame_callback( - lambda: center_item(tag), - frame_count=_SETTLE_FRAMES, - ) + FrameCallbackManager.set_frame_callback(partial(_center_once_settled, tag, None)) + + +def _center_once_settled(tag: str, previous: Optional[Tuple[int, int]]) -> None: + """Centers the window where it now stands, and reads again while it is still growing.""" + if not dpg.does_item_exist(tag): + return + + width, height = dpg.get_item_rect_size(tag) + center_item(tag) + if previous == (width, height): + return + + FrameCallbackManager.set_frame_callback(partial(_center_once_settled, tag, (width, height))) def table_wrapper( diff --git a/src/sampletones_application/utils/gui/callbacks.py b/src/sampletones_application/utils/gui/callbacks.py new file mode 100644 index 000000000..8ff0b2a62 --- /dev/null +++ b/src/sampletones_application/utils/gui/callbacks.py @@ -0,0 +1,43 @@ +from inspect import Parameter, signature +from typing import Any, Final, Sequence + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import Callback + +POSITIONAL: Final = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + + +def hold_callbacks() -> None: + """Have DearPyGui gather a widget's callback rather than running it where the gesture lands. + + DearPyGui answers a gesture on a thread of its own, so a callback that rebuilds widgets there + runs while the render loop is walking the very items it drops — a crash rather than a glitch. + Gathered callbacks wait in a queue the frame drains, which is what makes every gesture reach + the interface from the thread the context belongs to. + """ + dpg.configure_app(manual_callback_management=True) + + +def run_held_callbacks() -> None: + """Run what DearPyGui gathered since the last frame, on the thread that drew it. + + A callback is handed as many of DearPyGui's three arguments as it declares, and each runs + through the reporting queued work runs through, so one failing gesture leaves the rest to run. + """ + for job in dpg.get_callback_queue() or (): + callback, *arguments = job + if callback is None: + continue + + CallbackQueue.run(callback, *_taken(callback, arguments)) + + +def _taken(callback: Callback, arguments: Sequence[Any]) -> Sequence[Any]: + """The arguments ``callback`` declares, out of the sender, the payload and the user data.""" + parameters = signature(callback).parameters.values() + if any(parameter.kind is Parameter.VAR_POSITIONAL for parameter in parameters): + return arguments + + return arguments[: sum(1 for parameter in parameters if parameter.kind in POSITIONAL)] diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py deleted file mode 100644 index 9b26290ce..000000000 --- a/src/sampletones_application/utils/gui/clipboard.py +++ /dev/null @@ -1,42 +0,0 @@ -import threading -from typing import Protocol, cast - -import dearpygui.dearpygui as dpg - -from sampletones_application.utils.gui.dpg import dpg_configure_item - - -class TextClipboard(Protocol): - """The clipboard the desktop shares between applications, as text going out and coming back.""" - - def read(self) -> str: ... - - def write(self, text: str) -> None: ... - - -class SystemTextClipboard: - """The desktop's clipboard, reached through the one DearPyGui holds for the viewport.""" - - def read(self) -> str: - return cast(str, dpg.get_clipboard_text()) - - def write(self, text: str) -> None: - dpg.set_clipboard_text(text) - - -def copy_to_clipboard( - text: str, - label: str, - button_tag: str, - *, - copied_label: str, -) -> None: - SystemTextClipboard().write(text) - - dpg_configure_item(button_tag, label=copied_label) - - def restore_label() -> None: - dpg_configure_item(button_tag, label=label) - - timer = threading.Timer(1.0, restore_label) - timer.start() diff --git a/src/sampletones_application/utils/gui/clipboard/__init__.py b/src/sampletones_application/utils/gui/clipboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/clipboard/backends/__init__.py b/src/sampletones_application/utils/gui/clipboard/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py b/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py new file mode 100644 index 000000000..d4930c020 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py @@ -0,0 +1,18 @@ +from typing import cast + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.gui.clipboard.protocol import ClipboardTextCallback + + +class DearPyGuiTextClipboard: + """The desktop's clipboard, reached through the one DearPyGui holds for the viewport. + + The platform's clipboard hands its text over within the call, so a read answers at once. + """ + + def read(self, on_text: ClipboardTextCallback) -> None: + on_text(cast(str, dpg.get_clipboard_text())) + + def write(self, text: str) -> None: + dpg.set_clipboard_text(text) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py new file mode 100644 index 000000000..48e29fe35 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py @@ -0,0 +1,65 @@ +from typing import Final, List, Optional, Protocol + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.clipboard.protocol import ClipboardTextCallback +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor + +CLIPBOARD_ANSWER_SECONDS: Final[float] = 1.0 + + +class SelectionReader(Protocol): + """Reads the clipboard's text, waiting ``seconds`` at most for the application holding it. + + An application answering nothing in that span leaves the text unknown, which reads as + ``None``. + """ + + def read_text(self, seconds: float) -> Optional[str]: ... + + +class X11TextClipboard: + """The desktop's clipboard on X11: written through DearPyGui, read over a connection of its own. + + DearPyGui reads the clipboard through GLFW, which waits for the application holding it for as + long as that application takes, holding DearPyGui's lock and the interpreter meanwhile; an owner + that stays silent stops every frame for good. This clipboard reads on a worker instead, giving + the owner ``CLIPBOARD_ANSWER_SECONDS`` to hand its text over, and an owner silent past that + answers nothing at all. The frames keep being drawn meanwhile, which is also what lets this + application's own window answer: GLFW hands over the text it holds as a frame polls its events. + + The answer reaches the render thread through ``CallbackQueue``, so the shutdown that stops the + queue drops an answer still on its way, and the worker joins at teardown like any other. Reads + asked while one is out wait for its answer, so a burst of them shares one transfer. + """ + + def __init__(self, reader: SelectionReader) -> None: + self._reader = reader + self._executor = SingleThreadExecutor() + self._waiting: List[ClipboardTextCallback] = [] + + def read(self, on_text: ClipboardTextCallback) -> None: + self._waiting.append(on_text) + if len(self._waiting) == 1: + self._executor.execute(self._transfer, wait=True) + + def write(self, text: str) -> None: + dpg.set_clipboard_text(text) + + def _transfer(self) -> None: + """Reads on the worker and hands the answer to the render thread, none where the read fails. + + The reads waiting on this transfer hear from it whichever way it ends, so the next read + starts a transfer of its own, and a failure still reaches the thread's own report. + """ + text: Optional[str] = None + try: + text = self._reader.read_text(CLIPBOARD_ANSWER_SECONDS) + finally: + CallbackQueue.add(self._answer, text) + + def _answer(self, text: Optional[str]) -> None: + waiting, self._waiting = self._waiting, [] + for on_text in waiting: + on_text(text) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py new file mode 100644 index 000000000..9206b4dfa --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import ctypes +import select +import time +from dataclasses import dataclass +from enum import IntEnum +from types import TracebackType +from typing import Any, Final, Optional, Type, Union + +from sampletones_application.utils.gui.clipboard.backends.x11.library import ( + Cookie, + GenericEvent, + PropertyNotifyEvent, + Screen, + SelectionNotifyEvent, + XcbLibrary, +) + +SENT_EVENT_BIT: Final[int] = 0x80 +PROPERTY_NEW_VALUE: Final[int] = 0 +COPY_FROM_PARENT: Final[int] = 0 +INPUT_ONLY_WINDOW: Final[int] = 2 +EVENT_MASK_VALUE: Final[int] = 1 << 11 +PROPERTY_CHANGE_EVENTS: Final[int] = 1 << 22 +CURRENT_TIME: Final[int] = 0 +ANY_PROPERTY_TYPE: Final[int] = 0 +DELETE_AFTER_READING: Final[int] = 1 +WHOLE_PROPERTY_WORDS: Final[int] = 0x3FFFFFFF + + +class EventCode(IntEnum): + PROPERTY_NOTIFY = 28 + SELECTION_NOTIFY = 31 + + +@dataclass(frozen=True) +class SelectionNotify: + """The owner's answer to a conversion: the property it wrote the text to, or none at all.""" + + requestor: int + landing: int + + +@dataclass(frozen=True) +class PropertyNotify: + """A property on a window changed, which is how a text sent in pieces announces each piece.""" + + window: int + atom: int + new_value: bool + + +SelectionEvent = Union[SelectionNotify, PropertyNotify] + + +@dataclass(frozen=True) +class PropertyValue: + """What a window property held when it was read: its type and its bytes.""" + + value_type: int + data: bytes + + +class XcbConnection: + """A connection of our own to the X server, speaking the calls a selection transfer makes. + + A connection carries its own requests and events, so a transfer run on it stays apart from the + one DearPyGui draws through. Every call that waits on the server asks the X server alone, which + answers right away; the owner of the clipboard is heard from through events, which + :meth:`next_event` waits for until a deadline. + + Raises: + ConnectionError: If the display refuses the connection or the connection breaks. + """ + + def __init__(self, library: XcbLibrary, display: str) -> None: + self._library = library + self._handle: int = library.connect(display.encode(), None) + if library.connection_has_error(self._handle): + library.disconnect(self._handle) + raise ConnectionError(f"The X display {display} refused a connection") + + def __enter__(self) -> XcbConnection: + return self + + def __exit__( + self, + _exception_type: Optional[Type[BaseException]], + _exception: Optional[BaseException], + _traceback: Optional[TracebackType], + ) -> None: + self._library.disconnect(self._handle) + + def atom(self, name: str) -> int: + encoded = name.encode() + cookie = self._library.intern_atom(self._handle, 0, len(encoded), encoded) + reply = self._library.intern_atom_reply(self._handle, cookie, None) + self._require(reply) + atom = int(reply.contents.atom) + self._library.free(reply) + return atom + + def selection_owner(self, selection: int) -> int: + cookie = self._library.get_selection_owner(self._handle, selection) + reply = self._library.get_selection_owner_reply(self._handle, cookie, None) + self._require(reply) + owner = int(reply.contents.owner) + self._library.free(reply) + return owner + + def create_requestor(self) -> int: + """Creates the window an owner writes the text to, reporting each change to its properties.""" + roots = self._library.setup_roots_iterator(self._library.get_setup(self._handle)) + screen = ctypes.cast(roots.data, ctypes.POINTER(Screen)).contents + window = int(self._library.generate_id(self._handle)) + events = ctypes.c_uint32(PROPERTY_CHANGE_EVENTS) + self._library.create_window( + self._handle, + COPY_FROM_PARENT, + window, + screen.root, + 0, + 0, + 1, + 1, + 0, + INPUT_ONLY_WINDOW, + COPY_FROM_PARENT, + EVENT_MASK_VALUE, + ctypes.byref(events), + ) + return window + + def convert_selection( + self, + *, + requestor: int, + selection: int, + target: int, + landing: int, + ) -> None: + """Asks the owner of ``selection`` to write it as ``target`` to ``landing`` on ``requestor``.""" + self._library.convert_selection(self._handle, requestor, selection, target, landing, CURRENT_TIME) + self._library.flush(self._handle) + + def take_property(self, window: int, atom: int) -> PropertyValue: + """Reads a property whole and deletes it, which an owner sending in pieces waits for.""" + cookie: Cookie = self._library.get_property( + self._handle, + DELETE_AFTER_READING, + window, + atom, + ANY_PROPERTY_TYPE, + 0, + WHOLE_PROPERTY_WORDS, + ) + reply = self._library.get_property_reply(self._handle, cookie, None) + self._require(reply) + value_type = int(reply.contents.type) + data = ctypes.string_at( + self._library.get_property_value(reply), + self._library.get_property_value_length(reply), + ) + self._library.free(reply) + self._library.flush(self._handle) + return PropertyValue(value_type=value_type, data=data) + + def next_event(self, deadline: float) -> Optional[SelectionEvent]: + """The next selection or property event, or ``None`` once ``deadline`` passes first. + + ``deadline`` is read against ``time.monotonic``. Events of any other kind are passed over. + """ + while True: + raw = self._poll_event() + if raw is not None: + event = self._selection_event(raw) + if event is not None: + return event + + continue + + remaining = deadline - time.monotonic() + if remaining <= 0.0: + return None + + select.select([self._library.get_file_descriptor(self._handle)], [], [], remaining) + + def _poll_event(self) -> Optional[bytes]: + """The next event libxcb has read, as its raw bytes, while one is waiting.""" + event = self._library.poll_for_event(self._handle) + if not event: + if self._library.connection_has_error(self._handle): + raise ConnectionError("The connection to the X display broke") + + return None + + raw = ctypes.string_at(event, ctypes.sizeof(GenericEvent)) + self._library.free(event) + return raw + + @staticmethod + def _selection_event(raw: bytes) -> Optional[SelectionEvent]: + match raw[0] & ~SENT_EVENT_BIT: + case EventCode.SELECTION_NOTIFY: + notify = SelectionNotifyEvent.from_buffer_copy(raw) + return SelectionNotify(requestor=notify.requestor, landing=notify.property) + case EventCode.PROPERTY_NOTIFY: + changed = PropertyNotifyEvent.from_buffer_copy(raw) + return PropertyNotify( + window=changed.window, + atom=changed.atom, + new_value=changed.state == PROPERTY_NEW_VALUE, + ) + + return None + + @staticmethod + def _require(reply: Any) -> None: + """Stops the transfer once the server's reply is missing, which a broken connection leaves.""" + if not reply: + raise ConnectionError("The X display answered no reply") diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py new file mode 100644 index 000000000..c48e6ad92 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py @@ -0,0 +1,206 @@ +import ctypes +from functools import cache +from typing import Any, Final, Optional + +XCB_LIBRARY: Final[str] = "libxcb.so.1" + + +class Cookie(ctypes.Structure): + _fields_ = [("sequence", ctypes.c_uint)] + + +class ScreenIterator(ctypes.Structure): + _fields_ = [ + ("data", ctypes.c_void_p), + ("rem", ctypes.c_int), + ("index", ctypes.c_int), + ] + + +class Screen(ctypes.Structure): + """A screen as libxcb lays it out, read as far as the root window it opens with.""" + + _fields_ = [("root", ctypes.c_uint32)] + + +class InternAtomReply(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("length", ctypes.c_uint32), + ("atom", ctypes.c_uint32), + ] + + +class SelectionOwnerReply(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("length", ctypes.c_uint32), + ("owner", ctypes.c_uint32), + ] + + +class PropertyReply(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("format", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("length", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("bytes_after", ctypes.c_uint32), + ("value_len", ctypes.c_uint32), + ("pad0", ctypes.c_uint8 * 12), + ] + + +class GenericEvent(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("body", ctypes.c_uint8 * 31), + ] + + +class SelectionNotifyEvent(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("time", ctypes.c_uint32), + ("requestor", ctypes.c_uint32), + ("selection", ctypes.c_uint32), + ("target", ctypes.c_uint32), + ("property", ctypes.c_uint32), + ] + + +class PropertyNotifyEvent(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("window", ctypes.c_uint32), + ("atom", ctypes.c_uint32), + ("time", ctypes.c_uint32), + ("state", ctypes.c_uint8), + ] + + +class XcbLibrary: + """libxcb as ctypes reaches it, each call declared with the C types it takes and returns. + + Replies and events arrive in memory libxcb allocated, and the C library's ``free`` releases + them, so it is declared here beside the calls that hand them out. + """ + + def __init__(self, library: ctypes.CDLL) -> None: + connection = ctypes.c_void_p + window = ctypes.c_uint32 + atom = ctypes.c_uint32 + self.free = self._declare(ctypes.CDLL(None).free, None, ctypes.c_void_p) + self.connect = self._declare(library.xcb_connect, connection, ctypes.c_char_p, ctypes.c_void_p) + self.connection_has_error = self._declare(library.xcb_connection_has_error, ctypes.c_int, connection) + self.disconnect = self._declare(library.xcb_disconnect, None, connection) + self.flush = self._declare(library.xcb_flush, ctypes.c_int, connection) + self.get_file_descriptor = self._declare(library.xcb_get_file_descriptor, ctypes.c_int, connection) + self.get_setup = self._declare(library.xcb_get_setup, ctypes.c_void_p, connection) + self.setup_roots_iterator = self._declare(library.xcb_setup_roots_iterator, ScreenIterator, ctypes.c_void_p) + self.generate_id = self._declare(library.xcb_generate_id, ctypes.c_uint32, connection) + self.create_window = self._declare( + library.xcb_create_window, + Cookie, + connection, + ctypes.c_uint8, + window, + window, + ctypes.c_int16, + ctypes.c_int16, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ) + self.intern_atom = self._declare( + library.xcb_intern_atom, + Cookie, + connection, + ctypes.c_uint8, + ctypes.c_uint16, + ctypes.c_char_p, + ) + self.intern_atom_reply = self._declare( + library.xcb_intern_atom_reply, + ctypes.POINTER(InternAtomReply), + connection, + Cookie, + ctypes.c_void_p, + ) + self.get_selection_owner = self._declare(library.xcb_get_selection_owner, Cookie, connection, atom) + self.get_selection_owner_reply = self._declare( + library.xcb_get_selection_owner_reply, + ctypes.POINTER(SelectionOwnerReply), + connection, + Cookie, + ctypes.c_void_p, + ) + self.convert_selection = self._declare( + library.xcb_convert_selection, + Cookie, + connection, + window, + atom, + atom, + atom, + ctypes.c_uint32, + ) + self.get_property = self._declare( + library.xcb_get_property, + Cookie, + connection, + ctypes.c_uint8, + window, + atom, + atom, + ctypes.c_uint32, + ctypes.c_uint32, + ) + self.get_property_reply = self._declare( + library.xcb_get_property_reply, + ctypes.POINTER(PropertyReply), + connection, + Cookie, + ctypes.c_void_p, + ) + self.get_property_value = self._declare( + library.xcb_get_property_value, + ctypes.c_void_p, + ctypes.POINTER(PropertyReply), + ) + self.get_property_value_length = self._declare( + library.xcb_get_property_value_length, + ctypes.c_int, + ctypes.POINTER(PropertyReply), + ) + self.poll_for_event = self._declare(library.xcb_poll_for_event, ctypes.POINTER(GenericEvent), connection) + + @staticmethod + def _declare(function: Any, returns: Any, *takes: Any) -> Any: + function.restype = returns + function.argtypes = list(takes) + return function + + +@cache +def load_xcb() -> Optional[XcbLibrary]: + """libxcb, loaded once, while the system provides it.""" + try: + library = ctypes.CDLL(XCB_LIBRARY) + except OSError: + return None + + return XcbLibrary(library) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py new file mode 100644 index 000000000..8874354c6 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py @@ -0,0 +1,34 @@ +import time +from typing import Optional + +from sampletones_application.utils.gui.clipboard.backends.x11.connection import XcbConnection +from sampletones_application.utils.gui.clipboard.backends.x11.library import XcbLibrary +from sampletones_application.utils.gui.clipboard.backends.x11.transfer import SelectionTransfer +from sampletones_shared.logger import logger + + +class XcbSelectionReader: + """Reads the clipboard's text over a connection of its own to ``display``, one per read. + + A fresh connection leaves nothing behind from a read before it, so an answer arriving after + its deadline lands on a connection already closed. + """ + + def __init__(self, library: XcbLibrary, display: str) -> None: + self._library = library + self._display = display + + def read_text(self, seconds: float) -> Optional[str]: + """The clipboard's text, or ``None`` where the read went unanswered. + + An owner staying silent past ``seconds`` and a display refusing the connection both leave + the clipboard's text unknown, which reads as no answer and is logged. A clipboard holding + nothing answers with an empty text. + """ + deadline = time.monotonic() + seconds + try: + with XcbConnection(self._library, self._display) as connection: + return SelectionTransfer(connection, deadline=deadline).text() + except (ConnectionError, TimeoutError) as error: + logger.warning(f"The clipboard answered nothing: {error}") + return None diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py new file mode 100644 index 000000000..f1bb68e79 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py @@ -0,0 +1,136 @@ +from typing import Final, List, Optional, Protocol, Tuple + +from sampletones_application.utils.gui.clipboard.backends.x11.connection import ( + PropertyNotify, + PropertyValue, + SelectionEvent, + SelectionNotify, +) + +CLIPBOARD: Final[str] = "CLIPBOARD" +INCREMENTAL: Final[str] = "INCR" +LANDING_PROPERTY: Final[str] = "SAMPLETONES_CLIPBOARD" +NO_WINDOW: Final[int] = 0 +NO_PROPERTY: Final[int] = 0 +TEXT_TARGETS: Final[Tuple[Tuple[str, str], ...]] = ( + ("UTF8_STRING", "utf-8"), + ("STRING", "latin-1"), +) +UNREADABLE_BYTES: Final[str] = "replace" + + +class SelectionConnection(Protocol): + """The calls a selection transfer makes on its connection to the X server.""" + + def atom(self, name: str) -> int: ... + + def selection_owner(self, selection: int) -> int: ... + + def create_requestor(self) -> int: ... + + def convert_selection( + self, + *, + requestor: int, + selection: int, + target: int, + landing: int, + ) -> None: ... + + def take_property(self, window: int, atom: int) -> PropertyValue: ... + + def next_event(self, deadline: float) -> Optional[SelectionEvent]: ... + + +class SelectionTransfer: + """One conversation with the application holding the clipboard, carrying its text across. + + The conversation is the one the ICCCM lays out: the requestor asks the owner to convert the + clipboard to a text type, the owner writes the text to a property of the requestor's window + and says so, and a long text comes in pieces, each written once the requestor has taken the + one before. UTF-8 is asked for first and Latin-1 after it, the order GLFW asks in. + + The whole conversation runs until ``deadline``, read against ``time.monotonic``, and an owner + still silent then ends it with a :class:`TimeoutError`. + """ + + def __init__(self, connection: SelectionConnection, *, deadline: float) -> None: + self._connection = connection + self._deadline = deadline + + def text(self) -> str: + """The clipboard's text, an empty one where it holds nothing a reader can take. + + Raises: + TimeoutError: If the application holding the clipboard answers nothing in time. + """ + clipboard = self._connection.atom(CLIPBOARD) + if self._connection.selection_owner(clipboard) == NO_WINDOW: + return "" + + return self._first_text(clipboard) + + def _first_text(self, clipboard: int) -> str: + """The clipboard's text in the first type the owner writes it as, or an empty text.""" + requestor = self._connection.create_requestor() + landing = self._connection.atom(LANDING_PROPERTY) + for target, encoding in TEXT_TARGETS: + data = self._convert(clipboard, self._connection.atom(target), requestor, landing) + if data is not None: + return data.decode(encoding, errors=UNREADABLE_BYTES) + + return "" + + def _convert( + self, + clipboard: int, + target: int, + requestor: int, + landing: int, + ) -> Optional[bytes]: + """The clipboard's bytes as ``target``, or ``None`` once the owner declines that type.""" + self._connection.convert_selection( + requestor=requestor, + selection=clipboard, + target=target, + landing=landing, + ) + if self._await_notify(requestor) == NO_PROPERTY: + return None + + value = self._connection.take_property(requestor, landing) + if value.value_type != self._connection.atom(INCREMENTAL): + return value.data + + return self._receive_pieces(requestor, landing) + + def _await_notify(self, requestor: int) -> int: + """The property the owner names in its answer, the changes announced ahead of it passed over.""" + while True: + match self._next_event(): + case SelectionNotify(requestor=answered, landing=landing) if answered == requestor: + return landing + + def _receive_pieces(self, requestor: int, landing: int) -> bytes: + """Takes each piece as the owner writes it, until the empty piece that closes the text.""" + pieces: List[bytes] = [] + while True: + match self._next_event(): + case PropertyNotify(window=window, atom=atom, new_value=True) if (window, atom) == (requestor, landing): + piece = self._connection.take_property(requestor, landing).data + if not piece: + return b"".join(pieces) + + pieces.append(piece) + + def _next_event(self) -> SelectionEvent: + """The next event the owner's side of the conversation brings. + + Raises: + TimeoutError: If the deadline passes before the next event. + """ + event = self._connection.next_event(self._deadline) + if event is None: + raise TimeoutError("The application holding the clipboard answered nothing in time") + + return event diff --git a/src/sampletones_application/utils/gui/clipboard/copy_button.py b/src/sampletones_application/utils/gui/clipboard/copy_button.py new file mode 100644 index 000000000..dce14a78f --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/copy_button.py @@ -0,0 +1,27 @@ +from functools import partial +from typing import Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.delay import call_after +from sampletones_application.utils.gui.dpg import dpg_configure_item + +COPIED_LABEL_SECONDS: Final[float] = 1.0 + + +def copy_to_clipboard( + text: str, + label: str, + button_tag: str, + *, + copied_label: str, +) -> None: + """Puts ``text`` on the clipboard, with the button reading ``copied_label`` for a moment. + + The button's own label comes back on the render thread once the moment has passed, the thread + the button lives on. + """ + dpg.set_clipboard_text(text) + + dpg_configure_item(button_tag, label=copied_label) + call_after(COPIED_LABEL_SECONDS, partial(dpg_configure_item, button_tag, label=label)) diff --git a/src/sampletones_application/utils/gui/clipboard/protocol.py b/src/sampletones_application/utils/gui/clipboard/protocol.py new file mode 100644 index 000000000..d62c5b018 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/protocol.py @@ -0,0 +1,22 @@ +from typing import Callable, Optional, Protocol + +ClipboardTextCallback = Callable[[Optional[str]], None] + + +class TextClipboard(Protocol): + """The clipboard the desktop shares between applications, as text going out and coming back. + + The application holding the clipboard hands its text over in its own time, so a read names what + receives the text once it has arrived. The selector in ``selection`` picks the implementation + that fits the running environment. + """ + + def read(self, on_text: ClipboardTextCallback) -> None: + """Hands the text standing on the clipboard to ``on_text``, on the render thread. + + A read is asked for on the render thread, where every gesture reaching the clipboard runs. + An application that gives no answer leaves ``on_text`` with ``None``, which stands apart + from the empty text a clipboard holding nothing reads as. + """ + + def write(self, text: str) -> None: ... diff --git a/src/sampletones_application/utils/gui/clipboard/selection.py b/src/sampletones_application/utils/gui/clipboard/selection.py new file mode 100644 index 000000000..a0cbc556c --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/selection.py @@ -0,0 +1,34 @@ +import os +from typing import Final, Optional + +from sampletones_application.utils.gui.clipboard.backends.dearpygui import DearPyGuiTextClipboard +from sampletones_application.utils.gui.clipboard.backends.x11.clipboard import X11TextClipboard +from sampletones_application.utils.gui.clipboard.backends.x11.library import load_xcb +from sampletones_application.utils.gui.clipboard.backends.x11.reader import XcbSelectionReader +from sampletones_application.utils.gui.clipboard.protocol import TextClipboard +from sampletones_shared.utils.system.system import System + +DISPLAY_VARIABLE: Final[str] = "DISPLAY" + + +def select_text_clipboard() -> TextClipboard: + """Returns the clipboard implementation that fits the running environment. + + On Linux, DearPyGui draws on an X display, and the X11 clipboard reads over a connection of its + own to that same display while libxcb, which the X client library itself stands on, loads. On + other platforms DearPyGui's own clipboard serves reads and writes alike. + """ + match System.current(): + case System.LINUX: + return _x11_clipboard() or DearPyGuiTextClipboard() + case _: + return DearPyGuiTextClipboard() + + +def _x11_clipboard() -> Optional[TextClipboard]: + display = os.environ.get(DISPLAY_VARIABLE) + library = load_xcb() + if not display or library is None: + return None + + return X11TextClipboard(XcbSelectionReader(library, display)) diff --git a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py index ba4bb99a2..2e172807c 100644 --- a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py +++ b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py @@ -52,7 +52,7 @@ def dispose(self) -> None: return self._disposed = True - self._router.pop_modal() + self._router.pop_modal(self) def _focus_initial(self) -> None: if dpg.does_item_exist(self._window_tag): diff --git a/src/sampletones_application/utils/gui/dialog_navigation/ring.py b/src/sampletones_application/utils/gui/dialog_navigation/ring.py index afa3861be..c466d41b5 100644 --- a/src/sampletones_application/utils/gui/dialog_navigation/ring.py +++ b/src/sampletones_application/utils/gui/dialog_navigation/ring.py @@ -2,7 +2,6 @@ import dearpygui.dearpygui as dpg -from sampletones_application.tags.general import TAG_GLOBAL_THEME_FOCUSED_BUTTON from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dialog_navigation.stop import FocusStop @@ -14,8 +13,10 @@ class FocusRing: user is actively editing, which keeps the cycle correct whether or not a focused button reports its focus, and lets the field being typed into keep Enter for itself. - As focus moves onto a button the ring paints it with the focus outline theme and rebinds the - button's own theme once focus leaves, so the button the user is on carries a visible accent + As focus moves onto a button the ring paints it with the stop's own focused theme and rebinds + it to the stop's base theme once focus leaves, so the button the user is on carries a visible + accent border. A bound theme replaces a button's colors outright, so a button styled beyond + the default names both themes, and the focused one carries its own colors forward under the border. Fields show their own edit caret, so the ring leaves their appearance to DearPyGui. """ @@ -58,10 +59,10 @@ def _focus(self, index: int) -> None: def _apply_focus_outline(self, index: int) -> None: stop = self._stops[index] - if stop.base_theme_tag is None: + if stop.base_theme_tag is None or stop.focused_theme_tag is None: return - ThemeRegistry.get(TAG_GLOBAL_THEME_FOCUSED_BUTTON).bind_to_item(stop.focus_tag) + ThemeRegistry.get(stop.focused_theme_tag).bind_to_item(stop.focus_tag) def _restore_focus_outline(self, index: int) -> None: stop = self._stops[index] diff --git a/src/sampletones_application/utils/gui/dialog_navigation/stop.py b/src/sampletones_application/utils/gui/dialog_navigation/stop.py index 53eb51ec5..011f8a073 100644 --- a/src/sampletones_application/utils/gui/dialog_navigation/stop.py +++ b/src/sampletones_application/utils/gui/dialog_navigation/stop.py @@ -4,7 +4,11 @@ from typing import Optional from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_BUTTON, TAG_GLOBAL_THEME_DEFAULT +from sampletones_application.tags.general import ( + SUF_BUTTON, + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_FOCUSED_BUTTON, +) from sampletones_shared.types.callback import VoidCallback @@ -17,14 +21,17 @@ class FocusStop: combos leave ``activate`` empty so Enter stays with the control (Enter in the multiline comment inserts a line break, and a text-input dialog keeps its own submit-on-Enter). ``base_theme_tag`` names the theme a button wears while unfocused; the ring rebinds it once - the focus outline moves on. Fields leave it empty, marking them as stops the ring styles by - their own edit caret. + the focus outline moves on. ``focused_theme_tag`` names the theme it wears while focused, + which carries the base theme's own colors forward under the accent border, since a bound + theme replaces a button's colors outright rather than layering over another bind. Fields + leave both empty, marking them as stops the ring styles by their own edit caret. """ focus_tag: str enabled_tag: str activate: Optional[VoidCallback] base_theme_tag: Optional[str] = None + focused_theme_tag: Optional[str] = None @classmethod def button( @@ -33,17 +40,22 @@ def button( activate: VoidCallback, *, base_theme_tag: str = TAG_GLOBAL_THEME_DEFAULT, + focused_theme_tag: str = TAG_GLOBAL_THEME_FOCUSED_BUTTON, ) -> FocusStop: """Builds a stop for a :class:`GUIButton`, focusing its inner button item. ``base_theme_tag`` is the button's own theme, which the ring rebinds when the focus - outline leaves the button. + outline leaves the button. A button carrying a theme of its own beyond the default, + such as a danger button, passes the matching ``focused_theme_tag`` too, which is that + same theme with the accent border added, so the button keeps its own color while it + holds focus rather than reading as an unstyled default button. """ return cls( focus_tag=compose_tag(tag, SUF_BUTTON), enabled_tag=tag, activate=activate, base_theme_tag=base_theme_tag, + focused_theme_tag=focused_theme_tag, ) @classmethod diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py deleted file mode 100644 index 9cc89a16f..000000000 --- a/src/sampletones_application/utils/gui/dialogs.py +++ /dev/null @@ -1,717 +0,0 @@ -import re -import uuid -from pathlib import Path -from typing import Callable, Dict, List, Optional, Pattern, Tuple - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.manager import LanguageManager -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, - TAG_GLOBAL_DIALOG_ERROR, - TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, - TAG_GLOBAL_DIALOG_PATH_MESSAGE, - TAG_GLOBAL_THEME_DIALOG_WINDOW, -) -from sampletones_application.tags.reconstructions import ( - TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_NOT_LOADED, -) -from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.fonts.font import Font -from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.path import 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.dialog_navigation import ( - DialogKeyboardNavigator, - FocusStop, -) -from sampletones_application.utils.gui.dpg import ( - dpg_configure_item, - 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 -from sampletones_shared.types.callback import Callback, StringCallback, VoidCallback - -_TEMPLATE_PLACEHOLDER: Pattern[str] = re.compile(r"\{(\w+)\}") - - -def get_dialog_tag(base_tag: str) -> str: - dialog_hash = uuid.uuid4().hex - return compose_tag(base_tag, dialog_hash) - - -def _bind_dialog_theme(tag: str) -> None: - ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG_WINDOW).bind_to_item(tag) - - -def _install_navigation( - *, - window_tag: str, - stops: List[FocusStop], - on_escape: VoidCallback, - key_router: KeyRouter, - shortcut_source: ShortcutSource, - initial_index: int = 0, -) -> DialogKeyboardNavigator: - """Builds and installs the keyboard navigator that claims the keyboard for ``window_tag``.""" - navigator = DialogKeyboardNavigator( - window_tag=window_tag, - stops=stops, - on_escape=on_escape, - key_router=key_router, - shortcut_source=shortcut_source, - initial_index=initial_index, - ) - navigator.install() - return navigator - - -def _show_modal_dialog( - tag: str, - title: str, - content: StringCallback, - *, - ok_label: str, - width: int, - height: int, - key_router: KeyRouter, - shortcut_source: ShortcutSource, - modal: bool = True, -) -> None: - 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=title, - tag=tag, - modal=modal, - width=width, - min_size=(width, height), - no_resize=True, - autosize=True, - on_close=close, - ): - _bind_dialog_theme(tag) - content(tag) - dpg.add_separator() - GUIButton( - tag=ok_button_tag, - label=ok_label, - callback=close, - width=-1, - ) - - center_when_settled(tag) - - if modal: - navigator = _install_navigation( - window_tag=tag, - stops=[FocusStop.button(ok_button_tag, close)], - on_escape=close, - key_router=key_router, - shortcut_source=shortcut_source, - ) - - -class DialogsRenderer: - def __init__( - self, - *, - layout: GeneralLayout, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - key_router: KeyRouter, - shortcut_source: ShortcutSource, - ) -> None: - self._language_manager = language_manager - self._status_bar = status_bar - self._router = key_router - self._shortcuts = shortcut_source - self._default_width = layout.dialogs.default.width - self._default_height = layout.dialogs.default.height - self._error_width = layout.dialogs.error.width - self._error_height = layout.dialogs.error.height - self._confirmation_height = layout.dialogs.confirmation.height - self._default_wrap = layout.dialogs.default.width - 10 - self._error_wrap = layout.dialogs.error.width - 10 - self._col_text_error = layout.colors.text.error - self._col_text_highlight = layout.colors.text.highlight - self._col_path = layout.colors.paths.default - self._col_path_hover = layout.colors.paths.hover - self._recovery_width = layout.dialogs.recovery.width - self._recovery_height = layout.dialogs.recovery.height - self._recovery_wrap = layout.dialogs.recovery.width - 10 - - 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"] - - def show_modal( - self, - tag: str, - title: str, - content: StringCallback, - *, - width: Optional[int] = None, - height: Optional[int] = None, - modal: bool = True, - ) -> None: - _show_modal_dialog( - tag, - title, - content, - ok_label=self._lbl_ok, - key_router=self._router, - shortcut_source=self._shortcuts, - width=width if width is not None else self._default_width, - height=height if height is not None else self._default_height, - modal=modal, - ) - - def show_info( - self, - tag: str, - message: str, - title: str, - *, - modal: bool = False, - ) -> None: - def content(parent: str) -> None: - dpg.add_text(message, parent=parent, wrap=self._error_wrap) - - info_tag = compose_tag(tag, SUF_DIALOG_INFO) - dpg_delete_item(info_tag) - _show_modal_dialog( - tag=info_tag, - title=title, - content=content, - ok_label=self._lbl_ok, - key_router=self._router, - shortcut_source=self._shortcuts, - width=self._default_width, - height=self._default_height, - modal=modal, - ) - - def show_config_recovery( - self, - *, - tag: str, - source_version: Optional[str], - target_version: str, - properties: Tuple[str, ...], - config_path: Path, - ) -> None: - """ - 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 - opens the configuration file's directory so the user can edit it directly. - """ - source = ( - source_version - if source_version is not None - else self._language_manager["global.dialog.message.configuration_recovery_earlier_version"] - ) - - def content(parent: str) -> None: - self._render_template_bold( - parent, - self._language_manager["global.dialog.template.configuration_recovery_intro"], - {"source": source, "target": target_version}, - ) - - dpg.add_text( - self._language_manager["global.dialog.message.configuration_recovery_list_header"], - parent=parent, - wrap=self._recovery_wrap, - ) - for property_name in properties: - property_text = dpg.add_text( - f"- {property_name}", - parent=parent, - wrap=self._recovery_wrap, - ) - dpg_set_palette_color(property_text, self._col_text_highlight) - - dpg.add_text( - self._language_manager["global.dialog.message.configuration_recovery_path_prefix"], - parent=parent, - wrap=self._recovery_wrap, - ) - GUIPathText( - tag=compose_tag(parent, SUF_PATH), - path=config_path, - parent=parent, - color=self._col_path, - hover_color=self._col_path_hover, - status_message=self._msg_path, - status_bar=self._status_bar, - ) - - dpg_delete_item(tag) - _show_modal_dialog( - tag=tag, - title=self._language_manager["global.dialog.title.configuration_recovery"], - content=content, - ok_label=self._lbl_ok, - key_router=self._router, - shortcut_source=self._shortcuts, - width=self._recovery_width, - height=self._recovery_height, - modal=False, - ) - - def _render_template_bold( - self, - parent: str, - template: str, - substitutions: Dict[str, str], - ) -> None: - """ - Renders a placeholder template on one line with the substituted values in bold. - - The literal spans of ``template`` are drawn as regular text and each ``{name}`` - placeholder is replaced by ``substitutions[name]`` in bold, so a sentence keeps its - natural word order in the language file while its dynamic values stand out. The - literal spans carry their own spacing, so the runs sit flush at exactly that spacing. - """ - group_tag = compose_tag(parent, SUF_GROUP) - with dpg.group( - horizontal=True, - horizontal_spacing=0, - tag=group_tag, - parent=parent, - ): - position = 0 - for match in _TEMPLATE_PLACEHOLDER.finditer(template): - literal = template[position : match.start()] - if literal: - dpg.add_text(literal, parent=group_tag) - - value_item = dpg.add_text( - substitutions[match.group(1)], - parent=group_tag, - ) - FontRegistry.bind_to_item(value_item, Font.BOLD) - position = match.end() - - trailing = template[position:] - 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, - key_router=self._router, - shortcut_source=self._shortcuts, - initial_index=1, - ) - center_when_settled(tag) - - def show_file_not_found(self, filepath: Path, message: str) -> None: - tag = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) - - def content(parent: str) -> None: - dpg.add_text(message, parent=parent, wrap=self._error_wrap) - path_text = dpg.add_text( - str(filepath), - parent=parent, - wrap=self._error_wrap, - ) - dpg_set_palette_color(path_text, self._col_path) - - _show_modal_dialog( - tag=tag, - title=self._language_manager["global.dialog.title.file_not_found"], - content=content, - ok_label=self._lbl_ok, - key_router=self._router, - shortcut_source=self._shortcuts, - width=self._error_width, - height=self._default_height, - ) - - # TODO: refactor - def show_confirmation( - self, - tag: str, - message: str, - title: str, - on_confirm: Callback, - *, - ok_label: str, - cancel_label: Optional[str] = None, - path: Optional[Path] = None, - opt_out_label: Optional[str] = None, - on_opt_out: Optional[Callback] = None, - on_cancel: Optional[Callback] = None, - ) -> None: - """Modal confirmation. ``on_confirm``/``on_cancel`` run on the respective choice. - - The title bar's close button reads as the negative choice, so every way out of the - prompt reaches the caller and a dialog waiting behind it hears the answer. - - ``cancel_label`` names the negative button; it falls back to the shared Cancel label. - When ``opt_out_label`` is given, a checkbox is shown; if it is ticked when the user - 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, - key_router=self._router, - shortcut_source=self._shortcuts, - initial_index=1, - ) - center_when_settled(tag) - - # TODO: refactor - def show_save_confirmation( - self, - tag: str, - message: str, - title: str, - on_save: Callable[[], bool], - on_confirm: Callback, - *, - ok_label: str, - ) -> None: - """Modal save-or-proceed prompt for an unsaved document. - - ``on_save`` writes the document and reports whether it completed; the prompt runs - ``on_confirm`` and closes once the save reports success, so a cancelled 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, - key_router=self._router, - shortcut_source=self._shortcuts, - initial_index=2, - ) - center_when_settled(tag) - - def show_reconstruction_not_loaded(self) -> None: - tag = get_dialog_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_NOT_LOADED) - - def content(parent: str) -> None: - dpg.add_text( - self._language_manager["global.dialog.message.reconstruction_no_data"], - parent=parent, - wrap=self._error_wrap, - ) - - _show_modal_dialog( - tag=tag, - title=self._language_manager["reconstructions.instruments.title.not_loaded_dialog"], - content=content, - ok_label=self._lbl_ok, - key_router=self._router, - shortcut_source=self._shortcuts, - width=self._error_width, - height=self._default_height, - modal=False, - ) - - def show_message_with_path( - self, - title: str, - message: str, - path: Path, - ) -> None: - tag = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) - - def content(parent: str) -> None: - group_tag = compose_tag(parent, SUF_GROUP) - with dpg.group(parent=parent): - dpg.add_text(message, parent=group_tag, wrap=self._error_wrap) - GUIPathText( - tag=compose_tag(group_tag, SUF_PATH), - path=path, - parent=group_tag, - color=self._col_path, - hover_color=self._col_path_hover, - status_message=self._msg_path, - status_bar=self._status_bar, - ) - - _show_modal_dialog( - tag=tag, - title=title, - content=content, - ok_label=self._lbl_ok, - key_router=self._router, - shortcut_source=self._shortcuts, - width=self._error_width, - height=self._default_height, - modal=False, - ) 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/outcome.py b/src/sampletones_application/utils/gui/dialogs/outcome.py new file mode 100644 index 000000000..f7fda8492 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/outcome.py @@ -0,0 +1,14 @@ +from enum import Enum, auto + + +class SaveOutcome(Enum): + """What a save the reader asked for came to, which says where a save prompt goes next. + + A save prompt runs its save once it has left the screen, so the outcome decides what follows: + a document on disk goes on to what the prompt was guarding, a save the reader called off asks + the question again, and a failed save leaves its error on screen by itself. + """ + + WRITTEN = auto() + CALLED_OFF = auto() + FAILED = auto() diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py new file mode 100644 index 000000000..cb7997513 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -0,0 +1,419 @@ +import re +import uuid +from pathlib import Path +from typing import Callable, Dict, Final, Optional, Pattern, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general import GeneralLayout +from sampletones_application.layout.primitives import DialogGeometry +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_DIALOG_INFO, + SUF_GROUP, + SUF_PATH, + SUF_REPORT, + TAG_GLOBAL_DIALOG_ERROR, + TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, + TAG_GLOBAL_DIALOG_PATH_MESSAGE, +) +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_NOT_LOADED, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.utils.gui.dialogs.windows.confirmation import ( + GUIConfirmationWindow, +) +from sampletones_application.utils.gui.dialogs.windows.error import ( + GUIErrorDialogWindow, +) +from sampletones_application.utils.gui.dialogs.windows.notice import show_notice +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.modal_queue import ModalQueue +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_shared.types.callback import Callback, StringCallback, VoidCallback + +_TEMPLATE_PLACEHOLDER: Pattern[str] = re.compile(r"\{(\w+)\}") +DIALOG_TEXT_MARGIN: Final[int] = 10 +NOTICE_CLAIMS_THE_SCREEN: Final[bool] = True +FAILURE_REPORT_TAG: Final[str] = compose_tag(TAG_GLOBAL_DIALOG_ERROR, SUF_REPORT) + + +def get_dialog_tag(base_tag: str) -> str: + dialog_hash = uuid.uuid4().hex + return compose_tag(base_tag, dialog_hash) + + +class DialogsRenderer: + def __init__( + self, + *, + layout: GeneralLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._language_manager = language_manager + self._status_bar = status_bar + self._router = key_router + self._shortcuts = shortcut_source + self._default = layout.dialogs.default + self._error = layout.dialogs.error + self._confirmation = layout.dialogs.confirmation + self._traceback_height = layout.dialogs.traceback_height + self._default_wrap = layout.dialogs.default.width - DIALOG_TEXT_MARGIN + self._error_wrap = layout.dialogs.error.width - DIALOG_TEXT_MARGIN + self._col_text_error = layout.colors.text.error + self._col_text_highlight = layout.colors.text.highlight + self._col_path = layout.colors.paths.default + self._col_path_hover = layout.colors.paths.hover + self._recovery = layout.dialogs.recovery + self._recovery_wrap = layout.dialogs.recovery.width - DIALOG_TEXT_MARGIN + + 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_save = language_manager["global.dialog.label.save"] + + @staticmethod + def when_free(continuation: VoidCallback) -> None: + """Runs ``continuation`` once the screen is free for it, as a turn in the modal line. + + The signature is a :data:`Wait`, so a guard reads what it asks about once its question may + reach the screen. + """ + ModalQueue.when_free(continuation) + + def show_modal( + self, + tag: str, + title: str, + content: StringCallback, + *, + geometry: Optional[DialogGeometry] = None, + ) -> None: + """Raises a notice at ``geometry``, or at the size every dialog opens at by default.""" + self._notice(tag, title, content, geometry if geometry is not None else self._default) + + def _notice( + self, + tag: str, + title: str, + content: StringCallback, + geometry: DialogGeometry, + *, + claims_the_screen: bool = NOTICE_CLAIMS_THE_SCREEN, + ) -> None: + """Raises one notice the reader acknowledges, drawn at the size ``geometry`` states.""" + show_notice( + tag, + title, + content, + geometry=geometry, + ok_label=self._lbl_ok, + key_router=self._router, + shortcut_source=self._shortcuts, + claims_the_screen=claims_the_screen, + ) + + def show_info( + self, + tag: str, + message: str, + title: str, + *, + modal: bool = False, + ) -> None: + def content(parent: str) -> None: + dpg.add_text(message, parent=parent, wrap=self._error_wrap) + + info_tag = compose_tag(tag, SUF_DIALOG_INFO) + dpg_delete_item(info_tag) + self._notice(info_tag, title, content, self._default, claims_the_screen=modal) + + def show_config_recovery( + self, + *, + tag: str, + source_version: Optional[str], + target_version: str, + properties: Tuple[str, ...], + config_path: Path, + ) -> None: + """ + Reports that a stored configuration was migrated, listing the discarded settings. + + 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 = ( + source_version + if source_version is not None + else self._language_manager["global.dialog.message.configuration_recovery_earlier_version"] + ) + + def content(parent: str) -> None: + self._render_template_bold( + parent, + self._language_manager["global.dialog.template.configuration_recovery_intro"], + {"source": source, "target": target_version}, + ) + + dpg.add_text( + self._language_manager["global.dialog.message.configuration_recovery_list_header"], + parent=parent, + wrap=self._recovery_wrap, + ) + for property_name in properties: + property_text = dpg.add_text( + f"- {property_name}", + parent=parent, + wrap=self._recovery_wrap, + ) + dpg_set_palette_color(property_text, self._col_text_highlight) + + dpg.add_text( + self._language_manager["global.dialog.message.configuration_recovery_path_prefix"], + parent=parent, + wrap=self._recovery_wrap, + ) + GUIPathText( + tag=compose_tag(parent, SUF_PATH), + path=config_path, + parent=parent, + color=self._col_path, + hover_color=self._col_path_hover, + status_message=self._msg_path, + status_bar=self._status_bar, + ) + + dpg_delete_item(tag) + self._notice( + tag, + self._language_manager["global.dialog.title.configuration_recovery"], + content, + self._recovery, + claims_the_screen=False, + ) + + def _render_template_bold( + self, + parent: str, + template: str, + substitutions: Dict[str, str], + ) -> None: + """ + Renders a placeholder template on one line with the substituted values in bold. + + The literal spans of ``template`` are drawn as regular text and each ``{name}`` + placeholder is replaced by ``substitutions[name]`` in bold, so a sentence keeps its + natural word order in the language file while its dynamic values stand out. The + literal spans carry their own spacing, so the runs sit flush at exactly that spacing. + """ + group_tag = compose_tag(parent, SUF_GROUP) + with dpg.group( + horizontal=True, + horizontal_spacing=0, + tag=group_tag, + parent=parent, + ): + position = 0 + for match in _TEMPLATE_PLACEHOLDER.finditer(template): + literal = template[position : match.start()] + if literal: + dpg.add_text(literal, parent=group_tag) + + value_item = dpg.add_text( + substitutions[match.group(1)], + parent=group_tag, + ) + FontRegistry.bind_to_item(value_item, Font.BOLD) + position = match.end() + + trailing = template[position:] + if trailing: + dpg.add_text(trailing, parent=group_tag) + + def show_error( + self, + exception: Exception, + message: Optional[str] = None, + ) -> None: + self._error_window(get_dialog_tag(TAG_GLOBAL_DIALOG_ERROR)).show(exception, message) + + def show_failure_report(self, exception: Exception, message: str) -> None: + """Reports a failure nothing recovered from, unless a report already stands or waits. + + A report stays until the reader dismisses it, and the failures meanwhile are in the log. A + failure repeating every frame is therefore read once, and each new attempt after the + dismissal is reported again. + """ + if ModalQueue.snapshot().holds(FAILURE_REPORT_TAG): + return + + self._error_window(FAILURE_REPORT_TAG).show(exception, message) + + def _error_window(self, tag: str) -> GUIErrorDialogWindow: + """The error dialog under ``tag``, drawn at the error geometry.""" + return GUIErrorDialogWindow( + tag=tag, + geometry=self._error, + wrap=self._error_wrap, + traceback_height=self._traceback_height, + language_manager=self._language_manager, + error_color=self._col_text_error, + key_router=self._router, + shortcut_source=self._shortcuts, + ) + + def show_file_not_found(self, filepath: Path, message: str) -> None: + tag = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) + + def content(parent: str) -> None: + dpg.add_text(message, parent=parent, wrap=self._error_wrap) + path_text = dpg.add_text( + str(filepath), + parent=parent, + wrap=self._error_wrap, + ) + dpg_set_palette_color(path_text, self._col_path) + + self._notice(tag, self._language_manager["global.dialog.title.file_not_found"], content, self._error) + + def show_confirmation( + self, + tag: str, + message: str, + title: str, + on_confirm: Callback, + *, + ok_label: str, + cancel_label: Optional[str] = None, + path: Optional[Path] = None, + opt_out_label: Optional[str] = None, + on_opt_out: Optional[Callback] = None, + on_cancel: Optional[Callback] = None, + ) -> None: + """Modal confirmation. ``on_confirm``/``on_cancel`` run on the respective choice. + + The title bar's close button and Escape read as the negative choice, so every way out of + the prompt reaches the caller and a dialog waiting behind it hears the answer. The answer + runs a frame after the prompt has left the screen, so a prompt it raises opens alone. + + ``cancel_label`` names the negative button; it falls back to the shared Cancel label. + When ``opt_out_label`` is given, a checkbox is shown; if it is ticked when the user + confirms, ``on_opt_out`` runs as well — letting the caller suppress future prompts. + """ + GUIConfirmationWindow( + tag=get_dialog_tag(tag), + geometry=self._confirmation, + 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, + ).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, + ) + + def show_save_confirmation( + self, + tag: str, + message: str, + title: str, + on_save: Callable[[], SaveOutcome], + on_confirm: Callback, + on_cancel: Callback, + *, + ok_label: str, + ) -> None: + """Modal save-or-proceed prompt for an unsaved document. + + Every answer runs once the prompt has left the screen, so whatever it opens stands alone. + ``on_save`` writes the document and reports a :class:`SaveOutcome`: a written document + runs ``on_confirm``, a save the reader called off brings the prompt back with the same + question, and a failed save leaves the error it showed alone on screen and runs + ``on_cancel``. The middle button discards the pending changes and runs ``on_confirm`` to + proceed. Cancel, the initially focused button, runs ``on_cancel``, and so do Escape and the + title bar's close button. + """ + GUISaveConfirmationWindow( + tag=get_dialog_tag(tag), + geometry=self._confirmation, + wrap=self._default_wrap, + save_label=self._lbl_save, + cancel_label=self._lbl_cancel, + key_router=self._router, + shortcut_source=self._shortcuts, + ).show( + message, + title, + on_save, + on_confirm, + on_cancel, + ok_label=ok_label, + ) + + def show_reconstruction_not_loaded(self) -> None: + tag = get_dialog_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_DIALOG_NOT_LOADED) + + def content(parent: str) -> None: + dpg.add_text( + self._language_manager["global.dialog.message.reconstruction_no_data"], + parent=parent, + wrap=self._error_wrap, + ) + + self._notice( + tag, + self._language_manager["reconstructions.instruments.title.not_loaded_dialog"], + content, + self._error, + claims_the_screen=False, + ) + + def show_message_with_path( + self, + title: str, + message: str, + path: Path, + ) -> None: + tag = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) + + def content(parent: str) -> None: + group_tag = compose_tag(parent, SUF_GROUP) + with dpg.group(parent=parent): + dpg.add_text(message, parent=group_tag, wrap=self._error_wrap) + GUIPathText( + tag=compose_tag(group_tag, SUF_PATH), + path=path, + parent=group_tag, + color=self._col_path, + hover_color=self._col_path_hover, + status_message=self._msg_path, + status_bar=self._status_bar, + ) + + self._notice(tag, title, content, self._error, claims_the_screen=False) 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..d030ba901 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/confirmation.py @@ -0,0 +1,180 @@ +from pathlib import Path +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.primitives import DialogGeometry +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.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, VoidCallback + +CANCEL_FOCUS_STOP: Final[int] = 1 + + +class GUIConfirmationWindow(GUIDialogWindow): + """A modal asking one question, answered through OK, Cancel, Escape, or the title-bar close. + + ``on_confirm``/``on_cancel`` run on the respective choice, and the title bar and Escape + read as the negative one, so every way out of the prompt reaches the caller. Each answer + runs a frame after the prompt has left the screen, so a question the answer asks next + opens alone. A checked opt-out checkbox adds ``on_opt_out`` to a confirmation, letting + the caller suppress future prompts; the box is read as the prompt leaves. Cancel — the + initially focused button — keeps the prompt answerable by keyboard alone. + """ + + def __init__( + self, + tag: str, + *, + geometry: DialogGeometry, + 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, + geometry, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare( # pylint: disable=arguments-differ + 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 _on_confirm() -> None: + self._leave_then(self._confirmation(opted_out=self._is_opted_out(opt_out_tag))) + + def _on_cancel() -> None: + if self._on_cancel is None: + self.hide() + return + + self._leave_then(self._on_cancel) + + 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, + ) + + def _is_opted_out(self, opt_out_tag: str) -> bool: + """Whether the reader ticked the opt-out box, read while the prompt still stands.""" + return self._opt_out_label is not None and dpg.does_item_exist(opt_out_tag) and bool(dpg.get_value(opt_out_tag)) + + def _confirmation(self, *, opted_out: bool) -> VoidCallback: + """The confirmation this appearance answers with, the opt-out riding it when it was ticked.""" + on_confirm = self._on_confirm + on_opt_out = self._on_opt_out if opted_out else None + + def confirm() -> None: + if on_opt_out is not None: + on_opt_out() + + on_confirm() + + return confirm diff --git a/src/sampletones_application/utils/gui/dialogs/windows/error.py b/src/sampletones_application/utils/gui/dialogs/windows/error.py new file mode 100644 index 000000000..60731a1c6 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/error.py @@ -0,0 +1,141 @@ +from typing import Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.primitives import DialogGeometry +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 center_when_settled, 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. + + Unfolding the traceback is the one gesture that changes a dialog's size while it stands, so + it centers the window again on the height it reaches: a report grown from its own height to + the traceback's would otherwise carry its buttons below the screen. + """ + + def __init__( + self, + tag: str, + *, + geometry: DialogGeometry, + wrap: int, + traceback_height: int, + language_manager: LanguageManager, + error_color: BaseColor, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + ) -> None: + self._language_manager = language_manager + self._wrap = wrap + self._traceback_height = traceback_height + self._error_color = error_color + self._exception: Exception + self._message: Optional[str] + + super().__init__( + tag, + geometry, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare(self, exception: Exception, message: Optional[str]) -> None: # pylint: disable=arguments-differ + """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 self.dialog_window( + label=self._language_manager["global.dialog.title.error"], + 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, + height=self._traceback_height, + ) + + 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"] + ), + ) + center_when_settled(self.tag) + + 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/notice.py b/src/sampletones_application/utils/gui/dialogs/windows/notice.py new file mode 100644 index 000000000..e80131ea5 --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/notice.py @@ -0,0 +1,94 @@ +from typing import Any + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.primitives import DialogGeometry +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON_OK +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +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 +from sampletones_shared.types.callback import StringCallback + + +class GUINoticeWindow(GUIDialogWindow): + """A dialog stating something the reader acknowledges, with one button to dismiss it. + + What the notice says is drawn by the caller into the window's own tag, so a message, a + path or a list of settings all reach the reader through one dialog. A notice claiming + the screen answers to Tab, Enter and Escape; one reporting something already done stands + beside an interface that stays live, where the keys keep reaching what the reader was + working in and the button is clicked. + """ + + def __init__( + self, + tag: str, + *, + geometry: DialogGeometry, + title: str, + content: StringCallback, + ok_label: str, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + claims_the_screen: bool, + ) -> None: + self._title = title + self._content = content + self._ok_label = ok_label + self._claims_the_screen = claims_the_screen + + super().__init__( + tag, + geometry, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """What the notice says was named when it was built.""" + + def create_window(self) -> None: + ok_button_tag = compose_tag(self.tag, SUF_BUTTON_OK) + with self.dialog_window(label=self._title, on_close=self.hide): + self._content(self.tag) + dpg.add_separator(parent=self.tag) + GUIButton( + tag=ok_button_tag, + label=self._ok_label, + callback=self.hide, + parent=self.tag, + width=-1, + ) + + if self._claims_the_screen: + self._install_navigation( + [FocusStop.button(ok_button_tag, self.hide)], + on_escape=self.hide, + ) + + +def show_notice( + tag: str, + title: str, + content: StringCallback, + *, + geometry: DialogGeometry, + ok_label: str, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + claims_the_screen: bool = True, +) -> None: + """Raises one notice the reader acknowledges, drawn at the size ``geometry`` states.""" + GUINoticeWindow( + tag=tag, + geometry=geometry, + title=title, + content=content, + ok_label=ok_label, + key_router=key_router, + shortcut_source=shortcut_source, + claims_the_screen=claims_the_screen, + ).show() 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..7321bfceb --- /dev/null +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -0,0 +1,169 @@ +from typing import Callable, Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.primitives import DialogGeometry +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.dialogs.outcome import SaveOutcome +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_shared.types.callback import Callback + +CANCEL_FOCUS_STOP: Final[int] = 2 + + +class GUISaveConfirmationWindow(GUIDialogWindow): + """A modal save-or-proceed prompt for an unsaved document. + + Every answer runs a frame after the prompt has left the screen, so whatever it opens stands + alone. Save runs ``on_save``, which reports a :class:`SaveOutcome`: a written document runs + ``on_confirm``, a save the reader called off brings the prompt back with the same question, + and a failed save leaves its error on screen by itself and runs ``on_cancel``. The middle + button discards the pending changes and runs ``on_confirm`` to proceed. Cancel, the initially + focused button, runs ``on_cancel``, and so do Escape and the title bar's close button, so every + way out of the prompt reaches the caller. + """ + + def __init__( + self, + tag: str, + *, + geometry: DialogGeometry, + 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[[], SaveOutcome] + self._on_confirm: Callback + self._on_cancel: Callback + self._ok_label: str + + super().__init__( + tag, + geometry, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def prepare( # pylint: disable=arguments-differ + self, + message: str, + title: str, + on_save: Callable[[], SaveOutcome], + on_confirm: Callback, + on_cancel: Callback, + *, + ok_label: str, + ) -> None: + """Captures the pending document's write, the two ways forward and the way back.""" + self._message = message + self._title = title + self._on_save = on_save + self._on_confirm = on_confirm + self._on_cancel = on_cancel + 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 _on_save() -> None: + self._leave_then(self._save_and_go_on) + + def _on_confirm() -> None: + self._leave_then(self._on_confirm) + + def _on_cancel() -> None: + self._leave_then(self._on_cancel) + + 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, + ) + + def _save_and_go_on(self) -> None: + """Writes the document and goes where the outcome leads, once the prompt has left. + + A written document goes on to what the prompt was guarding. A save the reader called off + puts the same question again. A failed save showed its error, which stands alone, and the + request the prompt guarded goes back the way Cancel takes it. A save that raises turns the + request back the same way, and its error goes on up. + """ + match self._saved(): + case SaveOutcome.WRITTEN: + self._on_confirm() + case SaveOutcome.CALLED_OFF: + self.show( + self._message, + self._title, + self._on_save, + self._on_confirm, + self._on_cancel, + ok_label=self._ok_label, + ) + case SaveOutcome.FAILED: + self._on_cancel() + + def _saved(self) -> SaveOutcome: + """What the save came to, with the request the prompt guarded turned back where the save raises.""" + reported = False + try: + outcome = self._on_save() + reported = True + finally: + if not reported: + self._on_cancel() + + return outcome diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 4f1c9a31b..50afc54c6 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -118,13 +118,18 @@ def dpg_get_item_parent( ) -> Optional[Sender]: """The item's parent, or None when the item is absent. - Queued callbacks mutate the item tree on the callback-queue thread, so an item read - from another thread can be removed underneath this lookup; DearPyGui reports the - absent item by raising, which resolves here to None. + Queued callbacks mutate the item tree on the callback-queue thread, so an item read from + another thread can be removed underneath this lookup; DearPyGui reports the absent item by + raising, which resolves here to None. + + The catch is as broad as what it answers: DearPyGui raises ``Exception`` itself for an absent + item rather than a type of its own, so there is nothing narrower to name. + A test pins that, and the day the library raises something of its own is the day this + narrows to it. """ try: parent: Optional[Sender] = dpg.get_item_parent(tag, *args, **kwargs) - except Exception: # TODO: unsafe broad exception + except Exception: # pylint: disable=broad-exception-caught return None return parent @@ -135,6 +140,17 @@ def dpg_configure_item(tag: Sender, /, *args: Any, **kwargs: Any) -> None: dpg.configure_item(tag, *args, **kwargs) +@dpg_wrapper() +def dpg_get_item_user_data(tag: Sender, /) -> Any: + """The user data an item carries, or ``None`` once the item is gone. + + A widget's callback runs a frame after DearPyGui gathered it, by which time a rebuild may have + taken the item it names away. A callback reading what a hover, a click or a drop landed on + therefore reads the items still standing, and a gesture on a row that is gone says nothing. + """ + return dpg.get_item_user_data(tag) + + @dpg_wrapper(button_function=GUIButton.set_item_callback) def dpg_set_item_callback( tag: Sender, @@ -193,3 +209,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/frame.py b/src/sampletones_application/utils/gui/frame.py index 9542c3962..f781f83f4 100644 --- a/src/sampletones_application/utils/gui/frame.py +++ b/src/sampletones_application/utils/gui/frame.py @@ -7,6 +7,7 @@ import dearpygui.dearpygui as dpg +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_shared.meta import NonInstantiableMeta from sampletones_shared.types.callback import VoidCallback @@ -21,6 +22,18 @@ def __lt__(self, other: FrameCallback) -> bool: class FrameCallbackManager(metaclass=NonInstantiableMeta): + """Work picked up once a named frame has been drawn. + + Reading a laid-out size or letting a configuration take effect needs a frame drawn with it, and + naming the frame is how a callback asks for one. The render thread inside the callback queue's + drain stands between frames rather than inside one, which leaves 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, while a frame count asks for the same thing and lets the loop keep running. + + Each callback due runs through the callback queue's reporting, so one that fails is reported and + the others due in that frame still run. + """ + _callbacks: ClassVar[List[FrameCallback]] = [] _lock: ClassVar[threading.Lock] = threading.Lock() @@ -37,6 +50,14 @@ def set_frame_callback( dpg.set_frame_callback(frame_count, cls.process) + @classmethod + def clear(cls) -> None: + """Forgets every callback waiting for a frame, which is where a DearPyGui context that was just + made starts from. + """ + with cls._lock: + cls._callbacks.clear() + @classmethod def process(cls) -> None: current_frame = dpg.get_frame_count() @@ -48,4 +69,4 @@ def process(cls) -> None: frame_callback = heapq.heappop(cls._callbacks) - frame_callback.callback() + CallbackQueue.run(frame_callback.callback) diff --git a/src/sampletones_application/utils/gui/hover.py b/src/sampletones_application/utils/gui/hover.py new file mode 100644 index 000000000..87d984507 --- /dev/null +++ b/src/sampletones_application/utils/gui/hover.py @@ -0,0 +1,40 @@ +from typing import Callable, Final + +from sampletones_application.utils.gui.frame import FrameCallbackManager + +HOVER_RECHECK_FRAMES: Final[int] = 2 + + +class HoverWatch: + """Holds a hover look on items for as long as the pointer rests on them. + + DearPyGui reports a hover on every frame the pointer stands over an item, so the watch reads the + hover again a couple of frames on while it lasts, which is how it sees the pointer leave. Every report + arriving meanwhile joins the reading already under way, which keeps one frame callback waiting + however long the pointer rests. + + ``paint`` gives each watched item the look the pointer's place calls for and answers whether the + pointer still stands over one of them, so the reading that finds it gone paints the idle look last. + """ + + def __init__(self, paint: Callable[[], bool]) -> None: + self._paint = paint + self._watching = False + + def report(self) -> None: + """Take a hover handler's report, which starts the watch when none is under way.""" + if self._watching: + return + + self._check() + + def _check(self) -> None: + """Paints the look the pointer's place calls for and waits for the next reading while it rests. + + The watch counts as under way only once the next reading is waiting, so a paint that fails + leaves the next report free to start it again. + """ + self._watching = False + if self._paint(): + FrameCallbackManager.set_frame_callback(self._check, HOVER_RECHECK_FRAMES) + self._watching = True diff --git a/src/sampletones_application/utils/gui/keyboard/__init__.py b/src/sampletones_application/utils/gui/keyboard/__init__.py index 1d6e00ca7..659abdf85 100644 --- a/src/sampletones_application/utils/gui/keyboard/__init__.py +++ b/src/sampletones_application/utils/gui/keyboard/__init__.py @@ -8,6 +8,7 @@ KeyRouter, ModalKeyHandler, ) +from sampletones_application.utils.gui.keyboard.scope import panel_scope_active __all__ = [ "PRIORITY_MODAL", @@ -18,4 +19,5 @@ "KeyEvent", "KeyRouter", "ModalKeyHandler", + "panel_scope_active", ] diff --git a/src/sampletones_application/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py index 1bbb001ef..10f88a471 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: @@ -59,7 +59,7 @@ def stop(self) -> None: return self._listening = False - self._router.pop_modal() + self._router.pop_modal(self) def handle_key(self, event: KeyEvent) -> None: """Reads the press, reporting the combination it names once one arrives.""" @@ -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/combination.py b/src/sampletones_application/utils/gui/keyboard/combination.py index 828e9ef8b..92ed994b9 100644 --- a/src/sampletones_application/utils/gui/keyboard/combination.py +++ b/src/sampletones_application/utils/gui/keyboard/combination.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final, Set +from typing import Final, Iterable, List, Set, Tuple from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.keys import ( @@ -18,6 +18,8 @@ ) COMBINATION_SEPARATOR: Final[str] = "+" +KEY_LIST_SEPARATOR: Final[str] = "," +KEY_LIST_JOINER: Final[str] = ", " @dataclass(frozen=True) @@ -65,7 +67,7 @@ def parse(cls, text: str) -> KeyCombination: plus key. Args: - text: A combination as :meth:`display` writes it, in any capitalisation. + text: A combination as :meth:`display` writes it, in any capitalization. Returns: KeyCombination: The combination the text names. @@ -84,3 +86,59 @@ def parse(cls, text: str) -> KeyCombination: key=key_code(COMBINATION_SEPARATOR.join(parts[index:])), modifiers=frozenset(modifiers), ) + + +def combination_parts(text: str) -> Tuple[str, ...]: + """The combinations a written list such as ``"Ctrl+Y, Ctrl+Shift+Z"`` names, each as it is written. + + A comma separates two combinations, and a comma written where a key goes is the comma key: one + after the modifiers of a combination, as in ``"Ctrl+,"``, and one standing alone before a + separator or the end of the list. Each part is trimmed, and a blank part names nothing. A list + is displayed with the comma key spelled ``Comma``, so a displayed list reads back as itself. + """ + parts: List[str] = [] + part = "" + for index, character in enumerate(text): + if character == KEY_LIST_SEPARATOR and not _is_comma_key(part, text[index + 1 :]): + parts.append(part) + part = "" + else: + part += character + + parts.append(part) + return tuple(part.strip() for part in parts if part.strip()) + + +def _is_comma_key(written: str, rest: str) -> bool: + """Whether a comma after ``written`` and ahead of ``rest`` in one part of a list is a key.""" + before = written.strip() + if before: + return _awaits_key(before) + + after = rest.lstrip() + return not after or after.startswith(KEY_LIST_SEPARATOR) + + +def _awaits_key(written: str) -> bool: + """Whether ``written`` names modifiers alone, each followed by the separator, so its key comes next.""" + if not written.endswith(COMBINATION_SEPARATOR): + return False + + return all( + part.casefold() in MODIFIER_NAMES + for part in written.removesuffix(COMBINATION_SEPARATOR).split(COMBINATION_SEPARATOR) + ) + + +def parse_combinations(text: str) -> Tuple[KeyCombination, ...]: + """The combinations a written list names, in the order it names them, each one once. + + Raises: + KeyError: If a part of the list names no key. + """ + return tuple(dict.fromkeys(KeyCombination.parse(part) for part in combination_parts(text))) + + +def display_combinations(combinations: Iterable[KeyCombination]) -> str: + """The combinations as a list reads them, in their order and joined by commas.""" + return KEY_LIST_JOINER.join(combination.display() for combination in combinations) diff --git a/src/sampletones_application/utils/gui/keyboard/event.py b/src/sampletones_application/utils/gui/keyboard/event.py index 3383d791e..07ade4db2 100644 --- a/src/sampletones_application/utils/gui/keyboard/event.py +++ b/src/sampletones_application/utils/gui/keyboard/event.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from sampletones_application.utils.gui.keyboard.modifiers import ( + TYPING_MODIFIERS, ModifierSet, capture_modifiers, ) @@ -18,6 +19,15 @@ class KeyEvent: key: int modifiers: ModifierSet + @property + def is_plain(self) -> bool: + """Whether the press types its key, held alone or with Shift as a character is typed. + + Ctrl, Alt and Super make a press a command, which the shortcuts answer. A panel that reads + keys as entry, such as a note or a hex digit, takes plain presses and yields the rest. + """ + return self.modifiers <= TYPING_MODIFIERS + @classmethod def capture(cls, key: int) -> KeyEvent: """Builds an event for ``key`` with the modifiers currently held.""" diff --git a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py index 4634ab14d..bfb597881 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py @@ -3,8 +3,15 @@ import dearpygui.dearpygui as dpg from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind -from sampletones_application.utils.gui.keyboard.keys import FUNCTION_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier, ModifierSet +from sampletones_application.utils.gui.keyboard.keys import CHARACTER_KEYS, FUNCTION_KEYS +from sampletones_application.utils.gui.keyboard.modifiers import ( + CTRL, + CTRL_SHIFT, + SUPER, + SUPER_SHIFT, + Modifier, + ModifierSet, +) EDITING_KEYS: Final[FrozenSet[int]] = frozenset( { @@ -25,18 +32,23 @@ NO_KEYS: Final[FrozenSet[int]] = frozenset() +TEXT_EDIT_KEYS: Final[FrozenSet[int]] = frozenset( + { + dpg.mvKey_A, + dpg.mvKey_C, + dpg.mvKey_V, + dpg.mvKey_X, + dpg.mvKey_Z, + dpg.mvKey_Y, + } +) +REDO_KEYS: Final[FrozenSet[int]] = frozenset({dpg.mvKey_Z}) + TEXT_EDIT_CHORDS: Final[Dict[ModifierSet, FrozenSet[int]]] = { - frozenset({Modifier.CTRL}): frozenset( - { - dpg.mvKey_A, - dpg.mvKey_C, - dpg.mvKey_V, - dpg.mvKey_X, - dpg.mvKey_Z, - dpg.mvKey_Y, - } - ), - frozenset({Modifier.CTRL, Modifier.SHIFT}): frozenset({dpg.mvKey_Z}), + CTRL: TEXT_EDIT_KEYS, + CTRL_SHIFT: REDO_KEYS, + SUPER: TEXT_EDIT_KEYS, + SUPER_SHIFT: REDO_KEYS, } @@ -44,25 +56,39 @@ def field_consumes_key(kind: FieldKind, key: int, modifiers: ModifierSet) -> boo """Whether a focused field of ``kind`` acts on this key, so a matching shortcut yields to it. A field keeps the keys it uses and lets the rest reach the shortcut. Plain characters and the - caret, commit, and cancel keys belong to whichever field is focused; a text-entry field also - keeps the chords the modifiers name it, so Ctrl+Z undoes and Ctrl+Shift+Z redoes the text. - Every other combination passes through: command chords such as Ctrl+Space, the Ctrl+Shift - combinations a text field has no use for, Alt shortcuts, and the function keys, so intentional - playback and Stop stay reachable while a field is focused. + caret, commit, and cancel keys belong to whichever field is focused. + + A command chord, one held with Ctrl or Super, reaches the shortcuts, so Ctrl+Space plays and + Cmd+S saves from a field. A field that types keeps the text-edit chords alone: select all, + copy, cut, paste, undo and redo, spelled with Ctrl and with Super, the key macOS spells them + with. Super decides first, so Cmd+Option+S is a command as well. + + A text-entry field also keeps an Alt chord on a key that types a character, because AltGr and + Option type characters that way: Linux reports AltGr as Alt and Windows as Ctrl+Alt. An Alt + chord on a function, caret or editing key reaches the shortcuts, so Alt+F4 stays reachable + while a field is focused. A number field types no such character, so every Alt chord reaches + the shortcuts from it. """ if kind is FieldKind.NONE: return False + if Modifier.SUPER in modifiers: + return _keeps_text_edit_chord(kind, key, modifiers) + if Modifier.ALT in modifiers: - return False + return kind is FieldKind.TEXT_ENTRY and key in CHARACTER_KEYS if Modifier.CTRL in modifiers: - return kind is FieldKind.TEXT_ENTRY and key in TEXT_EDIT_CHORDS.get( - modifiers, - NO_KEYS, - ) + return _keeps_text_edit_chord(kind, key, modifiers) if key in EDITING_KEYS: return True - return kind is FieldKind.TEXT_ENTRY and key not in FUNCTION_KEYS + return kind.takes_typing and key not in FUNCTION_KEYS + + +def _keeps_text_edit_chord(kind: FieldKind, key: int, modifiers: ModifierSet) -> bool: + return kind.takes_typing and key in TEXT_EDIT_CHORDS.get( + modifiers, + NO_KEYS, + ) diff --git a/src/sampletones_application/utils/gui/keyboard/focus/items.py b/src/sampletones_application/utils/gui/keyboard/focus/items.py index d97dbd752..95b861d6a 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/items.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/items.py @@ -2,9 +2,10 @@ from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind -TEXT_ENTRY_ITEM_TYPES: Final[FrozenSet[str]] = frozenset( +TEXT_ENTRY_ITEM_TYPES: Final[FrozenSet[str]] = frozenset({"mvAppItemType::mvInputText"}) + +NUMBER_ENTRY_ITEM_TYPES: Final[FrozenSet[str]] = frozenset( { - "mvAppItemType::mvInputText", "mvAppItemType::mvInputInt", "mvAppItemType::mvInputFloat", "mvAppItemType::mvInputDouble", @@ -19,6 +20,7 @@ FIELD_KINDS: Final[Dict[str, FieldKind]] = { **{item_type: FieldKind.TEXT_ENTRY for item_type in TEXT_ENTRY_ITEM_TYPES}, + **{item_type: FieldKind.NUMBER_ENTRY for item_type in NUMBER_ENTRY_ITEM_TYPES}, **{item_type: FieldKind.CHOICE for item_type in CHOICE_ITEM_TYPES}, } diff --git a/src/sampletones_application/utils/gui/keyboard/focus/kind.py b/src/sampletones_application/utils/gui/keyboard/focus/kind.py index d7965f5b4..425dd20b8 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/kind.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/kind.py @@ -4,10 +4,17 @@ class FieldKind(Enum): """What a focused widget does with a key press, which decides the keys it keeps for itself. - ``TEXT_ENTRY`` inserts typed characters (text and number inputs); ``CHOICE`` navigates a list - of options (an open combo); ``NONE`` is any other focus, which yields every key. + ``TEXT_ENTRY`` inserts any typed character (a text input). ``NUMBER_ENTRY`` inserts the + characters of a number (a number input, a slider or a drag being typed into). ``CHOICE`` + navigates a list of options (an open combo). ``NONE`` is any other focus, which yields every key. """ NONE = "none" TEXT_ENTRY = "text_entry" + NUMBER_ENTRY = "number_entry" CHOICE = "choice" + + @property + def takes_typing(self) -> bool: + """Whether the field inserts the characters typed into it.""" + return self in (FieldKind.TEXT_ENTRY, FieldKind.NUMBER_ENTRY) diff --git a/src/sampletones_application/utils/gui/keyboard/focus/search.py b/src/sampletones_application/utils/gui/keyboard/focus/search.py index c751211e7..36cdcdd6d 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/search.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/search.py @@ -53,7 +53,7 @@ def _active_descendant_field_kind(node: ItemNode) -> FieldKind: def _leads_to_focused_widget(node: ItemNode) -> bool: - """Whether the search follows ``node`` towards the field being edited. + """Whether the search follows ``node`` toward the field being edited. Through a container that carries its children's state the search follows the one branch that reports focus, which keeps a key press to the cost of the path down to its field. Every other diff --git a/src/sampletones_application/utils/gui/keyboard/keys.py b/src/sampletones_application/utils/gui/keyboard/keys.py index 5042625af..aeb0e88ef 100644 --- a/src/sampletones_application/utils/gui/keyboard/keys.py +++ b/src/sampletones_application/utils/gui/keyboard/keys.py @@ -34,6 +34,39 @@ FUNCTION_KEYS: Final[FrozenSet[int]] = frozenset(FUNCTION_KEY_NAMES) +PUNCTUATION_KEYS: Final[FrozenSet[int]] = frozenset( + { + dpg.mvKey_Comma, + dpg.mvKey_Period, + dpg.mvKey_Slash, + dpg.mvKey_Backslash, + dpg.mvKey_Open_Brace, + dpg.mvKey_Close_Brace, + KEY_SEMICOLON, + KEY_QUOTE, + KEY_TILDE, + dpg.mvKey_Minus, + KEY_PLUS, + } +) +KEYPAD_CHARACTER_KEYS: Final[FrozenSet[int]] = frozenset(KEYPAD_DIGIT_NAMES) | frozenset( + { + dpg.mvKey_Decimal, + dpg.mvKey_Divide, + dpg.mvKey_Multiply, + dpg.mvKey_Subtract, + dpg.mvKey_Add, + dpg.mvKey_NumPadEqual, + } +) +CHARACTER_KEYS: Final[FrozenSet[int]] = ( + frozenset(LETTER_NAMES) + | frozenset(DIGIT_NAMES) + | PUNCTUATION_KEYS + | KEYPAD_CHARACTER_KEYS + | frozenset({dpg.mvKey_Spacebar}) +) + KEY_DISPLAY_NAMES: Final[Dict[int, str]] = { **LETTER_NAMES, **DIGIT_NAMES, @@ -160,7 +193,7 @@ def key_display(key: int) -> str: def key_code(name: str) -> int: - """The key a written name stands for, however the name is capitalised. + """The key a written name stands for, however the name is capitalized. Reading a name back into a code is what lets a binding be written down, so a configured combination and a declared one arrive at the same key. A key answers to the name it displays diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py index 0cab9693b..62b897432 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -33,6 +33,8 @@ class Modifier(StrEnum): CTRL_ALT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT}) CTRL_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.SHIFT}) CTRL_ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT, Modifier.SHIFT}) +SUPER_SHIFT: Final[ModifierSet] = frozenset({Modifier.SUPER, Modifier.SHIFT}) +TYPING_MODIFIERS: Final[ModifierSet] = SHIFT MODIFIER_NAMES: Final[Dict[str, Modifier]] = { "ctrl": Modifier.CTRL, @@ -101,7 +103,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..70efaeaa8 --- /dev/null +++ b/src/sampletones_application/utils/gui/keyboard/piano.py @@ -0,0 +1,61 @@ +from typing import Dict, Final, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.gui.keyboard.event import KeyEvent +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[Dict[int, int]] = { + dpg.mvKey_Q: 0, + dpg.mvKey_W: 2, + dpg.mvKey_E: 4, + dpg.mvKey_R: 5, + dpg.mvKey_T: 7, + dpg.mvKey_Y: 9, + dpg.mvKey_U: 11, +} + +PIANO_KEYS: Final[Dict[int, int]] = { + **{key: semitone for semitone, key in enumerate(_LOWER_ROW)}, + **{key: OCTAVE_SEMITONES + semitone for key, semitone in _UPPER_ROW.items()}, +} +"""Each note key, as the semitones it stands above the C of the octave being typed at. + +Two rows of the keyboard make a piano the way a tracker lays them out: the bottom row opens at the +octave in force, with its black keys on the row over it, and the top row plays the white keys an +octave above. The digit keys type a step into a pitch cell, so they name no note. +""" + + +def semitone_of(event: KeyEvent) -> Optional[int]: + """The note a plain press of a piano key names, as semitones above the C being typed at. + + A note key held with Ctrl, Alt or Super is a combination, which belongs to the shortcuts, so + Ctrl+Z undoes wherever the Z key plays a C. + + Args: + event: The press, carrying the modifiers held as it fired. + + Returns: + Optional[int]: The semitones the key stands above the C, or ``None`` for a press that + names no note. + """ + if not event.is_plain: + return None + + return PIANO_KEYS.get(event.key) diff --git a/src/sampletones_application/utils/gui/keyboard/router.py b/src/sampletones_application/utils/gui/keyboard/router.py index fe80f5565..e50bdb516 100644 --- a/src/sampletones_application/utils/gui/keyboard/router.py +++ b/src/sampletones_application/utils/gui/keyboard/router.py @@ -95,10 +95,16 @@ def push_modal(self, handler: ModalKeyHandler) -> None: """ self._modal_stack.append(handler) - def pop_modal(self) -> None: - """Releases the top modal dialog's claim on the keyboard, ignored when none is open.""" - if self._modal_stack: - self._modal_stack.pop() + def pop_modal(self, handler: ModalKeyHandler) -> None: + """Releases the latest claim ``handler`` holds on the keyboard, wherever it stands. + + A dialog can close while one it raised still stands, so a release names whose claim it + gives up and every other claim keeps its place. A handler holding no claim changes nothing. + """ + for index in range(len(self._modal_stack) - 1, -1, -1): + if self._modal_stack[index] is handler: + del self._modal_stack[index] + return def route(self, event: KeyEvent) -> bool: """Offers the event to the active scopes, highest priority first, stopping at the diff --git a/src/sampletones_application/utils/gui/keyboard/scope.py b/src/sampletones_application/utils/gui/keyboard/scope.py new file mode 100644 index 000000000..accf30b8f --- /dev/null +++ b/src/sampletones_application/utils/gui/keyboard/scope.py @@ -0,0 +1,24 @@ +from sampletones_application.utils.gui.keyboard.router import ActivePredicate, KeyRouter + + +def panel_scope_active( + *, + tab_active: ActivePredicate, + router: KeyRouter, + holds: bool, + card_open: ActivePredicate, +) -> bool: + """Whether a panel scope owns the next key. + + A panel answers while its tab is in front, while its card stands open, while it holds the + thing the keys act on, and while no field has the keyboard. What a panel holds differs — a + cursor, a row picked out, an open audition — so each states its own and the rest of the rule + is answered here. + + The tab and the card are read at the moment of the press, since a cursor, a pick and an + audition outlive both a move to another tab and a card put away: the reader comes back to + where they left off, and what rests meanwhile is the panel's claim on the keyboard. A modal + dialog claims keys above this priority in the router, which is what holds every panel off + while one stands open. + """ + return tab_active() and card_open() and holds and not router.is_field_focused diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py new file mode 100644 index 000000000..5c2541bd7 --- /dev/null +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -0,0 +1,328 @@ +from collections import deque +from dataclasses import dataclass +from typing import ClassVar, Deque, Dict, List, Optional, Tuple, Union + +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_shared.meta import NonInstantiableMeta +from sampletones_shared.types.callback import VoidCallback + + +@dataclass(frozen=True) +class WaitingModal: + """A modal window asked for while the screen belonged to another conversation. + + Attributes: + tag: The window the request builds. + build: What builds it, with everything the request carried. + """ + + tag: str + build: VoidCallback + + +@dataclass(frozen=True) +class WaitingTurn: + """A gesture waiting for the screen before it reads what it asks about. + + Attributes: + continuation: What runs once the screen is free for it. + """ + + continuation: VoidCallback + + +WaitingEntry = Union[WaitingModal, WaitingTurn] + + +@dataclass(frozen=True) +class ModalQueueSnapshot: + """What the line holds at one moment, for a reader watching the screen from outside it. + + Attributes: + shown: The window standing on the screen, or ``None`` while the screen is free. + aside: The windows standing aside for the modals they handed the screen to. + waiting: The windows waiting for the screen, in the order they open. + turns: How many gestures wait in line for the screen before they ask. + turning: Whether the line moves on in a coming frame. + """ + + shown: Optional[str] + aside: Tuple[str, ...] + waiting: Tuple[str, ...] + turns: int + turning: bool + + @property + def is_settled(self) -> bool: + """Whether the line stands still: nothing waits, nothing stands aside, and no turn is due.""" + return not self.aside and not self.waiting and not self.turns and not self.turning + + def holds(self, tag: str) -> bool: + """Whether the window ``tag`` stands on the screen, stands aside or waits for the screen.""" + return tag == self.shown or tag in self.aside or tag in self.waiting + + +class ModalQueue(metaclass=NonInstantiableMeta): + """The one modal window DearPyGui shows at a time, and the modal windows waiting for the screen. + + DearPyGui shows one modal window at a time. A modal built while another one stands opens hidden, + out of every reader's reach, and runs the answer its title-bar close gives. A modal built in the + frame another one left in meets the same fate, since that frame still draws the one that left. + + The screen therefore belongs to one conversation at a time: a modal window, the modals it hands + the screen to while it steps aside, and the ones its answers raise. A modal asked for from + anywhere else, such as the report of a job that finished, takes its place in line and opens once + the conversation holding the screen has ended, a frame after its last window left. A window asked + for again while it waits takes one place, at the back of the line, with the newer request. The line + is one for the process, as DearPyGui's context is. + + A guard with something to ask takes a turn in the line, so it reads what it asks about once the + screen is free for its question. The conversation that held the screen has settled what it + changes by then, such as a project its answer closed. + + A hand-off is how a conversation keeps the screen while its windows change: what runs a frame + after a window stepped aside or left, opening the modal that comes next or bringing the window + back. Every call arrives on the render thread, where windows are built and deleted. + """ + + _shown: ClassVar[Optional[str]] = None + _aside: ClassVar[List[str]] = [] + _returning: ClassVar[Dict[str, VoidCallback]] = {} + _hand_offs: ClassVar[List[VoidCallback]] = [] + _waiting: ClassVar[Deque[WaitingEntry]] = deque() + _clearing: ClassVar[bool] = False + _handing_off: ClassVar[bool] = False + _turning: ClassVar[bool] = False + _turn_due: ClassVar[bool] = False + + @classmethod + def open(cls, tag: str, build: VoidCallback) -> None: + """Builds a modal window now where the screen is free for it, and once it is free otherwise. + + A modal a hand-off raises joins the conversation that handed it the screen, ahead of every + modal waiting in line. Any other one waits for the conversation holding the screen to end. + + Args: + tag: The window ``build`` builds. + build: What builds the window. + """ + cls._forget_waiting(tag) + cls._admit(WaitingModal(tag=tag, build=build)) + + @classmethod + def when_free(cls, continuation: VoidCallback) -> None: + """Runs ``continuation`` once the screen is free for it, as a turn in the line. + + The turn goes by the rule a modal opens by: at once where the screen is free or a hand-off + carries it, and in its place in line otherwise. Once it has run, the line goes on to the next + window waiting while the screen stays free, so a turn that opens a modal holds the line. The + signature is a :data:`Wait`, so a guard reads what it asks about once its question may reach + the screen. + + Args: + continuation: What runs once the screen is free for it. + """ + cls._admit(WaitingTurn(continuation=continuation)) + + @classmethod + def leave(cls, tag: str) -> None: + """Gives up the screen a deleted window held, standing on it or stepped aside. + + A window still waiting for the screen gives up its place in line instead. + """ + cls._forget_waiting(tag) + cls._returning.pop(tag, None) + if cls._shown == tag: + cls._shown = None + cls._clearing = True + cls._take_a_turn() + elif tag in cls._aside: + cls._aside.remove(tag) + cls._take_a_turn() + + @classmethod + def step_aside(cls, tag: str) -> None: + """Keeps the screen for a window that went off it, so the modal it hands over to opens next.""" + if cls._shown != tag: + return + + cls._shown = None + cls._aside.append(tag) + cls._clearing = True + + @classmethod + def come_back(cls, tag: str, reveal: VoidCallback) -> None: + """Brings a window that stepped aside back onto the screen once the screen is free for it. + + Args: + tag: The window that stepped aside. + reveal: What puts the window back on screen. + """ + if tag not in cls._aside: + return + + cls._returning[tag] = reveal + cls._take_a_turn() + + @classmethod + def hand_off(cls, continuation: VoidCallback) -> None: + """Runs ``continuation`` a frame from now as part of the conversation holding the screen. + + A modal ``continuation`` opens takes the screen ahead of every modal waiting in line. + """ + cls._hand_offs.append(continuation) + cls._take_a_turn() + + @classmethod + def snapshot(cls) -> ModalQueueSnapshot: + """What the line holds now: the window standing, the ones aside and the ones waiting.""" + return ModalQueueSnapshot( + shown=cls._shown, + aside=tuple(cls._aside), + waiting=tuple(entry.tag for entry in cls._waiting if isinstance(entry, WaitingModal)), + turns=sum(1 for entry in cls._waiting if isinstance(entry, WaitingTurn)), + turning=cls._turn_due, + ) + + @classmethod + def clear(cls) -> None: + """Forgets every window, which is where a DearPyGui context that was just made starts from.""" + cls._shown = None + cls._aside = [] + cls._returning = {} + cls._hand_offs = [] + cls._waiting = deque() + cls._clearing = False + cls._handing_off = False + cls._turning = False + cls._turn_due = False + + @classmethod + def _screen_taken(cls) -> bool: + """Whether a conversation holds the screen, or a window left it in the frame being drawn.""" + return cls._shown is not None or bool(cls._aside) or bool(cls._hand_offs) or cls._clearing + + @classmethod + def _admit(cls, entry: WaitingEntry) -> None: + """Takes ``entry`` now where the screen is free for it, and puts it in line otherwise. + + An entry a hand-off or a turn asks for joins the conversation at the head of the line, ahead of + every entry waiting. Any other one takes a free screen only while nothing waits in line, so an + entry asked for in the frame after a failing turn waits behind the ones the failure left. + """ + if cls._shown is None and not cls._clearing and cls._head_is_free(): + cls._take(entry) + elif cls._joins_the_head(): + cls._waiting.appendleft(entry) + else: + cls._waiting.append(entry) + + @classmethod + def _joins_the_head(cls) -> bool: + """Whether what is asked for now comes from the head of the line: a hand-off or a turn running.""" + return cls._handing_off or cls._turning + + @classmethod + def _head_is_free(cls) -> bool: + """Whether an entry asked for now stands at the head of a line free for it.""" + return cls._joins_the_head() or (not cls._screen_taken() and not cls._waiting) + + @classmethod + def _take(cls, entry: WaitingEntry) -> None: + """Gives ``entry`` the screen: a window is built on it, and a turn runs.""" + match entry: + case WaitingModal(tag=tag, build=build): + cls._shown = tag + build() + case WaitingTurn(continuation=continuation): + cls._run_turn(continuation) + + @classmethod + def _run_turn(cls, continuation: VoidCallback) -> None: + """Runs a turn at the head of the line, so what it asks for joins it ahead of the entries waiting.""" + turning, cls._turning = cls._turning, True + try: + continuation() + finally: + cls._turning = turning + + @classmethod + def _forget_waiting(cls, tag: str) -> None: + cls._waiting = deque( + entry for entry in cls._waiting if not (isinstance(entry, WaitingModal) and entry.tag == tag) + ) + + @classmethod + def _take_a_turn(cls) -> None: + if cls._turn_due: + return + + cls._turn_due = True + FrameCallbackManager.set_frame_callback(cls._turn) + + @classmethod + def _turn(cls) -> None: + """Carries the screen a frame further: the hand-offs, then a window coming back, then the line. + + A window leaving during the turn keeps what comes after it for the next frame, since the + frame being drawn still carries the window that left. + """ + cls._turn_due = False + cls._clearing = False + cls._run_hand_offs() + if cls._clearing or cls._shown is not None: + return + + if cls._bring_back(): + return + + cls._move_the_line() + + @classmethod + def _move_the_line(cls) -> None: + """Gives the screen to the entries at the head of the line while it stays free. + + A turn that raises leaves the entries behind it for a coming frame, so the line goes on there, + and an entry asked for meanwhile waits behind them. + """ + moved = False + try: + while cls._waiting and not cls._screen_taken(): + cls._take(cls._waiting.popleft()) + moved = True + finally: + if not moved: + cls._take_a_turn() + + @classmethod + def _run_hand_offs(cls) -> None: + """Runs the hand-offs due, in the order they were handed. + + A hand-off that raises keeps the ones after it for a coming frame, and the line goes on from + there, so one failing answer leaves the screen to the rest of the conversation and the line. + """ + hand_offs, cls._hand_offs = deque(cls._hand_offs), [] + cls._handing_off = True + ran = False + try: + while hand_offs: + hand_offs.popleft()() + ran = True + finally: + cls._handing_off = False + if not ran: + cls._hand_offs = list(hand_offs) + cls._hand_offs + cls._take_a_turn() + + @classmethod + def _bring_back(cls) -> bool: + """Puts the latest window that stepped aside and asked to come back on the screen.""" + for tag in reversed(cls._aside): + reveal = cls._returning.pop(tag, None) + if reveal is not None: + cls._aside.remove(tag) + cls._shown = tag + reveal() + return True + + return False diff --git a/src/sampletones_application/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..7728c801a 100644 --- a/src/sampletones_application/utils/gui/palette/palette.py +++ b/src/sampletones_application/utils/gui/palette/palette.py @@ -1,5 +1,5 @@ from itertools import chain -from typing import ClassVar, Dict, Iterator +from typing import ClassVar, Dict, Final, Iterator import dearpygui.dearpygui as dpg @@ -13,21 +13,26 @@ from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_shared.types.application import Sender +PRUNE_FLOOR: Final[int] = 256 + 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. Items come and go with every + menu and every rebuild, so the registry lets the deleted ones go whenever it has doubled since + it last did, which keeps it to the items alive at a cost spread over the binds. """ _arguments: ClassVar[Dict[ArgumentKey, ArgumentBinding]] = {} _theme_colors: ClassVar[Dict[Sender, ThemeColorBinding]] = {} + _pruned_at: ClassVar[int] = PRUNE_FLOOR @classmethod def bind( @@ -37,7 +42,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, @@ -45,37 +50,54 @@ def bind( ) binding.push() cls._arguments[item, argument] = binding + cls._prune_once_doubled() @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, ) + cls._prune_once_doubled() @classmethod def apply(cls) -> None: """Hands DearPyGui the value every registered token carries now. - Bindings whose item has since been deleted are dropped, so the registry tracks the - items that are alive and a long session's worth of transient widgets leaves nothing - behind. + Bindings whose item has since been deleted are dropped first, so only live items are + colored. """ - cls._arguments = {key: binding for key, binding in cls._arguments.items() if cls._is_live(binding)} - cls._theme_colors = {key: binding for key, binding in cls._theme_colors.items() if cls._is_live(binding)} + cls._prune() for binding in cls.bindings(): binding.push() + @classmethod + def _prune_once_doubled(cls) -> None: + if cls._size() >= 2 * cls._pruned_at: + cls._prune() + + @classmethod + def _prune(cls) -> None: + """Lets go of the bindings whose item has been deleted.""" + cls._arguments = {key: binding for key, binding in cls._arguments.items() if cls._is_live(binding)} + cls._theme_colors = {key: binding for key, binding in cls._theme_colors.items() if cls._is_live(binding)} + cls._pruned_at = max(PRUNE_FLOOR, cls._size()) + + @classmethod + def _size(cls) -> int: + return len(cls._arguments) + len(cls._theme_colors) + @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 def clear(cls) -> None: cls._arguments.clear() cls._theme_colors.clear() + cls._pruned_at = PRUNE_FLOOR @staticmethod def _is_live(binding: PaletteBinding) -> bool: diff --git a/src/sampletones_application/utils/gui/press.py b/src/sampletones_application/utils/gui/press.py new file mode 100644 index 000000000..dbd790f30 --- /dev/null +++ b/src/sampletones_application/utils/gui/press.py @@ -0,0 +1,36 @@ +from typing import Generic, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +PressT = TypeVar("PressT") + + +class LeftPress(Generic[PressT]): + """A left-button press that went down on a widget, with what the widget keeps while it is held. + + A widget begins the press it hears go down on it and ends it on the release DearPyGui reports. + DearPyGui reports no release made outside the window, so a press also ends once it is read with + the left button up. A widget reads it from a handler that runs with the button up as well, such + as a mouse move, so a press begun elsewhere later finds nothing held. + """ + + def __init__(self) -> None: + self._held: Optional[PressT] = None + + def begin(self, held: PressT) -> None: + """Keeps ``held`` for the press that went down, in place of any press before it.""" + self._held = held + + def end(self) -> None: + """Ends the press, on the release DearPyGui reports.""" + self._held = None + + def settle(self) -> None: + """Ends the press once the left button reads up, which is a release DearPyGui never reported.""" + if self._held is not None and not dpg.is_mouse_button_down(dpg.mvMouseButton_Left): + self._held = None + + def held(self) -> Optional[PressT]: + """What the press keeps while the left button stays down, and ``None`` once it has ended.""" + self.settle() + return self._held diff --git a/src/sampletones_application/utils/gui/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py new file mode 100644 index 000000000..43a1d5b4a --- /dev/null +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -0,0 +1,121 @@ +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from enum import Enum, auto +from typing import Any, Callable, Final, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import Callback + +AnswerT = TypeVar("AnswerT") + +FRAME_PAUSE: Final[float] = 1 / 60 + + +class RenderThreadPhase(Enum): + """Where a run stands with DearPyGui's context, which decides the thread work runs on.""" + + BUILDING = auto() + DRAWING = auto() + STOPPED = auto() + + +_PHASE: RenderThreadPhase = RenderThreadPhase.BUILDING +_RENDER_THREAD: Optional[int] = None + + +def reset_render_thread() -> None: + """Hands a fresh context to whichever thread asks, the state a new interface is built in.""" + global _PHASE, _RENDER_THREAD # pylint: disable=global-statement + _PHASE = RenderThreadPhase.BUILDING + _RENDER_THREAD = None + + +def claim_render_thread() -> None: + """Names the thread DearPyGui's context belongs to, which is the one drawing the frames.""" + global _PHASE, _RENDER_THREAD # pylint: disable=global-statement + _PHASE = RenderThreadPhase.DRAWING + _RENDER_THREAD = threading.get_ident() + + +def release_render_thread() -> None: + """Marks the loop as stopped, which a run does as its teardown begins. + + The thread that ran the loop keeps the context until it is destroyed, since the teardown runs + there. Work from any other thread joins the queue, which nothing drains once the loop has stopped. + A run taken down before its loop claimed a thread leaves the context to the thread taking it + down. + """ + global _PHASE, _RENDER_THREAD # pylint: disable=global-statement + _PHASE = RenderThreadPhase.STOPPED + if _RENDER_THREAD is None: + _RENDER_THREAD = threading.get_ident() + + +def is_render_thread() -> bool: + """Whether the caller stands where DearPyGui's context is. + + While the interface is being built, no run has claimed the thread, and whichever thread is + asking is the one holding the context. Once a run claims the thread, it holds the context for + as long as the context lasts: while the loop draws, and while the run is taken down after it. + """ + match _PHASE: + case RenderThreadPhase.BUILDING: + return True + case RenderThreadPhase.DRAWING | RenderThreadPhase.STOPPED: + return threading.get_ident() == _RENDER_THREAD + + +def is_drawing_thread() -> bool: + """Whether the caller is the thread drawing the frames, while the loop draws them.""" + return _PHASE is RenderThreadPhase.DRAWING and threading.get_ident() == _RENDER_THREAD + + +def on_render_thread( + work: Callback, + *args: Any, + priority: int = 0, + **kwargs: Any, +) -> None: + """Runs ``work`` where DearPyGui's context belongs: the thread drawing the frames. + + A worker of our own reaches the interface while the render thread is walking the very items it + would create and drop, and an item freed there is freed with no Python thread state — a crash + rather than a glitch. Work already on the render thread runs where it stands; work arriving + from any other thread joins the queue the render loop drains, so it lands between frames. Once + the loop has stopped, that queue is stopped too, so a worker's late work is let go of. + + A widget's own callback reaches this as a direct call, since DearPyGui gathers it for the + frame to run rather than answering the gesture on a thread of its own. + """ + if is_render_thread(): + work(*args, **kwargs) + return + + CallbackQueue.add(work, *args, priority=priority, **kwargs) + + +def answered_while_drawing(work: Callable[[], AnswerT]) -> AnswerT: + """Run ``work`` beside the frames rather than in place of them, and report what it answers. + + A native dialog answers when the reader does, which is as long as they take. Standing on the + render thread for that leaves the window with nothing drawing it, so the work goes to a thread + of its own and the frames keep being drawn until it reports back. The gestures those frames + gather wait for the drain that follows, so the interface stays painted while it stands inert — + which is what a dialog standing in front of it means. + + Work reached anywhere else runs where it stands: on any other thread, and on the render thread + before the loop draws or after it stops, since nothing there holds frames up. + """ + if not is_drawing_thread(): + return work() + + with ThreadPoolExecutor(max_workers=1) as pool: + answer = pool.submit(work) + while not answer.done() and dpg.is_dearpygui_running(): + dpg.render_dearpygui_frame() + time.sleep(FRAME_PAUSE) + + return answer.result() diff --git a/src/sampletones_application/utils/gui/shortcuts/draft.py b/src/sampletones_application/utils/gui/shortcuts/draft.py index 94a4f7253..f7851bcae 100644 --- a/src/sampletones_application/utils/gui/shortcuts/draft.py +++ b/src/sampletones_application/utils/gui/shortcuts/draft.py @@ -3,10 +3,12 @@ from dataclasses import dataclass, replace from typing import Dict, Mapping, Optional, Tuple -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme +Keys = Tuple[KeyCombination, ...] + @dataclass(frozen=True) class ShortcutDraft: @@ -14,13 +16,15 @@ class ShortcutDraft: An editor works on a draft and hands a scheme over once, which leaves the keys in force steady while Escape, Tab and Enter are themselves being rebound. A draft is kept as the actions the - reader touched and what they gave them, since an override replaces a whole binding: every other - action answers the scheme the build ships, and the touched entries are what a session stores. + reader moved off the keys the scheme ships and the whole list of keys each answers, main key + first, since an override replaces a whole binding: every other action answers the scheme the + build ships, and the moved entries are what a session stores. An empty list leaves an action + unbound. """ base: ShortcutScheme - stored: Dict[ShortcutId, Optional[KeyCombination]] - edits: Dict[ShortcutId, Optional[KeyCombination]] + stored: Dict[ShortcutId, Keys] + edits: Dict[ShortcutId, Keys] @classmethod def open( @@ -36,14 +40,15 @@ def open( Args: base: The scheme as the build ships it, which the draft states its edits against. - overrides: The combination each rebound action answers to, keyed by the action's name. + overrides: The keys each rebound action answers to, joined by commas and keyed by the + action's name. Returns: ShortcutDraft: The draft holding what the session stores and that alone. """ preferred = base.with_overrides(overrides) - stored: Dict[ShortcutId, Optional[KeyCombination]] = { - shortcut_id: preferred.shortcut(shortcut_id).combination + stored: Dict[ShortcutId, Keys] = { + shortcut_id: preferred.shortcut(shortcut_id).combinations() for shortcut_id in ShortcutId if preferred.shortcut(shortcut_id) != base.shortcut(shortcut_id) } @@ -59,12 +64,27 @@ def is_dirty(self) -> bool: """Whether the draft holds keys the session has yet to store.""" return self.edits != self.stored - def combination(self, shortcut_id: ShortcutId) -> Optional[KeyCombination]: - """The keys an action answers to as the draft stands, ``None`` while it is unbound.""" + def keys(self, shortcut_id: ShortcutId) -> Keys: + """Every key an action answers to as the draft stands, main key first, and none while unbound.""" if shortcut_id in self.edits: return self.edits[shortcut_id] - return self.base.shortcut(shortcut_id).combination + return self.base.shortcut(shortcut_id).combinations() + + def keys_led_by( + self, + shortcut_id: ShortcutId, + combination: KeyCombination, + ) -> Keys: + """The keys an action answers once ``combination`` becomes its main key. + + A pressed key leads the list and the action keeps the keys it had after it, so pressing one + of its own keys moves that key to the front. + """ + return ( + combination, + *(key for key in self.keys(shortcut_id) if key != combination), + ) def claimant( self, @@ -88,41 +108,76 @@ def claimant( if other is shortcut_id or other.category is not shortcut_id.category: continue - if combination in self._claimed(other): + if combination in self.keys(other): return other return None + def holders( + self, + shortcut_id: ShortcutId, + combinations: Keys, + ) -> Dict[KeyCombination, ShortcutId]: + """Each of ``combinations`` another action of the category holds, with the action holding it.""" + holders: Dict[KeyCombination, ShortcutId] = {} + for combination in combinations: + claimant = self.claimant(shortcut_id, combination) + if claimant is not None: + holders[combination] = claimant + + return holders + def assign( self, shortcut_id: ShortcutId, - combination: KeyCombination, + combinations: Keys, ) -> ShortcutDraft: - """The draft with an action answering ``combination``, taken from whichever action holds it. + """The draft with an action answering exactly ``combinations``, taken from whichever action + holds them. - Leaving the holder unbound in the same step is what keeps every scheme a draft produces - valid, since one combination reaches one action within a category. An edit is held to the - keys the table names, which is what lets every draft be written down and read back. + A holder gives up the keys taken and keeps the rest, its next key becoming its main key, + which keeps every scheme a draft produces valid, since one combination reaches one action + within a category. An edit is held to the keys the table names, which is what lets every + draft be written down and read back. An action left on the keys the scheme ships records no + edit, so an assignment that changes nothing leaves the draft as it was. + + Args: + shortcut_id: The action given the keys. + combinations: Its keys, main key first; a repeated key counts once, and an empty list + leaves it unbound. Raises: - KeyError: when the combination is built on a key the table names none of. + KeyError: when a combination is built on a key the table names none of. """ - if not combination.is_writable: - raise KeyError(f"The key {combination.key} carries no name a binding is written under") + given = tuple(dict.fromkeys(combinations)) + for combination in given: + if not combination.is_writable: + raise KeyError(f"The key {combination.key} carries no name a binding is written under") - claimant = self.claimant(shortcut_id, combination) - edits: Dict[ShortcutId, Optional[KeyCombination]] = { - **self.edits, - shortcut_id: combination, - } - if claimant is not None: - edits[claimant] = None + edits: Dict[ShortcutId, Keys] = dict(self.edits) + self._record(edits, shortcut_id, given) + for holder in dict.fromkeys(self.holders(shortcut_id, given).values()): + self._record(edits, holder, tuple(key for key in self.keys(holder) if key not in given)) return replace(self, edits=edits) + def _record( + self, + edits: Dict[ShortcutId, Keys], + shortcut_id: ShortcutId, + keys: Keys, + ) -> None: + """Writes ``keys`` into ``edits`` as the keys an action answers, an action on the keys the + scheme ships recording no edit.""" + if keys == self.base.shortcut(shortcut_id).combinations(): + edits.pop(shortcut_id, None) + return + + edits[shortcut_id] = keys + def clear(self, shortcut_id: ShortcutId) -> ShortcutDraft: - """The draft with an action left unbound, its keys free for another action to take.""" - return replace(self, edits={**self.edits, shortcut_id: None}) + """The draft with an action left unbound, every key it held free for another action to take.""" + return self.assign(shortcut_id, ()) def reset(self) -> ShortcutDraft: """The draft with every action back on the keys the scheme ships.""" @@ -137,20 +192,9 @@ def scheme(self) -> ShortcutScheme: return self.base.with_bindings(self.edits) def overrides(self) -> Dict[str, Optional[str]]: - """The edits as a stored preference writes them, keyed by each action's name.""" + """The edits as a stored preference writes them: each action's keys joined by commas, keyed by + the action's name, and ``None`` for an action left unbound. + """ return { - shortcut_id.value: None if combination is None else combination.display() - for shortcut_id, combination in self.edits.items() + shortcut_id.value: display_combinations(keys) if keys else None for shortcut_id, keys in self.edits.items() } - - def _claimed(self, shortcut_id: ShortcutId) -> Tuple[KeyCombination, ...]: - """Every combination an action answers to as the draft stands. - - An action the reader touched answers the one combination they gave it, while the rest answer - the aliases the scheme ships beside their combination, which an assignment has to take too. - """ - if shortcut_id not in self.edits: - return self.base.shortcut(shortcut_id).combinations() - - combination = self.edits[shortcut_id] - return () if combination is None else (combination,) diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index dde05a076..52aa7003d 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): @@ -18,9 +18,10 @@ class ShortcutCategory(StrEnum): """ APPLICATION = "application" + SOURCES = "sources" ORDER = "order" TRACKER = "tracker" - SAMPLES = "samples" + VOICES = "voices" DIALOG = "dialog" @@ -48,6 +49,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 +66,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) @@ -102,6 +108,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: SELECT_TAB_SEQUENCER = ("SelectTabSequencer", ShortcutCategory.APPLICATION) SELECT_TAB_INSTRUCTIONS = ("SelectTabInstructions", ShortcutCategory.APPLICATION) + SOURCES_REMOVE_SOURCE = ("SourcesRemoveSource", ShortcutCategory.SOURCES) + ORDER_PREVIOUS_POSITION = ("OrderPreviousPosition", ShortcutCategory.ORDER) ORDER_NEXT_POSITION = ("OrderNextPosition", ShortcutCategory.ORDER) ORDER_PREVIOUS_CHANNEL = ("OrderPreviousChannel", ShortcutCategory.ORDER) @@ -180,13 +188,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 +221,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/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 9cb137288..4a78c35eb 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -40,6 +40,10 @@ def register(self, shortcut_id: ShortcutId, callback: Callback) -> None: """Names the call an action makes when its combination is pressed or its menu item chosen.""" self._callbacks[shortcut_id] = callback + def shortcut(self, shortcut_id: ShortcutId) -> Shortcut: + """The binding an action answers to under the scheme in place, rebinds included.""" + return self._source.shortcut(shortcut_id) + def add_menu_item( self, shortcut_id: ShortcutId, @@ -115,8 +119,8 @@ def _dispatch(self, event: KeyEvent) -> bool: """Fires the shortcut matching the event, yielding its key to a focused field that acts on it unless the shortcut is field-transparent. - A field keeps only the keys it uses, so a text field holds plain Space and its editing keys - while Ctrl+Space and Escape still reach playback and Stop from the same field. + A field keeps only the keys it uses, so a text field holds plain Space, Escape and its + editing keys while Ctrl+Space still reaches playback from the same field. """ for shortcut, callback in self._bindings_by_key.get(event.key, ()): if not shortcut.matches(event): diff --git a/src/sampletones_application/utils/gui/shortcuts/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py index 38f0d439b..2ff090425 100644 --- a/src/sampletones_application/utils/gui/shortcuts/scheme.py +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -2,11 +2,15 @@ from functools import cached_property from pathlib import Path -from typing import Dict, List, Mapping, Optional, Self +from typing import Dict, List, Mapping, Optional, Self, Tuple from pydantic import BaseModel, model_validator -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, + display_combinations, + parse_combinations, +) from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( SHORTCUT_IDS_BY_NAME, @@ -118,35 +122,36 @@ def action( def with_binding( self, shortcut_id: ShortcutId, - combination: Optional[KeyCombination], + combinations: Tuple[KeyCombination, ...], ) -> ShortcutScheme: - """The scheme with one action answering ``combination``, as it stands for every other entry. + """The scheme with one action answering ``combinations``, as it stands for every other entry. Args: shortcut_id: The action being given keys. - combination: The keys it answers to, ``None`` leaving it unbound. + combinations: The keys it answers to, main key first; an empty list leaves it unbound. Returns: ShortcutScheme: The scheme every action resolves against once the binding is read. Raises: - SystemError: when another action of the same category already answers the combination. - KeyError: when the combination names a key the key table holds none of. + SystemError: when another action of the same category already answers one of the keys. + KeyError: when a combination names a key the key table holds none of. """ - return self.with_bindings({shortcut_id: combination}) + return self.with_bindings({shortcut_id: combinations}) def with_bindings( self, - combinations: Mapping[ShortcutId, Optional[KeyCombination]], + bindings: Mapping[ShortcutId, Tuple[KeyCombination, ...]], ) -> ShortcutScheme: - """The scheme as the named actions answer the combinations given, read in one step. + """The scheme as the named actions answer the keys given, read in one step. - A named action answers the combination stated and that alone, so the aliases the scheme - shipped it with go with the keys they extended. Reading the whole set at once is what lets - two actions trade combinations, each arriving at keys the other is leaving. + A named action answers the list stated: its first key is the one the action displays, and + the rest answer beside it. Reading the whole set at once is what lets two actions trade + keys, each arriving at keys the other is leaving. Args: - combinations: The keys each named action answers to, ``None`` leaving an action unbound. + bindings: The keys each named action answers to, main key first; an empty list leaves an + action unbound. Returns: ShortcutScheme: The scheme every action resolves against once the bindings are read. @@ -157,9 +162,9 @@ def with_bindings( """ entries: Dict[ShortcutId, WrittenShortcut] = { shortcut_id: self.bindings[shortcut_id].rebound( - None if combination is None else combination.display(), + tuple(combination.display() for combination in combinations), ) - for shortcut_id, combination in combinations.items() + for shortcut_id, combinations in bindings.items() } return ShortcutScheme( @@ -174,12 +179,14 @@ def with_overrides( """The scheme as a reader rebound it, each entry giving one action the keys it names. An override names its action the way a keybinding file writes it, which lets a preference - outlive the build that stored it. The set is read at once, so entries that pass combinations - between them arrive together; where the whole leaves the scheme unresolvable, the entries are - read one at a time and each that stands aside costs only itself. + outlive the build that stored it, and lists the action's keys joined by commas. The entries + are read together, in any order, so entries that pass keys between their actions all stand. + An entry naming an action or a key this build has none of is reported and left out, and so + is one giving its action a key another action of the category keeps. The action of an entry + left out keeps the scheme's keys. Args: - overrides: The combination each rebound action answers to, keyed by the action's name. + overrides: The keys each rebound action answers to, keyed by the action's name. Returns: ShortcutScheme: The scheme every action resolves against once the overrides are read. @@ -187,39 +194,7 @@ def with_overrides( if not overrides: return self - try: - return self.with_bindings(self._read_overrides(overrides)) - except (KeyError, SystemError) as exception: - logger.warning(f"Keybindings overrides read one entry at a time: {exception}") - return self._rebound_each(overrides) - - def rebound(self, name: str, combination: Optional[str]) -> ShortcutScheme: - """The scheme as one stored preference rebinds it, read the way a preference is read. - - An entry takes effect while it names an action this build carries, a key the table holds and - a combination its category has room for; anything else is reported and the scheme is - returned as it stands, so one unreadable preference costs only itself. - - Args: - name: The action the entry rebinds, named the way a keybinding file writes it. - combination: The keys it answers to, ``None`` leaving the action unbound. - - Returns: - ShortcutScheme: The scheme the entry leaves in place. - """ - shortcut_id = SHORTCUT_IDS_BY_NAME.get(name) - if shortcut_id is None: - logger.warning(f"Keybinding override names unknown action {name!r}, keeping the scheme's own keys") - return self - - try: - return self.with_binding( - shortcut_id, - None if combination is None else KeyCombination.parse(combination), - ) - except (KeyError, SystemError) as exception: - logger.warning(f"Keybinding override giving {name!r} the combination {combination!r} left out: {exception}") - return self + return self.with_bindings(self._standing_overrides(self._readable_overrides(overrides))) @classmethod def load(cls, path: Path) -> ShortcutScheme: @@ -239,28 +214,73 @@ def load(cls, path: Path) -> ShortcutScheme: return cls.model_validate(raw) - def _read_overrides( + def _readable_overrides( self, overrides: Mapping[str, Optional[str]], - ) -> Dict[ShortcutId, Optional[KeyCombination]]: - """Every override as the action and the combination it names. + ) -> Dict[ShortcutId, Tuple[KeyCombination, ...]]: + """Every override naming an action this build carries and keys the table holds, as that action + and its keys, each other override reported and left out.""" + readable: Dict[ShortcutId, Tuple[KeyCombination, ...]] = {} + for name, keys in overrides.items(): + shortcut_id = SHORTCUT_IDS_BY_NAME.get(name) + if shortcut_id is None: + logger.warning(f"Keybinding override names unknown action {name!r}, keeping the scheme's own keys") + continue + + try: + readable[shortcut_id] = self._read_keys(keys) + except KeyError as exception: + logger.warning(f"Keybinding override giving {name!r} the keys {keys!r} left out: {exception}") + + return readable + + @staticmethod + def _read_keys(keys: Optional[str]) -> Tuple[KeyCombination, ...]: + """The keys a stored override lists, none for an override stating ``None``. Raises: - KeyError: when an entry names an action this build carries none of, or a key the table - holds none of. + KeyError: when a listed combination names a key the table holds none of. """ - return { - SHORTCUT_IDS_BY_NAME[name]: None if combination is None else KeyCombination.parse(combination) - for name, combination in overrides.items() - } + return () if keys is None else parse_combinations(keys) + + def _standing_overrides( + self, readable: Dict[ShortcutId, Tuple[KeyCombination, ...]] + ) -> Dict[ShortcutId, Tuple[KeyCombination, ...]]: + """The overrides left once each one sharing a key with another action of its category is + reported and left out. + + An entry left out brings its action's keys back, which can meet the keys of another entry, so + the check runs again until every key reaches one action of its category. + """ + standing = dict(readable) + colliding = self._colliding_overrides(standing) + while colliding: + for shortcut_id in colliding: + logger.warning( + f"Keybinding override giving {shortcut_id.value!r} the keys " + f"{display_combinations(standing.pop(shortcut_id))!r} left out: " + f"another action of the {shortcut_id.category} category answers one of them" + ) + + colliding = self._colliding_overrides(standing) + + return standing + + def _colliding_overrides(self, overrides: Dict[ShortcutId, Tuple[KeyCombination, ...]]) -> Tuple[ShortcutId, ...]: + """The overrides answering a key another action of their category answers, every action not + overridden keeping the scheme's keys.""" + holders: Dict[Tuple[ShortcutCategory, KeyCombination], Dict[ShortcutId, None]] = {} + for shortcut_id in ShortcutId: + keys = overrides[shortcut_id] if shortcut_id in overrides else self.shortcut(shortcut_id).combinations() + for combination in keys: + holders.setdefault((shortcut_id.category, combination), {})[shortcut_id] = None - def _rebound_each(self, overrides: Mapping[str, Optional[str]]) -> ShortcutScheme: - """The scheme as every override that stands rebinds it, read one entry at a time.""" - scheme = self - for name, combination in overrides.items(): - scheme = scheme.rebound(name, combination) + colliding: Dict[ShortcutId, None] = {} + for actions in holders.values(): + if len(actions) > 1: + colliding.update(dict.fromkeys(shortcut_id for shortcut_id in actions if shortcut_id in overrides)) - return scheme + return tuple(colliding) def _require_every_action_answered(self) -> None: unanswered: List[str] = [shortcut_id.value for shortcut_id in ShortcutId if shortcut_id not in self.bindings] diff --git a/src/sampletones_application/utils/gui/shortcuts/written.py b/src/sampletones_application/utils/gui/shortcuts/written.py index 035b2a8ff..af9f7f6c5 100644 --- a/src/sampletones_application/utils/gui/shortcuts/written.py +++ b/src/sampletones_application/utils/gui/shortcuts/written.py @@ -23,17 +23,20 @@ class WrittenShortcut(BaseModel, frozen=True): aliases: Tuple[str, ...] = NO_WRITTEN_ALIASES field_transparent: bool = False - def rebound(self, combination: Optional[str]) -> WrittenShortcut: - """The entry as a reader rebound it, answering the combination they named and that alone. + def rebound(self, combinations: Tuple[str, ...]) -> WrittenShortcut: + """The entry as a reader rebound it, answering the keys they named, the first one displayed. - The reader states one combination, which is the whole of what reaches the action; the field - transparency stays, since it follows from the action's role rather than from its keys. + The reader states the whole list of keys that reach the action: the first is the one menus + print and the rest answer beside it. The field transparency stays, since it follows from the + action's role. Args: - combination: The keys the action answers to, ``None`` leaving it unbound. + combinations: The keys the action answers to, main key first; an empty list leaves it + unbound. """ return WrittenShortcut( - combination=combination, + combination=combinations[0] if combinations else None, + aliases=combinations[1:], field_transparent=self.field_transparent, ) 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..f995fdfb8 100644 --- a/src/sampletones_application/utils/gui/tooltip.py +++ b/src/sampletones_application/utils/gui/tooltip.py @@ -1,4 +1,5 @@ -from typing import List, Optional, Tuple +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -9,42 +10,80 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_shared.types.application import Sender from sampletones_shared.types.data import SerializedData +@dataclass(frozen=True) +class DetailSwatch: + """One thing a hover lists, named and marked in the color that thing is drawn in elsewhere.""" + + name: str + color: BaseColor + + def show_tooltip( parent: Sender, 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, *, tag: str, + text_tag: Optional[str] = None, ) -> None: """Attaches an explanatory tooltip to ``parent`` — an enabled group wrapping a control that can be disabled — and hides it by default. Toggle ``tag``'s ``show`` to reveal the explanation while the control is unavailable. The wrapper group is the hover target because DearPyGui surfaces a tooltip - for an enabled item, so the explanation reaches the user even when the inner control is disabled.""" + for an enabled item, so the explanation reaches the user even when the inner control is disabled. + + ``text_tag`` names the message itself, which is what a caller gives it where the reason the + control is unavailable changes while the control stands.""" + text_kwargs: SerializedData = {} + if text_tag is not None: + text_kwargs["tag"] = text_tag + with dpg.tooltip(parent, tag=tag, show=False, hide_on_activity=True) as tooltip: ThemeRegistry.get(TAG_GLOBAL_THEME_TOOLTIP).bind_to_item(tooltip) - tooltip_text = dpg.add_text(message) + tooltip_text = dpg.add_text(message, **text_kwargs) FontRegistry.bind_to_item(tooltip_text, Font.REGULAR_SMALL) @@ -59,13 +98,36 @@ def create_detail_tooltip(parent: str, *, tag: str) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_TOOLTIP).bind_to_item(tag) -def populate_detail_tooltip(tag: str, items: List[Tuple[str, str]]) -> None: - """Replaces the tooltip's content with ``label``/``value`` pairs in two aligned columns. +def populate_detail_tooltip( + tag: str, + items: List[Tuple[str, str]], + *, + swatch_glyph: str, + swatch_label: str, + swatches: Sequence[DetailSwatch], +) -> None: + """Replaces the tooltip's content with ``label``/``value`` pairs and the list that follows them. ``mvTable_SizingFixedFit`` sizes each column to its own content, so the label column tracks its own text width independently of the value column. A compact theme tightens the row padding. + The list below the pairs reads under ``swatch_label``, one row per thing, each marked with + ``swatch_glyph`` in that thing's own color. A tooltip given nothing to list shows the pairs + alone. """ dpg.delete_item(tag, children_only=True) + if items: + _add_detail_table(tag, items) + + if not swatches: + return + + if items: + dpg.add_separator(parent=tag) + + _add_swatch_list(tag, swatch_glyph, swatch_label, swatches) + + +def _add_detail_table(tag: str, items: List[Tuple[str, str]]) -> None: with dpg.table( parent=tag, header_row=False, @@ -84,3 +146,21 @@ def populate_detail_tooltip(tag: str, items: List[Tuple[str, str]]) -> None: FontRegistry.bind_to_item(label_text, Font.REGULAR_SMALL) value_text = dpg.add_text(value) FontRegistry.bind_to_item(value_text, Font.MONO_SMALL) + + +def _add_swatch_list( + tag: str, + glyph: str, + label: str, + swatches: Sequence[DetailSwatch], +) -> None: + """Lists each thing under its heading, the mark ahead of the name reading in the thing's color.""" + heading = dpg.add_text(label, parent=tag) + FontRegistry.bind_to_item(heading, Font.REGULAR_SMALL) + for swatch in swatches: + with dpg.group(horizontal=True, parent=tag): + mark = dpg.add_text(glyph) + dpg_set_palette_color(mark, swatch.color) + FontRegistry.bind_to_item(mark, Font.ICON) + name = dpg.add_text(swatch.name) + FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) 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..e782ad793 100644 --- a/src/sampletones_application/utils/palette/colors/written.py +++ b/src/sampletones_application/utils/palette/colors/written.py @@ -5,7 +5,10 @@ from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_application.utils.palette.colors.named import NamedColor -from sampletones_application.utils.palette.reference import PaletteReference, is_reference +from sampletones_application.utils.palette.reference import ( + PaletteReference, + is_reference, +) from sampletones_application.utils.palette.source import PaletteSource from sampletones_shared.utils.color import parse_hex_color @@ -13,7 +16,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 +36,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 +50,9 @@ 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( # noqa: TRY004 + 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/background.py b/src/sampletones_application/utils/parallelization/background.py index 04606c0b7..edf1495fe 100644 --- a/src/sampletones_application/utils/parallelization/background.py +++ b/src/sampletones_application/utils/parallelization/background.py @@ -11,9 +11,10 @@ def stop_background_workers(timeout: float = SHUTDOWN_JOIN_TIMEOUT) -> None: The shutdown is requested first so any in-flight task unwinds at its next cancellation point, letting the join return promptly. Stopping the callback - queue then discards any results still pending delivery to the main thread, and - the concurrent executor threads touch the dearpygui context, so both are halted - and awaited before that context is destroyed. + queue then discards any results still pending delivery to the main thread and + lets go of any posted after it, and the concurrent executor threads touch the + dearpygui context, so both are halted and awaited before that context is + destroyed. """ SingleThreadExecutor.request_shutdown() CallbackQueue.stop() diff --git a/src/sampletones_application/utils/parallelization/coalescing.py b/src/sampletones_application/utils/parallelization/coalescing.py index 8ca8e0872..13d43d9ae 100644 --- a/src/sampletones_application/utils/parallelization/coalescing.py +++ b/src/sampletones_application/utils/parallelization/coalescing.py @@ -1,9 +1,11 @@ import threading -from typing import Optional +from typing import Final, Optional -from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor, run_background_task from sampletones_shared.types.callback import VoidCallback +TASK_FAILURE_ORIGIN: Final[str] = "Error in a task of a latest-wins executor" + class LatestWinsExecutor: """Runs tasks on a single background thread, keeping only the latest queued task. @@ -13,7 +15,8 @@ class LatestWinsExecutor: submission always runs. The worker drains to the newest pending task before it stops, and ``wait=True`` on the next launch joins a worker still tearing down, so a submission made during that hand-off carries into the next run. Built on :class:`SingleThreadExecutor`, - so its worker joins at teardown like any other background thread. + so its worker joins at teardown like any other background thread. A task that fails is + reported, and the worker goes on to the next one. ``is_running`` reports the busy span as a single truth: true from the first submission until the worker drains every queued task and stops. Callers read this single span to @@ -56,9 +59,10 @@ def _drain(self) -> None: """Runs queued tasks to exhaustion, always taking the newest pending one. Between tasks the worker pops the latest submission under the lock, so a burst - collapses to a single trailing run. It clears the running flag and exits only once - no task remains, so every submission is picked up by the running worker or launches - a fresh one. + collapses to a single trailing run. Each task runs through :func:`run_background_task`, + which reports a failing one and hands the worker back to the loop. The worker clears the + running flag and exits once no task remains, so every submission is picked up by the + running worker or launches a fresh one. """ while True: with self._lock: @@ -68,4 +72,4 @@ def _drain(self) -> None: self._running = False return - task() + run_background_task(task, TASK_FAILURE_ORIGIN) diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py index d13f101c6..adcdf4c9e 100644 --- a/src/sampletones_application/utils/parallelization/thread.py +++ b/src/sampletones_application/utils/parallelization/thread.py @@ -2,16 +2,17 @@ import threading import time -from functools import wraps +from functools import partial, wraps from typing import Any, Callable, ClassVar, Final, List, Optional, Set, cast +from sampletones_application.utils.callbacks.failures import UnhandledFailures from sampletones_shared.logger import logger from sampletones_shared.types.callback import CallbackT, VoidCallback 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 +65,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() @@ -116,6 +117,24 @@ def _report_surviving_workers(cls, threads: List[threading.Thread]) -> None: logger.warning(f"Background workers still running after the shutdown deadline: {names}") +def run_background_task(task: VoidCallback, origin: str) -> None: + """Runs one task a worker thread was handed, where the task's failure ends the task alone. + + A worker thread is an entry point, so whatever escapes a task is reported here and the worker goes + on to its next task. A task unwinding through :class:`BackgroundWorkCanceled` ends quietly. + + Args: + task: The work to run. + origin: The log line's opening, naming the task that failed. + """ + try: + task() + except BackgroundWorkCanceled: + return + except Exception as exception: # pylint: disable=broad-exception-caught + UnhandledFailures.report(exception, origin) + + def concurrent( wait: bool = True, method_bound: bool = False, @@ -138,15 +157,10 @@ def task() -> None: if SingleThreadExecutor.is_shutting_down(): return - try: - function(self, *args, **kwargs) - except BackgroundWorkCancelled: - return - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error_with_traceback( - exception, - f"Error in background task {function.__qualname__}", - ) + run_background_task( + partial(function, self, *args, **kwargs), + f"Error in background task {function.__qualname__}", + ) executor.execute(task, wait=wait) diff --git a/src/sampletones_application/utils/placement.py b/src/sampletones_application/utils/placement.py new file mode 100644 index 000000000..6ad940d7a --- /dev/null +++ b/src/sampletones_application/utils/placement.py @@ -0,0 +1,24 @@ +from typing import Tuple + + +def centered_position( + center: Tuple[int, int], + width: int, + height: int, +) -> Tuple[int, int]: + """Where a box of this size rests with its middle on ``center``. + + Each axis is held at zero at the least, so a box taller or wider than the space it is + centered in keeps its top-left corner reachable — which is what leaves a dialog's title bar + on screen when the reader has made the window smaller than the dialog it raises. + + Args: + center: The point the box is centered on. + width: The box's width. + height: The box's height. + + Returns: + Tuple[int, int]: The box's top-left corner. + """ + center_x, center_y = center + return max(0, round(center_x - width / 2)), max(0, round(center_y - height / 2)) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 89d5e1e31..1abb9dfbb 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -1,19 +1,25 @@ 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.constants.output import OutputKind 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): IDLE = "idle" WAITING = "waiting" RUNNING = "running" - CANCELLING = "cancelling" + CANCELING = "canceling" COMPLETED = "completed" - CANCELLED = "cancelled" + CANCELED = "canceled" FAILED = "failed" @@ -32,9 +38,10 @@ class ConverterAction(StrEnum): { ConversionPhase.WAITING, ConversionPhase.RUNNING, - ConversionPhase.CANCELLING, + ConversionPhase.CANCELING, } ) +SINGLE_SOURCE: Final[int] = 1 class ConverterViewModel(BaseModel, frozen=True): @@ -54,6 +61,16 @@ class ConverterViewModel(BaseModel, frozen=True): output_path: Optional[Path] is_file: bool other_operation_active: bool + output: OutputKind + stem_sources: Tuple[StemRowViewModel, ...] + hierarchy_mode: HierarchyMode + max_sources: int + selected_key: Optional[str] + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self.output.mixes @property def progress_overlay(self) -> str: @@ -64,13 +81,79 @@ def progress_overlay(self) -> str: def is_active(self) -> bool: return self.phase in ACTIVE_PHASES + @property + def live(self) -> bool: + """Whether the setup answers gestures, which a conversion holding resources holds still.""" + return not self.is_active + @property def subpanel_visible(self) -> bool: return self.phase != ConversionPhase.IDLE + @property + def has_input(self) -> bool: + """Something is there to convert: a gathered recording holding a channel the run enables.""" + return any(row.takes_part for row in self.stem_sources) + + @property + def source_count(self) -> int: + return len(self.stem_sources) + + @property + def listed(self) -> bool: + """Something stands in the list, which is what the run's own choices answer for.""" + return bool(self.stem_sources) + + @property + def mixes_several(self) -> bool: + """A mix is being built from more than one recording, which is what an order decides.""" + return self.mixes and self.source_count > SINGLE_SOURCE + + @property + def shows_input(self) -> bool: + """A run is reporting the recording it is on, which is what the input line names.""" + return self.input_path is not None and self.is_active + + @property + def channels_in_play(self) -> Tuple[ChannelName, ...]: + """The channels a row draws a box on, in the order the application names them. + + What a run reaches is what its rows hold, so every channel is put to a reader and the + settings card narrows a row that should reach fewer. + """ + return tuple(ChannelName.items()) + + @property + def stems_list(self) -> StemsListViewModel: + """The gathered recordings as the stems list draws them, inert while a conversion runs. + + A level is a turn to choose, so the bands and the drag that rearranges them arrive with + the second recording of a mix; one recording is its own order and reads as a plain run. + """ + return StemsListViewModel( + rows=self.stem_sources, + channels_in_play=self.channels_in_play, + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=self.live, + collapse_levels=not self.mixes_several, + selected_key=self.selected_key, + ) + + @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 can_add_source(self) -> bool: + """Another recording would reach the run, which a full mix answers no to.""" + return not self.mixes or 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: @@ -83,6 +166,6 @@ def primary_action(self) -> ConverterAction: @property def primary_action_enabled(self) -> bool: if self.primary_action == ConverterAction.CANCEL: - return self.phase != ConversionPhase.CANCELLING + return self.phase != ConversionPhase.CANCELING return self.convert_button_enabled diff --git a/src/sampletones_application/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/reconstructor.py deleted file mode 100644 index 06c155402..000000000 --- a/src/sampletones_application/view_model/main/reconstructor.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import FrozenSet - -from pydantic import BaseModel - -from sampletones_core.constants.enums import GeneratorName - - -class ReconstructorPanelViewModel(BaseModel, frozen=True): - generators: FrozenSet[GeneratorName] - drive: float diff --git a/src/sampletones_application/view_model/main/source.py b/src/sampletones_application/view_model/main/source.py new file mode 100644 index 000000000..aa4610366 --- /dev/null +++ b/src/sampletones_application/view_model/main/source.py @@ -0,0 +1,109 @@ +from typing import Final, FrozenSet, Optional, Tuple + +from pydantic import BaseModel + +from sampletones_application.constants.sources import SourceKind +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.algorithm import ( + ALL_STEMS_CHANNEL_CAP, + MIN_STEMS_CHANNEL_CAP, + UNIT_DRIVE, +) +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName + +CHANNEL_CAP_STEPS: Final[Tuple[int, ...]] = tuple(range(MIN_STEMS_CHANNEL_CAP, ALL_STEMS_CHANNEL_CAP + 1)) + + +class InspectedSourceViewModel(BaseModel, frozen=True): + """The row the settings card is editing, as the card names it. + + A folder reads its own name and how many recordings it stands for, so the card says what a + choice made here reaches; a recording reads its name alone. + """ + + name: str + kind: SourceKind + holds: int + + @property + def stands_for_a_folder(self) -> bool: + """The row is a folder, so its count is part of what names it.""" + return self.kind is SourceKind.FOLDER + + +class ChannelSettingsViewModel(BaseModel, frozen=True): + """One channel as the settings card draws it: its use, its bend and how hard it is driven. + + ``use`` and ``bend`` read the way a folder's boxes do — every inspected recording makes the + choice, some of them do, or none does. ``drive`` is the level they agree on, and nothing where + they differ. + """ + + channel: ChannelName + use: Agreement + bend: Agreement + drive: Optional[float] + + @property + def used(self) -> bool: + """An inspected recording occupies the channel, which is what its bend and its drive belong to.""" + return self.use is not Agreement.NONE + + @property + def bendable(self) -> bool: + """The channel's hardware loads a divider, which is what puts a box in the bend column.""" + return self.channel in TONE_CHANNELS + + @property + def bend_offered(self) -> bool: + """A bend is put to a reader here: the hardware reads one and a recording occupies it.""" + return self.bendable and self.used + + @property + def drive_mixed(self) -> bool: + """The recordings the card inspects drive the channel differently.""" + return self.drive is None + + @property + def drive_shown(self) -> float: + """Where the channel's slider rests, unit drive standing for a mixed reading.""" + return UNIT_DRIVE if self.drive is None else self.drive + + +class SourceSettingsPanelViewModel(BaseModel, frozen=True): + """What the settings card shows: one line per channel, and the row it edits them on. + + ``subject`` names the row a reader picked out of the converter's list; with none picked the + card edits the settings a recording joins with, which is what every recording gathered from + then on starts with. ``caps`` are the counts the inspected recordings hold, so a folder whose + recordings differ reads half-lit, and ``channels_used`` is the most channels any of them + occupies, past which a step reaches nothing. ``live`` says whether the card takes a gesture, + which a conversion under way answers. + """ + + subject: Optional[InspectedSourceViewModel] + channels: Tuple[ChannelSettingsViewModel, ...] + caps: FrozenSet[int] + channels_used: int + live: bool + + @property + def edits_new_recordings(self) -> bool: + """No row is picked out, so the card edits what a recording joins with.""" + return self.subject is None + + @property + def channel_cap(self) -> Optional[int]: + """The count the inspected recordings agree on, and nothing where they differ.""" + return next(iter(self.caps)) if len(self.caps) == 1 else None + + def step_agreement(self, step: int) -> Agreement: + """How the inspected recordings read on one step of the count.""" + if self.caps == {step}: + return Agreement.ALL + + return Agreement.SOME if step in self.caps else Agreement.NONE + + def step_reaches(self, step: int) -> bool: + """A run could sound this many channels, which the channels a recording occupies allow.""" + return step <= self.channels_used diff --git a/src/sampletones_application/view_model/main/updates.py b/src/sampletones_application/view_model/main/updates.py index 1c5b0d5ee..97b66658b 100644 --- a/src/sampletones_application/view_model/main/updates.py +++ b/src/sampletones_application/view_model/main/updates.py @@ -1,9 +1,8 @@ from pathlib import Path -from typing import List from pydantic import BaseModel -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.constants.enums import SpectrumMethod class AudioSettingsUpdate(BaseModel, frozen=True): @@ -16,11 +15,6 @@ class LibrarySettingsUpdate(BaseModel, frozen=True): nes_frequency: int -class GenerationSettingsUpdate(BaseModel, frozen=True): - drive: float - generators: List[GeneratorName] - - class AdvancedSettingsUpdate(BaseModel, frozen=True): max_workers: int spectrum_method: SpectrumMethod diff --git a/src/sampletones_application/view_model/reconstruction/envelopes.py b/src/sampletones_application/view_model/reconstruction/envelopes.py new file mode 100644 index 000000000..cafbbbf4a --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/envelopes.py @@ -0,0 +1,30 @@ +from typing import Dict + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.ownership import OwnershipLaneViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features + + +class ChannelEnvelopesViewModel(BaseModel, extra="forbid", frozen=True): + """What the instruments panel plots: each channel's envelopes and who holds their frames. + + The two are read from one part of the document, frame for frame, so a stretch painted under + a dimension's bars stands under the frames those bars draw. A voice written by hand answers + to no recording, and so does a document holding one, so both carry envelopes alone. + + Attributes: + channels: The envelopes each channel plots. + ownership: The recordings behind each channel's frames. + """ + + channels: Dict[ChannelName, Features] + ownership: Dict[ChannelName, OwnershipLaneViewModel] + + def __getitem__(self, channel_name: ChannelName) -> Features: + return self.channels[channel_name] + + def lane(self, channel_name: ChannelName) -> OwnershipLaneViewModel: + """The stretches under one channel's bars, empty where no recording is told apart there.""" + return self.ownership.get(channel_name, OwnershipLaneViewModel(channel_name=channel_name, runs=())) 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/rate.py b/src/sampletones_application/view_model/reconstruction/rate.py new file mode 100644 index 000000000..e6b40418d --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/rate.py @@ -0,0 +1,12 @@ +from enum import StrEnum + + +class RateLock(StrEnum): + """Why the open reconstruction's NES frequency is locked, which the Source card explains. + + A sample of the project runs at the rate the project sets. A reconstruction with no file keeps + its rate until it is saved to one. + """ + + PROJECT_SAMPLE = "project_sample" + NO_FILE = "no_file" diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index 7c48cf0e3..e322cd9f1 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -1,56 +1,53 @@ -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 +from .rate import RateLock 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. :attr:`rate_lock` says why the tab may not change that rate, + and is ``None`` for a document living on disk, which takes a new rate from the tab. """ 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] + rate_lock: Optional[RateLock] @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 nes_frequency_editable(self) -> bool: + """The rate can be changed while a loaded document has no reason to keep it.""" + return self.reconstruction_loaded and self.rate_lock is None + + @property + def show_nes_frequency_hint(self) -> bool: + """The hint explains the locked rate, so it appears exactly when a loaded document's rate is locked.""" + return self.reconstruction_loaded and self.rate_lock is not None @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..e6a46eb2a --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/stems.py @@ -0,0 +1,29 @@ +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 + + @property + def show_setup_line(self) -> bool: + """The setup line states how the levels of a stems record took turns.""" + 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 deleted file mode 100644 index 81f525015..000000000 --- a/src/sampletones_application/view_model/reconstruction/update.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import NamedTuple - -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.types.feature import FeatureValue - - -class ReconstructionUpdate(NamedTuple): - generator_name: GeneratorName - feature_key: FeatureKey - data: FeatureValue 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..9135c1193 --- /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 an instrument 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..eb9f75a6a --- /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 sample states and an instrument does not, + so it takes a sample 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..96f92911b 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) @@ -35,7 +35,7 @@ class GridRegion(BaseModel, frozen=True): Both bounds are inclusive, so a region always covers the cell it was started from and the smallest one covers exactly that cell. A producer orders the bounds it was given, which is - what makes a selection dragged upwards name the same region as one dragged down to the same + what makes a selection dragged upward name the same region as one dragged down to the same pair of cells. """ @@ -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..9f667a6d8 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -1,89 +1,181 @@ -from typing import Callable, Dict, FrozenSet, Set, Tuple +from typing import Callable, Dict, Final, 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.project.patterns.pitch import RowPitch +from sampletones_core.project.patterns.row import NoteCommand from sampletones_core.utils.display import ( display_id, - display_transpose, + display_pitch, display_volume, ) +from sampletones_shared.utils.agreement import Agreement + +NO_REACH: Final[int] = 0 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 - tracker grid renders :attr:`label`, the combined cell text. + source of tracker cell formatting (voice position, pitch, volume). The + tracker grid renders :attr:`label`, the combined cell text. The values the + faces were printed from travel beside them, so a reader describing the cell + speaks of what it holds. """ - 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. + """ + pitch: Optional[RowPitch] + level: Optional[int] + command: Optional[NoteCommand] @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. An 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. + 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 instruments spans none, since each of those sounds on the one + channel it is named in. + """ + carried_channels: FrozenSet[ChannelName] + """Channels a sample is playing on at this row, whichever row of the frame started it. - The sample column summarises a subcolumn only across these channels, so a - sample that spans more channels than it currently occupies reads as mixed. + The reading runs down the frame from its first row, so a channel cut since, or now carrying an + instrument, falls out of it. A row naming no sample hands its pitch and volume to these. """ @property - def subcolumn_generators(self) -> FrozenSet[GeneratorName]: - """Channels every sample column summary spans. + def note_channels(self) -> FrozenSet[ChannelName]: + """Channels the sample column's voice slot summarizes. + + A sample governs the channels its reconstruction covers, so the slot summarizes exactly + those. A row naming no sample spans every channel, so a row cut on each reads as a cut. + """ + return self.sample_channels or frozenset(self.cells) + + @property + def offset_channels(self) -> FrozenSet[ChannelName]: + """Channels the sample column's pitch and volume summarize, and write to. - 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. + A row placing a sample spans that sample's channels, and a row below it the channels a + sample is still playing on. A row where no sample plays spans none and reads empty. """ - return self.relevant_generators or frozenset(self.cells) + return self.sample_channels or self.carried_channels @property - def sample_instrument(self) -> str: - return self._aggregate(lambda cell: cell.instrument, display_id(None)) + def takes_offsets(self) -> bool: + """Whether a pitch or a volume typed in the sample column reaches a channel of this row.""" + return bool(self.offset_channels) @property - def sample_transpose(self) -> str: - return self._aggregate(lambda cell: cell.transpose, display_transpose(None)) + def sample(self) -> str: + return self._aggregate( + _sample_reading, + display_id(None), + self.note_channels, + ) @property - def sample_volume(self) -> str: - return self._aggregate(lambda cell: cell.volume, display_volume(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 transpose(self) -> str: + return self._aggregate( + lambda cell: cell.transpose, + display_pitch(None), + self.offset_channels, + ) + + @property + def volume(self) -> str: + return self._aggregate( + lambda cell: cell.volume, + display_volume(None), + self.offset_channels, + ) def _aggregate( self, select: Callable[[SequencerCellViewModel], str], default: str, + channels: FrozenSet[ChannelName], ) -> str: - """Summarise one subcolumn across the channels the sample column spans. + """Summarize one subcolumn across the channels the sample column spans in it. 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. + blank on the rest. A subcolumn spanning no channel 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 channels} return aggregate_labels(values, default=default) +class SequencerContextRowViewModel(BaseModel, frozen=True): + """A row of another frame, drawn beside the shown one so the song reads on past the frame's edges. + + ``row`` reads as it does while its own frame is shown, and ``frame_index`` names that frame. + """ + + frame_index: int + row: SequencerRowViewModel + + class SequencerTrackerViewModel(BaseModel, frozen=True): - """The tracker view for a single order frame across the four channels. + """The tracker view for a single order frame across the four channels, with the song around it. Each channel plays its ``order[frame_index]`` pattern; the grid shows those patterns aligned row by row. Channels whose order is shorter than ``frame_index`` contribute empty cells. + + ``lead`` holds the rows the song plays before the frame and ``trail`` the rows it plays after, + each in playing order and as many as the tracker reaches, so a row near the frame's edge stands + among its neighbors. Both end where the song does. """ frame_index: int frame_count: int rows: Tuple[SequencerRowViewModel, ...] + lead: Tuple[SequencerContextRowViewModel, ...] + trail: Tuple[SequencerContextRowViewModel, ...] 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..91bd7ee80 --- /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. An 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/agreement.py b/src/sampletones_application/view_model/shared/agreement.py new file mode 100644 index 000000000..4f07cad42 --- /dev/null +++ b/src/sampletones_application/view_model/shared/agreement.py @@ -0,0 +1,44 @@ +from enum import StrEnum +from typing import Iterable, Self + + +class Agreement(StrEnum): + """How a group of recordings stands on one choice: all of them make it, some do, or none does. + + A row standing for several recordings shows this reading, and one gesture settles the whole + group from it. + """ + + NONE = "none" + SOME = "some" + ALL = "all" + + @classmethod + def over(cls, holdings: Iterable[bool]) -> Self: + """The reading a group of recordings gives, each stating whether it makes the choice. + + A group holding nothing reads as ``NONE``, which is what an empty folder shows. + """ + readings = tuple(holdings) + if readings and all(readings): + return cls.ALL + + return cls.SOME if any(readings) else cls.NONE + + @property + def settles_to(self) -> bool: + """What one gesture makes of this reading: every recording holds the choice. + + A group already agreeing on it lets it go instead, so a reader reaches both answers from + wherever the group stands. + """ + return self is not Agreement.ALL + + @property + def reads_held(self) -> bool: + """Whether a box standing for this reading is drawn ticked. + + A group only some of which makes the choice reads clear and takes the softened tone that + says so, since a tick states an answer the group has yet to give. + """ + return self is Agreement.ALL diff --git a/src/sampletones_application/view_model/shared/audio_settings.py b/src/sampletones_application/view_model/shared/audio_settings.py index f4303e94d..e8c69385d 100644 --- a/src/sampletones_application/view_model/shared/audio_settings.py +++ b/src/sampletones_application/view_model/shared/audio_settings.py @@ -62,9 +62,15 @@ def default_sample_rate_label(self, sample_rate_format: str) -> str: class AudioSettingsViewModel(BaseModel, frozen=True): + """The audio settings dialog's values: the devices to choose from, the one in force and its rate. + + A machine offering no output device has no device in force, so the current index and rate are + ``None`` and their labels read empty. + """ + devices: Tuple[AudioDeviceItem, ...] - current_device_index: int - current_sample_rate: SampleRate + current_device_index: Optional[int] + current_sample_rate: Optional[SampleRate] buffer_size: BufferSize master_gain: float @@ -80,8 +86,8 @@ def from_device_manager( devices=tuple( AudioDeviceItem.from_device(device) for device in audio_device_manager.list_devices().values() ), - current_device_index=current_device.device_index, - current_sample_rate=current_device.sample_rate, + current_device_index=None if current_device is None else current_device.device_index, + current_sample_rate=None if current_device is None else current_device.sample_rate, buffer_size=audio_device_manager.buffer_size, master_gain=master_gain, ) @@ -103,6 +109,10 @@ def current_device(self) -> Optional[AudioDeviceItem]: return None def current_sample_rate_label(self, sample_rate_format: str) -> str: + """The rate in force, formatted, or empty while no device is in force.""" + if self.current_sample_rate is None: + return "" + return format_sample_rate(self.current_sample_rate, sample_rate_format) @property diff --git a/src/sampletones_application/view_model/shared/display_settings.py b/src/sampletones_application/view_model/shared/display_settings.py index 804c2d00f..20b9abbbb 100644 --- a/src/sampletones_application/view_model/shared/display_settings.py +++ b/src/sampletones_application/view_model/shared/display_settings.py @@ -125,6 +125,7 @@ class DisplaySettings(BaseModel, frozen=True): window: WindowMode vsync: bool frame_rate: int + show_frame_rate: bool def with_palette(self, palette: str) -> DisplaySettings: return self.model_copy(update={"palette": palette}) @@ -138,6 +139,9 @@ def with_vsync(self, vsync: bool) -> DisplaySettings: def with_frame_rate(self, frame_rate: int) -> DisplaySettings: return self.model_copy(update={"frame_rate": frame_rate}) + def with_show_frame_rate(self, show_frame_rate: bool) -> DisplaySettings: + return self.model_copy(update={"show_frame_rate": show_frame_rate}) + class DisplaySettingsViewModel(BaseModel, frozen=True): """What the display settings dialog draws: the options offered, and the selection standing. @@ -202,6 +206,7 @@ def build( window=selected, vsync=settings.vsync, frame_rate=nearest_frame_rate(settings.frame_rate, frame_rates), + show_frame_rate=settings.show_frame_rate, ), resolutions=offered, frame_rates=frame_rates, 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..dee7cf5b7 --- /dev/null +++ b/src/sampletones_application/view_model/shared/export.py @@ -0,0 +1,105 @@ +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" + CANCELING = "canceling" + + +ACTIVE_PHASES: Final[FrozenSet[ExportPhase]] = frozenset( + { + ExportPhase.EXPORTING, + ExportPhase.CANCELING, + } +) + + +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 figure_visible(self) -> bool: + """Whether the figure stands beside the turning indicator, which words to state earn. + + A stage with nothing to state leaves the indicator alone, centered on its own width. + """ + return bool(self.figure) + + @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..f0e14199e 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 raw bytes one instrument's envelopes take, every item counted. 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 an instrument 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/keybindings.py b/src/sampletones_application/view_model/shared/keybindings.py index eb272731b..34fd1d461 100644 --- a/src/sampletones_application/view_model/shared/keybindings.py +++ b/src/sampletones_application/view_model/shared/keybindings.py @@ -9,25 +9,25 @@ class KeybindingRow(BaseModel, frozen=True): """One action as the keybindings dialog lists it: its name, its label, and the keys it answers. An action travels under the name a keybinding file writes it by, which is the identity a stored - preference is keyed by as well, so a row states which action it stands for without the view - reaching into the shortcut vocabulary. + preference is keyed by as well, so a row states which action it stands for in the terms the view + reads. Its keys read as a list joined by commas, main key first, and empty while it is unbound. """ action: str label: str - combination: str + keys: str def matches(self, text: str) -> bool: """Whether the row answers a filter, which reads both what it is called and what it answers. Args: - text: What the reader typed, matched in any capitalisation. + text: What the reader typed, matched in any capitalization. Returns: - bool: True while the label or the combination holds the text, and for an empty filter. + bool: True while the label or the keys hold the text, and for an empty filter. """ wanted = text.strip().casefold() - return wanted in self.label.casefold() or wanted in self.combination.casefold() + return wanted in self.label.casefold() or wanted in self.keys.casefold() class KeybindingGroup(BaseModel, frozen=True): @@ -46,13 +46,14 @@ class KeybindingGroup(BaseModel, frozen=True): class KeybindingsViewModel(BaseModel, frozen=True): """What the keybindings dialog draws: the actions listed, the selection standing, and its state. - The dialog edits a draft the owner holds, so what shows here is the draft rather than the keys - the application is running under; the two meet when the reader confirms. + The dialog edits a draft the owner holds, so what shows here is the draft. It reaches the keys + the application runs under when the reader confirms. ``keys`` is what the entry box shows: the + selected action's keys joined by commas. """ groups: Tuple[KeybindingGroup, ...] schemes: Tuple[str, ...] scheme: str selected: Optional[str] - combination: str + keys: str message: str diff --git a/src/sampletones_application/view_model/shared/nsf/__init__.py b/src/sampletones_application/view_model/shared/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/view_model/shared/nsf/choices.py b/src/sampletones_application/view_model/shared/nsf/choices.py new file mode 100644 index 000000000..fc6768e2a --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/choices.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import FrozenSet, Optional + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME, NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation + + +class NSFExportChoices(BaseModel, frozen=True): + """The choices a program is written under, as the export dialog edits them. + + The player owns what a program may state, so the text is held as the header fields it becomes, + cut to the room each has, and every ``with_`` method reconciles its edit against what the source + offers: a channel the source leaves silent stays unticked, a repeat returns to a frame the song + has, and a scheme is one that writes the source differently. Every value held here is therefore + one the program is written with. + + Attributes: + information: The text the file is listed under. + channels: The channels ticked for the program to sound. + repeat: Where the program goes once its song ends. + loop_frame: The order frame a repeat from a frame returns to. + scheme: The layers of the codec the song is written with. + """ + + information: NSFInformation + channels: FrozenSet[ChannelName] + repeat: NSFRepeat + loop_frame: int + scheme: CompressionScheme + + @classmethod + def initial(cls, program: NSFProgram, offer: NSFExportOffer) -> NSFExportChoices: + """The choices a dialog opens on: the program an export writes as its source states it. + + Args: + program: The program the source states. + offer: What the source leaves to choose. + + Returns: + NSFExportChoices: The stated program, its channels narrowed to the ones the source sounds. + + Raises: + ValueError: If the program repeats from a tick past the song's start. + """ + return cls( + information=program.information, + channels=program.channels.intersection(offer.channels), + repeat=cls._stated_repeat(program.loop_tick), + loop_frame=FIRST_FRAME, + scheme=program.scheme, + ) + + @property + def writable(self) -> bool: + """Whether a program is written under these choices, which a ticked channel is what makes.""" + return bool(self.channels) + + def with_title(self, title: str) -> NSFExportChoices: + """The choices listing the file under ``title``, as much of it as the field holds.""" + return self._with_information( + NSFInformation( + title=title, + artist=self.information.artist, + copyright=self.information.copyright, + ) + ) + + def with_artist(self, artist: str) -> NSFExportChoices: + """The choices crediting the file to ``artist``, as much of it as the field holds.""" + return self._with_information( + NSFInformation( + title=self.information.title, + artist=artist, + copyright=self.information.copyright, + ) + ) + + def with_copyright(self, copyright_text: str) -> NSFExportChoices: + """The choices stating ``copyright_text`` as the rights, as much of it as the field holds.""" + return self._with_information( + NSFInformation( + title=self.information.title, + artist=self.information.artist, + copyright=copyright_text, + ) + ) + + def with_channel( + self, + channel: ChannelName, + sounded: bool, + offer: NSFExportOffer, + ) -> NSFExportChoices: + """The choices sounding ``channel`` or resting it, among the channels the source sounds. + + Args: + channel: The channel ticked or unticked. + sounded: Whether the program sounds it. + offer: What the source leaves to choose. + + Returns: + NSFExportChoices: The choices with the channel ticked as asked where the source sounds + it, and the choices as they stand otherwise. + """ + if channel not in offer.channels: + return self + + channels = self.channels | {channel} if sounded else self.channels - {channel} + return self.model_copy(update={"channels": channels}) + + def with_repeat(self, repeat: NSFRepeat, offer: NSFExportOffer) -> NSFExportChoices: + """The choices going on as ``repeat`` states, where the source offers it. + + Returns: + NSFExportChoices: The choices with the repeat as asked where the source offers it, and + the choices as they stand otherwise. + """ + if repeat not in offer.repeats: + return self + + return self.model_copy(update={"repeat": repeat}) + + def with_loop_frame(self, frame: int, offer: NSFExportOffer) -> NSFExportChoices: + """The choices returning to ``frame``, brought within the frames the song is laid out in.""" + return self.model_copy(update={"loop_frame": min(max(frame, FIRST_FRAME), offer.last_frame)}) + + def with_scheme(self, scheme: CompressionScheme, offer: NSFExportOffer) -> NSFExportChoices: + """The choices writing the song under ``scheme``, where the source offers it. + + Returns: + NSFExportChoices: The choices with the scheme as asked where the source offers it, and + the choices as they stand otherwise. + """ + if scheme not in offer.schemes: + return self + + return self.model_copy(update={"scheme": scheme}) + + def _with_information(self, information: NSFInformation) -> NSFExportChoices: + return self.model_copy(update={"information": information}) + + @staticmethod + def _stated_repeat(loop_tick: Optional[int]) -> NSFRepeat: + """The repeat a stated program's loop reads as: once without one, and from the start with one. + + Raises: + ValueError: If the loop returns to a tick past the song's start. + """ + match loop_tick: + case None: + return NSFRepeat.ONCE + case tick if tick == SONG_START: + return NSFRepeat.FROM_START + case tick: + raise ValueError(f"A stated program repeats from the song's start, and this one repeats from {tick}") diff --git a/src/sampletones_application/view_model/shared/nsf/offer.py b/src/sampletones_application/view_model/shared/nsf/offer.py new file mode 100644 index 000000000..f39dec3d7 --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/offer.py @@ -0,0 +1,42 @@ +from typing import Final, Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme + +FIRST_FRAME: Final[int] = 0 +NO_FRAMES: Final[int] = 0 + + +class NSFExportOffer(BaseModel, frozen=True): + """What one source leaves to choose when it is written as a program. + + A project lays its song out in order frames and seeds the dictionary with its samples, while a + reconstruction plays its slices once through and seeds nothing, so the choices worth offering + follow the source: the channels it sounds, the frames a repeat can return to and the schemes + that write it differently. + + Attributes: + channels: The channels the source sounds, in channel order. + frame_count: The order frames the song is laid out in, none for a reconstruction. + schemes: The compression schemes that write the source differently, lightest first. + """ + + channels: Tuple[ChannelName, ...] + frame_count: int + schemes: Tuple[CompressionScheme, ...] + + @property + def last_frame(self) -> int: + """The frame a repeat reaches furthest into the song.""" + return max(self.frame_count - 1, FIRST_FRAME) + + @property + def repeats(self) -> Tuple[NSFRepeat, ...]: + """The ways the program goes on at its end, a return to a frame offered where frames exist.""" + if self.frame_count > NO_FRAMES: + return (NSFRepeat.ONCE, NSFRepeat.FROM_START, NSFRepeat.FROM_FRAME) + + return (NSFRepeat.ONCE, NSFRepeat.FROM_START) diff --git a/src/sampletones_application/view_model/shared/nsf/repeat.py b/src/sampletones_application/view_model/shared/nsf/repeat.py new file mode 100644 index 000000000..bea04f94e --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/repeat.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class NSFRepeat(StrEnum): + """Where a program goes once its song reaches the end.""" + + ONCE = "once" + FROM_START = "from_start" + FROM_FRAME = "from_frame" diff --git a/src/sampletones_application/view_model/shared/nsf/view.py b/src/sampletones_application/view_model/shared/nsf/view.py new file mode 100644 index 000000000..a3379ac7c --- /dev/null +++ b/src/sampletones_application/view_model/shared/nsf/view.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import field_size +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from sampletones_shared.utils.time import format_span + + +class NSFExportViewModel(BaseModel, frozen=True): + """What the NSF export dialog draws: what the source offers, the choices standing, and the file. + + The dialog stands while an export is set up, and the run that follows reports through the + export's own window, so everything here describes the setup. + + Attributes: + offer: What the source leaves to choose. + choices: The choices the dialog is standing at. + destination: The file the export writes. + ticks: The ticks the song lasts with the ticked channels sounding. + nes_frequency: The rate in Hz the console plays those ticks at. + """ + + offer: NSFExportOffer + choices: NSFExportChoices + destination: Path + ticks: int + nes_frequency: int + + @property + def channels(self) -> Tuple[ChannelName, ...]: + return self.offer.channels + + @property + def repeats(self) -> Tuple[NSFRepeat, ...]: + return self.offer.repeats + + @property + def schemes(self) -> Tuple[CompressionScheme, ...]: + return self.offer.schemes + + @property + def last_frame(self) -> int: + return self.offer.last_frame + + @property + def loop_frame_visible(self) -> bool: + """Whether a frame is chosen here, which a repeat returning to one is what asks for.""" + return self.choices.repeat == NSFRepeat.FROM_FRAME + + @property + def export_enabled(self) -> bool: + """Whether the program is written from here: at least one channel sounds in it.""" + return self.choices.writable + + @property + def duration_seconds(self) -> float: + """How long the song plays for, in seconds.""" + return self.ticks / self.nes_frequency + + def channel_offered(self, channel: ChannelName) -> bool: + """Whether ``channel`` takes a tick, which the source sounding it is what decides.""" + return channel in self.offer.channels + + def channel_sounded(self, channel: ChannelName) -> bool: + """Whether the program sounds ``channel``.""" + return channel in self.choices.channels + + def length_label(self, template: str) -> str: + """How long the song plays for, as a span and in ticks, as ``template`` states it.""" + return template.format( + length=format_span(self.duration_seconds), + ticks=self.ticks, + rate=self.nes_frequency, + ) + + def text_size_label(self, text: str, template: str) -> str: + """The bytes ``text`` takes in its header field against the room there, as ``template`` states them.""" + return template.format(used=field_size(text), room=STRING_TEXT_SIZE) + + def loop_frame_label(self, template: str) -> str: + """The order frame a repeat returns to, as ``template`` states it.""" + return template.format(frame=self.choices.loop_frame) + + def frame_count_label(self, template: str) -> str: + """The frames the song is laid out in, as ``template`` states them.""" + return template.format(count=self.offer.frame_count) diff --git a/src/sampletones_application/view_model/shared/ownership.py b/src/sampletones_application/view_model/shared/ownership.py new file mode 100644 index 000000000..a1f34f554 --- /dev/null +++ b/src/sampletones_application/view_model/shared/ownership.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Tuple + +from pydantic import BaseModel + +from sampletones_core.constants.enums import ChannelName + + +class OwnershipRunViewModel(BaseModel, extra="forbid", frozen=True): + """One stretch of a channel held by a single recording, as the ribbon paints it. + + Attributes: + start_frame: The first frame of the stretch. + end_frame: The frame the stretch runs up to, one past its last. + stem_id: The stem holding it, which picks the color. + heard: Whether the reader hears that recording here, which settles how solidly it paints. + """ + + start_frame: int + end_frame: int + stem_id: int + heard: bool + + +class OwnershipLaneViewModel(BaseModel, extra="forbid", frozen=True): + """One channel's stretch of ribbon: the channel it stands for and the runs along it.""" + + channel_name: ChannelName + runs: Tuple[OwnershipRunViewModel, ...] + + def up_to(self, frame_count: int) -> Tuple[OwnershipRunViewModel, ...]: + """The lane's runs over the first ``frame_count`` frames, the last of them ending there. + + A surface draws a reading as far as that reading goes, and the channel holds one lane for + every reading taken from it, so a surface drawing fewer frames than the channel holds + asks the lane for the stretches standing under them. + """ + return tuple( + run if run.end_frame <= frame_count else run.model_copy(update={"end_frame": frame_count}) + for run in self.runs + if run.start_frame < frame_count + ) + + +class OwnershipRibbonViewModel(BaseModel, extra="forbid", frozen=True): + """The recordings behind each stretch of what a reader is listening to. + + The ribbon reads along the same span the waveform above it draws, so a lane is stated in + frames and the frame length turns them into the samples the plot counts. A document whose + frames all answer to one recording has nothing to tell apart, so it offers no lanes. + """ + + lanes: Tuple[OwnershipLaneViewModel, ...] + frame_length: int + total_frames: int + + @classmethod + def empty(cls) -> OwnershipRibbonViewModel: + """The ribbon standing for nothing, which is what a closed document leaves.""" + return cls(lanes=(), frame_length=1, total_frames=0) + + @property + def is_drawn(self) -> bool: + """Whether the ribbon has stretches to tell apart.""" + return bool(self.lanes) and self.total_frames > 0 + + @property + def total_samples(self) -> int: + """The span the ribbon covers, in the samples the waveform's axis counts.""" + return self.total_frames * self.frame_length 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/recording.py b/src/sampletones_application/view_model/shared/recording.py new file mode 100644 index 000000000..9ee49d97b --- /dev/null +++ b/src/sampletones_application/view_model/shared/recording.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class NamedRecordingViewModel(BaseModel, extra="forbid", frozen=True): + """One recording a document names, as a list of them shows it. + + The id is the entry the recording was converted as, which picks the color it is known by, so + a mark beside its name reads as the stretches it holds wherever those are drawn. + + Attributes: + stem_id: The entry the recording was converted as. + name: What the recording is called. + """ + + stem_id: int + name: str diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py index 5e8928c7d..9de53fb6d 100644 --- a/src/sampletones_application/view_model/shared/render.py +++ b/src/sampletones_application/view_model/shared/render.py @@ -19,16 +19,16 @@ mp3_bitrates, ) from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.parallelization import ETAEstimator +from sampletones_shared.utils.time import format_span class RenderPhase(StrEnum): IDLE = "idle" CONFIGURING = "configuring" RENDERING = "rendering" - CANCELLING = "cancelling" + CANCELING = "canceling" COMPLETED = "completed" - CANCELLED = "cancelled" + CANCELED = "canceled" FAILED = "failed" @@ -36,7 +36,7 @@ class RenderPhase(StrEnum): { RenderPhase.CONFIGURING, RenderPhase.RENDERING, - RenderPhase.CANCELLING, + RenderPhase.CANCELING, } ) @@ -251,7 +251,7 @@ def duration_seconds(self) -> float: @property def duration_label(self) -> str: """The length the render is projected to run to, as the dialog states it.""" - return ETAEstimator.format_duration(self.duration_seconds) + return format_span(self.duration_seconds) @property def progress_overlay(self) -> str: 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..f58c35d22 --- /dev/null +++ b/src/sampletones_application/view_model/shared/stems.py @@ -0,0 +1,283 @@ +from functools import cached_property +from pathlib import Path +from typing import Dict, FrozenSet, Iterator, Optional, Self, Tuple + +from pydantic import BaseModel + +from sampletones_application.constants.sources import SourceKind +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName + + +class StemRowViewModel(BaseModel, frozen=True): + """One row of a stems list, as the list renders it. + + A row stands for a recording or for a folder of them, and answers the same way either way: + ``offered_channels`` names the boxes it draws, ``channels`` the ones every recording it stands + for holds, and ``partial_channels`` the ones some of them hold. A recording reads as ticked or + clear; a folder its recordings disagree on reads as half-lit, and one gesture settles 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 source's path where the list gathers files, the stem id where it describes a recorded + assignment. + + ``held`` carries the recordings a folder stands for, each a row of its own, which is what a + reader reaches by opening it. They stand where the folder stands, so a recording answers for + itself while the folder answers for them all. + + ``name`` is what the row reads as and ``path`` where its source lives, which a row standing + for a recording a document was detached from carries no more. Gathering a file names the row + after it; a recorded assignment names it after the recording the document remembers. + + ``stem_id`` is the entry the row's recording was converted as, which is what picks the color + it is known by, so the swatch beside a name reads as the stretches that recording holds in the + ribbon. A row answering to an entry of its own carries it. + """ + + key: str + kind: SourceKind + name: str + path: Optional[Path] + held: Tuple["StemRowViewModel", ...] + channels: FrozenSet[ChannelName] + partial_channels: FrozenSet[ChannelName] + offered_channels: FrozenSet[ChannelName] + available: bool + level: int + position: int + stem_id: Optional[int] + level_size: int + level_count: int + + @property + def holds(self) -> int: + """How many recordings the row stands for, which a folder reads out beside its name.""" + return len(self.held) + + @property + def recordings(self) -> Tuple["StemRowViewModel", ...]: + """The recordings this row stands for: what a folder holds, or the row itself. + + This is the one reading that goes from a row to recordings, so whatever counts, picks or + folds them asks here rather than telling the two kinds apart again. + """ + return self.held or (self,) + + @property + def stands_for_a_folder(self) -> bool: + """The row is a folder, standing for every recording gathered below it.""" + return self.kind is SourceKind.FOLDER + + @property + def stands_for_edits(self) -> bool: + """The row stands for the frames the reader wrote, which no recording answers for.""" + return self.kind is SourceKind.EDITS + + @property + def releasable(self) -> bool: + """Whether the row names something a removal takes out of the document. + + A removal takes a recording out of the recorded setup, so the frames the reader wrote + stand outside what it reaches. + """ + return not self.stands_for_edits + + @property + def takes_part(self) -> bool: + """The row holds a channel, so the list counts it in.""" + return bool(self.channels or self.partial_channels) + + def agreement_on(self, channel_name: ChannelName) -> Agreement: + """How the recordings this row stands for read on ``channel_name``.""" + if channel_name in self.channels: + return Agreement.ALL + + return Agreement.SOME if channel_name in self.partial_channels else Agreement.NONE + + @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. + ``selected_key`` names the row a reader is inspecting, which the list draws picked out. + ``picked_keys`` names the recordings standing picked where the list asks which ones to mix, + and ``picking_room`` how many recordings that pick may hold. A list putting no such question + names no room. + """ + + rows: Tuple[StemRowViewModel, ...] + channels_in_play: Tuple[ChannelName, ...] + muted_channels: FrozenSet[ChannelName] + picked_keys: FrozenSet[str] + picking_room: Optional[int] + live: bool + collapse_levels: bool + selected_key: Optional[str] + + @classmethod + def empty(cls) -> Self: + """The view a list stands at before anything has been drawn into it.""" + return cls( + rows=(), + channels_in_play=(), + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=False, + selected_key=None, + ) + + @property + def row_count(self) -> int: + return len(self.rows) + + @property + def releasable_count(self) -> int: + """How many rows name something a removal takes out, which leaves the reader's own frames out of the count.""" + return sum(1 for row in self.rows if row.releasable) + + @property + def holds_folders(self) -> bool: + """A folder stands among the rows, which is what gives the list a disclosure column.""" + return any(row.stands_for_a_folder for row in 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) + + def row(self, key: str) -> Optional[StemRowViewModel]: + """The row a gesture named, where the view still holds one.""" + return self._by_key.get(key) + + def soloed(self, key: str) -> bool: + """Whether the row is the one recording heard, on every channel it offers. + + A list holding a single recording has no other to silence, so none of its rows stands + soloed. + """ + row = self.row(key) + if row is None or self.row_count < 2 or not row.offers_channels: + return False + + if row.channels != row.offered_channels: + return False + + return not any(other.takes_part for other in self.rows if other.key != key) + + def rows_on(self, level_index: int) -> Tuple[StemRowViewModel, ...]: + """The rows one band holds, in the order they stand.""" + return tuple(row for row in self.rows if row.level == level_index) + + @property + def recordings(self) -> Tuple[StemRowViewModel, ...]: + """Every recording the rows stand for, folders walked through to what they hold.""" + return tuple(recording for row in self.rows for recording in row.recordings) + + def picking_of(self, row: StemRowViewModel) -> Agreement: + """How the recordings ``row`` stands for read on standing picked. + + A folder reads the three ways its channels read: picked where every recording it holds is, + half-lit where some are, clear where none is, so one gesture answers for the whole folder. + """ + return Agreement.over(recording.key in self.picked_keys for recording in row.recordings) + + @property + def picking_full(self) -> bool: + """The pick holds all the room it has, so what stands clear waits for something to leave.""" + return self.picking_room is not None and len(self.picked_keys) >= self.picking_room + + def reaches(self, row: StemRowViewModel) -> bool: + """Whether a click on the row's box moves it. + + A full pick answers only the rows it already holds, since taking another would build a + reconstruction from more recordings than one is made of. Letting recordings go stays open, + which is how a reader swaps one for another. + """ + return not self.picking_full or self.picking_of(row) is not Agreement.NONE + + def picking_settled(self, row: StemRowViewModel) -> FrozenSet[str]: + """The pick a click on ``row`` leaves behind. + + A row every recording of which stands picked gives them all up, and so does one the mix + has no room to take more of; any other takes as many as the room still has, in the order + they were gathered. So a click settles the row either way from wherever it stands, and + half-lit is a state the reader arrives at rather than one a click makes. + """ + if self._takes(row): + return self.picked_keys | frozenset(self._joining(row)) + + return self.picked_keys - frozenset(recording.key for recording in row.recordings) + + def _takes(self, row: StemRowViewModel) -> bool: + """Whether a click on ``row`` brings recordings in: it settles that way, and there is room.""" + return self.picking_of(row).settles_to and not self.picking_full + + def _joining(self, row: StemRowViewModel) -> Iterator[str]: + """The recordings a click on ``row`` takes in: its own, as far as the room left reaches.""" + standing = len(self.picked_keys) + for recording in row.recordings: + if recording.key in self.picked_keys: + continue + + if self.picking_room is not None and standing >= self.picking_room: + return + + yield recording.key + standing += 1 + + @property + def picked_paths(self) -> Tuple[Path, ...]: + """The recordings standing picked, in the order the list draws them. + + A list gathering files names a path for every row it holds, which is what a pick reaches. + """ + return tuple( + recording.path + for recording in self.recordings + if recording.key in self.picked_keys and recording.path is not None + ) + + def boxes_of(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: + """The channels ``row`` draws a box for, in the order the columns stand.""" + return tuple(channel for channel in self.channels_in_play if channel in row.offered_channels) + + @cached_property + def _by_key(self) -> Dict[str, StemRowViewModel]: + """Every row a gesture can land on, the recordings inside a folder among them.""" + return {held.key: held for row in self.rows for held in (*row.held, row)} diff --git a/src/sampletones_application/view_model/shared/waveform_data.py b/src/sampletones_application/view_model/shared/waveform_data.py index 4f70af87c..c75e23801 100644 --- a/src/sampletones_application/view_model/shared/waveform_data.py +++ b/src/sampletones_application/view_model/shared/waveform_data.py @@ -3,34 +3,34 @@ 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 + sample_rate: 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..5abef6e47 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -1,4 +1,5 @@ import sys +from dataclasses import dataclass from typing import Tuple import dearpygui.dearpygui as dpg @@ -10,9 +11,19 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.monitors import MonitorArea, monitor_area_for_window from sampletones_shared.application import SAMPLETONES_NAME +from sampletones_shared.display import Resolution from sampletones_shared.types.callback import VoidCallback +@dataclass(frozen=True) +class WindowPlacement: + """Where the window stands on the desktop and the size it shows at.""" + + x: int + y: int + resolution: Resolution + + class ViewportManager: def __init__( self, @@ -77,6 +88,27 @@ def resolution(self) -> Tuple[int, int]: """The size the window is showing at right now.""" return dpg.get_viewport_width(), dpg.get_viewport_height() + @property + def placement(self) -> WindowPlacement: + """Where the window stands and the size it shows at right now.""" + viewport_x, viewport_y = dpg.get_viewport_pos() + width, height = self.resolution + return WindowPlacement( + x=int(viewport_x), + y=int(viewport_y), + resolution=Resolution(width=width, height=height), + ) + + def place(self, placement: WindowPlacement) -> None: + """Moves the window to ``placement`` and sizes it there. + + DearPyGui answers a place and a size set this way from its very next read, ahead of a drawn + frame, so a record of the window taken right after names them. + """ + dpg.set_viewport_pos([placement.x, placement.y]) + dpg.set_viewport_width(placement.resolution.width) + dpg.set_viewport_height(placement.resolution.height) + @property def monitor_area(self) -> MonitorArea: """The area of the monitor the window currently sits on, and the room it leaves a window.""" @@ -85,9 +117,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) @@ -115,13 +147,13 @@ def save_window_state(self) -> None: if self._session_manager.fullscreen: return - viewport_x, viewport_y = dpg.get_viewport_pos() + placement = self.placement self._session_manager.set_window_state( fullscreen=False, - x=int(viewport_x), - y=int(viewport_y), - width=dpg.get_viewport_width(), - height=dpg.get_viewport_height(), + x=placement.x, + y=placement.y, + width=placement.resolution.width, + height=placement.resolution.height, ) def _persist_fullscreen(self, fullscreen: bool) -> None: diff --git a/src/sampletones_assets/mark/geometry.py b/src/sampletones_assets/mark/geometry.py deleted file mode 100644 index 5fd855bdd..000000000 --- a/src/sampletones_assets/mark/geometry.py +++ /dev/null @@ -1,128 +0,0 @@ -import itertools -from dataclasses import dataclass -from typing import List - -from sampletones_assets.mark.specification.point import CubicCurve, Point -from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare - - -@dataclass(frozen=True) -class Rectangle: - """An axis-aligned box in grid units, the shape one segment of the stepped half fills.""" - - left: float - top: float - right: float - bottom: float - - -def _cubic_coordinate( - start: float, - control_start: float, - control_end: float, - end: float, - progress: float, -) -> float: - remainder = 1.0 - progress - return ( - remainder**3 * start - + 3 * remainder**2 * progress * control_start - + 3 * remainder * progress**2 * control_end - + progress**3 * end - ) - - -def _cubic_point( - start: Point, - curve: CubicCurve, - progress: float, -) -> Point: - return Point( - x=_cubic_coordinate(start.x, curve.control_start.x, curve.control_end.x, curve.end.x, progress), - y=_cubic_coordinate(start.y, curve.control_start.y, curve.control_end.y, curve.end.y, progress), - ) - - -def sine_points(sine: MarkSine, samples: int) -> List[Point]: - """The smooth half as a polyline, sampled evenly along every segment. - - Each segment contributes ``samples`` points, ending on its own end point, so the next - segment starts where the previous one arrived and the polyline runs unbroken from the - wave's start to its handover. - """ - points = [sine.start] - position = sine.start - for curve in sine.curves: - for step in range(1, samples + 1): - points.append(_cubic_point(position, curve, step / samples)) - - position = curve.end - - return points - - -def _direction(delta: float) -> float: - if delta > 0: - return 1.0 - - if delta < 0: - return -1.0 - - return 0.0 - - -def _segment_rectangle( - start: Point, - end: Point, - *, - half_width: float, - joined_start: bool, - joined_end: bool, -) -> Rectangle: - """The stroke rectangle of one axis-aligned segment. - - A joined end reaches half the stroke width past its corner, so consecutive rectangles - fill their right-angle miter; an open end keeps a butt cap. - """ - direction_x = _direction(end.x - start.x) - direction_y = _direction(end.y - start.y) - start_reach = half_width if joined_start else 0.0 - end_reach = half_width if joined_end else 0.0 - - reached_start = ( - start.x - direction_x * start_reach, - start.y - direction_y * start_reach, - ) - reached_end = ( - end.x + direction_x * end_reach, - end.y + direction_y * end_reach, - ) - across_x = half_width * abs(direction_y) - across_y = half_width * abs(direction_x) - - return Rectangle( - left=min(reached_start[0], reached_end[0]) - across_x, - top=min(reached_start[1], reached_end[1]) - across_y, - right=max(reached_start[0], reached_end[0]) + across_x, - bottom=max(reached_start[1], reached_end[1]) + across_y, - ) - - -def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]: - """The stepped half as filled rectangles, one per segment between its corners. - - The rectangles meet at every corner the wave turns at, so the sequence covers the - stroke a vector renderer draws with square joins. - """ - segments = list(itertools.pairwise(square.points)) - final_segment = len(segments) - 1 - return [ - _segment_rectangle( - start, - end, - half_width=width / 2, - joined_start=index > 0, - joined_end=index < final_segment, - ) - for index, (start, end) in enumerate(segments) - ] diff --git a/src/sampletones_assets/mark/paths.py b/src/sampletones_assets/mark/paths.py deleted file mode 100644 index 4b9decd97..000000000 --- a/src/sampletones_assets/mark/paths.py +++ /dev/null @@ -1,7 +0,0 @@ -from importlib.resources import files -from pathlib import Path -from typing import Final - -MARK_DIRECTORY: Final[Path] = Path(str(files("sampletones_assets.mark"))) -MARK_PATH: Final[Path] = MARK_DIRECTORY / "mark.yaml" -TEMPLATE_PATH: Final[Path] = MARK_DIRECTORY / "template.svg" diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py deleted file mode 100644 index fa600fe11..000000000 --- a/src/sampletones_assets/mark/specification/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Self - -from pydantic import BaseModel, Field - -from sampletones_assets.mark.paths import MARK_PATH -from sampletones_assets.mark.specification.colors import MarkColors -from sampletones_assets.mark.specification.frame import MarkFrame -from sampletones_assets.mark.specification.render import MarkRender -from sampletones_assets.mark.specification.waves import MarkWaves -from sampletones_shared.utils.serialization import load_yaml_model - - -class Mark(BaseModel, extra="forbid", frozen=True): - """The design definition of the application mark. - - Every shipped icon derives from this one definition — the vector, the raster the - application loads, and the multi-resolution Windows icon — so the mark is drawn from a - single source and stays the same shape at every size. Coordinates are written on the - frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an - icon size. - """ - - frame: MarkFrame = Field(description="The rounded square the mark sits on.") - colors: MarkColors = Field(description="The colours the mark is drawn in.") - waves: MarkWaves = Field(description="The wave crossing the frame.") - render: MarkRender = Field(description="How the mark is rasterized.") - - @classmethod - def load(cls) -> Self: - """Load the packaged mark definition. - - Returns: - The mark validated from `sampletones_assets/mark/mark.yaml`. - - Raises: - TypeError: If the definition file holds anything other than a mapping. - """ - return load_yaml_model(MARK_PATH, cls) diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py deleted file mode 100644 index 1063ea2b7..000000000 --- a/src/sampletones_assets/mark/specification/colors.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Annotated - -from pydantic import AfterValidator, BaseModel, Field - -from sampletones_shared.utils.color import parse_hex_color - - -def _validate_hex_color(value: str) -> str: - parse_hex_color(value) - return value - - -HexColor = Annotated[str, AfterValidator(_validate_hex_color)] - - -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.") - - -class MarkColors(BaseModel, extra="forbid", frozen=True): - """The mark's colours, 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.") diff --git a/src/sampletones_assets/mark/specification/frame.py b/src/sampletones_assets/mark/specification/frame.py deleted file mode 100644 index 4c9111a16..000000000 --- a/src/sampletones_assets/mark/specification/frame.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Self - -from pydantic import BaseModel, Field, PositiveFloat, PositiveInt, model_validator - - -class MarkRim(BaseModel, extra="forbid", frozen=True): - """The hairline drawn just inside the frame's edge, lifting it off a dark desktop.""" - - inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.") - width: PositiveFloat = Field(description="Stroke width of the hairline.") - opacity: float = Field(gt=0.0, le=1.0, description="Share of full opacity the hairline is drawn at.") - - -class MarkFrame(BaseModel, extra="forbid", frozen=True): - """The rounded square the mark sits on. - - ``grid`` is the edge length every other coordinate is expressed in, so the whole design - follows from this one number and scales to any icon size. - """ - - grid: PositiveInt = Field(description="Edge length of the design grid.") - corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.") - rim: MarkRim = Field(description="The hairline inside the frame's edge.") - - @property - def rim_radius(self) -> float: - """Corner radius the rim follows, keeping it concentric with the frame.""" - return self.corner_radius - self.rim.inset - - @property - def rim_extent(self) -> float: - """Edge length of the rim's square, inset on both sides.""" - return self.grid - 2 * self.rim.inset - - @model_validator(mode="after") - def _validate_rounding(self) -> Self: - if 2 * self.corner_radius > self.grid: - raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}") - - if self.rim.inset >= self.corner_radius: - raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}") - - return self diff --git a/src/sampletones_assets/mark/suite.py b/src/sampletones_assets/mark/suite.py deleted file mode 100644 index a464b50a0..000000000 --- a/src/sampletones_assets/mark/suite.py +++ /dev/null @@ -1,67 +0,0 @@ -from pathlib import Path -from typing import List, Tuple - -from PIL import Image - -from sampletones_assets.mark.raster import MarkRaster -from sampletones_assets.mark.specification import Mark -from sampletones_assets.mark.vector import render_vector -from sampletones_shared.paths.resources import ( - ICON_UNIX_FILENAME, - ICON_VECTOR_FILENAME, - ICON_WIN_FILENAME, -) - - -def _resized(master: Image.Image, size: int) -> Image.Image: - return master.resize((size, size), Image.Resampling.LANCZOS) - - -def _write_vector(path: Path, mark: Mark) -> Path: - path.write_text(render_vector(mark), encoding="utf-8") - return path - - -def _write_raster(path: Path, master: Image.Image, size: int) -> Path: - _resized(master, size).save(path) - return path - - -def _write_windows_icon( - path: Path, - master: Image.Image, - sizes: Tuple[int, ...], -) -> Path: - """Writes the multi-resolution icon, rendering one frame per declared size. - - Every frame is resampled from the supersampled master, so a 16 px frame carries the - detail the design grid puts there. - """ - primary, *appended = (_resized(master, size) for size in sizes) - primary.save( - path, - format="ICO", - sizes=[(size, size) for size in sizes], - append_images=appended, - ) - return path - - -def write_icon_suite(directory: Path, mark: Mark) -> List[Path]: - """Writes the vector, the raster and the Windows icon the application ships. - - Args: - directory (Path): Directory receiving the icon files, created where it is missing. - mark (Mark): Design definition every file is drawn from. - - Returns: - List[Path]: The files written, in the order they were produced. - """ - directory.mkdir(parents=True, exist_ok=True) - master = MarkRaster(mark).render() - - return [ - _write_vector(directory / ICON_VECTOR_FILENAME, mark), - _write_raster(directory / ICON_UNIX_FILENAME, master, mark.render.raster_size), - _write_windows_icon(directory / ICON_WIN_FILENAME, master, mark.render.windows_sizes), - ] diff --git a/src/sampletones_config/README.md b/src/sampletones_config/README.md index e54fd9655..204682e19 100644 --- a/src/sampletones_config/README.md +++ b/src/sampletones_config/README.md @@ -8,8 +8,8 @@ programmatic role is to be importable so consumers can resolve its directory 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_tools` — the import boundaries. +- `sampletones_shared` — the loader primitives. The data package must not import a schema, and a schema package must not inline data. @@ -19,13 +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` | -| `calibration/` | DSP calibration tuning | `CorpusConfig`, `RefereeConfig` | +| `boundaries/` | The imports the source and scripts trees are held to | `ImportBoundaryRules` | | `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 -[`docs/development/config-organization.md`](../../docs/development/config-organization.md). +[`docs/development/application/config-organization.md`](../../docs/development/application/config-organization.md). 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/behavior/general.yaml b/src/sampletones_config/behavior/general.yaml index 1d4e14a92..82a25cbd7 100644 --- a/src/sampletones_config/behavior/general.yaml +++ b/src/sampletones_config/behavior/general.yaml @@ -1,7 +1,6 @@ scheduling: delays: schedule: 12 - reconstruction_update: 1 cancel: 30 priorities: update_status: 0 @@ -12,6 +11,9 @@ scheduling: batch_size: 128 queue_budget_seconds: 0.005 +rendering: + cache_megabytes: 512 + ui: status_bar_display_time: 2.0 fps_update_interval: 2.0 diff --git a/src/sampletones_config/boundaries/checkout.yaml b/src/sampletones_config/boundaries/checkout.yaml new file mode 100644 index 000000000..310bb1909 --- /dev/null +++ b/src/sampletones_config/boundaries/checkout.yaml @@ -0,0 +1,6 @@ +- root: automation + pattern: "**/*.py" + forbidden: [assets, tests] +- root: assets + pattern: "**/*.py" + forbidden: [tests] diff --git a/src/sampletones_config/boundaries/general.yaml b/src/sampletones_config/boundaries/general.yaml new file mode 100644 index 000000000..be8ddf4d4 --- /dev/null +++ b/src/sampletones_config/boundaries/general.yaml @@ -0,0 +1,15 @@ +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.folder_scan.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..54a9fb886 --- /dev/null +++ b/src/sampletones_config/boundaries/graphs.yaml @@ -0,0 +1,25 @@ +packages: + layers: + sampletones_shared: [] + sampletones_config: [] + sampletones_assets: [] + sampletones_core: [sampletones_shared] + sampletones_player: [sampletones_shared, sampletones_core] + sampletones_application: [sampletones_shared, sampletones_core, sampletones_player] + sampletones_tools: [sampletones_shared, sampletones_core, sampletones_player, sampletones_application] + sampletones: [sampletones_shared, sampletones_core, sampletones_application, sampletones_tools] + +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] + nsf: [song.py, specification, compression, driver] + export: [builder.py, song.py, nsf, driver, compression] + driver: [specification] diff --git a/src/sampletones_config/boundaries/rules.yaml b/src/sampletones_config/boundaries/rules.yaml new file mode 100644 index 000000000..3084d5636 --- /dev/null +++ b/src/sampletones_config/boundaries/rules.yaml @@ -0,0 +1,61 @@ +- 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 +- root: "" + pattern: "**/*.py" + forbidden: + - automation + - assets + - tests diff --git a/src/sampletones_config/boundaries/standalone.yaml b/src/sampletones_config/boundaries/standalone.yaml new file mode 100644 index 000000000..599690650 --- /dev/null +++ b/src/sampletones_config/boundaries/standalone.yaml @@ -0,0 +1,5 @@ +- pattern: "**/*.py" + reserved: [build, test, tests] + message: >- + a bootstrap script runs on the system interpreter, so it imports the standard library and + the scripts tree alone; code that needs the project environment is a tool, run through uv diff --git a/src/sampletones_config/boundaries/tokens.yaml b/src/sampletones_config/boundaries/tokens.yaml new file mode 100644 index 000000000..0eeed6346 --- /dev/null +++ b/src/sampletones_config/boundaries/tokens.yaml @@ -0,0 +1,56 @@ +- 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 + +- root: sampletones_application + pattern: "**/*.py" + forbidden: '\bsampletones_tools\b' + message: >- + sampletones_application is shipped code and names no tool; the command line is the one importer of + sampletones_tools + +- root: sampletones_core + pattern: "**/*.py" + forbidden: '\bsampletones_tools\b' + message: >- + sampletones_core is shipped code and names no tool; the command line is the one importer of + sampletones_tools + +- root: sampletones_player + pattern: "**/*.py" + forbidden: '\bsampletones_tools\b' + message: >- + sampletones_player is shipped code and names no tool; the command line is the one importer of + sampletones_tools + +- root: sampletones_shared + pattern: "**/*.py" + forbidden: '\bsampletones_tools\b' + message: >- + sampletones_shared is shipped code and names no tool; the command line is the one importer of + sampletones_tools + +- root: sampletones_assets + pattern: "**/*.py" + forbidden: '\bsampletones_tools\b' + message: >- + sampletones_assets is shipped code and names no tool; the command line is the one importer of + sampletones_tools diff --git a/src/sampletones_config/calibration/corpus.yaml b/src/sampletones_config/calibration/corpus.yaml deleted file mode 100644 index 4d9d5d3d8..000000000 --- a/src/sampletones_config/calibration/corpus.yaml +++ /dev/null @@ -1,19 +0,0 @@ -seed: 420 -item_seconds: 1.5 -amplitude: 0.5 -reference_frequency: 440.0 -tone: - frequencies: [55.0, 110.0, 220.0, 440.0, 880.0, 1760.0] -timbre: - duty_cycles: [0.125, 0.25, 0.5] - frequency: 220.0 -noise: - white_level: 0.5 -mix: - noise_levels: [0.05, 0.15] -transient: - snare_decay_seconds: 0.15 - kick_decay_seconds: 0.25 - kick_sweep_frequencies: [150.0, 50.0] - attack_seconds: 0.005 - attack_tone_decay_seconds: 0.3 diff --git a/src/sampletones_config/calibration/referee.yaml b/src/sampletones_config/calibration/referee.yaml deleted file mode 100644 index 09995b2ea..000000000 --- a/src/sampletones_config/calibration/referee.yaml +++ /dev/null @@ -1,6 +0,0 @@ -window_sizes: [512, 2048, 8192] -hop_divisor: 4 -band_count: 40 -low_frequency: 30.0 -energy_floor: 1.0e-10 -audibility_range_decibels: 60.0 diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index e373d87a1..22be58d6f 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"} @@ -43,10 +50,10 @@ bindings: FollowPatterns: {combination: "Ctrl+Shift+F"} FollowOff: {combination: "Ctrl+Alt+F"} ToggleLoopSong: {combination: "Ctrl+L"} - ToggleChannelPulse1: {combination: "1"} - ToggleChannelPulse2: {combination: "2"} - ToggleChannelTriangle: {combination: "3"} - ToggleChannelNoise: {combination: "4"} + ToggleChannelPulse1: {combination: "1", aliases: ["Ctrl+1"]} + ToggleChannelPulse2: {combination: "2", aliases: ["Ctrl+2"]} + ToggleChannelTriangle: {combination: "3", aliases: ["Ctrl+3"]} + ToggleChannelNoise: {combination: "4", aliases: ["Ctrl+4"]} UnmuteAllChannels: {combination: ~} # view @@ -65,6 +72,9 @@ bindings: SelectTabSequencer: {combination: "F3", field_transparent: true} SelectTabInstructions: {combination: "F4", field_transparent: true} + # sources + SourcesRemoveSource: {combination: "Del"} + # order table OrderPreviousPosition: {combination: "Left"} OrderNextPosition: {combination: "Right", aliases: ["Enter"]} @@ -133,14 +143,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..0adfb26c1 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"} @@ -43,10 +50,10 @@ bindings: FollowPatterns: {combination: "Cmd+Shift+F"} FollowOff: {combination: "Cmd+Alt+F"} ToggleLoopSong: {combination: "Cmd+L"} - ToggleChannelPulse1: {combination: "1"} - ToggleChannelPulse2: {combination: "2"} - ToggleChannelTriangle: {combination: "3"} - ToggleChannelNoise: {combination: "4"} + ToggleChannelPulse1: {combination: "1", aliases: ["Cmd+1"]} + ToggleChannelPulse2: {combination: "2", aliases: ["Cmd+2"]} + ToggleChannelTriangle: {combination: "3", aliases: ["Cmd+3"]} + ToggleChannelNoise: {combination: "4", aliases: ["Cmd+4"]} UnmuteAllChannels: {combination: ~} # view @@ -65,6 +72,9 @@ bindings: SelectTabSequencer: {combination: "F3", field_transparent: true} SelectTabInstructions: {combination: "F4", field_transparent: true} + # sources + SourcesRemoveSource: {combination: "Del"} + # order table OrderPreviousPosition: {combination: "Left"} OrderNextPosition: {combination: "Right", aliases: ["Enter"]} @@ -133,14 +143,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..76f660893 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -15,6 +15,7 @@ global.dialog.label.remove: "Remove" global.dialog.label.change_and_retune: "Change and retune" global.dialog.label.dont_ask_again: "Don't ask again" global.dialog.label.add_anyway: "Add anyway" +global.dialog.label.unknown_duration: "?" # Global — Dialog titles global.dialog.title.main_window: "SampleToNES" @@ -29,18 +30,21 @@ global.dialog.title.reconstruct_directory: "Reconstruct directory" global.dialog.title.exit_confirmation: "Exit" global.dialog.title.save_reconstruction: "Save reconstruction" global.dialog.title.reconstruction_saved: "Reconstruction saved" +global.dialog.title.edit_voice_unsaved_reconstruction: "Edit voice" global.dialog.title.close_unsaved_reconstruction: "Close reconstruction" global.dialog.title.load_unsaved_reconstruction: "Load reconstruction" global.dialog.title.save_project: "Save project" 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.no_audio_output: "Can't play sound" +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 +56,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" @@ -67,15 +72,25 @@ global.dialog.message.reconstruction_save_failed: "Failed to save reconstruction global.dialog.message.config_save_failed: "Failed to save configuration." global.dialog.message.exit_conversion_in_progress: "A conversion is currently in progress. Are you sure you want to exit?" global.dialog.message.exit_library_generation_in_progress: "Library generation is currently in progress. Are you sure you want to exit?" -global.dialog.message.exit_unsaved_reconstruction: "The current reconstruction has unsaved changes. Are you sure you want to exit?" +global.dialog.message.exit_unsaved_reconstruction: "The current reconstruction has unsaved changes. Do you want to save it before exiting?" +global.dialog.message.edit_voice_unsaved_reconstruction: "The current reconstruction has unsaved changes. Do you want to save it before you edit this voice?" global.dialog.message.close_unsaved_reconstruction: "The current reconstruction has unsaved changes. Are you sure you want to close it?" global.dialog.message.load_unsaved_reconstruction: "The current reconstruction has unsaved changes. Do you want to save it before loading a new one?" +global.dialog.message.load_replaced_reconstruction: "The conversion wrote over the reconstruction you have open. Loading it throws away your unsaved changes. To keep them, click Cancel. Then choose Save reconstruction as... in the Reconstruction menu and pick another file." global.dialog.message.project_saved_successfully: "Project saved successfully." global.dialog.message.project_save_failed: "Failed to save project." global.dialog.message.project_exported_successfully: "FamiTracker module exported successfully." 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.export_skipped_rows: "These rows play a voice on a channel it doesn't use. The file silences the channel there instead:" +global.dialog.message.export_untransposed_rows: "These rows change the pitch of a playing note. The file can't make that change there, so the note keeps its old pitch:" +global.dialog.message.export_carried_pitch_rows: "These rows start an instrument at the pitch already playing. Their pattern plays in several frames, and the file keeps what the first frame plays there, so these rows sound differently:" +global.dialog.template.export_skipped_row: "Frame {frame}, {channel}, row {row}: {voice}" +global.dialog.template.export_skipped_rows_more: "and {count} more" +global.dialog.template.export_truncated: "Instruments cut short: {instruments}. The file keeps the first {frames} ticks of each one." global.dialog.message.new_unsaved_project: "The current project has unsaved changes. Do you want to save it before starting a new one?" global.dialog.message.open_unsaved_project: "The current project has unsaved changes. Do you want to save it before opening another?" global.dialog.message.close_unsaved_project: "The current project has unsaved changes. Do you want to save it before closing?" @@ -91,9 +106,11 @@ global.dialog.message.configuration_recovery_earlier_version: "an earlier versio global.dialog.message.configuration_recovery_list_header: "The following settings could not be carried over and were discarded:" 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.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.unexpected_failure: "Something went wrong, and the last action did not finish." +global.dialog.message.no_project_open: "No project is open. Open or create a project to add voices." +global.dialog.message.no_audio_output: "Can't play sound: no audio output is available." +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: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. 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." global.dialog.message.about_description: "An application for approximating audio samples with the NES 2A03 oscillators and exporting them as FamiTracker instruments/modules." @@ -156,21 +173,27 @@ 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.letter.pulse_1: "P" +global.context.letter.pulse_2: "p" +global.context.letter.triangle: "T" +global.context.letter.noise: "N" 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" global.context.label.detail_configuration: "Configuration" global.context.label.detail_reconstructions: "Reconstructions" +global.context.label.detail_stems: "Stems" global.context.label.instrument_size: "Instrument size" global.context.label.sample_size: "Sample size" -global.context.template.size_bytes: "{bytes} B" -global.context.tooltip.size_bytes: "How many bytes this takes as a FamiTracker instrument." +global.context.template.size_bytes: "{bytes} B (uncompressed)" +global.context.tooltip.size_bytes: "The size of this sound's data before NSF compression." # ============================================================================= # Global — Menu @@ -184,6 +207,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 +227,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 +279,35 @@ 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.template.folder_row: "{name} ({count})" +global.stems.message.folder_tooltip: "Every recording in this folder, converted on its own." +global.stems.message.status_folder_channel: "Turn the {channel} channel on or off for every recording in {name}." +global.stems.message.status_folder_remove: "Remove {name} and the recordings in it." +global.stems.message.status_folder_row: "Open {name} to see its {count} recordings. Right-click for more actions." +global.stems.message.status_folder_open: "Show the recordings in {name}." +global.stems.message.status_folder_close: "Hide the recordings in {name}." +global.stems.label.remove: "x" +global.stems.label.edits: "Edits" +global.stems.label.channel_on: "on" +global.stems.label.channel_bend: "bend" +global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the recording." +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 has no frames." +global.stems.message.edits_tooltip: "The frames you wrote by hand. Removing a recording leaves them alone." +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_play: "Double-click {name} to hear it." +global.stems.message.status_row_reveal: "Double-click {name} to show it 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 # ============================================================================= @@ -260,7 +315,6 @@ global.player.label.play: "Play" global.player.label.pause: "Pause" global.player.label.resume: "Resume" global.player.label.stop: "Stop" -global.player.message.audio_playback_error: "Audio playback error." # ============================================================================= # Global — Graph @@ -275,7 +329,8 @@ global.graph.label.spectrum_frequency_axis: "Frequency" global.graph.label.spectrum_name: "Spectrum" global.graph.label.bar_display: "Bar plot display" global.graph.message.spectrum_navigation: "Right-click for more options." -global.graph.message.waveform_navigation: "Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Right-click for more options." +global.graph.message.waveform_navigation: "Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options." +global.graph.message.waveform_playable_navigation: "Click to play from a point. Double-click to fit the view. Drag to navigate waveform. Scroll to zoom horizontally. Shift + scroll to zoom vertically. Alt + scroll to pan. Alt + Shift + scroll to pan vertically. Right-click for more options." global.graph.message.waveform_regenerating: "Regenerating reconstruction..." # ============================================================================= @@ -287,13 +342,13 @@ 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" 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." -main.explorer.message.status_node_audio: "Click to play audio. Double-click to reconstruct audio. Right-click to open context menu." +main.explorer.message.status_node_audio_no_autoplay: "Double-click to add audio to the converter. Right-click to open context menu." +main.explorer.message.status_node_audio: "Click to play audio. Double-click to add it to the converter. Right-click to open context menu." main.explorer.message.status_refresh: "Rescan the filesystem for audio files." -main.explorer.message.converter_running_msg: "A conversion is already running. Please wait for it to complete or cancel the current operation before starting a new one." -main.explorer.title.converter_running_dialog: "Conversion in progress" # ============================================================================= # Main tab — Config panel @@ -310,12 +365,17 @@ main.config.tooltip.tooltip_sample_rate: "Set the sample rate (in Hz) for audio main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for audio processing.\n • NTSC = 60 Hz,\n • PAL = 50 Hz." # ============================================================================= -# Main tab — Reconstructor panel +# Main tab — Source settings panel # ============================================================================= -main.reconstructor.label.section_generators: "Generators" -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." +main.source.label.section_settings: "Source settings" +main.source.label.new_recordings: "New recordings" +main.source.label.drive: "drive" +main.source.label.drive_mixed: "mixed" +main.source.label.channel_cap: "Channels at once" +main.source.tooltip.tooltip_new_recordings: "Every recording you add starts with these settings.\nPick a row in the Converter to change one recording or folder." +main.source.tooltip.tooltip_drive: "How hard this channel is driven while a recording is converted.\nAt 1.00 the level is calibrated. Higher values reach for louder instructions, adding a distortion-like effect." +main.source.tooltip.tooltip_channel_cap: "How many of its channels a recording may sound in one frame,\nup to the channels it uses." +main.source.message.status_channel_cap: "Sound at most {count} channels in one frame." # ============================================================================= # Main tab — Converter @@ -327,22 +387,37 @@ main.converter.label.load_button: "Load" main.converter.label.open_button: "Open" main.converter.label.stop_button: "Stop" main.converter.label.continue_button: "Continue" -main.converter.label.convert_sample_button: "Convert sample" -main.converter.label.convert_directory_button: "Convert directory" +main.converter.message.mode_tooltip: "One reconstruction per recording, or one reconstruction mixing them all." +main.converter.template.convert_recordings: "Convert {count} recordings" +main.converter.template.mix_recordings: "Mix {count} recordings" +main.converter.template.convert_recording: "Convert {name}" +main.converter.label.convert_button: "Convert" 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_files: "Every gathered recording is reconstructed already." +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.status_updating_library: "Updating instructions library... (this may take a while)" +main.converter.message.status_canceling: "Aborting the conversion..." +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_gathering: "gathering 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." +main.converter.message.status_output_label: "Destination:" main.converter.message.status_convert: "Reconstruct the selected audio into NES instructions." main.converter.message.status_cancel: "Stop the running reconstruction." +main.converter.title.scan_dialog: "Reading the folder" +main.converter.template.scan_progress: "Found {count} recordings in {name}" +main.converter.message.scan_opening: "Looking through {name}..." +main.converter.message.scan_nothing_below: "There are no recordings in this folder." +main.converter.message.scan_failed: "Can't read this folder, so no recordings were added." +main.converter.message.conversion_running: "Wait for the conversion to finish before you add recordings." +main.converter.title.conversion_running_dialog: "Conversion running" +main.converter.label.stop_scan_button: "Stop" main.converter.title.progress_dialog: "Reconstruction progress" main.converter.title.load_dialog: "Reconstruction complete" main.converter.title.cancel_dialog: "Cancel conversion?" @@ -350,7 +425,38 @@ 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.convert_label_template: "{}: {}" +main.converter.template.stage_template: " — {stage} {completed}/{total}" +main.converter.template.single_progress_template: "Reconstructing {}..." +main.converter.label.mode: "Output" +main.converter.label.mode_each: "One per recording" +main.converter.label.mode_mixed: "One from all" +main.converter.label.hierarchy_mode: "Order" +main.converter.label.hierarchy_round_robin: "Round robin" +main.converter.label.hierarchy_strict: "Strict" +main.converter.label.discard_stems_button: "Replace it" +main.converter.label.keep_stems_button: "Keep the mix" +main.converter.label.add_stems_button: "Add" +main.converter.label.overwrite_target_button: "Convert anyway" +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: "Double-click a recording in the browser to add it. Add a folder to convert everything inside it." +main.converter.message.discard_stems_prompt: "This replaces the mix with a list that converts each recording on its own. Continue?" +main.converter.message.overwrite_target_prompt: "A reconstruction with this name already exists. Converting replaces it." +main.converter.message.overwrite_targets_prompt: "{count} reconstructions already exist. Converting replaces them." +main.converter.message.stem_selection_prompt: "Pick the recordings to mix." +main.converter.title.discard_stems_dialog: "Replace the mix?" +main.converter.title.overwrite_target_dialog: "Replace it?" +main.converter.title.overwrite_targets_dialog: "Replace them?" +main.converter.title.stem_selection_dialog: "Pick recordings to mix" +main.converter.template.stem_selection_limit: "{picked} of {total} picked. A mix holds up to {room}." +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.converter.label.context_open_folder: "Show the recordings" +main.converter.label.context_close_folder: "Hide the recordings" +main.converter.label.context_remove_folder: "Remove the folder from the conversion" # ============================================================================= # Main tab — Advanced @@ -362,7 +468,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,16 +503,32 @@ 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.tooltip.nes_frequency_locked: "This reconstruction is a sample of the project, which sets its NES frequency." +reconstructions.reconstruction.tooltip.nes_frequency_no_file: "This reconstruction has no file. Save it to a file to change its 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" reconstructions.reconstruction.label.reconstruction_radio: "Reconstruction" reconstructions.reconstruction.label.waveform_label: "Waveform" -reconstructions.reconstruction.tooltip.autoscale_tooltip: "Include reconstruction's amplification coefficient and auto-scale waveforms accordingly." +reconstructions.reconstruction.tooltip.autoscale_tooltip: "Scale the waveforms by the reconstruction's own amplification coefficient, so they can be compared directly." 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 has no sources" +reconstructions.reconstruction.label.stems_mode_round_robin: "Round robin" +reconstructions.reconstruction.label.stems_mode_strict: "Strict" +reconstructions.reconstruction.label.stem_mute: "Mute" +reconstructions.reconstruction.label.stem_unmute: "Unmute" +reconstructions.reconstruction.label.stem_solo: "Solo" +reconstructions.reconstruction.label.stem_unsolo: "Unsolo" +reconstructions.reconstruction.template.stems_setup: "Mode: {mode}" +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,19 +543,25 @@ 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_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Right-click for more options." -reconstructions.instruments.message.status_sequence: "Edit and press Enter to change {instrument_feature}." -reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature}: {items} items, truncated to {limit} on a FamiTracker export." +reconstructions.instruments.message.status_audition: "Click to choose the generator the note keys sound this instrument on." +reconstructions.instruments.message.status_bar: "Click to change {instrument_feature}. Scroll to zoom horizontally. Alt + scroll to pan. Right-click for more options." +reconstructions.instruments.message.status_sequence: "Type whole numbers to change {instrument_feature}, and \"|\" before the item it repeats from. Press Enter to apply." +reconstructions.instruments.message.status_sequence_too_long: "{instrument_feature} has {items} items. {kept}." +reconstructions.instruments.template.kept_famitracker: "FamiTracker keeps the first {limit}" +reconstructions.instruments.template.kept_bitphase: "a Bitphase project keeps the first {limit}" +reconstructions.instruments.template.kept_bitphase_preset: "a Bitphase preset keeps the first {limit}" +reconstructions.instruments.template.kept_separator: ", " reconstructions.instruments.message.status_copy_sequence: "Copy sequence to clipboard." -reconstructions.instruments.message.status_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, in the format the chosen file extension selects." reconstructions.instruments.message.export_instrument_success: "Instrument saved successfully." reconstructions.instruments.message.export_instruments_success: "Reconstruction instruments saved successfully." -reconstructions.instruments.message.export_instrument_truncated: "The envelope was truncated from {source_frames} to {frames} frames." -reconstructions.instruments.message.export_instruments_truncated: "{instruments} envelopes were truncated to {frames} frames." +reconstructions.instruments.message.export_instrument_truncated: "This instrument is {source_frames} ticks long. The file keeps the first {frames}." +reconstructions.instruments.message.export_instruments_truncated: "Instruments cut short: {instruments}. The file keeps the first {frames} ticks of each one." reconstructions.instruments.message.export_instrument_failed: "Failed to export instrument." reconstructions.instruments.message.export_instruments_failed: "Failed to export reconstruction instruments." reconstructions.instruments.title.export_status_dialog: "Export status" @@ -441,6 +569,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 +594,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 +608,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" @@ -494,6 +627,20 @@ sequencer.tracker.label.context_solo: "Solo" sequencer.tracker.label.context_unsolo: "Unsolo" sequencer.tracker.label.context_mute_all: "Mute all channels" sequencer.tracker.label.context_unmute_all: "Unmute all channels" +sequencer.tracker.label.status_voice: "Voice" +sequencer.tracker.label.status_sample: "Sample" +sequencer.tracker.label.status_pitch: "Pitch" +sequencer.tracker.label.status_volume: "Volume" +sequencer.tracker.label.status_note_off: "Note off" +sequencer.tracker.label.status_different_voices: "Different voices" +sequencer.tracker.label.status_different_pitches: "Different pitches" +sequencer.tracker.label.status_different_volumes: "Different volumes" +sequencer.tracker.template.status_voice: "Voice {voice}" +sequencer.tracker.template.status_sample: "Sample {voice}" +sequencer.tracker.template.status_note: "Note {note}" +sequencer.tracker.template.status_period: "Period {period}" +sequencer.tracker.template.status_transpose: "Transpose {step}" +sequencer.tracker.template.status_volume: "Volume {volume}" sequencer.tracker.tooltip.header_channel: "Click to mute or unmute this channel.\n{modifier}+click to solo it, right-click for channel actions." sequencer.tracker.tooltip.header_sample: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions." @@ -528,20 +675,41 @@ 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 outrunning its arpeggio" +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 playable on any channel" +sequencer.voices.tooltip.new_instrument_no_project: "Open or create a project to add voices." +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}." +sequencer.voices.template.status_instrument: "{name} is an instrument every channel can play. It takes {bytes}." +sequencer.voices.template.status_channel_separator: ", " +sequencer.voices.template.instrument_omissions: "\"{name}\" plays every envelope the file states.\nThe file also states:" sequencer.history.label.history_text: "History" sequencer.history.label.undo: "Undo" @@ -567,14 +735,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" @@ -588,6 +755,7 @@ sequencer.history.label.untracked: "Edit" # ============================================================================= instructions.library.label.generate_library_button: "Generate library" instructions.library.label.regenerate_library_button: "Regenerate instructions" +instructions.library.label.rebuild_library_button: "Rebuild library" instructions.library.label.refresh_libraries_button: "Refresh instructions library" instructions.library.label.cancel_generation_button: "Cancel generation" instructions.library.label.libraries_text: "Library" @@ -604,7 +772,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." @@ -620,11 +788,16 @@ instructions.library.title.remove_library_dialog: "Remove library" instructions.library.template.generation_progress_template: "Generating: {}/{} " instructions.library.template.library_not_exists_template: "{} doesn't exist." instructions.library.template.library_exists_template: "{} exists." +instructions.library.template.library_outdated_template: "{} was built by another version." +instructions.library.template.library_node_outdated_template: "[!] {}" instructions.library.template.library_loaded_template: "{} loaded." instructions.library.template.incompatible_version_template: "Incompatible library data version: {}, expected {}." instructions.library.title.regenerate_confirmation_dialog: "Regenerate library?" instructions.library.message.regenerate_confirmation_message: "A library for the current configuration already exists. Regenerate it?" instructions.library.label.regenerate_confirmation_ok: "Regenerate" +instructions.library.title.rebuild_confirmation_dialog: "Rebuild library?" +instructions.library.message.rebuild_confirmation_message: "This library was built by another version of SampleToNES. Rebuild it now? This takes a while." +instructions.library.label.rebuild_confirmation_ok: "Rebuild" # ============================================================================= # Instructions tab — Instruction panel @@ -686,6 +859,7 @@ settings.display.label.borderless: "Borderless window" settings.display.label.fullscreen: "Fullscreen" settings.display.label.vsync: "Vertical sync" settings.display.label.frame_rate: "Frame rate limit" +settings.display.label.show_frame_rate: "Show frame rate" settings.display.label.theme: "Theme" settings.display.label.unlimited_frame_rate: "Unlimited" settings.display.label.keep_button: "Keep" @@ -718,17 +892,55 @@ settings.render.template.sample_rate: "{rate} Hz" 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_canceling: "Stopping the render..." +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_canceling: "Stopping the export..." +settings.nsf.title.window_title: "Export NSF program" +settings.nsf.title.section_program: "Program" +settings.nsf.title.section_channels: "Channels" +settings.nsf.title.section_playback: "Playback" +settings.nsf.title.section_compression: "Compression" +settings.nsf.label.title: "Title" +settings.nsf.label.artist: "Artist" +settings.nsf.label.copyright: "Copyright" +settings.nsf.label.repeat: "Repeat" +settings.nsf.label.repeat_once: "Play once" +settings.nsf.label.repeat_from_start: "From the start" +settings.nsf.label.repeat_from_frame: "From a frame" +settings.nsf.label.loop_frame: "Frame" +settings.nsf.label.length: "Length" +settings.nsf.label.scheme: "Level" +settings.nsf.label.scheme_none: "None" +settings.nsf.label.scheme_runs: "Held sounds" +settings.nsf.label.scheme_instruments: "Samples" +settings.nsf.label.scheme_search: "Full search" +settings.nsf.label.destination: "File" +settings.nsf.label.browse_button: "Browse..." +settings.nsf.label.export_button: "Export" +settings.nsf.message.no_channel: "Select at least one channel to export." +settings.nsf.message.scheme_none: "The song is saved without compression. The fastest export, but the largest file." +settings.nsf.message.scheme_runs: "A sound that stays the same is saved once, instead of many times a second." +settings.nsf.message.scheme_instruments: "Each sample is saved once, and the song points to it every time it plays. Held sounds are saved once too." +settings.nsf.message.scheme_search: "Every part of the song that repeats is saved once. The slowest export, but the smallest file." +settings.nsf.template.text_size: "{used}/{room}" +settings.nsf.template.frame: "{frame:02X}" +settings.nsf.template.frame_count: "of {count:02X}" +settings.nsf.template.length: "{length}" settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" +settings.keybindings.title.sources: "Converter list" 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" @@ -746,6 +958,8 @@ settings.keybindings.message.capturing: "Press a combination..." settings.keybindings.message.reset_confirmation: "Give every action the keys this scheme ships with?" settings.keybindings.message.discard_confirmation: "Discard the changes to the keyboard shortcuts?" settings.keybindings.template.reassign_confirmation: "{combination} is assigned to {holder}. Give it to {action} instead?" +settings.keybindings.template.reassign_several_confirmation: "These keys are assigned to other actions:\n{assignments}\nGive them to {action} instead?" +settings.keybindings.template.assignment: "{combination}: {holder}" settings.keybindings.template.unreadable_combination: "{combination} names no key on the keyboard." settings.keybindings.label.new_project: "New project" settings.keybindings.label.open_project: "Open project" @@ -754,6 +968,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 +985,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" @@ -801,6 +1020,7 @@ settings.keybindings.label.select_tab_main: "Go to the Main tab" settings.keybindings.label.select_tab_reconstructions: "Go to the Reconstruction tab" settings.keybindings.label.select_tab_sequencer: "Go to the Sequencer tab" settings.keybindings.label.select_tab_instructions: "Go to the Instructions tab" +settings.keybindings.label.sources_remove_source: "Remove the picked recording" settings.keybindings.label.order_previous_position: "Previous position" settings.keybindings.label.order_next_position: "Next position" settings.keybindings.label.order_previous_channel: "Previous channel" @@ -865,13 +1085,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/fonts.yaml b/src/sampletones_config/layout/fonts.yaml index afc296a77..3e12bf36f 100644 --- a/src/sampletones_config/layout/fonts.yaml +++ b/src/sampletones_config/layout/fonts.yaml @@ -1,15 +1,18 @@ scale: 1 sans: + tiny: 14 small: 22 medium: 23 large: 25 title: 34 mono: + tiny: 12 small: 19 medium: 22 large: 26 title: 30 icon: + tiny: 13 small: 20 medium: 27 large: 33 diff --git a/src/sampletones_config/layout/general/colors.yaml b/src/sampletones_config/layout/general/colors.yaml index afb8048a2..fbfd9bb79 100644 --- a/src/sampletones_config/layout/general/colors.yaml +++ b/src/sampletones_config/layout/general/colors.yaml @@ -19,4 +19,23 @@ 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 +stems: + recordings: + - .stem_1 + - .stem_2 + - .stem_3 + - .stem_4 + - .stem_5 + - .stem_6 + - .stem_7 + - .stem_8 + authored: .stem_authored + rest: .stem_rest + left_out_fraction: 0.45 diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml index 48e41eaba..3ede5f4f5 100644 --- a/src/sampletones_config/layout/general/dialogs.yaml +++ b/src/sampletones_config/layout/general/dialogs.yaml @@ -8,14 +8,12 @@ recovery: width: 640 height: 120 confirmation: + width: 420 height: 100 -text_input: - height: 104 -traceback: - width: 0 - height: 400 +traceback_height: 400 about: - width: 480 - height: 210 + window: + width: 480 + height: 210 logo: 56 padding: 40 diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml new file mode 100644 index 000000000..359ac5778 --- /dev/null +++ b/src/sampletones_config/layout/general/stems.yaml @@ -0,0 +1,16 @@ +master_column_width: 44 +channel_column_width: 62 +channel_box_width: 29 +remove_button_width: 30 +swatch_size: 14 +level_strip_height: 6 +well_padding: 8 +well_margin: 4 +well_ceiling: 420 +twisty_width: 22 +folder_ceiling: 264 +folder_indent: 14 +window_overscan: 4 +scrollbar_width: 13 +cell_padding: 4 +name_height: 30 diff --git a/src/sampletones_config/layout/glyphs.yaml b/src/sampletones_config/layout/glyphs.yaml index a564c1537..74acd8dc9 100644 --- a/src/sampletones_config/layout/glyphs.yaml +++ b/src/sampletones_config/layout/glyphs.yaml @@ -1,11 +1,16 @@ common: tick: "▍" + swatch: "■" favorite: "★" expanded: "▾" collapsed: "▸" chevron_left: "◂" chevron_right: "▸" +voices: + sample: "≈" + instrument: "∿" + headers: waveform: "∿" spectrum: "⌇" @@ -18,8 +23,9 @@ headers: details: "✎" parameters: "▦" source: "♪" + stems: "☰" instruments: "♬" - samples: "♫" + voices: "♫" tracker: "▦" order: "≣" history: "↺" diff --git a/src/sampletones_config/layout/graphs/bar_plot.yaml b/src/sampletones_config/layout/graphs/bar_plot.yaml index 795a95fe0..e7199004c 100644 --- a/src/sampletones_config/layout/graphs/bar_plot.yaml +++ b/src/sampletones_config/layout/graphs/bar_plot.yaml @@ -3,3 +3,5 @@ min_y: 0.0 max_y: 100.0 bar_weight: 0.8 hover_alpha: 80 +minimum_span: 2.0 +ownership_band: 0.12 diff --git a/src/sampletones_config/layout/graphs/clock.yaml b/src/sampletones_config/layout/graphs/clock.yaml new file mode 100644 index 000000000..d11121209 --- /dev/null +++ b/src/sampletones_config/layout/graphs/clock.yaml @@ -0,0 +1,21 @@ +steps_seconds: + - 0.01 + - 0.02 + - 0.05 + - 0.1 + - 0.2 + - 0.5 + - 1.0 + - 2.0 + - 5.0 + - 10.0 + - 15.0 + - 30.0 + - 60.0 + - 120.0 + - 300.0 + - 600.0 + - 900.0 + - 1800.0 + - 3600.0 +tick_count: 8 diff --git a/src/sampletones_config/layout/graphs/ribbon.yaml b/src/sampletones_config/layout/graphs/ribbon.yaml new file mode 100644 index 000000000..a65718fb4 --- /dev/null +++ b/src/sampletones_config/layout/graphs/ribbon.yaml @@ -0,0 +1,2 @@ +lane_height: 16 +lane_gap: 0.22 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/graphs/waveform.yaml b/src/sampletones_config/layout/graphs/waveform.yaml index 071d554d5..155e3c33b 100644 --- a/src/sampletones_config/layout/graphs/waveform.yaml +++ b/src/sampletones_config/layout/graphs/waveform.yaml @@ -1,3 +1,5 @@ reconstruction_dim_opacity: 0.5 zoom_factor: 0.2 +pan_factor: 0.1 max_display_points: 4096 +click_travel: 4 diff --git a/src/sampletones_config/layout/project_properties/window.yaml b/src/sampletones_config/layout/project_properties/window.yaml index 4ba986957..f783886f5 100644 --- a/src/sampletones_config/layout/project_properties/window.yaml +++ b/src/sampletones_config/layout/project_properties/window.yaml @@ -1,2 +1 @@ width: 500 -height: 0 diff --git a/src/sampletones_config/layout/settings/audio.yaml b/src/sampletones_config/layout/settings/audio.yaml index 3bed24006..c279aba2b 100644 --- a/src/sampletones_config/layout/settings/audio.yaml +++ b/src/sampletones_config/layout/settings/audio.yaml @@ -1,6 +1,5 @@ window: width: 600 - height: 0 master_gain: slider_width: -65 label_color: .text_default diff --git a/src/sampletones_config/layout/settings/display.yaml b/src/sampletones_config/layout/settings/display.yaml index 3b53ce894..f873132fb 100644 --- a/src/sampletones_config/layout/settings/display.yaml +++ b/src/sampletones_config/layout/settings/display.yaml @@ -1,6 +1,4 @@ window: width: 460 - height: 0 countdown: width: 360 - height: 0 diff --git a/src/sampletones_config/layout/settings/export.yaml b/src/sampletones_config/layout/settings/export.yaml new file mode 100644 index 000000000..beb55ad60 --- /dev/null +++ b/src/sampletones_config/layout/settings/export.yaml @@ -0,0 +1,6 @@ +window: + width: 460 + height: 120 +indicator: + radius: 1.6 + thickness: 1.5 diff --git a/src/sampletones_config/layout/settings/keybindings.yaml b/src/sampletones_config/layout/settings/keybindings.yaml index 165bfc38d..2a4fc14e6 100644 --- a/src/sampletones_config/layout/settings/keybindings.yaml +++ b/src/sampletones_config/layout/settings/keybindings.yaml @@ -1,5 +1,4 @@ window: width: 620 - height: 0 list_height: 420 action_width: 320 diff --git a/src/sampletones_config/layout/settings/nsf.yaml b/src/sampletones_config/layout/settings/nsf.yaml new file mode 100644 index 000000000..19fca9030 --- /dev/null +++ b/src/sampletones_config/layout/settings/nsf.yaml @@ -0,0 +1,4 @@ +window: + width: 540 +text_width: -64 +frame_width: 40 diff --git a/src/sampletones_config/layout/settings/render.yaml b/src/sampletones_config/layout/settings/render.yaml index 3703a5caf..2165ce973 100644 --- a/src/sampletones_config/layout/settings/render.yaml +++ b/src/sampletones_config/layout/settings/render.yaml @@ -1,3 +1,2 @@ window: width: 540 - height: 0 diff --git a/src/sampletones_config/layout/tabs/main/config.yaml b/src/sampletones_config/layout/tabs/main/config.yaml index 60a85cd67..612da3332 100644 --- a/src/sampletones_config/layout/tabs/main/config.yaml +++ b/src/sampletones_config/layout/tabs/main/config.yaml @@ -1 +1 @@ -height: 260 +height: 285 diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index 955e82339..8bdfbcc8d 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -1,3 +1,10 @@ width: -1 -height: 180 button_height: 45 +stem_selection: + width: 480 + height: 560 +stem_selection_footer: 44 +stem_selection_list: 380 +scan: + width: 420 + height: 150 diff --git a/src/sampletones_config/layout/tabs/main/reconstructor.yaml b/src/sampletones_config/layout/tabs/main/reconstructor.yaml deleted file mode 100644 index 2db2bbaea..000000000 --- a/src/sampletones_config/layout/tabs/main/reconstructor.yaml +++ /dev/null @@ -1,2 +0,0 @@ -drive_format: "%.2f" -height: 260 diff --git a/src/sampletones_config/layout/tabs/main/source.yaml b/src/sampletones_config/layout/tabs/main/source.yaml new file mode 100644 index 000000000..9f762e2a9 --- /dev/null +++ b/src/sampletones_config/layout/tabs/main/source.yaml @@ -0,0 +1,4 @@ +drive_decimals: 2 +name_column_width: 72 +choice_column_width: 34 +step_button_width: 34 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..fe9493c6a 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -32,6 +32,7 @@ colors: accent_hover: "#6fb8ff" accent_active: "#3585d6" accent_muted: "#3a5570" + accent_partial: "#4fa6ffaa" on_accent: "#0b1520" primary: "#0e639c" @@ -49,6 +50,7 @@ colors: danger_hover: "#b85850" danger_active: "#853830" on_danger: "#f2f2f4" + danger_disabled: "#4a3230" dialog_surface: "#2c2c30" dialog_title: "#333a44" @@ -61,6 +63,21 @@ colors: channel_pulse2_soft: "#dcd3a4" channel_triangle_soft: "#aecadd" channel_noise_soft: "#c4c6cc" + channel_pulse1_partial: "#f0925688" + channel_pulse2_partial: "#f2d15f88" + channel_triangle_partial: "#7fc3f288" + channel_noise_partial: "#b4b8c088" + + stem_1: "#d86e7c" + stem_2: "#6ed8c3" + stem_3: "#c76ed8" + stem_4: "#87d86e" + stem_5: "#d86eb3" + stem_6: "#6ed88c" + stem_7: "#6e7cd8" + stem_8: "#bed86e" + stem_authored: "#b882ed" + stem_rest: "#2a2a2d" tab: "#232325" tab_hovered: "#2d2d31" @@ -114,10 +131,11 @@ colors: favorite: "#ffd76e" favorite_child: "#ddd2ac" - library_generator: "#cbe6cb" + generator: "#cbe6cb" library_group: "#c8e4e6" library_instruction: "#cccccf" library_root: "#dadade" + library_outdated: "#bfad6c" text_default: "#d4d4d4" text_inactive: "#808085" @@ -151,6 +169,7 @@ colors: feature_volume: "#7ee787" feature_arpeggio: "#f0a35e" feature_pitch: "#6fb8ff" + feature_hi_pitch: "#a99cff" feature_duty_cycle: "#e6c26a" caret_fill: "#4fa6ff55" @@ -187,9 +206,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..5c0f08cdc 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -32,6 +32,7 @@ colors: accent_hover: "#1163ac" accent_active: "#073a6c" accent_muted: "#9dbcd9" + accent_partial: "#0b4c8caa" on_accent: "#ffffff" primary: "#0b4c8c" @@ -49,6 +50,7 @@ colors: danger_hover: "#c33a30" danger_active: "#851f18" on_danger: "#ffffff" + danger_disabled: "#dfd0ce" dialog_surface: "#f2f4f8" dialog_title: "#c6cbd5" @@ -61,6 +63,21 @@ colors: channel_pulse2_soft: "#75683c" channel_triangle_soft: "#456c8c" channel_noise_soft: "#767b85" + channel_pulse1_partial: "#a8410a88" + channel_pulse2_partial: "#7a5c0088" + channel_triangle_partial: "#0f528888" + channel_noise_partial: "#4a4f5988" + + stem_1: "#911828" + stem_2: "#189179" + stem_3: "#7d1891" + stem_4: "#349118" + stem_5: "#911866" + stem_6: "#18913a" + stem_7: "#182891" + stem_8: "#729118" + stem_authored: "#5a2d8c" + stem_rest: "#aab0bb" tab: "#b9bfca" tab_hovered: "#a9b0bd" @@ -114,10 +131,11 @@ colors: favorite: "#8a6000" favorite_child: "#75663c" - library_generator: "#194c26" + generator: "#194c26" library_group: "#125a60" library_instruction: "#3d434d" library_root: "#20252c" + library_outdated: "#7a6524" text_default: "#12161c" text_inactive: "#6e7580" @@ -151,6 +169,7 @@ colors: feature_volume: "#16702e" feature_arpeggio: "#a8410a" feature_pitch: "#0a5aa8" + feature_hi_pitch: "#4a3fa8" feature_duty_cycle: "#7a5c00" caret_fill: "#0b4c8c58" @@ -187,9 +206,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..2223d0cd2 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -32,6 +32,7 @@ colors: accent_hover: "#a180ce" accent_active: "#7b629e" accent_muted: "#685386" + accent_partial: "#b98af3aa" on_accent: "#17131f" primary: "#8f6fc0" @@ -49,6 +50,7 @@ colors: danger_hover: "#bd6a6a" danger_active: "#8e4747" on_danger: "#f1f1f5" + danger_disabled: "#4d3d47" dialog_surface: "#313547" dialog_title: "#34405e" @@ -61,6 +63,21 @@ colors: channel_pulse2_soft: "#dfd6a8" channel_triangle_soft: "#b9cedf" channel_noise_soft: "#cbcace" + channel_pulse1_partial: "#f0925688" + channel_pulse2_partial: "#f2d15f88" + channel_triangle_partial: "#8cc1ed88" + channel_noise_partial: "#bbb8c288" + + stem_1: "#de7381" + stem_2: "#73dec9" + stem_3: "#cc73de" + stem_4: "#8cde73" + stem_5: "#de73b8" + stem_6: "#73de91" + stem_7: "#7381de" + stem_8: "#c3de73" + stem_authored: "#b98af3" + stem_rest: "#2a2937" tab: "#24283a" tab_hovered: "#2f3a56" @@ -114,10 +131,11 @@ colors: favorite: "#ffd76e" favorite_child: "#e7dbb7" - library_generator: "#d2e8d2" + generator: "#d2e8d2" library_group: "#d2e8e8" library_instruction: "#d2d2d2" library_root: "#dcdcdc" + library_outdated: "#c7b575" text_default: "#dcdcdc" text_inactive: "#828282" @@ -151,6 +169,7 @@ colors: feature_volume: "#64ff64" feature_arpeggio: "#ff9664" feature_pitch: "#64c8ff" + feature_hi_pitch: "#9664ff" feature_duty_cycle: "#ffc864" caret_fill: "#8888ff80" @@ -187,9 +206,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/button/danger_focused.yaml b/src/sampletones_config/theme/button/danger_focused.yaml new file mode 100644 index 000000000..c2c6f8853 --- /dev/null +++ b/src/sampletones_config/theme/button/danger_focused.yaml @@ -0,0 +1,13 @@ +name: danger_button_focused +tag: global.theme.danger_button_focused +extends: danger_button + +components: + - item_type: Button + entries: + - type: color + key: Border + value: .accent + - type: style + key: FrameBorderSize + x: 1 diff --git a/src/sampletones_config/theme/button/step.yaml b/src/sampletones_config/theme/button/step.yaml new file mode 100644 index 000000000..cd8904dd1 --- /dev/null +++ b/src/sampletones_config/theme/button/step.yaml @@ -0,0 +1,18 @@ +name: step_button +tag: global.theme.step_button + +components: + - item_type: Button + entries: + - type: color + key: Button + value: .control + - type: color + key: ButtonHovered + value: .control_hovered + - type: color + key: ButtonActive + value: .control_active + - type: color + key: Text + value: .text diff --git a/src/sampletones_config/theme/button/step_dim.yaml b/src/sampletones_config/theme/button/step_dim.yaml new file mode 100644 index 000000000..4e2bb0518 --- /dev/null +++ b/src/sampletones_config/theme/button/step_dim.yaml @@ -0,0 +1,18 @@ +name: step_button_dim +tag: global.theme.step_button_dim + +components: + - item_type: Button + entries: + - type: color + key: Button + value: .control + - type: color + key: ButtonHovered + value: .control_hovered + - type: color + key: ButtonActive + value: .control_active + - type: color + key: Text + value: .text_muted diff --git a/src/sampletones_config/theme/button/step_lit.yaml b/src/sampletones_config/theme/button/step_lit.yaml new file mode 100644 index 000000000..5c2cb63a0 --- /dev/null +++ b/src/sampletones_config/theme/button/step_lit.yaml @@ -0,0 +1,18 @@ +name: step_button_lit +tag: global.theme.step_button_lit + +components: + - item_type: Button + entries: + - type: color + key: Button + value: .accent + - type: color + key: ButtonHovered + value: .accent_hover + - type: color + key: ButtonActive + value: .accent_active + - type: color + key: Text + value: .on_accent diff --git a/src/sampletones_config/theme/button/step_partial.yaml b/src/sampletones_config/theme/button/step_partial.yaml new file mode 100644 index 000000000..500519b95 --- /dev/null +++ b/src/sampletones_config/theme/button/step_partial.yaml @@ -0,0 +1,18 @@ +name: step_button_partial +tag: global.theme.step_button_partial + +components: + - item_type: Button + entries: + - type: color + key: Button + value: .accent_partial + - type: color + key: ButtonHovered + value: .accent_hover + - type: color + key: ButtonActive + value: .accent_active + - type: color + key: Text + value: .text diff --git a/src/sampletones_config/theme/channels/muted.yaml b/src/sampletones_config/theme/channels/muted.yaml new file mode 100644 index 000000000..95cd338db --- /dev/null +++ b/src/sampletones_config/theme/channels/muted.yaml @@ -0,0 +1,28 @@ +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 + - item_type: Text + entries: + - type: color + key: Text + value: .text_muted + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .text_muted + - type: color + key: SliderGrabActive + value: .text_muted + - type: color + key: Text + value: .text_muted diff --git a/src/sampletones_config/theme/channels/noise.yaml b/src/sampletones_config/theme/channels/noise.yaml index bc49519ab..d709f9fab 100644 --- a/src/sampletones_config/theme/channels/noise.yaml +++ b/src/sampletones_config/theme/channels/noise.yaml @@ -10,3 +10,16 @@ components: - type: color key: Text value: .channel_noise_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_noise + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_noise + - type: color + key: SliderGrabActive + value: .channel_noise_soft diff --git a/src/sampletones_config/theme/channels/noise_partial.yaml b/src/sampletones_config/theme/channels/noise_partial.yaml new file mode 100644 index 000000000..a705fef32 --- /dev/null +++ b/src/sampletones_config/theme/channels/noise_partial.yaml @@ -0,0 +1,29 @@ +name: channel_noise_partial +tag: global.theme.channel_noise_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_noise_partial + - type: color + key: FrameBgHovered + value: .channel_noise_partial + - type: color + key: FrameBgActive + value: .channel_noise_partial + - type: color + key: CheckMark + value: .channel_noise_soft + - type: color + key: Text + value: .channel_noise_soft + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_noise_soft + - type: color + key: SliderGrabActive + value: .channel_noise diff --git a/src/sampletones_config/theme/channels/pulse1.yaml b/src/sampletones_config/theme/channels/pulse1.yaml index d15e69dcd..17566bca6 100644 --- a/src/sampletones_config/theme/channels/pulse1.yaml +++ b/src/sampletones_config/theme/channels/pulse1.yaml @@ -10,3 +10,16 @@ components: - type: color key: Text value: .channel_pulse1_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_pulse1 + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_pulse1 + - type: color + key: SliderGrabActive + value: .channel_pulse1_soft diff --git a/src/sampletones_config/theme/channels/pulse1_partial.yaml b/src/sampletones_config/theme/channels/pulse1_partial.yaml new file mode 100644 index 000000000..5dfffd54b --- /dev/null +++ b/src/sampletones_config/theme/channels/pulse1_partial.yaml @@ -0,0 +1,29 @@ +name: channel_pulse1_partial +tag: global.theme.channel_pulse1_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_pulse1_partial + - type: color + key: FrameBgHovered + value: .channel_pulse1_partial + - type: color + key: FrameBgActive + value: .channel_pulse1_partial + - type: color + key: CheckMark + value: .channel_pulse1_soft + - type: color + key: Text + value: .channel_pulse1_soft + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_pulse1_soft + - type: color + key: SliderGrabActive + value: .channel_pulse1 diff --git a/src/sampletones_config/theme/channels/pulse2.yaml b/src/sampletones_config/theme/channels/pulse2.yaml index 854ab1b53..ae398bc1d 100644 --- a/src/sampletones_config/theme/channels/pulse2.yaml +++ b/src/sampletones_config/theme/channels/pulse2.yaml @@ -10,3 +10,16 @@ components: - type: color key: Text value: .channel_pulse2_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_pulse2 + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_pulse2 + - type: color + key: SliderGrabActive + value: .channel_pulse2_soft diff --git a/src/sampletones_config/theme/channels/pulse2_partial.yaml b/src/sampletones_config/theme/channels/pulse2_partial.yaml new file mode 100644 index 000000000..66e5cabc4 --- /dev/null +++ b/src/sampletones_config/theme/channels/pulse2_partial.yaml @@ -0,0 +1,29 @@ +name: channel_pulse2_partial +tag: global.theme.channel_pulse2_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_pulse2_partial + - type: color + key: FrameBgHovered + value: .channel_pulse2_partial + - type: color + key: FrameBgActive + value: .channel_pulse2_partial + - type: color + key: CheckMark + value: .channel_pulse2_soft + - type: color + key: Text + value: .channel_pulse2_soft + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_pulse2_soft + - type: color + key: SliderGrabActive + value: .channel_pulse2 diff --git a/src/sampletones_config/theme/channels/triangle.yaml b/src/sampletones_config/theme/channels/triangle.yaml index 2ee9814c8..6d5a13301 100644 --- a/src/sampletones_config/theme/channels/triangle.yaml +++ b/src/sampletones_config/theme/channels/triangle.yaml @@ -10,3 +10,16 @@ components: - type: color key: Text value: .channel_triangle_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_triangle + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_triangle + - type: color + key: SliderGrabActive + value: .channel_triangle_soft diff --git a/src/sampletones_config/theme/channels/triangle_partial.yaml b/src/sampletones_config/theme/channels/triangle_partial.yaml new file mode 100644 index 000000000..95fe9ad7c --- /dev/null +++ b/src/sampletones_config/theme/channels/triangle_partial.yaml @@ -0,0 +1,29 @@ +name: channel_triangle_partial +tag: global.theme.channel_triangle_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_triangle_partial + - type: color + key: FrameBgHovered + value: .channel_triangle_partial + - type: color + key: FrameBgActive + value: .channel_triangle_partial + - type: color + key: CheckMark + value: .channel_triangle_soft + - type: color + key: Text + value: .channel_triangle_soft + - item_type: SliderFloat + entries: + - type: color + key: SliderGrab + value: .channel_triangle_soft + - type: color + key: SliderGrabActive + value: .channel_triangle 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/default.yaml b/src/sampletones_config/theme/default.yaml index fc61e3668..165c50f12 100644 --- a/src/sampletones_config/theme/default.yaml +++ b/src/sampletones_config/theme/default.yaml @@ -373,3 +373,30 @@ components: - type: style key: FrameBorderSize x: 1 + - item_type: SliderFloat + enabled: false + entries: + - type: color + key: Text + value: .text_muted + - type: color + key: FrameBg + value: .button_disabled + - type: color + key: FrameBgHovered + value: .button_disabled + - type: color + key: FrameBgActive + value: .button_disabled + - type: color + key: SliderGrab + value: .text_disabled + - type: color + key: SliderGrabActive + value: .text_disabled + - type: color + key: Border + value: .border + - type: style + key: FrameBorderSize + x: 1 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/nodes/library/outdated.yaml b/src/sampletones_config/theme/nodes/library/outdated.yaml new file mode 100644 index 000000000..a63338d68 --- /dev/null +++ b/src/sampletones_config/theme/nodes/library/outdated.yaml @@ -0,0 +1,9 @@ +name: node_library_outdated +tag: instructions.library.theme.outdated + +components: + - item_type: TreeNode + entries: + - type: color + key: Text + value: .library_outdated 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/grid.yaml b/src/sampletones_config/theme/stems/grid.yaml new file mode 100644 index 000000000..fd7cc4a8e --- /dev/null +++ b/src/sampletones_config/theme/stems/grid.yaml @@ -0,0 +1,12 @@ +name: stems_grid +tag: global.theme.stems_grid + +components: + - item_type: Table + entries: + - type: color + key: TableRowBg + value: .transparent + - type: color + key: TableRowBgAlt + value: .transparent diff --git a/src/sampletones_config/theme/stems/group_row.yaml b/src/sampletones_config/theme/stems/group_row.yaml new file mode 100644 index 000000000..706051636 --- /dev/null +++ b/src/sampletones_config/theme/stems/group_row.yaml @@ -0,0 +1,12 @@ +name: stems_group_row +tag: global.theme.stems_group_row + +components: + - item_type: All + entries: + - type: color + key: TableRowBg + value: .accent/0.16 + - type: color + key: TableRowBgAlt + value: .accent/0.16 diff --git a/src/sampletones_config/theme/stems/marker.yaml b/src/sampletones_config/theme/stems/marker.yaml new file mode 100644 index 000000000..8a8205481 --- /dev/null +++ b/src/sampletones_config/theme/stems/marker.yaml @@ -0,0 +1,19 @@ +name: stems_marker +tag: global.theme.stems_marker + +components: + - item_type: Button + entries: + - type: style + key: FramePadding + x: 0 + y: 0 + - type: color + key: Button + value: .transparent + - type: color + key: ButtonHovered + value: .selection_hovered + - type: color + key: ButtonActive + value: .selection_active diff --git a/src/sampletones_config/theme/stems/pick.yaml b/src/sampletones_config/theme/stems/pick.yaml new file mode 100644 index 000000000..220f8f6f4 --- /dev/null +++ b/src/sampletones_config/theme/stems/pick.yaml @@ -0,0 +1,15 @@ +name: stems_pick +tag: global.theme.stems_pick + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .control + - type: color + key: FrameBgHovered + value: .control_hovered + - type: color + key: FrameBgActive + value: .control_active diff --git a/src/sampletones_config/theme/stems/pick_partial.yaml b/src/sampletones_config/theme/stems/pick_partial.yaml new file mode 100644 index 000000000..34400aac4 --- /dev/null +++ b/src/sampletones_config/theme/stems/pick_partial.yaml @@ -0,0 +1,15 @@ +name: stems_pick_partial +tag: global.theme.stems_pick_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .accent_partial + - type: color + key: FrameBgHovered + value: .accent_partial + - type: color + key: FrameBgActive + value: .accent_partial diff --git a/src/sampletones_config/theme/stems/row.yaml b/src/sampletones_config/theme/stems/row.yaml new file mode 100644 index 000000000..23b05cb76 --- /dev/null +++ b/src/sampletones_config/theme/stems/row.yaml @@ -0,0 +1,13 @@ +name: stems_row +tag: global.theme.stems_row + +components: + - item_type: Selectable + entries: + - type: color + key: Text + value: .text + - type: style + key: SelectableTextAlign + x: 0.0 + y: 0.5 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..8180fcbf1 --- /dev/null +++ b/src/sampletones_config/theme/stems/row_inert.yaml @@ -0,0 +1,13 @@ +name: stems_row_inert +tag: global.theme.stems_row_inert + +components: + - item_type: Selectable + entries: + - type: color + key: Text + value: .text_muted + - type: style + key: SelectableTextAlign + x: 0.0 + y: 0.5 diff --git a/src/sampletones_config/theme/stems/slot_label.yaml b/src/sampletones_config/theme/stems/slot_label.yaml new file mode 100644 index 000000000..11e6034aa --- /dev/null +++ b/src/sampletones_config/theme/stems/slot_label.yaml @@ -0,0 +1,9 @@ +name: stems_slot_label +tag: global.theme.stems_slot_label + +components: + - item_type: Text + 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/instruments_row.yaml deleted file mode 100644 index 276e88836..000000000 --- a/src/sampletones_config/theme/tables/instruments_row.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: table_instruments_row -tag: sequencer.instruments.theme.row - -components: - - item_type: Table - entries: - - type: color - key: HeaderHovered - value: .overlay/0.25 - - type: color - key: HeaderActive - value: .transparent - - item_type: All - entries: - - type: color - key: ChildBg - value: .table_row diff --git a/src/sampletones_config/theme/tables/voices_row.yaml b/src/sampletones_config/theme/tables/voices_row.yaml new file mode 100644 index 000000000..f36c1536f --- /dev/null +++ b/src/sampletones_config/theme/tables/voices_row.yaml @@ -0,0 +1,17 @@ +name: table_voices_row +tag: sequencer.voices.theme.row + +components: + - item_type: Table + entries: + - type: color + key: HeaderHovered + value: .overlay/0.25 + - type: color + key: HeaderActive + value: .transparent + - item_type: All + entries: + - type: color + key: ChildBg + value: .table_row diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index 436ef3c6f..b9607a6ff 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 UNIT_SCALE, load_audio, mix_scale, read_stems, read_wave, scale_stems, write_flac, write_wave from .manager import CHANNELS, FORMAT, AudioDeviceManager +from .mixing import align, common_length, mix from .processing import ( active_frame_level, amplitude_to_decibels, @@ -23,24 +24,32 @@ __all__ = [ "CHANNELS", "FORMAT", + "UNIT_SCALE", "AudioDevice", "AudioDeviceManager", "CurrentDevice", "active_frame_level", + "align", "amplitude_to_decibels", "clip_audio", "clip_audio_inplace", + "common_length", "interpolate", "load_audio", "minmax_decimate", + "mix", + "mix_scale", "normalize", "quantize", + "read_stems", "read_wave", "resample", + "scale_stems", "silence", "to_mono", "validate_audio_array", "validate_buffer_size", "validate_sample_rate", + "write_flac", "write_wave", ] diff --git a/src/sampletones_core/audio/device.py b/src/sampletones_core/audio/device.py index 4c8db5c82..bdab68961 100644 --- a/src/sampletones_core/audio/device.py +++ b/src/sampletones_core/audio/device.py @@ -28,6 +28,20 @@ def default(cls) -> Self: host_api=-1, ) + @classmethod + def from_device( + cls, + device: AudioDevice, + sample_rate: SampleRate, + ) -> Self: + """The configuration of ``device`` playing at ``sample_rate``.""" + return cls( + device_index=device.index, + name=device.name, + sample_rate=sample_rate, + host_api=device.host_api, + ) + class AudioDevice(BaseModel): """ diff --git a/src/sampletones_core/audio/io.py b/src/sampletones_core/audio/io.py index 6d6eedbba..570cf14e4 100644 --- a/src/sampletones_core/audio/io.py +++ b/src/sampletones_core/audio/io.py @@ -1,19 +1,25 @@ from pathlib import Path -from typing import Optional, Tuple +from typing import Final, Optional, Sequence, Tuple import numpy as np from scipy.io import wavfile from soundfile import read as sf_read +from soundfile import write as sf_write 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 from .processing import resample, to_mono from .validation import validate_audio_array, validate_sample_rate +FLAC_FORMAT: Final[str] = "FLAC" +FLAC_SUBTYPE: Final[str] = "PCM_16" +UNIT_SCALE: Final[float] = 1.0 + def write_wave(path: Pathlike, sample_rate: int, audio: np.ndarray) -> None: """ @@ -36,6 +42,28 @@ def write_wave(path: Pathlike, sample_rate: int, audio: np.ndarray) -> None: wavfile.write(path, sample_rate, audio) +def write_flac(path: Pathlike, sample_rate: int, audio: np.ndarray) -> None: + """ + Write audio data to a FLAC file with specified sample rate. + + The samples are clipped to the valid wave range and stored as 16-bit integers, a lossless + encoding every audio player and every browser reads, at about a third of the size the same + audio takes as 32-bit floating point. + + Args: + path: File path where the FLAC file will be written. + sample_rate: Sample rate in Hz (must be one of the allowed rates). + audio: Audio array to write (can be mono or stereo). + + Raises: + TypeError: If sample_rate is not an integer or audio is not a numpy array. + ValueError: If sample_rate is not in allowed sample rates or audio has invalid dimensions. + """ + validate_sample_rate(sample_rate) + validate_audio_array(audio, allowed_dims=(1, 2)) + sf_write(path, clip_audio(audio), sample_rate, subtype=FLAC_SUBTYPE, format=FLAC_FORMAT) + + def read_wave(path: Pathlike) -> Tuple[np.ndarray, int]: """ Read audio data and sample rate from an audio file. @@ -118,3 +146,93 @@ def load_audio( audio = quantize_audio(audio, levels=quantization_levels) return audio + + +def read_stems( + paths: Sequence[Pathlike], + *, + target_sample_rate: int, +) -> Tuple[np.ndarray, ...]: + """ + Read a set of recordings onto one sample rate and one shared length, at the levels captured. + + Each recording is mixed to mono and resampled, a shorter one runs on in silence to the length + of the longest, and a sample the file states no finite value for reads as silence. The levels + stay as the files hold them, so the balance they were captured in is what a scale then keeps. + + Args: + paths: Paths to the recordings, in the order they are returned. + target_sample_rate: Sample rate every recording is resampled to. + + 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)) + return tuple(np.nan_to_num(recording, nan=0.0, posinf=0.0, neginf=0.0) for recording in aligned) + + +def mix_scale(recordings: Sequence[np.ndarray], *, normalize: bool) -> float: + """ + The factor a set of recordings is divided by to bring the peak of their mix to full range. + + The recordings of one piece stand in the balance the piece was mixed at, so the whole set is + scaled by the one 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. Scaling one recording by the peak of its own sum is normalizing it. + + Args: + recordings: The recordings of one set, sharing a length. + normalize: Whether the set is brought to full range at all. + + Returns: + float: The peak of the mix, and ``UNIT_SCALE`` where normalization is off or the set is + silent throughout. + """ + if not normalize or not recordings: + return UNIT_SCALE + + peak = float(np.max(np.abs(mix(list(recordings))))) + return peak if peak > 0.0 else UNIT_SCALE + + +def scale_stems( + recordings: Sequence[np.ndarray], + *, + scale: float, + quantize: bool, + quantization_levels: int, +) -> Tuple[np.ndarray, ...]: + """ + Bring every recording of a set to the level ``scale`` sets for it. + + Each recording is divided by the one factor, so the set keeps its balance. Quantization follows + the scaling, the order it is defined against. + + Args: + recordings: The recordings of one set. + scale: The factor every recording is divided by. + quantize: Whether each scaled recording is quantized. + quantization_levels: Number of amplitude levels used when quantization is enabled. + + Returns: + The scaled recordings, in the order given. + """ + scaled = [(recording / scale).astype(np.float32) for recording in recordings] + if quantize: + return tuple(quantize_audio(recording, levels=quantization_levels) for recording in scaled) + + return tuple(scaled) diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 66530df58..ccd853e5d 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -3,7 +3,7 @@ import sys import threading from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Optional, cast +from typing import Any, Callable, Dict, Final, Generator, List, Optional, cast import numpy as np import pyaudio @@ -11,10 +11,11 @@ from sampletones_core.constants.audio import ( DEFAULT_BUFFER_SIZE, SAMPLE_RATES, + START_OF_AUDIO, BufferSize, SampleRate, ) -from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -22,10 +23,13 @@ from .device import AudioDevice, CurrentDevice from .io import load_audio +from .stream import close_stream, write_to_stream from .validation import validate_buffer_size, validate_sample_rate CHANNELS = 1 FORMAT = pyaudio.paFloat32 +STOP_JOIN_SECONDS: Final[float] = 1.0 +LET_GO_JOIN_SECONDS: Final[float] = 2.0 OnPlaybackErrorCallback = Callable[[PlaybackError], None] @@ -90,8 +94,10 @@ def __init__(self) -> None: self._active_priority: int = 0 self._paused: bool = False self._stop: bool = False + self._generation: int = 0 self._playback_thread: Optional[threading.Thread] = None + self._let_go_workers: List[threading.Thread] = [] self._stream_owners: Dict[pyaudio.Stream, VoidCallback] = {} self._lock: threading.Lock = threading.Lock() self._resume_event: threading.Event = threading.Event() @@ -112,11 +118,13 @@ def reinitialize(self) -> None: Creates a new PyAudio instance if none exists, or terminates the existing instance and creates a new one. Active playback is stopped and every handed-out output stream is released first, since terminating closes any stream still open - while its owner writes to it. + while its owner writes to it. A worker a stop let go of is waited for too, since it + may still be opening its stream. Raises: PlaybackError: If an output stream is still held after its owner was asked to - release it, which leaves the running instance in place. + release it, or a worker a stop let go of outlives the wait, which leaves the + running instance in place. """ with _capture_stderr_to_logger(): if self._pyaudio is None: @@ -128,6 +136,9 @@ def reinitialize(self) -> None: if not self._release_output_streams(): raise PlaybackError("An output stream is still held; the audio backend stays as it is") + if not self._join_let_go_workers(): + raise PlaybackError("A playback worker still reaches into the audio backend; it stays as it is") + self._pyaudio.terminate() self._pyaudio = pyaudio.PyAudio() logger.debug("AudioDeviceManager reinitialized") @@ -249,10 +260,10 @@ def device_index(self) -> int: The device index. Raises: - ValueError: If no audio device is currently selected. + NoOutputDeviceError: If no audio device is currently selected. """ if self._device_index is None: - raise ValueError("No audio device selected") + raise NoOutputDeviceError("No audio device selected") return self._device_index @@ -323,10 +334,10 @@ def sample_rate(self) -> SampleRate: Sample rate in Hz. Raises: - ValueError: If no audio device is currently selected. + NoOutputDeviceError: If no audio device is currently selected. """ if self._sample_rate is None: - raise ValueError("No audio device selected") + raise NoOutputDeviceError("No audio device selected") return self._sample_rate @@ -351,19 +362,46 @@ def sample_rate(self, value: SampleRate) -> None: self._sample_rate = value - def get_current_device(self) -> CurrentDevice: + def get_current_device(self) -> Optional[CurrentDevice]: """ Get a snapshot of the current device configuration. + A machine offering no output device starts with nothing selected, and a refresh can take + the selected device off the list, so both read as no current device. + Returns: - CurrentDevice object containing device index, name, sample rate, and host API. + CurrentDevice object containing device index, name, sample rate, and host API, or + ``None`` while no listed device is selected. """ - return CurrentDevice( - device_index=self.device_index, - name=self.device_name, - sample_rate=self.sample_rate, - host_api=self._devices[self.device_index].host_api, - ) + if self._device_index is None or self._sample_rate is None: + return None + + device = self._devices.get(self._device_index) + if device is None: + return None + + return CurrentDevice.from_device(device, self._sample_rate) + + def require_output(self) -> CurrentDevice: + """ + Answer the device and rate a playback opens its stream on. + + Every way to start playback asks here first, so a machine offering no output device, or a + refresh that took the chosen one off the list, refuses the playback before any thread or + stream starts. + + Returns: + The device in force and the rate it plays at. + + Raises: + NoOutputDeviceError: If no listed device is selected. + """ + current_device = self.get_current_device() + if current_device is None: + logger.warning("Playback refused: no audio output device is in force") + raise NoOutputDeviceError("No audio output device is in force") + + return current_device def set_current_device(self, current_device: CurrentDevice) -> None: """ @@ -378,17 +416,18 @@ def set_current_device(self, current_device: CurrentDevice) -> None: """ device_index = self.find_device_index(current_device) if device_index != -1: - return self.configure_device( + self.configure_device( device_index=device_index, sample_rate=current_device.sample_rate, ) + return if current_device.name: logger.warning(f"Audio device '{current_device.name}' not found. " f"Falling back to default device.") else: logger.info("No device specified. Initializing the default audio device.") - return self._initialize_default_device() + self._initialize_default_device() def find_device_index( self, @@ -422,7 +461,7 @@ def configure_device( self, device_index: int, sample_rate: SampleRate, - ) -> None: + ) -> CurrentDevice: """ Configure the audio device and sample rate. @@ -437,6 +476,9 @@ def configure_device( device_index: Index of the device to configure. sample_rate: Desired sample rate in Hz. + Returns: + The device and the rate now in force. + Raises: ValueError: If the device index is not found. PlaybackError: If a handed-out stream survives its release, which leaves the device @@ -461,6 +503,7 @@ def configure_device( self.device_index = device_index self.sample_rate = sample_rate logger.info(f"Audio device configured: '{self.device_name}' (index={device_index}, sample_rate={sample_rate})") + return CurrentDevice.from_device(device, sample_rate) def set_buffer_size(self, buffer_size: BufferSize) -> None: """ @@ -483,18 +526,35 @@ def set_position_callback(self, callback: Optional[Callable[[int], None]]) -> No """ self.set_callbacks(_position_callback=callback) - def set_position(self, position: int) -> None: + def set_position( + self, + position: int, + *, + owner: Optional[Any], + ) -> bool: """ - Seek to a specific position in the audio. + Seek ``owner``'s playback to a specific position in the audio. - The position is clamped to valid range [0, audio_length]. + Ownership is checked under the lock the seek writes under, so a playback another source + took over in the meantime stays where it is. Args: - position: Target position in samples. + position: Target position in samples, clamped to the audio. + owner: Identity that must match the active playback's owner for the seek to apply. + + Returns: + bool: Whether ``owner``'s playback was moved. """ with self._lock: - if self._audio_data is not None: - self._position = max(0, min(position, len(self._audio_data))) + if not self._playing or self._audio_data is None or self._output_owner is not owner: + return False + + self._position = self._clamped_position(position, len(self._audio_data)) + return True + + @staticmethod + def _clamped_position(position: int, length: int) -> int: + return max(START_OF_AUDIO, min(position, length)) def play_file( self, @@ -506,11 +566,17 @@ def play_file( """ Load and play an audio file. + The output is asked for before the file is read, so a refused playback reads nothing. + Args: filepath: Path to the audio file. update: If True, invoke position callback during playback. priority: Output-request priority; see :meth:`play`. + + Raises: + NoOutputDeviceError: If no output device is in force. """ + self.require_output() audio = load_audio(filepath, normalize=False, quantize=False) self.play(audio, update=update, priority=priority) @@ -521,7 +587,8 @@ def play( update: bool = True, priority: int = 0, owner: Optional[Any] = None, - ) -> None: + start: int = START_OF_AUDIO, + ) -> bool: """ Play audio data. @@ -539,7 +606,17 @@ 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. + start: The sample playback begins at, clamped to the audio. It is placed before the + playback thread starts, so the first buffer written is the one beginning there. + + 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. + + Raises: + NoOutputDeviceError: If no output device is in force. """ + output = self.require_output() external_priority = self.call(self.external_output_priority) with self._lock: internal_priority = self._active_priority if self._playing else None @@ -548,7 +625,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: @@ -556,22 +633,25 @@ def play( with self._lock: self._audio_data = audio.astype(np.float32) - self._position = 0 + self._position = self._clamped_position(start, len(self._audio_data)) self._playing = True self._output_owner = owner self._active_priority = priority self._paused = False self._stop = False + self._generation += 1 + generation = self._generation self._resume_event.set() self._playback_thread = threading.Thread( target=self._playback_worker, - kwargs={"update": update}, + kwargs={"output": output, "update": update, "generation": generation}, daemon=True, name="AudioPlaybackWorker", ) self._playback_thread.start() + return True def replace_audio( self, @@ -602,22 +682,29 @@ def replace_audio( self._position = min(self._position, len(self._audio_data)) return True - def _playback_loop(self, stream: pyaudio.Stream, update: bool) -> None: + def _playback_loop( + self, + stream: pyaudio.Stream, + update: bool, + generation: int, + ) -> None: """ Internal audio playback loop. Continuously reads audio chunks and writes them to the stream until stopped, - paused, or audio ends. Respects pause state and stop flag. + paused, or audio ends. Respects pause state and stop flag, and ends once a newer + playback has taken the generation over. Args: stream: PyAudio stream to write audio data to. update: If True, invoke position callback after each chunk. + generation: The playback this loop plays. """ while True: self._resume_event.wait(timeout=0.1) with self._lock: - if self._stop or self._audio_data is None: + if self._stop or self._audio_data is None or generation != self._generation: break if self._paused: @@ -632,42 +719,82 @@ def _playback_loop(self, stream: pyaudio.Stream, update: bool) -> None: self._position += chunk_size current_position = self._position - stream.write(chunk.tobytes()) + write_to_stream(stream, chunk.tobytes()) if update and self._position_callback is not None: self.call(self._position_callback, current_position) - def _playback_worker(self, *, update: bool = True) -> None: + def _playback_worker( + self, + *, + output: CurrentDevice, + update: bool, + generation: int, + ) -> None: """ Playback thread worker function. - Opens an audio stream, runs the playback loop, and ensures cleanup. - Handles stream opening errors by invoking the error callback. + Opens an audio stream, runs the playback loop, and ensures cleanup. A stream the device + refuses to open, a write it fails and a stream it fails to stop each leave the playback + idle, as a finished one does, before the error callback reports the failure, so whoever + follows the playback reads it stopped. + + A stop waits a while for the worker and then lets it go, so a device slow to open can bring + the worker back once its playback was stopped, or after a newer play has begun. The worker + acts for its own ``generation`` alone: a refusal it meets then is left unreported, a stream + it opens then closes unplayed, and the state stands as the stop or the newer play left it. Args: + output: The device and rate in force when the playback was asked for. update: If True, invoke position callback during playback. + generation: The playback this worker plays, stamped by :meth:`play`. """ - assert self._pyaudio is not None, "PyAudio instance is not initialized" - logger.debug(f"Starting playback: device_index={self._device_index}, sample_rate={self._sample_rate}") + logger.debug(f"Starting playback: device_index={output.device_index}, sample_rate={output.sample_rate}") try: - stream = self._pyaudio.open( - format=FORMAT, - channels=CHANNELS, - rate=self.sample_rate, - output=True, - output_device_index=self._device_index, - ) - except OSError as exception: - playback_error = PlaybackError(f"Failed to open audio stream: {exception}") - self.call(self.on_playback_error, playback_error) - return + self._play_generation(output=output, update=update, generation=generation) + except PlaybackError as playback_error: + if self._is_current(generation): + self.call(self.on_playback_error, playback_error) + + def _play_generation( + self, + *, + output: CurrentDevice, + update: bool, + generation: int, + ) -> None: + """Plays ``generation``'s audio on a stream of its own, and leaves the playback idle however that ends. + Raises: + PlaybackError: If the device refuses the stream, fails a write, or fails to stop the stream. + """ try: - self._playback_loop(stream, update) + stream = self._open_stream( + device_index=output.device_index, + sample_rate=output.sample_rate, + frames_per_buffer=pyaudio.paFramesPerBufferUnspecified, + ) + try: + if self._is_current(generation): + self._playback_loop(stream, update, generation) + finally: + close_stream(stream) finally: - stream.stop_stream() - stream.close() - self._reset(update=update) + self._reset_generation(generation, update=update) + + def _is_current(self, generation: int) -> bool: + with self._lock: + return generation == self._generation + + def _reset_generation(self, generation: int, *, update: bool) -> None: + """Resets playback state to idle while ``generation`` is the playback in force.""" + with self._lock: + if generation != self._generation: + return + + self._clear_playback() + + self._report_the_start(update=update) def _reset(self, *, update: bool = True) -> None: """ @@ -680,12 +807,19 @@ def _reset(self, *, update: bool = True) -> None: update: If True, invoke position callback with 0. """ with self._lock: - self._playing = False - self._paused = False - self._position = 0 - self._audio_data = None - self._output_owner = None + self._clear_playback() + self._report_the_start(update=update) + + def _clear_playback(self) -> None: + """Puts the playback state at idle. The caller holds the lock.""" + self._playing = False + self._paused = False + self._position = 0 + self._audio_data = None + self._output_owner = None + + def _report_the_start(self, *, update: bool) -> None: if update and self._position_callback is not None: self.call(self._position_callback, 0) @@ -716,19 +850,53 @@ def stop(self) -> None: """ Stop playback and reset state. - Signals the playback thread to stop, waits for it to terminate (up to 1 second), - and resets all playback state. + Signals the playback thread to stop, waits for it to terminate (up to + ``STOP_JOIN_SECONDS``), and resets all playback state. The stopped playback's generation + ends here, so a worker the wait lets go of acts for no playback: a refusal it meets later + reaches nobody, and the state stays as the stop left it. The manager keeps that worker, so + a teardown of the backend waits for it. """ with self._lock: self._stop = True + self._generation += 1 self._resume_event.set() if self._playback_thread is not None: - self._playback_thread.join(timeout=1.0) + self._let_go(self._playback_thread) self._playback_thread = None self._reset() + def _let_go(self, worker: threading.Thread) -> None: + """Waits a while for a stopped worker, and keeps one that outlives the wait for a teardown to join.""" + worker.join(timeout=STOP_JOIN_SECONDS) + with self._lock: + self._let_go_workers = [kept for kept in self._let_go_workers if kept.is_alive()] + if worker.is_alive(): + self._let_go_workers.append(worker) + + def _join_let_go_workers(self) -> bool: + """Waits a while for each worker a stop let go of, and reports whether every one has ended. + + Such a worker may still be opening its stream on the backend, so the backend is torn down only + once it has ended. A worker that outlives ``LET_GO_JOIN_SECONDS`` is logged and kept, as a + stream that outlives its release is. + """ + with self._lock: + workers, self._let_go_workers = self._let_go_workers, [] + + outliving: List[threading.Thread] = [] + for worker in workers: + worker.join(timeout=LET_GO_JOIN_SECONDS) + if worker.is_alive(): + logger.error(f"AudioDeviceManager: {worker.name} outlived the teardown deadline") + outliving.append(worker) + + with self._lock: + self._let_go_workers.extend(outliving) + + return not outliving + def is_playing(self) -> bool: """ Check if audio is currently playing. @@ -759,6 +927,22 @@ def is_owned_by(self, owner: Any) -> bool: with self._lock: return self._playing and self._output_owner is owner + def position_of(self, owner: Any) -> int: + """Where ``owner``'s playback stands, sounding or held paused. + + The position is read under the same lock a seek writes it under, so a caller drawing a mark + from it draws the latest seek, while a position reported earlier may already be behind it. + + Returns: + int: The sample the playback has reached, or the start of the audio while ``owner`` holds + no output. + """ + with self._lock: + if self._playing and self._output_owner is owner: + return self._position + + return START_OF_AUDIO + def open_output_stream( self, *, @@ -781,18 +965,17 @@ def open_output_stream( release: Winds the caller's writing down; returns once the stream is handed back. Raises: - PlaybackError: If PyAudio is not initialized. + PlaybackError: If PyAudio is not initialized, or the device refuses to open the stream. + NoOutputDeviceError: If no output device is in force. """ if self._pyaudio is None: raise PlaybackError("PyAudio not initialized; call reinitialize() first") + output = self.require_output() self.stop() - stream = self._pyaudio.open( - format=FORMAT, - channels=CHANNELS, - rate=sample_rate, - output=True, - output_device_index=self._device_index, + stream = self._open_stream( + device_index=output.device_index, + sample_rate=sample_rate, frames_per_buffer=buffer_size, ) with self._lock: @@ -800,26 +983,59 @@ def open_output_stream( return stream + def _open_stream( + self, + *, + device_index: int, + sample_rate: int, + frames_per_buffer: int, + ) -> pyaudio.Stream: + """Opens an output stream on the device, naming a refusal the way every playback reports it. + + Both kinds of playback open their stream here, the one this manager plays and the one it hands + out, so a reader told of a refused stream reads the same words whichever source asked. + + Raises: + PlaybackError: If the device refuses to open the stream. + """ + assert self._pyaudio is not None, "PyAudio instance is not initialized" + try: + return self._pyaudio.open( + format=FORMAT, + channels=CHANNELS, + rate=sample_rate, + output=True, + output_device_index=device_index, + frames_per_buffer=frames_per_buffer, + ) + except OSError as exception: + raise PlaybackError(f"Failed to open audio stream: {exception}") from exception + def close_output_stream(self, stream: pyaudio.Stream) -> None: """Take a handed-out stream back and close it. Called by the owner from the thread that wrote to the stream, once that writing has - finished. Returning the stream is what tells the manager the backend is free again. + finished. Returning the stream is what tells the manager the backend is free again, so + the manager takes it back before it winds the stream down. + + Raises: + PlaybackError: If the device fails to stop or to close the stream. """ with self._lock: self._stream_owners.pop(stream, None) - stream.stop_stream() - stream.close() + close_stream(stream) def terminate(self) -> None: """ Clean up and terminate the audio device manager. - Stops any active playback, releases every handed-out output stream, and terminates - the PyAudio instance. A stream that survives its release leaves the instance running, - since terminating closes any open stream and the owning thread would go on writing to - freed memory. Should be called once you are finished with the manager. + Stops any active playback, releases every handed-out output stream, waits for the + workers a stop let go of, and terminates the PyAudio instance. A stream that survives its + release leaves the instance running, since terminating closes any open stream and the + owning thread would go on writing to freed memory. A worker that outlives the wait leaves it + running too, since it may still be opening its stream. Should be called once you are + finished with the manager. """ if self._pyaudio is None: return @@ -829,6 +1045,10 @@ def terminate(self) -> None: logger.error("AudioDeviceManager: an output stream is still held; PyAudio left running") return + if not self._join_let_go_workers(): + logger.error("AudioDeviceManager: a playback worker is still running; PyAudio left running") + return + self._pyaudio.terminate() self._pyaudio = None 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/audio/processing.py b/src/sampletones_core/audio/processing.py index 53f5bfe8a..59c03e24c 100644 --- a/src/sampletones_core/audio/processing.py +++ b/src/sampletones_core/audio/processing.py @@ -281,19 +281,19 @@ def active_frame_level( audibility_floor: float = COEFFICIENT_AUDIBILITY_FLOOR, ) -> float: """ - Robust reference level for normalization. + Robust RMS level of the typical audible frame. - Returns the ``percentile``-th percentile of the per-frame peak amplitudes over - frames whose peak exceeds ``audibility_floor`` times the global peak. Anchoring to - the typical audible frame keeps the reference stable when the signal holds a lone - loud transient or long stretches of silence. Falls back to the global peak when no - frame is audible or the audio is shorter than one frame. + Returns the ``percentile``-th percentile of the per-frame RMS levels over frames whose + level exceeds ``audibility_floor`` times the loudest frame's. Anchoring to the typical + audible frame keeps the reference stable when the signal holds a lone loud transient or + long stretches of silence, and measuring RMS states how much sound a frame carries, which + is what a channel's volume renders. Audio shorter than one frame reads as its own RMS level. Args: audio: Input audio array. frame_length: Number of samples per frame. - percentile: Percentile of the audible per-frame peaks to return. - audibility_floor: Fraction of the global peak below which a frame is silence. + percentile: Percentile of the audible per-frame levels to return. + audibility_floor: Fraction of the loudest frame's level below which a frame is silence. Returns: The robust level, or 0.0 for empty or silent audio. @@ -302,23 +302,21 @@ def active_frame_level( if audio.size == 0: return 0.0 - peak = float(np.max(np.abs(audio))) - if peak == 0.0: - return 0.0 - - frame_count = audio.shape[0] // frame_length + samples = audio.astype(np.float64) + frame_count = samples.shape[0] // frame_length if frame_count == 0: - return peak + return float(np.sqrt(np.mean(np.square(samples)))) - frames = audio[: frame_count * frame_length].reshape( + frames = samples[: frame_count * frame_length].reshape( frame_count, frame_length, ) - frame_peaks = np.max(np.abs(frames), axis=1) - audible = frame_peaks[frame_peaks > audibility_floor * peak] - if audible.size == 0: - return peak + frame_levels = np.sqrt(np.mean(np.square(frames), axis=1)) + loudest = float(np.max(frame_levels)) + if loudest == 0.0: + return 0.0 + audible = frame_levels[frame_levels > audibility_floor * loudest] return float(np.percentile(audible, percentile)) diff --git a/src/sampletones_core/audio/stream.py b/src/sampletones_core/audio/stream.py new file mode 100644 index 000000000..5eb4a9088 --- /dev/null +++ b/src/sampletones_core/audio/stream.py @@ -0,0 +1,35 @@ +import pyaudio + +from sampletones_shared.exceptions import PlaybackError + + +def write_to_stream(stream: pyaudio.Stream, data: bytes) -> None: + """Hands one block of audio to the device, naming a failed write the way every playback reports it. + + The manager's own playback and a source streaming from a thread of its own both write here, so a + reader told of a failed write reads the same words whichever source played. + + Raises: + PlaybackError: If the device fails the write. + """ + try: + stream.write(data) + except OSError as exception: + raise PlaybackError(f"Failed to write to audio stream: {exception}") from exception + + +def close_stream(stream: pyaudio.Stream) -> None: + """Stops a stream and closes it, naming a failure the way every playback reports it. + + The stream is closed even where the device fails to stop it, so its handle goes either way. + + Raises: + PlaybackError: If the device fails to stop or to close the stream. + """ + try: + try: + stream.stop_stream() + finally: + stream.close() + except OSError as exception: + raise PlaybackError(f"Failed to close audio stream: {exception}") from exception diff --git a/src/sampletones_core/calibration/__init__.py b/src/sampletones_core/calibration/__init__.py deleted file mode 100644 index e63aed347..000000000 --- a/src/sampletones_core/calibration/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -from .config.corpus import CorpusConfig -from .config.referee import RefereeConfig -from .corpus.item import CorpusItem -from .corpus.synthesis import build_corpus -from .corpus.writer import write_corpus -from .referee.auditory import MultiResolutionAuditoryReferee -from .referee.factory import build_referees -from .referee.protocol import Referee -from .referee.zimtohrli import ZimtohrliReferee, find_zimtohrli -from .report import write_csv, write_markdown -from .runner import ( - CalibrationRow, - CalibrationVariant, - build_variants, - ensure_library, - evaluate_variants, -) - -__all__ = [ - "CalibrationRow", - "CalibrationVariant", - "CorpusConfig", - "CorpusItem", - "MultiResolutionAuditoryReferee", - "Referee", - "RefereeConfig", - "ZimtohrliReferee", - "build_corpus", - "build_referees", - "build_variants", - "ensure_library", - "evaluate_variants", - "find_zimtohrli", - "write_corpus", - "write_csv", - "write_markdown", -] diff --git a/src/sampletones_core/calibration/config/__init__.py b/src/sampletones_core/calibration/config/__init__.py deleted file mode 100644 index 3f9ab7d39..000000000 --- a/src/sampletones_core/calibration/config/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from .corpus import CorpusConfig -from .mix import MixConfig -from .noise import NoiseConfig -from .referee import RefereeConfig -from .timbre import TimbreConfig -from .tone import ToneConfig -from .transient import TransientConfig - -__all__ = [ - "CorpusConfig", - "MixConfig", - "NoiseConfig", - "RefereeConfig", - "TimbreConfig", - "ToneConfig", - "TransientConfig", -] diff --git a/src/sampletones_core/calibration/config/corpus.py b/src/sampletones_core/calibration/config/corpus.py deleted file mode 100644 index b9fab70c5..000000000 --- a/src/sampletones_core/calibration/config/corpus.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Self - -from pydantic import BaseModel, Field - -from sampletones_core.calibration.paths import CORPUS_CONFIG_PATH -from sampletones_shared.utils.serialization import load_yaml_model - -from .mix import MixConfig -from .noise import NoiseConfig -from .timbre import TimbreConfig -from .tone import ToneConfig -from .transient import TransientConfig - - -class CorpusConfig(BaseModel, frozen=True): - """ - Tuning of the calibration probe corpus. - - Every probe is synthesized at unit scale and multiplied by the corpus - amplitude, so the per-class parameters compose under one loudness - convention. Values are loaded from the packaged `calibration/corpus.yaml`, - so corpus content stays reproducible across calibration runs while - remaining adjustable in one place. - """ - - seed: int = Field(ge=0, description="Seed of the random generator behind the stochastic probes.") - item_seconds: float = Field(gt=0.0, description="Duration of every corpus item in seconds.") - amplitude: float = Field(gt=0.0, le=1.0, description="Scale applied to every unit-level probe.") - reference_frequency: float = Field( - gt=0.0, - description="Anchor tone in Hz shared by the mix, pluck, and crescendo probes.", - ) - tone: ToneConfig = Field(description="Steady sine probes.") - timbre: TimbreConfig = Field(description="Pulse-wave probes.") - noise: NoiseConfig = Field(description="Broadband noise probes.") - mix: MixConfig = Field(description="Tone-plus-noise probes.") - transient: TransientConfig = Field(description="Percussive probes.") - - @classmethod - def load(cls) -> Self: - """ - Load the packaged corpus tuning. - - Returns: - The corpus configuration validated from `sampletones_config/calibration/corpus.yaml`. - - Raises: - TypeError: If the configuration file holds anything other than a mapping. - """ - return load_yaml_model(CORPUS_CONFIG_PATH, cls) diff --git a/src/sampletones_core/calibration/config/mix.py b/src/sampletones_core/calibration/config/mix.py deleted file mode 100644 index 319c90f6f..000000000 --- a/src/sampletones_core/calibration/config/mix.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Tuple - -from pydantic import BaseModel, Field, PositiveFloat - - -class MixConfig(BaseModel, frozen=True): - """Tone-plus-noise probes grading how noise masks a steady reference tone.""" - - noise_levels: Tuple[PositiveFloat, ...] = Field( - min_length=1, - description="Noise standard deviations relative to the unit reference tone, one probe each.", - ) diff --git a/src/sampletones_core/calibration/config/referee.py b/src/sampletones_core/calibration/config/referee.py deleted file mode 100644 index 9e4e2e65a..000000000 --- a/src/sampletones_core/calibration/config/referee.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Self, Tuple - -from pydantic import BaseModel, Field, PositiveInt - -from sampletones_core.calibration.paths import REFEREE_CONFIG_PATH -from sampletones_shared.utils.serialization import load_yaml_model - - -class RefereeConfig(BaseModel, frozen=True): - """ - Tuning of the built-in multi-resolution auditory referee. - - Values are loaded from the packaged `calibration/referee.yaml`, so referee - scores stay comparable across calibration runs while remaining adjustable - in one place. - """ - - window_sizes: Tuple[PositiveInt, ...] = Field( - min_length=1, - description="STFT window sizes covering the time-frequency trade-off.", - ) - hop_divisor: PositiveInt = Field(description="Window size divided by this gives the STFT hop.") - band_count: PositiveInt = Field(description="Number of ERB-spaced aggregation bands.") - low_frequency: float = Field(gt=0.0, description="Lower bound of the band axis in Hz.") - energy_floor: float = Field(gt=0.0, description="Absolute floor keeping silent band energies finite.") - audibility_range_decibels: float = Field( - gt=0.0, - description="Audible range below the reference's loudest band; quieter content saturates.", - ) - - @classmethod - def load(cls) -> Self: - """ - Load the packaged referee tuning. - - Returns: - The referee configuration validated from `sampletones_config/calibration/referee.yaml`. - - Raises: - TypeError: If the configuration file holds anything other than a mapping. - """ - return load_yaml_model(REFEREE_CONFIG_PATH, cls) diff --git a/src/sampletones_core/calibration/corpus/__init__.py b/src/sampletones_core/calibration/corpus/__init__.py deleted file mode 100644 index 54996e8b1..000000000 --- a/src/sampletones_core/calibration/corpus/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .item import CorpusItem -from .synthesis import build_corpus -from .writer import write_corpus - -__all__ = [ - "CorpusItem", - "build_corpus", - "write_corpus", -] diff --git a/src/sampletones_core/calibration/corpus/synthesis.py b/src/sampletones_core/calibration/corpus/synthesis.py deleted file mode 100644 index b8815670a..000000000 --- a/src/sampletones_core/calibration/corpus/synthesis.py +++ /dev/null @@ -1,243 +0,0 @@ -from typing import Final, List, Tuple - -import numpy as np - -from sampletones_core.audio.processing import clip_audio -from sampletones_core.calibration.config.corpus import CorpusConfig -from sampletones_synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope -from sampletones_synthesis.envelopes.linear_attack import LinearAttackEnvelope -from sampletones_synthesis.envelopes.linear_ramp import LinearRampEnvelope -from sampletones_synthesis.envelopes.types import EnvelopeUnion -from sampletones_synthesis.oscillators.exponential_glide import ( - ExponentialGlideOscillator, -) -from sampletones_synthesis.oscillators.pulse import PulseOscillator -from sampletones_synthesis.oscillators.sine import SineOscillator -from sampletones_synthesis.oscillators.types import OscillatorUnion -from sampletones_synthesis.oscillators.walk_noise import WalkNoiseOscillator -from sampletones_synthesis.oscillators.white_noise import WhiteNoiseOscillator -from sampletones_synthesis.voice.layer import Layer -from sampletones_synthesis.voice.voice import Voice - -from .item import CorpusItem - -Probe = Tuple[str, str, Voice] - -UNIT_GAIN: Final[float] = 1.0 - - -def build_corpus( - sample_rate: int, - *, - config: CorpusConfig, -) -> List[CorpusItem]: - """ - Synthesize the calibration probe corpus. - - The corpus spans the signal classes the criterion must arbitrate between: - steady tones across the pitch range, pulse timbres of several duty cycles, - broadband noise, tone-plus-noise mixes, percussive transients, and a - crescendo probing the dynamic range. Probes render in a fixed order from - one seeded generator, so every item is deterministic for a fixed sample - rate and configuration and referee scores are reproducible across runs. - - Args: - sample_rate: Sampling rate of the synthesized items in Hz. - config: Corpus tuning holding the probe parameters per signal class. - - Returns: - The corpus items, each carrying its category for per-class reporting. - """ - generator = np.random.default_rng(config.seed) - probes: List[Probe] = [ - *_tone_probes(config), - *_timbre_probes(config), - *_noise_probes(config), - *_mix_probes(config), - *_transient_probes(config), - _crescendo_probe(config), - ] - return [ - _item( - name, - category, - voice.render(sample_rate=sample_rate, generator=generator), - config, - ) - for name, category, voice in probes - ] - - -def _tone_probes(config: CorpusConfig) -> List[Probe]: - return [ - ( - f"tone-{frequency:g}hz", - "tone", - _voice( - config, - _layer( - SineOscillator( - kind="sine", - frequency=frequency, - ) - ), - ), - ) - for frequency in config.tone.frequencies - ] - - -def _timbre_probes(config: CorpusConfig) -> List[Probe]: - return [ - ( - f"pulse-duty{duty_cycle:g}", - "timbre", - _voice( - config, - _layer( - PulseOscillator( - kind="pulse", - frequency=config.timbre.frequency, - duty_cycle=duty_cycle, - ) - ), - ), - ) - for duty_cycle in config.timbre.duty_cycles - ] - - -def _noise_probes(config: CorpusConfig) -> List[Probe]: - return [ - ( - "noise-white", - "noise", - _voice( - config, - _layer( - WhiteNoiseOscillator(kind="white_noise"), - gain=config.noise.white_level, - ), - ), - ), - ( - "noise-dark", - "noise", - _voice( - config, - _layer( - WalkNoiseOscillator( - kind="walk_noise", - ) - ), - ), - ), - ] - - -def _mix_probes(config: CorpusConfig) -> List[Probe]: - return [ - ( - f"mix-noise{noise_level:g}", - "mix", - _voice( - config, - _layer( - SineOscillator( - kind="sine", - frequency=config.reference_frequency, - ) - ), - _layer( - WhiteNoiseOscillator(kind="white_noise"), - gain=noise_level, - ), - ), - ) - for noise_level in config.mix.noise_levels - ] - - -def _transient_probes(config: CorpusConfig) -> List[Probe]: - transient = config.transient - sweep_start, sweep_end = transient.kick_sweep_frequencies - snare_decay = ExponentialDecayEnvelope( - kind="exponential_decay", - time_constant_seconds=transient.snare_decay_seconds, - ) - kick_decay = ExponentialDecayEnvelope( - kind="exponential_decay", - time_constant_seconds=transient.kick_decay_seconds, - ) - pluck_attack = LinearAttackEnvelope( - kind="linear_attack", - attack_seconds=transient.attack_seconds, - ) - pluck_decay = ExponentialDecayEnvelope( - kind="exponential_decay", - time_constant_seconds=transient.attack_tone_decay_seconds, - ) - kick_glide = ExponentialGlideOscillator( - kind="exponential_glide", - frequency_start=sweep_start, - frequency_end=sweep_end, - time_constant_seconds=transient.kick_decay_seconds, - ) - reference_tone = SineOscillator( - kind="sine", - frequency=config.reference_frequency, - ) - return [ - ( - "transient-snare", - "transient", - _voice(config, _layer(WhiteNoiseOscillator(kind="white_noise"), snare_decay)), - ), - ("transient-kick", "transient", _voice(config, _layer(kick_glide, kick_decay))), - ("transient-pluck", "transient", _voice(config, _layer(reference_tone, pluck_attack, pluck_decay))), - ] - - -def _crescendo_probe(config: CorpusConfig) -> Probe: - return ( - "dynamics-crescendo", - "dynamics", - _voice( - config, - _layer( - SineOscillator(kind="sine", frequency=config.reference_frequency), - LinearRampEnvelope(kind="linear_ramp"), - ), - ), - ) - - -def _layer( - oscillator: OscillatorUnion, - *envelopes: EnvelopeUnion, - gain: float = UNIT_GAIN, -) -> Layer: - return Layer(oscillator=oscillator, envelopes=envelopes, gain=gain) - - -def _voice(config: CorpusConfig, *layers: Layer) -> Voice: - return Voice( - duration_seconds=config.item_seconds, - layers=layers, - filters=(), - ) - - -def _item(name: str, category: str, audio: np.ndarray, config: CorpusConfig) -> CorpusItem: - """ - Assemble a corpus item from a unit-scale probe. - - The probe is scaled by the corpus amplitude, clipped to the valid wave - range, and stored as float32, so every item shares one loudness convention. - """ - scaled = clip_audio(config.amplitude * audio) - return CorpusItem( - name=name, - category=category, - audio=scaled.astype(np.float32), - ) diff --git a/src/sampletones_core/calibration/paths.py b/src/sampletones_core/calibration/paths.py deleted file mode 100644 index 751bbb533..000000000 --- a/src/sampletones_core/calibration/paths.py +++ /dev/null @@ -1,8 +0,0 @@ -from pathlib import Path -from typing import Final - -from sampletones_shared.paths.resources import CONFIG_DIRECTORY - -CALIBRATION_CONFIG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "calibration" -REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml" -CORPUS_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "corpus.yaml" diff --git a/src/sampletones_core/calibration/referee/__init__.py b/src/sampletones_core/calibration/referee/__init__.py deleted file mode 100644 index 277778ea5..000000000 --- a/src/sampletones_core/calibration/referee/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .auditory import MultiResolutionAuditoryReferee -from .factory import build_referees -from .protocol import Referee -from .zimtohrli import ZimtohrliReferee, find_zimtohrli - -__all__ = [ - "MultiResolutionAuditoryReferee", - "Referee", - "ZimtohrliReferee", - "build_referees", - "find_zimtohrli", -] diff --git a/src/sampletones_core/calibration/referee/auditory.py b/src/sampletones_core/calibration/referee/auditory.py deleted file mode 100644 index f5f0e3046..000000000 --- a/src/sampletones_core/calibration/referee/auditory.py +++ /dev/null @@ -1,104 +0,0 @@ -from typing import Dict, Final, List - -import numpy as np -from scipy.signal import stft - -from sampletones_core.calibration.config.referee import RefereeConfig - -ERB_RATE_SCALE: Final[float] = 21.4 -ERB_RATE_FACTOR: Final[float] = 4.37e-3 - - -class MultiResolutionAuditoryReferee: - """ - Log-spectral distance over ERB-spaced bands at several STFT resolutions. - - Judges a reconstruction independently of the matching criterion: the configured - window sizes cover the time-frequency trade-off the criterion resolves with a - single frame, band energies aggregate on the ERB-rate axis with uniform band - weights, and distances compare log magnitudes (mean absolute deviation in dB). - Band energies are floored at the configured audibility range below the - reference's loudest band, so the score reflects audible content, holds steady - under a common gain, and saturates for bands one side leaves silent. Lower - scores mean closer reconstructions; identical signals score zero. - """ - - def __init__(self, sample_rate: int, *, config: RefereeConfig) -> None: - self.sample_rate = sample_rate - self.config = config - self._band_matrices: Dict[int, np.ndarray] = { - window_size: self._band_matrix(sample_rate, window_size, config) for window_size in config.window_sizes - } - - @property - def name(self) -> str: - return "mr-auditory-dB" - - def score(self, reference: np.ndarray, estimate: np.ndarray) -> float: - """ - Mean absolute log-spectral deviation between two equal-length signals. - - Args: - reference: Reference waveform. - estimate: Waveform under evaluation, of the same length. - - Returns: - Mean absolute deviation in dB over bands, frames, and resolutions. - - Raises: - ValueError: If the signals differ in length. - """ - if reference.shape != estimate.shape: - raise ValueError(f"signal shapes differ: {reference.shape} vs {estimate.shape}") - - distances: List[float] = [] - for window_size, band_matrix in self._band_matrices.items(): - reference_energy = self._band_energy(reference, window_size, band_matrix) - estimate_energy = self._band_energy(estimate, window_size, band_matrix) - floor = max(float(np.max(reference_energy)), self.config.energy_floor) * 10.0 ** ( - -self.config.audibility_range_decibels / 10.0 - ) - reference_bands = 10.0 * np.log10(reference_energy + floor) - estimate_bands = 10.0 * np.log10(estimate_energy + floor) - distances.append(float(np.mean(np.abs(reference_bands - estimate_bands)))) - - return float(np.mean(distances)) - - def _band_energy(self, audio: np.ndarray, window_size: int, band_matrix: np.ndarray) -> np.ndarray: - hop = window_size // self.config.hop_divisor - _, _, spectrum = stft( - audio.astype(np.float64), - fs=self.sample_rate, - nperseg=window_size, - noverlap=window_size - hop, - ) - energy = np.abs(spectrum) ** 2 - band_energy: np.ndarray = band_matrix @ energy - return band_energy - - @classmethod - def _band_matrix(cls, sample_rate: int, window_size: int, config: RefereeConfig) -> np.ndarray: - """ - Rectangular aggregation matrix from STFT bins onto ERB-spaced bands. - - Band edges are uniform on the ERB-rate axis between the low-frequency bound and - the Nyquist frequency; each STFT bin contributes its full energy to the band - containing its center, and bins below the low-frequency bound join the first band. - """ - frequencies = np.fft.rfftfreq(window_size, 1.0 / sample_rate) - nyquist = sample_rate / 2.0 - edges_rate = np.linspace( - cls._erb_rate(np.array([config.low_frequency]))[0], - cls._erb_rate(np.array([nyquist]))[0], - config.band_count + 1, - ) - bin_rates = cls._erb_rate(frequencies) - band_indices = np.clip(np.searchsorted(edges_rate, bin_rates, side="right") - 1, 0, config.band_count - 1) - - matrix = np.zeros((config.band_count, frequencies.shape[0])) - matrix[band_indices, np.arange(frequencies.shape[0])] = 1.0 - return matrix - - @staticmethod - def _erb_rate(frequencies: np.ndarray) -> np.ndarray: - return ERB_RATE_SCALE * np.log10(1.0 + ERB_RATE_FACTOR * frequencies) diff --git a/src/sampletones_core/calibration/referee/factory.py b/src/sampletones_core/calibration/referee/factory.py deleted file mode 100644 index e6310e878..000000000 --- a/src/sampletones_core/calibration/referee/factory.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import List - -from sampletones_core.calibration.config.referee import RefereeConfig - -from .auditory import MultiResolutionAuditoryReferee -from .protocol import Referee -from .zimtohrli import ZimtohrliReferee, find_zimtohrli - - -def build_referees(sample_rate: int) -> List[Referee]: - """ - Referees available on this system, the built-in one first. - - Args: - sample_rate: Sampling rate of the signals under evaluation in Hz. - - Returns: - The built-in multi-resolution referee with the packaged tuning, joined by - the external psychoacoustic referee when its binary is installed. - """ - referees: List[Referee] = [ - MultiResolutionAuditoryReferee( - sample_rate, - config=RefereeConfig.load(), - ), - ] - binary = find_zimtohrli() - if binary: - referees.append(ZimtohrliReferee(sample_rate, binary)) - - return referees diff --git a/src/sampletones_core/calibration/referee/protocol.py b/src/sampletones_core/calibration/referee/protocol.py deleted file mode 100644 index e5e5a2280..000000000 --- a/src/sampletones_core/calibration/referee/protocol.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Protocol - -import numpy as np - - -class Referee(Protocol): - """Full-reference audio distance: zero for identical signals, growing with audible difference.""" - - @property - def name(self) -> str: ... - - def score(self, reference: np.ndarray, estimate: np.ndarray) -> float: ... diff --git a/src/sampletones_core/calibration/referee/zimtohrli.py b/src/sampletones_core/calibration/referee/zimtohrli.py deleted file mode 100644 index 0a5157fb9..000000000 --- a/src/sampletones_core/calibration/referee/zimtohrli.py +++ /dev/null @@ -1,62 +0,0 @@ -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Final, Optional - -import numpy as np - -from sampletones_core.audio.io import write_wave - -ZIMTOHRLI_BINARY: Final[str] = "zimtohrli" - - -class ZimtohrliReferee: - """ - Psychoacoustic distance judged by an external `zimtohrli` comparison binary. - - Writes both signals as WAV files and reports the last numeric value the binary - prints for the pair. Availability is checked through `find_zimtohrli`. - """ - - def __init__(self, sample_rate: int, binary: Path) -> None: - self.sample_rate = sample_rate - self.binary = binary - - @property - def name(self) -> str: - return "zimtohrli" - - def score(self, reference: np.ndarray, estimate: np.ndarray) -> float: - with tempfile.TemporaryDirectory() as directory: - reference_path = Path(directory) / "reference.wav" - estimate_path = Path(directory) / "estimate.wav" - write_wave(reference_path, self.sample_rate, reference) - write_wave(estimate_path, self.sample_rate, estimate) - completed = subprocess.run( - [str(self.binary), str(reference_path), str(estimate_path)], - capture_output=True, - text=True, - check=True, - ) - - values = [token for token in completed.stdout.split() if self._is_float(token)] - if not values: - raise ValueError(f"zimtohrli produced no numeric output: {completed.stdout!r}") - - return float(values[-1]) - - @staticmethod - def _is_float(token: str) -> bool: - try: - float(token) - except ValueError: - return False - - return True - - -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 diff --git a/src/sampletones_core/calibration/report.py b/src/sampletones_core/calibration/report.py deleted file mode 100644 index f9a9ceae2..000000000 --- a/src/sampletones_core/calibration/report.py +++ /dev/null @@ -1,71 +0,0 @@ -import csv -from collections import defaultdict -from pathlib import Path -from typing import Dict, Iterable, List, Tuple - -import numpy as np - -from .runner import CalibrationRow - - -def write_csv(rows: List[CalibrationRow], path: Path) -> None: - """ - Write every calibration row as CSV. - - Args: - rows: Scored rows from the runner. - path: Target CSV path. - """ - with path.open("w", newline="") as handle: - writer = csv.writer(handle) - writer.writerow(["variant", "item", "category", "referee", "score"]) - for row in rows: - writer.writerow([row.variant, row.item, row.category, row.referee, f"{row.score:.6f}"]) - - -def write_markdown(rows: List[CalibrationRow], path: Path) -> None: - """ - Write a per-referee pivot of mean scores: one row per variant, one column per - category, with the overall mean last. Lower scores mean closer reconstructions. - - Args: - rows: Scored rows from the runner. - path: Target markdown path. - """ - lines: List[str] = ["# Calibration report", ""] - - for referee in _ordered(row.referee for row in rows): - referee_rows = [row for row in rows if row.referee == referee] - categories = _ordered(row.category for row in referee_rows) - variants = _ordered(row.variant for row in referee_rows) - means = _mean_scores(referee_rows) - - lines.append(f"## {referee}") - lines.append("") - lines.append("| variant | " + " | ".join(categories) + " | overall |") - lines.append("|---" * (len(categories) + 2) + "|") - for variant in variants: - cells = [f"{means.get((variant, category), float('nan')):.3f}" for category in categories] - overall = np.mean([row.score for row in referee_rows if row.variant == variant]) - lines.append(f"| {variant} | " + " | ".join(cells) + f" | {float(overall):.3f} |") - - lines.append("") - - path.write_text("\n".join(lines)) - - -def _mean_scores(rows: List[CalibrationRow]) -> Dict[Tuple[str, str], float]: - scores: Dict[Tuple[str, str], List[float]] = defaultdict(list) - for row in rows: - scores[(row.variant, row.category)].append(row.score) - - return {key: float(np.mean(values)) for key, values in scores.items()} - - -def _ordered(values: Iterable[str]) -> List[str]: - seen: List[str] = [] - for value in values: - if value not in seen: - seen.append(value) - - return seen diff --git a/src/sampletones_core/calibration/runner.py b/src/sampletones_core/calibration/runner.py deleted file mode 100644 index a4036c9a8..000000000 --- a/src/sampletones_core/calibration/runner.py +++ /dev/null @@ -1,167 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, List, Optional - -import numpy as np - -from sampletones_core.configs import Config -from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.fft import Window -from sampletones_core.library import InstructionLibrary -from sampletones_core.reconstructions import Reconstructor -from sampletones_core.scripts.library import generate_library -from sampletones_shared.logger import logger - -from .corpus.item import CorpusItem -from .referee.protocol import Referee - - -@dataclass(frozen=True) -class CalibrationVariant: - label: str - config: Config - - -@dataclass(frozen=True) -class CalibrationRow: - variant: str - item: str - category: str - referee: str - score: float - - -def build_variants( - base: Config, - methods: List[SpectrumMethod], - perceptual_exponents: List[float], - temporal_weights: List[float], -) -> List[CalibrationVariant]: - """ - Cartesian sweep of spectrum methods and criterion knobs over a base configuration. - - Each variant keeps the base configuration and overrides the spectrum method, the - perceptual exponent, and (when swept) the temporal loss weight; the label encodes - the overridden values so report rows stay self-describing. - - Args: - base: Configuration providing every value the sweep leaves untouched. - methods: Spectrum methods to evaluate. - perceptual_exponents: Values of `metric.perceptual_exponent` to evaluate. - temporal_weights: Values of `weights.temporal_loss_weight` to evaluate; - an empty list keeps the base blend. - - Returns: - One labeled variant per sweep combination. - """ - variants: List[CalibrationVariant] = [] - swept_temporal: List[Optional[float]] = list(temporal_weights) if temporal_weights else [None] - - for method in methods: - for exponent in perceptual_exponents: - for temporal_weight in swept_temporal: - label = f"{method.value}-pe{exponent:g}" - generation = base.generation.model_copy( - update={ - "metric": base.generation.metric.model_copy(update={"perceptual_exponent": exponent}), - } - ) - if temporal_weight is not None: - label = f"{label}-tw{temporal_weight:g}" - generation = generation.model_copy( - update={ - "weights": generation.weights.model_copy( - update={ - "temporal_loss_weight": temporal_weight, - "spectral_loss_weight": 1.0 - temporal_weight, - } - ) - } - ) - - config = base.model_copy( - update={ - "library": base.library.model_copy(update={"spectrum_method": method}), - "generation": generation, - } - ) - variants.append( - CalibrationVariant( - label=label, - config=config, - ) - ) - - return variants - - -def ensure_library(config: Config) -> None: - """ - Generate the instruction library of a configuration when it is absent. - - Args: - config: Configuration whose library must exist before reconstruction. - """ - window = Window.from_config(config) - library = InstructionLibrary.from_config(config) - key = library.create_key(config, window) - path = library.get_path(key) - if path.exists(): - return - - logger.info(f"Generating missing library: {path.name}") - generate_library(config) - - -def evaluate_variants( - variants: List[CalibrationVariant], - items: List[CorpusItem], - item_paths: Dict[str, Path], - referees: List[Referee], -) -> List[CalibrationRow]: - """ - Reconstruct the corpus under every variant and score the results. - - Each corpus item is reconstructed with the variant's configuration and every - referee scores the approximation against the preprocessed original, both on the - common scale set by the working-level coefficient. - - Args: - variants: Labeled configurations to evaluate. - items: Corpus items, carrying the category used in reports. - item_paths: Written WAV path per corpus item name. - referees: Referees scoring each reconstruction. - - Returns: - One row per (variant, item, referee). - """ - rows: List[CalibrationRow] = [] - for variant in variants: - ensure_library(variant.config) - reconstructor = Reconstructor(variant.config) - for item in items: - path = item_paths[item.name] - reconstruction = reconstructor(path) - if reconstruction is None: - logger.info(f"[{variant.label}] {item.name}: reconstruction unavailable") - continue - - reference = reconstructor.load_audio(path) / reconstruction.coefficient - estimate = np.asarray(reconstruction.approximation, dtype=np.float64) - length = min(reference.shape[0], estimate.shape[0]) - - for referee in referees: - score = referee.score(reference[:length], estimate[:length]) - rows.append( - CalibrationRow( - variant=variant.label, - item=item.name, - category=item.category, - referee=referee.name, - score=score, - ) - ) - - logger.info(f"[{variant.label}] {item.name}: scored") - - return rows diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py new file mode 100644 index 000000000..006a7fb19 --- /dev/null +++ b/src/sampletones_core/compatibility/fields.py @@ -0,0 +1,59 @@ +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" +APPROXIMATION: Final = "approximation" +APPROXIMATIONS_DATA: Final = "approximations_data" +RECONSTRUCTION_DATA_VERSION: Final = "reconstruction_data_version" + +STEMS_DATA: Final = "stems_data" +ENTRIES: Final = "entries" +ID: Final = "id" +BENDS: Final = "bends" +SETTINGS: Final = "settings" +HIERARCHY: Final = "hierarchy" +LEVELS: Final = "levels" +MODE: Final = "mode" +CHANNEL_CAP: Final = "channel_cap" +DRIVES: Final = "drives" +ASSIGNMENTS: Final = "assignments" +STEM_IDS: Final = "stem_ids" +INSTRUCTIONS: Final = "instructions" +INSTRUCTION: Final = "instruction" +UNION_DATA: Final = "_data" +ON: Final = "on" +SOURCES: Final = "sources" +SCALE: Final = "scale" +STEM_ID: Final = "stem_id" +PATH: Final = "path" + +CHANNELS: Final = "channels" +DRIVE: Final = "drive" +MIXER: Final = "mixer" +GENERATOR: Final = "generator" +NAME: Final = "name" + +SONG: Final = "song" +PATTERNS: Final = "patterns" +ROWS: Final = "rows" +COMMAND: Final = "command" +TRANSPOSE: Final = "transpose" +PITCH: Final = "pitch" +VALUE: Final = "value" +KIND_STEP: Final = "step" + +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/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..900047532 --- /dev/null +++ b/src/sampletones_core/compatibility/project/v1_1.py @@ -0,0 +1,119 @@ +from typing import Final, List + +from sampletones_core.compatibility.fields import ( + CHANNELS, + COMMAND, + GENERATOR, + KIND, + KIND_SAMPLE, + KIND_STEP, + LOOP_POINT, + NAME, + PATTERNS, + PITCH, + ROWS, + SAMPLE_ID, + SAMPLES, + SONG, + TRANSPOSE, + VALUE, + 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, names each channel once, and gives each row a pitch. + + 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, + wrote a row's pitch as a bare ``transpose`` integer, 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; a row's ``pitch`` states its ``kind``, a step from the voice's reference or a note the + channel sounds, so a stored transpose becomes a step; 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: + updated = dict(row) + command = row.get(COMMAND) + if isinstance(command, dict) and SAMPLE_ID in command: + updated[COMMAND] = {VOICE_ID: command[SAMPLE_ID]} + + if TRANSPOSE in updated: + transpose = updated.pop(TRANSPOSE) + if isinstance(transpose, int): + updated[PITCH] = {KIND: KIND_STEP, VALUE: transpose} + + return updated + + +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..de15e97de --- /dev/null +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -0,0 +1,271 @@ +from pathlib import Path +from typing import Any, Final, List + +from sampletones_core.compatibility.fields import ( + APPROXIMATION, + APPROXIMATIONS_DATA, + ASSIGNMENTS, + AUDIO_FILEPATH, + BENDS, + CHANNEL_CAP, + CHANNEL_NAME, + CHANNELS, + CONFIG, + DRIVE, + DRIVES, + ENTRIES, + GENERATION, + GENERATOR_NAME, + GENERATORS, + HIERARCHY, + ID, + INSTRUCTION, + INSTRUCTIONS, + INSTRUCTIONS_DATA, + LEVELS, + METADATA, + MIXER, + MODE, + NAME, + ON, + PATH, + RECONSTRUCTION_DATA_VERSION, + SCALE, + SETTINGS, + SOURCES, + STEM_ID, + STEM_IDS, + STEMS_DATA, + UNION_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 ( + ALL_STEMS_CHANNEL_CAP, + DEFAULT_STEMS_HIERARCHY_MODE, + MAX_DRIVE, + MIN_DRIVE, + RESTING_STEM_ID, + UNIT_DRIVE, +) +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" +SINGLE_STEM_ID: Final[int] = 0 +STORED_AUDIO: Final[tuple[str, str]] = (APPROXIMATION, APPROXIMATIONS_DATA) + + +def _without_the_stored_audio(data: SerializedData) -> SerializedData: + """The payload with the rendered audio let go of. + + A reconstruction renders its channels from the instructions it stores, so the waveform a 2.1 + file carried beside them describes nothing the document does not already say. Letting it go + first is also what keeps the rest of the step cheap: the audio is nearly the whole of such a + file, and every later transform would otherwise carry it along. + """ + return {key: value for key, value in data.items() if key not in STORED_AUDIO} + + +def _streams_named_by_channel(data: SerializedData) -> SerializedData: + """The payload with each stored stream named by the channel it plays. + + Data version 2.1 keyed a stream by ``generator_name``; a channel is what the reconstruction + calls it now. + """ + streams = data.get(INSTRUCTIONS_DATA) + if not isinstance(streams, list): + return data + + updated = dict(data) + updated[INSTRUCTIONS_DATA] = [ + renamed(stream, GENERATOR_NAME, CHANNEL_NAME) if isinstance(stream, dict) else stream for stream in streams + ] + return updated + + +def _stamped_embedded_config(data: SerializedData) -> SerializedData: + """The payload whose embedded configuration states the version its shape now matches. + + A reconstruction carries the configuration it was built under, and that configuration carries + metadata of its own. The load contract reads every metadata it meets, so the embedded one is + stamped alongside the outer. + """ + config = data.get(CONFIG) + if not isinstance(config, dict): + return data + + metadata = config.get(METADATA) + if not isinstance(metadata, dict) or not isinstance(metadata.get(RECONSTRUCTION_DATA_VERSION), str): + return data + + updated = dict(data) + updated[CONFIG] = { + **config, + METADATA: {**metadata, RECONSTRUCTION_DATA_VERSION: TARGET_DATA_VERSION}, + } + return updated + + +def _silent_streams_emptied(data: SerializedData) -> SerializedData: + """The payload with every stream resting through its frames stated as none at all. + + A channel resting through every frame stands by, and a channel standing by describes no frame. + The stream keeps the reference and the held dimensions it stored, so the channel keeps the base + it was shaped from. + """ + streams = data.get(INSTRUCTIONS_DATA) + if not isinstance(streams, list): + return data + + updated = dict(data) + updated[INSTRUCTIONS_DATA] = [_emptied_if_silent(stream) for stream in streams] + return updated + + +def _emptied_if_silent(stream: Any) -> Any: + """One stored stream, stating no frame where none of its frames sounds.""" + if not isinstance(stream, dict) or not isinstance(stream.get(INSTRUCTIONS), list): + return stream + + if any(_sounds(frame) for frame in stream[INSTRUCTIONS]): + return stream + + return {**stream, INSTRUCTIONS: []} + + +def _with_the_stems_record(data: SerializedData) -> SerializedData: + """The payload carrying the record of the one recording a 2.1 conversion answered to. + + A file written before the record states its channels and its drive in the configuration, and + names the recording it was built from beside them, so the record is read from those: one entry + covering every channel the run handed out, driven as it stored, holding every frame that + sounds, and naming the file it read. Such a file kept no scale, and its one recording reads at + its own peak, which is the scale its conversion divided it by. + """ + updated = dict(data) + channels = _stored_channels(data) + drive = _stored_drive(data) + updated[STEMS_DATA] = { + CONFIG: { + ENTRIES: [ + { + ID: SINGLE_STEM_ID, + SETTINGS: { + CHANNELS: channels, + BENDS: [], + DRIVES: {channel: drive for channel in channels}, + CHANNEL_CAP: ALL_STEMS_CHANNEL_CAP, + }, + } + ], + HIERARCHY: {LEVELS: [[SINGLE_STEM_ID]], MODE: str(DEFAULT_STEMS_HIERARCHY_MODE)}, + }, + SOURCES: _recorded_sources(data), + ASSIGNMENTS: _frame_owners(data), + SCALE: None, + } + return updated + + +def _stored_channels(data: SerializedData) -> List[Any]: + """The channels the run handed out, which a 2.1 file states in its configuration.""" + config = data.get(CONFIG) + generation = config.get(GENERATION, {}) if isinstance(config, dict) else {} + channels = generation.get(GENERATORS) if isinstance(generation, dict) else None + return list(channels) if isinstance(channels, list) else [] + + +def _stored_drive(data: SerializedData) -> float: + """The level the run played its channels at, held within the bounds a recording allows. + + A build before the setting was renamed stored it as ``mixer``, and one that stated it nowhere + played at unit drive. + """ + config = data.get(CONFIG) + generation = config.get(GENERATION, {}) if isinstance(config, dict) else {} + stored = generation.get(DRIVE, generation.get(MIXER)) if isinstance(generation, dict) else None + if not isinstance(stored, (int, float)): + return UNIT_DRIVE + + return min(max(float(stored), MIN_DRIVE), MAX_DRIVE) + + +def _recorded_sources(data: SerializedData) -> List[SerializedData]: + """Where the one recording came from, named after the file the conversion read. + + A document detached from its origin names no file, which a project carrying a reconstruction + stores, so such a payload records no source at all. + """ + stored = data.get(AUDIO_FILEPATH) + if not isinstance(stored, str) or not stored: + return [] + + return [{STEM_ID: SINGLE_STEM_ID, NAME: Path(stored).stem, PATH: stored}] + + +def _frame_owners(data: SerializedData) -> List[SerializedData]: + """Per channel, the recording holding each frame: the one entry where it sounds, rest where not. + + Rest and silence name the same frames, so the owners are read from the stream itself rather + than assumed, which is what makes the record answer for the document it describes. + """ + streams = data.get(INSTRUCTIONS_DATA) + if not isinstance(streams, list): + return [] + + return [ + { + CHANNEL_NAME: stream.get(CHANNEL_NAME, stream.get(GENERATOR_NAME)), + STEM_IDS: [SINGLE_STEM_ID if _sounds(frame) else RESTING_STEM_ID for frame in stream[INSTRUCTIONS]], + } + for stream in streams + if isinstance(stream, dict) and isinstance(stream.get(INSTRUCTIONS), list) and stream[INSTRUCTIONS] + ] + + +def _sounds(frame: Any) -> bool: + """Whether one stored frame sounds, read from the instruction the stream holds for it. + + A stream stores each frame as its instruction class beside the instruction itself, which the + payload tags and nests, so the flag every instruction carries is read from that nesting. + """ + if not isinstance(frame, dict): + return False + + instruction = frame.get(INSTRUCTION) + if not isinstance(instruction, dict): + return False + + stored = instruction.get(UNION_DATA) + return bool(stored.get(ON)) if isinstance(stored, dict) else False + + +def update(data: SerializedData) -> SerializedData: + """Reads a reconstruction the last release wrote as the document this build describes. + + Data version 2.1 stored the rendered audio beside the instructions, keyed each stream by + ``generator_name``, and stated in its configuration which channels a run handed out, how hard + it drove them, and the recording it was built from. Data version 2.2 renders its audio from the + instructions it keeps, names each stream by its channel, and carries the record of the + recordings behind its frames: the recording's own settings, where it was read from, and the + frame-by-frame account of what it holds, where a silent frame answers to rest, together with the + scale the conversion read its recordings at, which a 2.1 file leaves unmeasured. A channel + resting through every frame stands by in 2.2, so its stream states no frame and the record names + it nowhere, while the reference it stored stays. + """ + updated = _without_the_stored_audio(data) + updated = _streams_named_by_channel(updated) + updated = _stamped_embedded_config(updated) + updated = _silent_streams_emptied(updated) + return _with_the_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..0d626b202 --- /dev/null +++ b/src/sampletones_core/compatibility/upgrade.py @@ -0,0 +1,256 @@ +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.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.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. + + A binary format stores its data as a msgpack mapping 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 read_version(kind: ObjectKind, payload: Any) -> Optional[str]: + """The data version a parsed payload states, read where its format keeps it. + + A load reads the version before the rest of the payload, so the format's contract can refuse a + version no upgrade reaches before any model reads the shape. + + Returns: + Optional[str]: The stated version, or ``None`` when the payload is not a mapping or states + no version as text. + """ + if not isinstance(payload, dict): + return None + + path = _version_path(kind) + section_name = path.section + if section_name is None: + value = payload.get(path.field) + else: + section = payload.get(section_name) + if not isinstance(section, dict): + return None + + value = section.get(path.field) + + return value if isinstance(value, str) else None + + +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. + """ + 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 _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..98adf2e1f 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -1,23 +1,22 @@ from __future__ import annotations from pathlib import Path -from typing import Dict, List, Optional, Self +from typing import Dict, Optional, Self 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.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) @@ -106,14 +105,6 @@ def library_directory(self) -> Path: def output_directory(self) -> Path: return Path(self.general.reconstructions_directory) - @property - def drive(self) -> float: - return self.generation.drive - - @property - def generators(self) -> List[GeneratorName]: - return self.generation.generators.copy() - @property def normalize(self) -> bool: return self.general.normalize @@ -130,6 +121,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/defaults.py b/src/sampletones_core/configs/defaults.py new file mode 100644 index 000000000..15f4d433a --- /dev/null +++ b/src/sampletones_core/configs/defaults.py @@ -0,0 +1,42 @@ +from functools import lru_cache +from pathlib import Path +from typing import Any, Final, Mapping + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.utils.serialization import load_yaml + +GENERATION_DEFAULTS_PATH: Final[Path] = package_directory("sampletones_core.configs") / "generation.yaml" + + +@lru_cache(maxsize=1) +def _generation_defaults() -> Mapping[str, Any]: + values = load_yaml(GENERATION_DEFAULTS_PATH) + if not isinstance(values, dict): + raise TypeError(f"{GENERATION_DEFAULTS_PATH} must hold a mapping of settings, got {type(values)}") + + return values + + +def generation_default(*names: str) -> Any: + """The shipped value of one matching setting, read from the packaged `generation.yaml`. + + The file states every value a reconstruction starts from, so a tuning is changed there and + reaches the schema, the interface and the documented defaults together. + + Args: + names: The setting's path through the section, outermost name first. + + Returns: + Any: The value the file states for that setting. + + Raises: + KeyError: If the file states no value at that path. + """ + value: Any = _generation_defaults() + for name in names: + if not isinstance(value, Mapping) or name not in value: + raise KeyError(f"{GENERATION_DEFAULTS_PATH} states no default for {'.'.join(names)}") + + value = value[name] + + return value 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..420e1d3b4 100644 --- a/src/sampletones_core/configs/generation.py +++ b/src/sampletones_core/configs/generation.py @@ -1,31 +1,7 @@ -from typing import List - -from pydantic import AliasChoices, ConfigDict, Field - -from sampletones_core.constants.algorithm import ( - DECODER_TOP_K, - DIVERGENCE_BETA, - DRIVE, - FAST_DIFFERENCE, - FINAL_REGENERATION, - FIND_BEST_PHASE, - MAX_DRIVE, - PERCEPTUAL_EXPONENT, - PHASE_ALIGNER, - RESET_PHASE, - SELECTOR, - SPECTRAL_DISTANCE, - SPECTRAL_LOSS_WEIGHT, - TEMPORAL_LEVEL_FLOOR, - TEMPORAL_LOSS_WEIGHT, - TRANSITION_ON_OFF_WEIGHT, - TRANSITION_PITCH_WEIGHT, - TRANSITION_TIMBRE_WEIGHT, - TRANSITION_VOLUME_WEIGHT, -) +from pydantic import ConfigDict, Field + +from sampletones_core.configs.defaults import generation_default from sampletones_core.constants.enums import ( - DEFAULT_GENERATORS, - GeneratorName, PhaseAlignerName, SelectorName, SpectralDistance, @@ -34,58 +10,86 @@ class CalculationConfig(DataModel): - model_config = ConfigDict(extra="forbid", frozen=True) + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) - find_best_phase: bool = Field(default=FIND_BEST_PHASE) - fast_difference: bool = Field(default=FAST_DIFFERENCE) - phase_aligner: PhaseAlignerName = Field(default=PHASE_ALIGNER) + find_best_phase: bool = Field(default=generation_default("calculation", "find_best_phase")) + phase_aligner: PhaseAlignerName = Field(default=generation_default("calculation", "phase_aligner")) class WeightsConfig(DataModel): - model_config = ConfigDict(extra="forbid", frozen=True) + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) - spectral_loss_weight: float = Field(default=SPECTRAL_LOSS_WEIGHT, ge=0.0) - temporal_loss_weight: float = Field(default=TEMPORAL_LOSS_WEIGHT, ge=0.0) + spectral_loss_weight: float = Field(default=generation_default("weights", "spectral_loss_weight"), ge=0.0) + temporal_loss_weight: float = Field(default=generation_default("weights", "temporal_loss_weight"), ge=0.0) class MetricConfig(DataModel): - model_config = ConfigDict(extra="forbid", frozen=True, use_enum_values=True) - - spectral_distance: SpectralDistance = Field(default=SPECTRAL_DISTANCE) - beta: float = Field(default=DIVERGENCE_BETA, ge=0.0) - perceptual_exponent: float = Field(default=PERCEPTUAL_EXPONENT, ge=0.0) - temporal_level_floor: float = Field(default=TEMPORAL_LEVEL_FLOOR, gt=0.0) + """How far a candidate stands from a target frame, bin by bin and sample by sample. + + Attributes: + spectral_distance: The family the per-bin spectral distance belongs to. + beta: The beta of the beta-divergence distance. + perceptual_exponent: The power the loudness curve weighting each bin is raised to. + temporal_level_floor: The quietest level the temporal term normalizes by, as a share of + what one channel plays at full volume. + silence_floor: The power a frame whose own bins lie under it is measured from, which + holds a silent frame's cost finite. + dynamic_range_decibels: How far under a frame's loudest bin its floor sits, which sets how + quiet a bin stays worth covering. + """ + + model_config = ConfigDict(extra="forbid", frozen=True, use_enum_values=True, validate_default=True) + + spectral_distance: SpectralDistance = Field(default=generation_default("metric", "spectral_distance")) + beta: float = Field(default=generation_default("metric", "beta"), ge=0.0) + perceptual_exponent: float = Field(default=generation_default("metric", "perceptual_exponent"), ge=0.0) + temporal_level_floor: float = Field(default=generation_default("metric", "temporal_level_floor"), gt=0.0) + silence_floor: float = Field(default=generation_default("metric", "silence_floor"), gt=0.0) + dynamic_range_decibels: float = Field(default=generation_default("metric", "dynamic_range_decibels"), gt=0.0) class DecoderConfig(DataModel): - model_config = ConfigDict(extra="forbid", frozen=True) + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + selector: SelectorName = Field(default=generation_default("decoder", "selector")) + top_k: int = Field(default=generation_default("decoder", "top_k"), ge=1) + pitch_weight: float = Field(default=generation_default("decoder", "pitch_weight"), ge=0.0) + volume_weight: float = Field(default=generation_default("decoder", "volume_weight"), ge=0.0) + timbre_weight: float = Field(default=generation_default("decoder", "timbre_weight"), ge=0.0) + on_off_weight: float = Field(default=generation_default("decoder", "on_off_weight"), ge=0.0) + + +class RefinementConfig(DataModel): + """How far off the equal-tempered grid a conversion is allowed to place its notes. - selector: SelectorName = Field(default=SELECTOR) - top_k: int = Field(default=DECODER_TOP_K, ge=1) - pitch_weight: float = Field(default=TRANSITION_PITCH_WEIGHT, ge=0.0) - volume_weight: float = Field(default=TRANSITION_VOLUME_WEIGHT, ge=0.0) - timbre_weight: float = Field(default=TRANSITION_TIMBRE_WEIGHT, ge=0.0) - on_off_weight: float = Field(default=TRANSITION_ON_OFF_WEIGHT, ge=0.0) + 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 toward it, so material recorded off the grid + comes back in tune with itself. + + These settle how a bend is shaped and hold for a whole run. Which recordings bend, and on which + channels, each stem entry states for itself. + + Attributes: + 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, validate_default=True) + + confidence: float = Field(default=generation_default("refinement", "confidence"), ge=0.0, le=1.0) + change_weight: float = Field(default=generation_default("refinement", "change_weight"), ge=0.0) + window: int = Field(default=generation_default("refinement", "window"), ge=0) class GenerationConfig(DataModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - drive: float = Field( - default=DRIVE, - gt=0.0, - le=MAX_DRIVE, - validation_alias=AliasChoices( - "drive", - "mixer", - ), - ) - - reset_phase: bool = Field(default=RESET_PHASE) - final_regeneration: bool = Field(default=FINAL_REGENERATION) - - generators: List[GeneratorName] = Field(default_factory=DEFAULT_GENERATORS.copy) + model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True) + + reset_phase: bool = Field(default=generation_default("reset_phase")) + 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/generation.yaml b/src/sampletones_core/configs/generation.yaml new file mode 100644 index 000000000..1434f17f5 --- /dev/null +++ b/src/sampletones_core/configs/generation.yaml @@ -0,0 +1,30 @@ +reset_phase: false + +calculation: + find_best_phase: true + phase_aligner: cross_correlation + +weights: + spectral_loss_weight: 0.8 + temporal_loss_weight: 0.2 + +metric: + spectral_distance: beta_divergence + beta: 1.0 + perceptual_exponent: 1.0 + temporal_level_floor: 0.06666666666666667 + silence_floor: 0.0044444444444444444 + dynamic_range_decibels: 60.0 + +decoder: + selector: viterbi + top_k: 8 + pitch_weight: 0.03 + volume_weight: 0.02 + timbre_weight: 0.1 + on_off_weight: 0.2 + +refinement: + confidence: 0.10 + change_weight: 2.0 + window: 4 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..7eb9eb837 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -1,12 +1,12 @@ from typing import Final -from .enums import PhaseAlignerName, SelectorName, SpectralDistance -from .general import MAX_VOLUME, MIN_VOLUME - -# Matching floors - -SPECTRUM_FLOOR: Final[float] = (MIN_VOLUME / MAX_VOLUME) ** 2 -TEMPORAL_LEVEL_FLOOR: Final[float] = MIN_VOLUME / MAX_VOLUME +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.general import ( + MIXER_NOISE, + MIXER_PULSE, + MIXER_TRIANGLE, + QUIETEST_VOLUME_LEVEL, +) # Input preprocessing @@ -23,40 +23,34 @@ # Library creation MIN_SAMPLE_LENGTH: Final[float] = 0.05 -MAX_SAMPLE_LENGTH: Final[float] = 1.0 +MAX_SAMPLE_LENGTH: Final[float] = 2.0 LIBRARY_PHASES_PER_SAMPLE: Final[int] = 100 # Calculation methods TRANSFORMATION_GAMMA: Final[int] = 0 MAX_TRANSFORMATION_GAMMA: Final[int] = 100 -FIND_BEST_PHASE: Final[bool] = True -FAST_DIFFERENCE: Final[bool] = False -PHASE_ALIGNER: Final[PhaseAlignerName] = PhaseAlignerName.CROSS_CORRELATION - -RESET_PHASE: Final[bool] = False -FINAL_REGENERATION: Final[bool] = True -SPECTRAL_LOSS_WEIGHT: Final[float] = 0.80 -TEMPORAL_LOSS_WEIGHT: Final[float] = 0.20 - -SPECTRAL_DISTANCE: Final[SpectralDistance] = SpectralDistance.BETA_DIVERGENCE -DIVERGENCE_BETA: Final[float] = 1.0 -PERCEPTUAL_EXPONENT: Final[float] = 1.0 # Selection and continuity decoding -SELECTOR: Final[SelectorName] = SelectorName.VITERBI -DECODER_TOP_K: Final[int] = 8 -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 +SINGLE_STATE_LATTICE_WIDTH: Final[int] = 1 -# Mixer drive +# Drive -DRIVE: Final[float] = 1.0 +MIN_DRIVE: Final[float] = 0.1 +UNIT_DRIVE: Final[float] = 1.0 MAX_DRIVE: Final[float] = 5.0 +# Stems assignment + +MIN_STEMS_CHANNEL_CAP: Final[int] = 1 +ALL_STEMS_CHANNEL_CAP: Final[int] = len(ChannelName) +DEFAULT_STEMS_HIERARCHY_MODE: Final[HierarchyMode] = HierarchyMode.ROUND_ROBIN +RESTING_STEM_ID: Final[int] = -1 +AUTHORED_STEM_ID: Final[int] = -2 +RESTING_FRAME_COST: Final[float] = 0.0 +STEM_ACTIVITY_FLOOR: Final[float] = QUIETEST_VOLUME_LEVEL * min(MIXER_PULSE, MIXER_TRIANGLE, MIXER_NOISE) + # Execution MAX_WORKERS: Final[int] = 6 diff --git a/src/sampletones_core/constants/audio.py b/src/sampletones_core/constants/audio.py index 01b174702..c1a75e2c6 100644 --- a/src/sampletones_core/constants/audio.py +++ b/src/sampletones_core/constants/audio.py @@ -10,3 +10,5 @@ DEFAULT_SAMPLE_RATE: Final[SampleRate] = 44100 MIN_SAMPLE_RATE: Final[SampleRate] = 8000 MAX_SAMPLE_RATE: Final[SampleRate] = 192000 + +START_OF_AUDIO: Final[int] = 0 diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index 9d887fe9c..c23a12ee5 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -2,16 +2,16 @@ import re from enum import StrEnum -from typing import Dict, Final, List, Literal +from typing import AbstractSet, Dict, Final, FrozenSet, 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,58 @@ 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", +ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) + +PULSE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( + { + ChannelName.PULSE1, + ChannelName.PULSE2, + } +) + +TONE_CHANNELS: Final[FrozenSet[ChannelName]] = PULSE_CHANNELS | {ChannelName.TRIANGLE} + + +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 ordered_channels(channel_names: AbstractSet[ChannelName]) -> List[ChannelName]: + """``channel_names`` in the order the application names the channels. + + A set states which channels something reaches; a run hands them out in one settled order, so + everything built from a set is put back into that order here. + """ + return [name for name in ChannelName.items() if name in channel_names] + + +def bending_channels(channel_names: List[ChannelName]) -> List[ChannelName]: + """Those of ``channel_names`` whose hardware loads a divider a bend can move. + + A stem offered a set of channels carries every one of them that can be carried, which is what + a conversion does until a reader says otherwise. + """ + return [name for name in channel_names if name in TONE_CHANNELS] + + +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..f8864aa0a 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -1,19 +1,26 @@ from typing import Final, Tuple +from sampletones_shared.constants.music import LIMIT_MIN_PITCH + # 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 +MIN_SOUNDING_PULSE_TIMER: Final[int] = 8 +MIN_SOUNDING_TRIANGLE_TIMER: Final[int] = 2 +MAX_TIMER: Final[int] = 0x7FF MIN_PITCH: Final[int] = 33 MAX_PITCH: Final[int] = 119 -LIMIT_MAX_PITCH: Final[int] = 127 +MIN_PLAYED_PITCH: Final[int] = LIMIT_MIN_PITCH PITCH_RANGE: Final[int] = MAX_PITCH - MIN_PITCH -MIN_FREQUENCY: Final[float] = APU_CLOCK / 0x8000 -MAX_FREQUENCY: Final[float] = APU_CLOCK / 0x10 +TRIANGLE_PHASE_INCREMENT: Final[float] = 0.5 + +MIN_FREQUENCY: Final[float] = APU_CLOCK / (TIMER_CYCLE_DIVIDER * (MAX_TIMER + 1)) +MIN_TRIANGLE_FREQUENCY: Final[float] = MIN_FREQUENCY * TRIANGLE_PHASE_INCREMENT +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,17 +36,22 @@ "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 MIN_VOLUME: Final[int] = 1 MAX_VOLUME: Final[int] = 15 VOLUME_RANGE: Final[range] = range(MAX_VOLUME + 1) +QUIETEST_VOLUME_LEVEL: Final[float] = MIN_VOLUME / MAX_VOLUME MAX_DUTY_CYCLE: Final[int] = 3 # Channel-specific constants diff --git a/src/sampletones_core/constants/spectrum.py b/src/sampletones_core/constants/spectrum.py index e614c5e84..471e47677 100644 --- a/src/sampletones_core/constants/spectrum.py +++ b/src/sampletones_core/constants/spectrum.py @@ -2,10 +2,12 @@ from sampletones_shared.constants.music import OCTAVE_SEMITONES -from .general import MIN_FREQUENCY +from .general import MIN_FREQUENCY, MIN_TRIANGLE_FREQUENCY, QUIETEST_VOLUME_LEVEL BINS_PER_OCTAVE: Final[int] = OCTAVE_SEMITONES -CQT_CUTOFF_FREQUENCY: Final[float] = MIN_FREQUENCY +CQT_CUTOFF_FREQUENCY: Final[float] = MIN_TRIANGLE_FREQUENCY +LOG_FFT_CUTOFF_FREQUENCY: Final[float] = MIN_FREQUENCY +SPECTRUM_FLOOR: Final[float] = QUIETEST_VOLUME_LEVEL**2 CQT_REFERENCE_CONTEXT_FACTOR: Final[int] = 3 CQT_REFERENCE_COLUMNS: Final[int] = 8 diff --git a/src/sampletones_core/data/document.py b/src/sampletones_core/data/document.py new file mode 100644 index 000000000..6d816e264 --- /dev/null +++ b/src/sampletones_core/data/document.py @@ -0,0 +1,78 @@ +import gzip +from contextlib import contextmanager +from typing import Final, Iterator, Protocol + +from sampletones_shared.types.path import Pathlike + +DOCUMENT_MAGIC: Final[bytes] = b"\x1f\x8b" +COMPRESSION_LEVEL: Final[int] = 6 +STATED_TIMESTAMP: Final[int] = 0 + + +class ByteStream(Protocol): + """A source bytes are read from a piece at a time, which is all a streaming read asks of one.""" + + def read(self, size: int = ...) -> bytes: + """The next ``size`` bytes, or the rest of the stream where the read names no size.""" + + +def compress_document(payload: bytes) -> bytes: + """The bytes a stored document is written as, its payload deflated. + + A document states its fields as names spelled out once per record, which is most of what a + file of thousands of records holds, so the payload deflates to a fraction of its size. The + bytes carry the deflate format's own magic, which tells a stored document apart from the + payload of one written before this framing. + + The timestamp the framing carries is stated rather than taken from the clock, so saving one + document twice writes the same bytes both times. + + Args: + payload: The document's serialized payload. + + Returns: + bytes: The bytes to store. + """ + return gzip.compress(payload, compresslevel=COMPRESSION_LEVEL, mtime=STATED_TIMESTAMP) + + +def decompress_document(stored: bytes) -> bytes: + """The payload a stored document holds. + + A document written before this framing carries its payload as it stands, and reads that way. + + Args: + stored: The bytes read from the document. + + Returns: + bytes: The document's serialized payload. + """ + if stored.startswith(DOCUMENT_MAGIC): + return gzip.decompress(stored) + + return stored + + +@contextmanager +def open_document(path: Pathlike) -> Iterator[ByteStream]: + """Opens the document at ``path`` as a stream over the payload it holds. + + A read that ends at the front of a document reads the front of the file, which is what lets a + library state its header ahead of its entries and be read by that header alone. + + Args: + path: The stored document. + + Yields: + ByteStream: The payload, from its first byte. + """ + with open(path, "rb") as file: + if file.read(len(DOCUMENT_MAGIC)) == DOCUMENT_MAGIC: + file.seek(0) + with gzip.GzipFile(fileobj=file, mode="rb") as stream: + yield stream + + return + + file.seek(0) + yield file diff --git a/src/sampletones_core/data/metadata.py b/src/sampletones_core/data/metadata.py index aa8da0c06..9f43e9d45 100644 --- a/src/sampletones_core/data/metadata.py +++ b/src/sampletones_core/data/metadata.py @@ -61,6 +61,21 @@ def validate(self, metadata: Metadata, actual_version: str) -> None: f"Metadata application name mismatch: expected {SAMPLETONES_NAME}, got {metadata.application_name}." ) + self.validate_version(actual_version) + + def validate_version(self, actual_version: str) -> None: + """Holds the data version a file states against the one this build accepts. + + A format checks the version before it reads the rest of the file, so a file no upgrade + reaches is refused for its version. + + Args: + actual_version: The data version the file was written at. + + Raises: + IncompatibleVersionError: Of this contract's type, if the file's version departs from + the one this build accepts. + """ if compare_versions(actual_version, self.expected_version) != 0: raise self.error( f"{self.label} version mismatch: expected {self.expected_version}, got {actual_version}.", diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py index 82ca27112..a1db99e88 100644 --- a/src/sampletones_core/data/model.py +++ b/src/sampletones_core/data/model.py @@ -1,15 +1,18 @@ from __future__ import annotations from abc import ABC +from collections.abc import Mapping from enum import StrEnum from pathlib import Path from types import NoneType from typing import ( Any, Dict, + Final, List, Optional, Self, + Tuple, Type, TypeVar, Union, @@ -21,6 +24,7 @@ import numpy as np from pydantic import BaseModel +from sampletones_core.data.document import compress_document, decompress_document from sampletones_shared.array import to_numpy from sampletones_shared.exceptions import ( DeserializationError, @@ -39,6 +43,21 @@ save_binary, ) +MAPPING_ORIGINS: Final[Tuple[type, ...]] = (dict, Mapping) + + +def _packs_in_bulk(item_class: Any) -> bool: + """Whether a sequence of ``item_class`` packs as one list, the way a list field of it packs. + + A data model, a string, an enumeration or a number packs the same whether the field holding it + is a list or a tuple, so a tuple of them takes the list's bulk path and stores the same bytes. + """ + return ( + isinstance(item_class, type) + and get_origin(item_class) is None + and issubclass(item_class, (DataModel, str, StrEnum, int, float, bool)) + ) + def _optional_inner_annotation(annotation: Any) -> Optional[Any]: """Returns the wrapped annotation of an ``Optional`` field, or ``None`` for a non-optional Union. @@ -73,18 +92,35 @@ def deserialize( validation: Optional[Callback] = None, fast: bool = True, ) -> Self: - data = msgpack.unpackb(buffer, raw=False) - return cls.deserialize_inner(data, validation, fast=fast) + return cls.deserialize_inner(cls.unpack(buffer), validation, fast=fast) + + @staticmethod + def unpack(buffer: bytes) -> SerializedData: + """The fields a serialized payload states, before any of them is read into a model. + + A reader after one field of a large document unpacks the payload and reads that field + alone, which costs a fraction of building every model the document describes. + """ + unpacked: SerializedData = msgpack.unpackb(buffer, raw=False) + return unpacked def save(self, path: Pathlike) -> None: - save_binary(path, self.serialize()) + save_binary(path, compress_document(self.serialize())) @classmethod - def load(cls, path: Pathlike, fast: bool = True) -> Self: - return cls.deserialize(load_binary(path), fast=fast) + def load( + cls, + path: Pathlike, + fast: bool = True, + ) -> Self: + return cls.deserialize(decompress_document(load_binary(path)), fast=fast) @classmethod - def _construct(cls, fast: bool = True, **data: Any) -> Self: + def _construct( + cls, + fast: bool = True, + **data: Any, + ) -> Self: if fast: return cls.model_construct(**data) @@ -106,9 +142,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, @@ -124,7 +178,12 @@ def deserialize_inner( return cls._construct(fast=fast, **field_values) - def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any: + def _pack_value( + self, + value: Any, + annotation: Any, + field_name: str, + ) -> Any: if annotation is None: raise SerializationError(f"Field '{field_name}' has no annotation") @@ -147,6 +206,27 @@ 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] + if _packs_in_bulk(item_class): + return self._pack_list(list(value), field_name) + + return [ + self._pack_value( + item, + item_class, + field_name, + ) + for item in value + ] + + if get_origin(annotation) in MAPPING_ORIGINS: + key_class, item_class = get_args(annotation) + return { + self._pack_value(key, key_class, field_name): self._pack_value(item, item_class, field_name) + for key, item in value.items() + } + if issubclass(annotation, DataModel): return value.serialize_inner() @@ -205,6 +285,25 @@ def _unpack_value( fast, ) + if get_origin(annotation) is tuple: + item_class = get_args(annotation)[0] + if _packs_in_bulk(item_class): + return tuple(cls._unpack_list(raw, field_name, item_class, validation, fast)) + + return tuple(cls._unpack_value(item, item_class, field_name, validation, fast) for item in raw) + + if get_origin(annotation) in MAPPING_ORIGINS: + if not isinstance(raw, dict): + raise DeserializationError(f"Field '{field_name}' expects a mapping, got {type(raw).__name__}") + + key_class, item_class = get_args(annotation) + return { + cls._unpack_value(key, key_class, field_name, validation, fast): cls._unpack_value( + item, item_class, field_name, validation, fast + ) + for key, item in raw.items() + } + if issubclass(annotation, DataModel): return annotation.deserialize_inner(raw, validation, fast=fast) @@ -233,6 +332,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 +352,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,9 +384,16 @@ 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: + def _pack_array( + self, + array: Array, + field_name: str, + ) -> bytes: array = to_numpy(array) if array.dtype != np.float32: @@ -291,7 +416,11 @@ def _pack_array(self, array: Array, field_name: str) -> bytes: return array.tobytes() @classmethod - def _unpack_array(cls, raw: bytes, field_name: str) -> np.ndarray: + def _unpack_array( + cls, + raw: bytes, + field_name: str, + ) -> np.ndarray: array = np.frombuffer(raw, dtype=np.float32).copy() if np.isnan(array).any(): diff --git a/src/sampletones_core/data/stored.py b/src/sampletones_core/data/stored.py new file mode 100644 index 000000000..a2ad7495f --- /dev/null +++ b/src/sampletones_core/data/stored.py @@ -0,0 +1,45 @@ +import gzip +import zlib +from typing import FrozenSet + +import msgpack + +from sampletones_core.data.document import open_document +from sampletones_shared.types.data import SerializedData +from sampletones_shared.types.path import Pathlike + + +def read_leading_fields(path: Pathlike, names: FrozenSet[str]) -> SerializedData: + """The named top-level fields of a stored document, read from the front of its file. + + A stored :class:`DataModel` is one map whose fields follow the model's declaration order, so + the fields a model declares first sit in the first bytes of its payload. The read decodes those + fields and steps over any other in its way, and it ends once every named field is found, which + keeps it to the front of the payload however large the rest is. A payload whose front decodes + as no map reads as holding none of the fields, a file whose framing the read cannot follow + included. + + Args: + path: The stored document. + names: The top-level fields to read. + + Returns: + SerializedData: Each named field the file holds, as stored. + """ + found: SerializedData = {} + with open_document(path) as payload: + unpacker = msgpack.Unpacker(payload, raw=False) + try: + for _ in range(unpacker.read_map_header()): + name = unpacker.unpack() + if not isinstance(name, str) or name not in names: + unpacker.skip() + continue + + found[name] = unpacker.unpack() + if len(found) == len(names): + break + except (ValueError, msgpack.OutOfData, EOFError, gzip.BadGzipFile, zlib.error): + return {} + + return found diff --git a/src/sampletones_core/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py index f8a841f07..3bc1f1514 100644 --- a/src/sampletones_core/exporters/__init__.py +++ b/src/sampletones_core/exporters/__init__.py @@ -1,13 +1,15 @@ 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 .sounding import stands_by +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 +19,8 @@ "Features", "NoiseExporter", "PulseExporter", + "TonalExporter", "TriangleExporter", + "playing_channels", + "stands_by", ] diff --git a/src/sampletones_core/exporters/bend.py b/src/sampletones_core/exporters/bend.py new file mode 100644 index 000000000..5a02821d7 --- /dev/null +++ b/src/sampletones_core/exporters/bend.py @@ -0,0 +1,48 @@ +from typing import Optional + +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions.tonal import bend_steps + +NO_BEND_STEP = 0 + + +def bend_envelope(pitch: Optional[Envelope[int]], hi_pitch: Optional[Envelope[int]]) -> Envelope[int]: + """The timer steps a slice's two bend dimensions move its note by, one value per tick. + + A bend reaches a register as one divider whichever split of fine and coarse steps stated + it, and this is that reading taken from the envelopes — the dimension-side twin of + ``TonalExporter.read_timer_offsets``. Each dimension advances on a counter of its own, so + the pair states an offset for as long as the longer of them runs, and a dimension standing + past its end reads at the value it holds there. + + Args: + pitch: The dimension carrying one step per unit, where the channel offers it. + hi_pitch: The dimension carrying sixteen steps per unit, where the channel offers it. + + Returns: + Envelope[int]: The steps each tick stands away from its note, written where either + dimension carries items. + """ + fine = pitch if pitch is not None else Envelope[int]() + coarse = hi_pitch if hi_pitch is not None else Envelope[int]() + ticks = max(len(fine.items), len(coarse.items)) + if not ticks: + return Envelope[int]() + + items = tuple(bend_steps(_step(fine, tick), _step(coarse, tick)) for tick in range(ticks)) + return Envelope[int](items=items, loop_point=_repeat_point(fine, coarse)) + + +def _step(envelope: Envelope[int], tick: int) -> int: + """The value a dimension carries at a tick, which is nothing where it writes no items.""" + value = envelope.at(tick) + return NO_BEND_STEP if value is None else value + + +def _repeat_point(fine: Envelope[int], coarse: Envelope[int]) -> Optional[int]: + """The point the pair circles from, which is the earliest either dimension states.""" + points = [envelope.loop_point for envelope in (fine, coarse) if envelope.loop_point is not None] + if not points: + return None + + return min(points) 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..6d0e32e08 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 sounds it at the value every note starts on. + + 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 sounds them at the value every note starts on for as long as the instrument sounds. + These are the dimensions it leaves, listed in the order the model declares them. + """ + return tuple(feature_key for feature_key, envelope in self.envelopes.items() if not envelope.written) + + +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..dce76448f 100644 --- a/src/sampletones_core/exporters/implementation/noise.py +++ b/src/sampletones_core/exporters/implementation/noise.py @@ -1,24 +1,25 @@ -from typing import ClassVar, Dict, List, Tuple, Union - -import numpy as np +from typing import ClassVar, Dict, Final, List, Tuple, Union from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.features.spec import CHANNEL_FEATURE_DEFAULTS from sampletones_core.generators import GeneratorTypeUnion, NoiseGenerator from sampletones_core.instructions import ( InstructionFields, InstructionTypeUnion, NoiseInstruction, ) -from sampletones_core.types.feature import FeatureMap from ..exporter import Exporter +SHORT_MODE_BIT: Final[int] = 0x01 + class NoiseExporter(Exporter[NoiseInstruction]): _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = { FeatureKey.VOLUME: "volume", FeatureKey.ARPEGGIO: "period", + FeatureKey.PITCH: "detune", FeatureKey.DUTY_CYCLE: "short", } @@ -65,19 +66,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 @@ -86,11 +97,29 @@ def _features_dictionary_to_instruction( dictionary: Dict[str, Union[bool, int]], initial_pitch: int, ) -> NoiseInstruction: + """Builds one noise frame from a row of feature values. + + The arpeggio and the bend each move the period one step per unit, around the sixteen + periods, and the duty cycle's lowest bit selects the short mode. That is how FamiTracker + and Bitphase read an instrument on noise, and a reconstruction's noise channel, writing no + bend and a mode of 0 or 1, reads the same either way. + + Args: + dictionary: The per-attribute values for one frame. + initial_pitch: The period the arpeggio and the bend are measured against. + + Returns: + NoiseInstruction: The frame. + """ + steps = dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]] + dictionary.get( + cls._ATTRIBUTE_MAP[FeatureKey.PITCH], + CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH], + ) return NoiseInstruction( on=cls._infer_instruction_on(dictionary), - period=int((initial_pitch + dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]]) % NUM_PERIODS), + period=int((initial_pitch + steps) % NUM_PERIODS), volume=int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.VOLUME]]), - short=bool(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.DUTY_CYCLE]]), + short=bool(int(dictionary[cls._ATTRIBUTE_MAP[FeatureKey.DUTY_CYCLE]]) & SHORT_MODE_BIT), ) @classmethod diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py index 987810263..5cbd05797 100644 --- a/src/sampletones_core/exporters/implementation/pulse.py +++ b/src/sampletones_core/exporters/implementation/pulse.py @@ -1,9 +1,6 @@ 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 from sampletones_core.generators import GeneratorTypeUnion, PulseGenerator from sampletones_core.instructions import ( @@ -11,51 +8,37 @@ InstructionTypeUnion, PulseInstruction, ) -from sampletones_core.types.feature import FeatureMap -from sampletones_core.utils.frequencies import is_pitch_valid +from sampletones_core.utils.frequencies import played_pitch -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", } @classmethod def extract_data(cls, instructions: List[PulseInstruction]) -> Tuple[int, List[int], List[int], List[int]]: - initial_pitch = None - - pitch = MIN_PITCH + initial_pitch, pitches = cls.read_pitches(instructions) volume = 0 duty_cycle = 0 - - pitches: List[int] = [] volumes: List[int] = [] duty_cycles: List[int] = [] for instruction in instructions: - if instruction.on: - if initial_pitch is None: - initial_pitch = instruction.pitch - pitches = [initial_pitch for _ in range(len(pitches))] - - pitch = instruction.pitch - volume = instruction.volume - duty_cycle = instruction.duty_cycle - else: - volume = 0 - - pitches.append(pitch) + volume = instruction.volume if instruction.on else 0 + duty_cycle = instruction.duty_cycle if instruction.on else duty_cycle volumes.append(volume) duty_cycles.append(duty_cycle) if volume > 0: volumes.append(0) - initial_pitch = initial_pitch if initial_pitch is not None else MIN_PITCH return initial_pitch, pitches, volumes, duty_cycles @classmethod @@ -64,19 +47,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 @@ -85,15 +67,13 @@ def _features_dictionary_to_instruction( dictionary: Dict[str, Union[bool, int]], initial_pitch: int, ) -> PulseInstruction: - pitch = int(initial_pitch + dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]]) - if not is_pitch_valid(pitch): - return PulseInstruction.null_instruction() - + pitch = played_pitch(int(initial_pitch + dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]])) return PulseInstruction( on=cls._infer_instruction_on(dictionary), 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..0c7ccd521 100644 --- a/src/sampletones_core/exporters/implementation/triangle.py +++ b/src/sampletones_core/exporters/implementation/triangle.py @@ -1,9 +1,7 @@ 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.constants.general import MAX_VOLUME from sampletones_core.exporters.implementation.utils import center_pitch from sampletones_core.generators import GeneratorTypeUnion, TriangleGenerator from sampletones_core.instructions import ( @@ -11,46 +9,26 @@ InstructionTypeUnion, TriangleInstruction, ) -from sampletones_core.types.feature import FeatureMap -from sampletones_core.utils.frequencies import is_pitch_valid +from sampletones_core.utils.frequencies import played_pitch -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 def extract_data(cls, instructions: List[TriangleInstruction]) -> Tuple[int, List[int], List[int]]: - initial_pitch = None - - pitch = MIN_PITCH - volume = 0 - - pitches: List[int] = [] - volumes: List[int] = [] - - for instruction in instructions: - if instruction.on: - if initial_pitch is None: - initial_pitch = instruction.pitch - pitches = [initial_pitch for _ in range(len(pitches))] - - pitch = instruction.pitch - volume = MAX_VOLUME - else: - volume = 0 - - pitches.append(pitch) - volumes.append(volume) - - if volume > 0: + initial_pitch, pitches = cls.read_pitches(instructions) + volumes = [MAX_VOLUME if instruction.on else 0 for instruction in instructions] + if volumes and volumes[-1] > 0: volumes.append(0) - initial_pitch = initial_pitch if initial_pitch is not None else MIN_PITCH return initial_pitch, pitches, volumes @classmethod @@ -62,18 +40,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 @@ -82,13 +59,11 @@ def _features_dictionary_to_instruction( dictionary: Dict[str, Union[bool, int]], initial_pitch: int, ) -> TriangleInstruction: - pitch = int(initial_pitch + dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]]) - if not is_pitch_valid(pitch): - return TriangleInstruction.null_instruction() - + pitch = played_pitch(int(initial_pitch + dictionary[cls._ATTRIBUTE_MAP[FeatureKey.ARPEGGIO]])) 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..7db929c9d 100644 --- a/src/sampletones_core/exporters/naming.py +++ b/src/sampletones_core/exporters/naming.py @@ -1,19 +1,19 @@ -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 - instrument table. The parenthesised suffix keeps the base name readable while + instrument table. The parenthesized suffix keeps the base name readable while identifying the channel the slice drives. Args: base_name: The name of the reconstruction or sample the slice came from. - generator: The NES channel the slice covers. + 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/rows/__init__.py b/src/sampletones_core/exporters/rows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/exporters/rows/levels.py b/src/sampletones_core/exporters/rows/levels.py new file mode 100644 index 000000000..c787c9e8d --- /dev/null +++ b/src/sampletones_core/exporters/rows/levels.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import FrozenSet, Optional, Set + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME +from sampletones_core.performance.modifiers import triangle_sounds_at +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn + + +@dataclass(frozen=True) +class RowPlace: + """Where a row stands in the song, both as the order plays it and as the pattern pool holds it. + + Attributes: + order_position: The frame of the order the row is played in. + pattern_index: The pattern the frame plays on the row's channel. + row_index: The row's position within that pattern. + """ + + order_position: int + pattern_index: int + row_index: int + + +@dataclass(frozen=True) +class LevelWalk: + """One pass of a channel through the song, following the level a tracker's volume column carries. + + Attributes: + full_level_notes: The notes stating no level that the pass reaches while the channel carries + a level other than full. + closing_volume: The level the channel carries once the pass reaches the end of the order. + """ + + full_level_notes: FrozenSet[RowPlace] + closing_volume: int + + @classmethod + def walk( + cls, + song: Song, + channel_name: ChannelName, + opening_volume: int, + ) -> LevelWalk: + """Walks one channel through the order from ``opening_volume``, frame by frame and row by row. + + A tracker's volume column keeps the last level a cell wrote, so a row that states a level + sets it, and every other row leaves it where it stands. A note whose row states none starts + at the full level in the song, and the export writes it there wherever the tracker carries + another, so the channel stands at the full level after it either way. A note naming a voice + with no instrument on the channel counts alike: the song sets the level there too, and the + note cut the export writes in its place carries it. + + Args: + song: The arrangement being exported. + channel_name: The channel whose rows are walked. + opening_volume: The level the channel carries into the first frame. + + Returns: + LevelWalk: The notes reached below or above the full level, and the level the pass ends at. + """ + volume = opening_volume + notes: Set[RowPlace] = set() + for order_position, frame in enumerate(song.order): + pattern_index = frame.get(channel_name) + if pattern_index is None: + continue + + pattern = song.pattern(channel_name, pattern_index) + if pattern is None: + continue + + for row_index, row in enumerate(pattern.rows[: song.rows_per_pattern]): + if cls._starts_at_full_level(row) and volume != MAX_VOLUME: + notes.add( + RowPlace( + order_position=order_position, + pattern_index=pattern_index, + row_index=row_index, + ) + ) + + volume = cls._volume_after(row, volume) + + return cls(full_level_notes=frozenset(notes), closing_volume=volume) + + @staticmethod + def _starts_at_full_level(row: Row) -> bool: + """Whether a row starts a note at the full level, which is a note-on stating no level of its own.""" + match row.command: + case NoteOn(): + return row.volume is None + case _: + return False + + @staticmethod + def _volume_after(row: Row, volume: int) -> int: + """The level a tracker carries past one row: the level its cell writes, or the one it carried.""" + if row.volume is not None: + return row.volume + + match row.command: + case NoteOn(): + return MAX_VOLUME + case _: + return volume + + +def full_level_notes(song: Song, channel_name: ChannelName) -> FrozenSet[RowPlace]: + """The notes of one channel whose cell writes the full level, so a tracker starts them where the song does. + + The song starts a note at the full level wherever its row states none, while a tracker's volume + column carries the last level a cell wrote into every note after it. A note whose row states no + level is therefore written at the full level wherever the tracker reaches it carrying another, + which is where a user writing the song in the tracker would type one. A note the tracker + reaches at the full level keeps its blank cell, so the document states a level only where the + level changes. + + A tracker plays the order frame by frame and returns to the first frame at its end, keeping the + level each channel was left at. The walk therefore passes through the song twice: from the full + level a channel starts at, and from the level the song ends on. A pattern the order plays in + several frames is reached in each of them. + + Args: + song: The arrangement being exported. + channel_name: The channel whose notes are found. + + Returns: + FrozenSet[RowPlace]: The notes whose cell writes the full level. + """ + opening = LevelWalk.walk(song, channel_name, MAX_VOLUME) + looping = LevelWalk.walk(song, channel_name, opening.closing_volume) + return opening.full_level_notes | looping.full_level_notes + + +def cell_volume( + row: Row, + channel_name: ChannelName, + *, + full_level: bool, +) -> Optional[int]: + """The level a row's volume cell states, in the song's own terms. + + A row states its own level, a note :func:`full_level_notes` found states the full level, and any + other row states none, so the tracker carries the level on. The triangle reads a level as + whether it sounds: the song sounds it above half volume only, while both trackers sound it at + any level above silence. A triangle row at a level the song rests at therefore states silence, + and a row at a level it sounds at states that level. + + Args: + row: The row being written. + channel_name: The channel the row stands on. + full_level: Whether the row is a note that writes the full level. + + Returns: + Optional[int]: The level the cell states, or ``None`` where the cell stays empty. + """ + volume = row.volume + if volume is None and full_level: + volume = MAX_VOLUME + + if volume is None: + return None + + if channel_name == ChannelName.TRIANGLE and not triangle_sounds_at(volume): + return SILENT_VOLUME + + return volume diff --git a/src/sampletones_core/exporters/rows/pitch.py b/src/sampletones_core/exporters/rows/pitch.py new file mode 100644 index 000000000..e77fb69ec --- /dev/null +++ b/src/sampletones_core/exporters/rows/pitch.py @@ -0,0 +1,40 @@ +from typing import Final, Iterable + +from sampletones_core.constants.general import MAX_PITCH, MIN_PLAYED_PITCH + +FLAT_CONTOUR_STEP: Final[int] = 0 + + +def highest_step(steps: Iterable[int]) -> int: + """The highest semitone step a contour moves its note by, which is none for a contour of no steps. + + Args: + steps: The contour's steps, one per tick. + + Returns: + int: The highest of them. + """ + return max(steps, default=FLAT_CONTOUR_STEP) + + +def written_pitch(pitch: int, contour_top: int) -> int: + """The pitch a tracker cell writes for a transposed voice, so its contour sounds where the song's does. + + The song holds every tick's transposed pitch within the notes a tracker writes, C-0 to B-7 (see + :func:`played_pitch`). A tracker moves the written note by the contour's step each tick and holds + the result within the same notes, so a pitch inside them is written as it is, and one above the + top is written at the top. + + A tracker writes no note below C-0, so a pitch below it is raised only as far as bringing the + contour's highest step up to C-0. Each tick the song plays within the range keeps its own note, + and a contour lying wholly below the range sounds its highest step at C-0, which for a flat + contour is every tick. + + Args: + pitch: The voice's reference pitch with the row's transpose added. + contour_top: The highest semitone step the voice's contour moves the note by. + + Returns: + int: The pitch the cell writes. + """ + return min(MAX_PITCH, max(pitch, MIN_PLAYED_PITCH - contour_top)) diff --git a/src/sampletones_core/exporters/rows/transpose.py b/src/sampletones_core/exporters/rows/transpose.py new file mode 100644 index 000000000..8ca5642ab --- /dev/null +++ b/src/sampletones_core/exporters/rows/transpose.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Container, Dict, Final, List, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.levels import RowPlace +from sampletones_core.performance.rows import apply_row, note_step +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.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.voice import VoiceLookup + +NO_ROWS: Final[int] = 0 + +PatternCell = Tuple[int, int] + + +@dataclass(frozen=True) +class Repitch: + """A row stating a pitch and no note, which moves the note sounding on its channel. + + The song keeps the voice where it stands, so the note goes on from the tick it reached, and every + tick from this row on sounds at the step the row reaches. + + Attributes: + place: Where the row stands in the song. + step: The step the row moves the note to, measured from the voice's reference like a + note-on's. + rows: How many rows the note has sounded for when this row begins, counted across frames. + """ + + place: RowPlace + step: int + rows: int + + +@dataclass(frozen=True) +class NoteStart: + """What a note-on the order reaches started, as the song's walk played it. + + Attributes: + voice_id: The voice the note-on names. + step: The step the voice started at, measured from its reference, or ``None`` where the + row started nothing. + """ + + voice_id: str + step: Optional[int] + + +@dataclass(frozen=True) +class SoundingNote: + """A note-on the song sounds, with the pitch rows that move it while it sounds. + + Attributes: + place: Where the note-on stands in the song. + voice_id: The voice the note sounds. + step: The step the note starts at, measured from the voice's reference. + repitches: The pitch rows that reach the note, in the order the song plays them. + """ + + place: RowPlace + voice_id: str + step: int + repitches: Tuple[Repitch, ...] + + +@dataclass +class _OpenNote: + """The note a walk is following, gathering the rows that reach it until another row ends it.""" + + place: RowPlace + voice_id: str + step: int + rows: int = field(default=NO_ROWS) + repitches: List[Repitch] = field(default_factory=list) + + def closed(self) -> SoundingNote: + return SoundingNote( + place=self.place, + voice_id=self.voice_id, + step=self.step, + repitches=tuple(self.repitches), + ) + + +@dataclass +class _NoteFollower: + """One channel's pass through the song, following the note the channel sounds from row to row.""" + + channel_name: ChannelName + instruments: Container[Tuple[str, ChannelName]] + starts: Dict[RowPlace, NoteStart] = field(default_factory=dict) + notes: List[SoundingNote] = field(default_factory=list) + sounding: Optional[_OpenNote] = field(default=None) + + def read( + self, + row: Row, + place: RowPlace, + performance: ChannelPerformance, + restarted: bool, + ) -> None: + """Reads what the song's walk made of one row, once the channel has been moved onto it.""" + match row.command: + case NoteOn() as note_on: + self._close() + if (note_on.voice_id, self.channel_name) not in self.instruments: + return + + if not restarted: + self.starts[place] = NoteStart(voice_id=note_on.voice_id, step=None) + return + + self.starts[place] = NoteStart(voice_id=note_on.voice_id, step=performance.transpose) + self.sounding = _OpenNote( + place=place, + voice_id=note_on.voice_id, + step=performance.transpose, + ) + case NoteOff(): + self._close() + case None: + if row.pitch is not None and self.sounding is not None: + self.sounding.repitches.append( + Repitch( + place=place, + step=performance.transpose, + rows=self.sounding.rows, + ) + ) + + def pass_row(self) -> None: + """Counts one more row the sounding note has played through.""" + if self.sounding is not None: + self.sounding.rows += 1 + + def finish(self) -> Tuple[SoundingNote, ...]: + """The notes the pass found a pitch row reaching.""" + self._close() + return tuple(self.notes) + + def _close(self) -> None: + if self.sounding is not None and self.sounding.repitches: + self.notes.append(self.sounding.closed()) + + self.sounding = None + + +@dataclass(frozen=True) +class PitchWalk: + """One channel's pass through the song as playback makes it, read for what an export writes. + + The walk moves a channel through the order the way the song's own walk does, row by row through + :func:`apply_row`, so the step every note starts at and the step every pitch row reaches are the + ones the song sounds: an instrument placed without a pitch starts on the pitch the channel was + sounding, and one placed on a silent channel starts nothing. A note-on naming a voice the export + has no instrument for on the channel is written as a note cut, so the walk follows no note from + it. A frame leaving the channel empty plays on with the note it carries, so a note's rows are + counted across frames. + + Attributes: + starts: Per note-on the order reaches and the export has an instrument for, what it + started, in the order the song plays them. + notes: The notes at least one pitch row moves, in the order the song plays them. + """ + + starts: Dict[RowPlace, NoteStart] + notes: Tuple[SoundingNote, ...] + + @classmethod + def walk( + cls, + song: Song, + channel_name: ChannelName, + instruments: Container[Tuple[str, ChannelName]], + voices: VoiceLookup, + ) -> PitchWalk: + """Walks one channel through the order once. + + Args: + song: The arrangement being exported. + channel_name: The channel whose rows are walked. + instruments: The ``(voice id, channel)`` pairs the export holds an instrument for. + voices: Where a voice id resolves to the voice it names. + + Returns: + PitchWalk: What every note-on started at, and the notes pitch rows move. + """ + performance = ChannelPerformance() + follower = _NoteFollower(channel_name=channel_name, instruments=instruments) + for order_position, frame in enumerate(song.order): + pattern_index = frame.get(channel_name) + pattern = song.pattern(channel_name, pattern_index) if pattern_index is not None else None + for row_index in range(song.rows_per_pattern): + if pattern_index is not None and pattern is not None and row_index < len(pattern.rows): + row = pattern.rows[row_index] + place = RowPlace( + order_position=order_position, + pattern_index=pattern_index, + row_index=row_index, + ) + restarted = apply_row(performance, row, channel_name, voices) + follower.read(row, place, performance, restarted) + + follower.pass_row() + + return cls(starts=dict(follower.starts), notes=follower.finish()) + + def frame_starts(self, order_position: int) -> Dict[int, Optional[int]]: + """The step each note-on of one frame starts at, keyed by row.""" + return { + place.row_index: start.step + for place, start in self.starts.items() + if place.order_position == order_position + } + + +def unreached_start( + row: Row, + channel_name: ChannelName, + voices: VoiceLookup, +) -> Optional[int]: + """The step a note-on the order never reaches would start at, on a channel sounding nothing. + + A format writing every pattern the pool holds reaches rows the song never plays, and writes + them as the song would play them from silence. + + Args: + row: The row holding the note-on. + channel_name: The channel the row stands on. + voices: Where a voice id resolves to the voice it names. + + Returns: + Optional[int]: The step from the voice's reference, or ``None`` where the row starts + nothing or names a voice the project lacks. + """ + if not isinstance(row.command, NoteOn): + return None + + voice = voices(row.command.voice_id) + if voice is None: + return None + + return note_step(row.pitch, voice, channel_name, sounding=None) diff --git a/src/sampletones_core/exporters/skipped.py b/src/sampletones_core/exporters/skipped.py new file mode 100644 index 000000000..1fa72c2db --- /dev/null +++ b/src/sampletones_core/exporters/skipped.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Container, Final, Generic, Iterable, List, Optional, Tuple, TypeVar + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_on import NoteOn + +DocumentT = TypeVar("DocumentT") + + +class SkipReason(StrEnum): + """Why an export wrote a row other than the song plays it. + + ``NO_INSTRUMENT`` is a note-on naming a voice with no instrument on its channel, written as a note + cut. ``UNREACHED_TRANSPOSE`` is a transpose row moving a sounding note further than the format's + pitch change reaches, or a row a pattern shared by several frames needs a different change in, + written without the change there. ``CARRIED_PITCH`` is an instrument placed without a pitch, which + starts on the pitch its channel is sounding, in a pattern another frame reaches after a different + pitch, written with the note the first frame sounds there. + """ + + NO_INSTRUMENT = "no_instrument" + UNREACHED_TRANSPOSE = "unreached_transpose" + CARRIED_PITCH = "carried_pitch" + + +@dataclass(frozen=True) +class SkippedRow: + """A row the export wrote other than the song plays it, named by where the reader finds it in the tracker. + + A voice sounds on the channels its instruments cover, so a note-on naming it elsewhere plays + nothing in the song either, and the export writes a note cut. A transpose row the format has no + pitch change for keeps the note at the pitch it had. An instrument taking the channel's pitch in a + pattern several frames play keeps the note the first of them sounds. + + Attributes: + voice_id: The voice the row names, or the voice it moves. + channel: The channel the row stands on. + order_position: The frame of the order the row stands in. + row_index: The row's position within its pattern. + reason: What the export wrote in the row's place. + """ + + voice_id: str + channel: ChannelName + order_position: int + row_index: int + reason: SkipReason + + +NO_SKIPPED_ROWS: Final[Tuple[SkippedRow, ...]] = () + + +@dataclass(frozen=True) +class BuiltDocument(Generic[DocumentT]): + """A format's document with what its build left out: the rows it silenced and the envelopes it shortened. + + Attributes: + document: What the format serializes. + skipped_rows: The rows written as a note cut for lack of an instrument. + truncation: The instruments the format's value limit shortened, and ``None`` where every + instrument is held whole. + """ + + document: DocumentT + skipped_rows: Tuple[SkippedRow, ...] + truncation: Optional[EnvelopeTruncation] + + +def find_skipped_rows( + song: Song, + instruments: Container[Tuple[str, ChannelName]], +) -> Tuple[SkippedRow, ...]: + """Finds every row of the song naming a voice on a channel it has no instrument for. + + Rows are listed in the order the song plays them, frame by frame and channel by channel, so a + pattern the order plays twice reports each of its rows once for every frame it stands in. + + Args: + song: The arrangement being exported. + instruments: The ``(voice id, channel)`` pairs the export holds an instrument for. + + Returns: + Tuple[SkippedRow, ...]: The rows that name a voice without an instrument. + """ + ordered = {channel: song.ordered_patterns(channel) for channel in ChannelName.items()} + skipped: List[SkippedRow] = [] + for position in range(song.order_length()): + for channel in ChannelName.items(): + pattern = ordered[channel][position] + if pattern is None: + continue + + for row_index, row in enumerate(pattern.rows[: song.rows_per_pattern]): + match row.command: + case NoteOn() as reference if (reference.voice_id, channel) not in instruments: + skipped.append( + SkippedRow( + voice_id=reference.voice_id, + channel=channel, + order_position=position, + row_index=row_index, + reason=SkipReason.NO_INSTRUMENT, + ) + ) + + return tuple(skipped) + + +def in_song_order(rows: Iterable[SkippedRow]) -> Tuple[SkippedRow, ...]: + """Rows in the order the song plays them: frame by frame, channel by channel, then row by row. + + Args: + rows: The rows an export reports, gathered in any order. + + Returns: + Tuple[SkippedRow, ...]: The same rows, in the order the song reaches them. + """ + channels = ChannelName.items() + return tuple( + sorted( + rows, + key=lambda skipped: (skipped.order_position, channels.index(skipped.channel), skipped.row_index), + ) + ) diff --git a/src/sampletones_core/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..44b17593f --- /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 tracker instrument an instrument 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..0a699f573 --- /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 an instrument 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/sounding.py b/src/sampletones_core/exporters/sounding.py new file mode 100644 index 000000000..128262a38 --- /dev/null +++ b/src/sampletones_core/exporters/sounding.py @@ -0,0 +1,23 @@ +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import sounds + +from .feature import Features +from .maps import CHANNEL_TO_EXPORTER_MAP + + +def stands_by(channel_name: ChannelName, features: Features) -> bool: + """Whether a channel given these envelopes rests through every frame, and so stands by. + + An edit reaches a reconstruction as envelopes, which the regeneration rebuilds into the frames + the channel plays. Reading them through that same rebuild answers what the document will hold + before it holds it, so a figure measured as an edit arrives agrees with the one the + regenerated document reports. + + Args: + channel_name: The channel the envelopes are written into. + features: The envelopes the channel is given. + + Returns: + bool: True where no frame the envelopes describe sounds. + """ + return not sounds(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)) diff --git a/src/sampletones_core/exporters/tonal.py b/src/sampletones_core/exporters/tonal.py new file mode 100644 index 000000000..b538894e5 --- /dev/null +++ b/src/sampletones_core/exporters/tonal.py @@ -0,0 +1,130 @@ +from abc import ABC +from typing import Dict, List, Tuple, TypeVar, Union + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MIN_PITCH +from sampletones_core.features import BEND_FEATURES, CHANNEL_FEATURE_DEFAULTS +from sampletones_core.instructions import TonalInstruction +from sampletones_core.instructions.tonal import bend_steps + +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_pitches(cls, instructions: List[TonalInstructionT]) -> Tuple[int, List[int]]: + """The note each frame stands at, and the first note the channel sounds. + + A rest holds the note the last sounding frame stated, and the rests before the first take its + note, the rule a bend is read by too. A channel that never sounds stands at ``MIN_PITCH``. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + Tuple[int, List[int]]: The first note sounded, and one note per frame. + """ + pitches = list(held_across_rests(instructions, lambda instruction: instruction.pitch, MIN_PITCH)) + return (pitches[0] if pitches else MIN_PITCH), pitches + + @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 read_timer_offsets(cls, instructions: List[TonalInstructionT]) -> Tuple[int, ...]: + """The timer steps each frame stands away from its note, both bend dimensions together. + + A register takes one divider whichever split of fine and coarse steps reached it, so this + is the bend as a channel sounds it. It is held across rests by the rule ``extract_data`` + holds pitches by, which lines the two up frame for frame. + + Args: + instructions: The channel's per-frame instructions. + + Returns: + Tuple[int, ...]: One offset per frame. + """ + return held_across_rests( + instructions, + lambda instruction: instruction.timer_offset, + bend_steps( + CHANNEL_FEATURE_DEFAULTS[FeatureKey.PITCH], + 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..00d6f75c3 100644 --- a/src/sampletones_core/exporters/truncation.py +++ b/src/sampletones_core/exporters/truncation.py @@ -1,7 +1,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Sequence +from typing import Callable, Final, List, Optional, Sequence + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exporters.feature import Features +from sampletones_core.features.envelope import Envelope + +ONE_INSTRUMENT: Final[int] = 1 + +StoredLength = Callable[[FeatureKey, Envelope[int]], int] @dataclass(frozen=True) @@ -18,23 +26,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, @@ -58,3 +49,58 @@ def summarize( source_frames=max(truncation.source_frames for truncation in shortened), instruments=sum(truncation.instruments for truncation in shortened), ) + + +def is_shortened( + feature_key: FeatureKey, + envelope: Envelope[int], + stored_length: StoredLength, +) -> bool: + """Whether a format leaves items out of one dimension. + + A reader watching an envelope grow and an export reporting what it wrote ask the same question + of a format, so each format states the length it stores once and both read that answer. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + stored_length: The items the format stores of a dimension. + + Returns: + bool: Whether the format stores fewer items than the dimension carries. + """ + return stored_length(feature_key, envelope) < len(envelope.items) + + +def instrument_truncation( + features: Features, + stored_length: StoredLength, +) -> Optional[EnvelopeTruncation]: + """What a format leaves out of one instrument's envelopes. + + The report counts the dimensions the format shortens, so a dimension it stores whole, however + long, leaves the figures to the others. + + Args: + features: The per-dimension envelopes describing the instrument. + stored_length: The items the format stores of a dimension. + + Returns: + Optional[EnvelopeTruncation]: The longest a shortened dimension stays and the longest it + was, and ``None`` where every dimension is stored whole. + """ + stored: List[int] = [] + source: List[int] = [] + for feature_key, envelope in features.envelopes.items(): + if is_shortened(feature_key, envelope, stored_length): + stored.append(stored_length(feature_key, envelope)) + source.append(len(envelope.items)) + + if not stored: + return None + + return EnvelopeTruncation( + frames=max(stored), + source_frames=max(source), + instruments=ONE_INSTRUMENT, + ) 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/exports/artifact.py b/src/sampletones_core/exports/artifact.py new file mode 100644 index 000000000..ccb12fd15 --- /dev/null +++ b/src/sampletones_core/exports/artifact.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.truncation import EnvelopeTruncation + + +@dataclass(frozen=True) +class ExportArtifact: + """What one export run left on disk. + + Attributes: + paths: Every file the run wrote, in write order. + truncation: What the target format's item limit left out, and ``None`` when + every instrument carries its whole envelope. + skipped_rows: The rows of a song written as a note cut, because the voice they name + has no instrument on their channel. + """ + + paths: Tuple[Path, ...] + truncation: Optional[EnvelopeTruncation] + skipped_rows: Tuple[SkippedRow, ...] diff --git a/src/sampletones_core/exports/backend.py b/src/sampletones_core/exports/backend.py new file mode 100644 index 000000000..a3f9049b1 --- /dev/null +++ b/src/sampletones_core/exports/backend.py @@ -0,0 +1,111 @@ +from pathlib import Path +from typing import FrozenSet, Protocol + +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ExportReporter +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope +from sampletones_shared.utils.progress import silent_reporter + + +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 export_format(self) -> ExportFormat: + """The format this backend writes.""" + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + """The scopes this format can express.""" + + def extension(self, scope: ExportScope) -> str: + """The extension the files of ``scope`` carry, leading dot included. + + Args: + scope: The scope about to be exported. + + Returns: + str: The extension of each file the run writes. + """ + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """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. + """ + + def write_sample( + self, + destination: Path, + request: SampleExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """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. + """ + + 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/exports/batch.py b/src/sampletones_core/exports/batch.py new file mode 100644 index 000000000..b88f727a6 --- /dev/null +++ b/src/sampletones_core/exports/batch.py @@ -0,0 +1,70 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional + +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.progress import ExportReporter, announce +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.utils.progress import silent_reporter +from sampletones_shared.utils.system.paths import get_filename + +NOTHING_WRITTEN: Final[int] = 0 +FIRST_FILE: Final[int] = 1 + +InstrumentWriter = Callable[[Path, InstrumentExport, ExportReporter], ExportArtifact] + + +def write_instrument_files( + destination: Path, + request: SampleExport, + report: ExportReporter, + *, + extension: str, + write_instrument: InstrumentWriter, +) -> ExportArtifact: + """Writes every slice of a reconstruction as a file of its own beside ``destination``. + + A format that holds one instrument per file lands a reconstruction as a set of them, each named + after the instrument it carries. The run counts the files as it writes them, and the report + gathers what each file left out. + + Args: + destination: The file the scope was given, which the slices are written beside. + request: The reconstruction's slices. + report: Hears each file written, and answers whether the run goes on. + extension: The extension each file carries, leading dot included. + write_instrument: Writes one slice to the file it is given. + + Returns: + ExportArtifact: Every file written, in write order, and the slices shortened. + + Raises: + OperationCanceled: If ``report`` withdraws the run. + OSError: If a file cannot be written. + """ + 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 index, instrument in enumerate(request.instruments, start=FIRST_FILE): + filepath = destination.with_name( + get_filename( + instrument.name, + extension, + ) + ) + artifact = 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), + truncation=EnvelopeTruncation.summarize(truncations), + skipped_rows=NO_SKIPPED_ROWS, + ) diff --git a/src/sampletones_core/exports/ceilings.py b/src/sampletones_core/exports/ceilings.py new file mode 100644 index 000000000..4edcb73df --- /dev/null +++ b/src/sampletones_core/exports/ceilings.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import Final, Literal, Mapping, Tuple + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exporters.truncation import StoredLength, is_shortened +from sampletones_core.exports.format import ExportFormat +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.truncation import ( + document_stored_length, + preset_stored_length, +) +from sampletones_core.formats.famitracker.sequences.features import stored_length + +TrackerFormat = Literal[ + ExportFormat.FAMITRACKER, + ExportFormat.BITPHASE, + ExportFormat.BITPHASE_PRESET, +] + +FORMAT_STORED_LENGTHS: Final[Mapping[TrackerFormat, StoredLength]] = { + ExportFormat.FAMITRACKER: stored_length, + ExportFormat.BITPHASE: document_stored_length, + ExportFormat.BITPHASE_PRESET: preset_stored_length, +} + + +@dataclass(frozen=True) +class FormatShortening: + """What one tracker format keeps of a dimension it stores shorter. + + Attributes: + export_format: The format that shortens the dimension. + kept: The items it keeps. + """ + + export_format: TrackerFormat + kept: int + + +def format_shortenings( + feature_key: FeatureKey, + envelope: Envelope[int], +) -> Tuple[FormatShortening, ...]: + """Every tracker format that shortens a dimension, with the items each keeps. + + Each format stores a bounded number of items per dimension, and each states its bound in its + own package, so a reader editing an envelope sees what every tracker export would keep of it + before one is made. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + Tuple[FormatShortening, ...]: One entry per format that shortens the dimension, in the order + the formats are listed, and none where every format stores it whole. + """ + return tuple( + FormatShortening( + export_format=export_format, + kept=stored(feature_key, envelope), + ) + for export_format, stored in FORMAT_STORED_LENGTHS.items() + if is_shortened(feature_key, envelope, stored) + ) diff --git a/src/sampletones_core/exports/extensions.py b/src/sampletones_core/exports/extensions.py new file mode 100644 index 000000000..1ee0dc2d3 --- /dev/null +++ b/src/sampletones_core/exports/extensions.py @@ -0,0 +1,33 @@ +from typing import Mapping, Optional + +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[ExportFormat, ExportBackend], + scope: ExportScope, + extension: str, +) -> Optional[ExportFormat]: + """The format whose ``scope`` files carry ``extension``. + + 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. + + Args: + backends: Every backend the application writes through, keyed by its format. + scope: The scope about to be exported. + extension: The extension the chosen destination carries, leading dot included. + + Returns: + Optional[ExportFormat]: The format claiming ``extension``, or ``None`` when no + format able to express ``scope`` writes it. + """ + wanted = extension.casefold() + for export_format, backend in backends.items(): + if scope in backend.supported_scopes and backend.extension(scope).casefold() == wanted: + 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/exports/implementation/bitphase.py b/src/sampletones_core/exports/implementation/bitphase.py new file mode 100644 index 000000000..5f20c3c8f --- /dev/null +++ b/src/sampletones_core/exports/implementation/bitphase.py @@ -0,0 +1,171 @@ +from pathlib import Path +from typing import Final, FrozenSet + +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.batch import write_instrument_files +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 ( + build_bitphase, + instrument_to_bitphase, + sample_to_bitphase, +) +from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset +from sampletones_core.formats.bitphase.truncation import document_truncation, preset_truncation +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_shared.utils.progress import silent_reporter + +DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) +PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) + +NOTHING_WRITTEN: Final[int] = 0 +ONE_FILE: Final[int] = 1 + + +class BitphaseBackend: + """Writes Bitphase's ``.btp`` documents. + + A ``.btp`` holds a whole document, so every scope lands in one file: an instrument + and a reconstruction each become a playable document whose pattern triggers the + instruments it carries. A macro holds the values of one dimension, up to the 512 a + Bitphase instrument stores, and a table carries a contour of any length. Every scope + reports the instruments a macro shortened. + """ + + @property + def export_format(self) -> ExportFormat: + return ExportFormat.BITPHASE + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return DOCUMENT_SCOPES + + 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=document_truncation(request.features), + skipped_rows=NO_SKIPPED_ROWS, + ) + + 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=EnvelopeTruncation.summarize( + [document_truncation(instrument.features) for instrument in request.instruments], + ), + skipped_rows=NO_SKIPPED_ROWS, + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) + built = build_bitphase(request.project) + write_btp(destination, built.document) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + + return ExportArtifact( + paths=(destination,), + truncation=built.truncation, + skipped_rows=built.skipped_rows, + ) + + +class BitphasePresetBackend: + """Writes the single-instrument ``.json`` files Bitphase's instruments panel loads. + + The panel reads one instrument per file into the slot the user has selected, so a + whole reconstruction lands as a set of them beside the chosen destination, one file + per channel slice named after the instrument. A preset carries macros alone, so its + pitch contour rides in the tone offset each tick takes, and every dimension, the contour + among them, keeps the values a macro holds. Every scope reports the instruments a macro + shortened. + """ + + @property + 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: # 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=preset_truncation(request.features), + skipped_rows=NO_SKIPPED_ROWS, + ) + + def write_sample( + self, + destination: Path, + request: SampleExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + return write_instrument_files( + destination, + request, + report, + extension=EXT_FILE_JSON, + write_instrument=self.write_instrument, + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Reports that a preset holds one instrument. + + Raises: + ValueError: Always, since a preset file carries a single instrument. + """ + raise ValueError("A Bitphase instrument preset holds one instrument, not a whole project") diff --git a/src/sampletones_core/exports/implementation/famitracker.py b/src/sampletones_core/exports/implementation/famitracker.py new file mode 100644 index 000000000..8c6865c03 --- /dev/null +++ b/src/sampletones_core/exports/implementation/famitracker.py @@ -0,0 +1,101 @@ +from pathlib import Path +from typing import Final, FrozenSet + +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.batch import write_instrument_files +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_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE +from sampletones_shared.utils.progress import silent_reporter + +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 channel slice named + after the instrument. + """ + + @property + def export_format(self) -> ExportFormat: + return ExportFormat.FAMITRACKER + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return SUPPORTED_SCOPES + + def extension(self, scope: ExportScope) -> str: + return EXT_FILE_MODULE if scope == ExportScope.PROJECT else EXT_FILE_INSTRUMENT + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) + instrument = build_instrument( + STANDALONE_INSTRUMENT_INDEX, + request.name, + request.features, + repitched=False, + ) + write_fti(destination, instrument) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + + return ExportArtifact( + paths=(destination,), + truncation=features_truncation(request.features), + skipped_rows=NO_SKIPPED_ROWS, + ) + + def write_sample( + self, + destination: Path, + request: SampleExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + return write_instrument_files( + destination, + request, + report, + extension=EXT_FILE_INSTRUMENT, + write_instrument=self.write_instrument, + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_WRITTEN, ONE_FILE) + built = write_ftm(destination, request.project) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + + return ExportArtifact( + paths=(destination,), + truncation=built.truncation, + skipped_rows=built.skipped_rows, + ) 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/exports/scope.py b/src/sampletones_core/exports/scope.py new file mode 100644 index 000000000..af7f5ee54 --- /dev/null +++ b/src/sampletones_core/exports/scope.py @@ -0,0 +1,13 @@ +from enum import StrEnum + + +class ExportScope(StrEnum): + """How much of the application's work one export run carries. + + A backend decides how each scope materializes on disk, so a format that reads a + whole reconstruction from a single file is free to write one. + """ + + INSTRUMENT = "instrument" + SAMPLE = "sample" + PROJECT = "project" diff --git a/src/sampletones_core/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..0a9762813 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", + "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", ] 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/limits.py b/src/sampletones_core/features/limits.py new file mode 100644 index 000000000..22b4e1987 --- /dev/null +++ b/src/sampletones_core/features/limits.py @@ -0,0 +1,33 @@ +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope, releases + + +def within_limit(feature_key: FeatureKey, envelope: Envelope[int], limit: int) -> Envelope[int]: + """One dimension within the items a format stores, the release a volume ends on among them. + + A dimension carries whatever length it was written at, and a format bounds how many items it + stores of it, so this is where the two meet. A dimension 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. + limit: The most items the format stores. + + Returns: + Envelope[int]: The dimension within that limit. + """ + if feature_key is FeatureKey.VOLUME and releases(envelope): + return _keeping_release(envelope, limit) + + return envelope.limited(limit) + + +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:]) diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index dd3c0b0b1..8ea22f4eb 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,106 @@ class FeatureRange: } -GENERATOR_KIND: Final[Dict[GeneratorName, LibraryGeneratorName]] = { - GeneratorName.PULSE1: LibraryGeneratorName.PULSE, - GeneratorName.PULSE2: LibraryGeneratorName.PULSE, - GeneratorName.TRIANGLE: LibraryGeneratorName.TRIANGLE, - GeneratorName.NOISE: LibraryGeneratorName.NOISE, +INSTRUMENT_ONLY_READINGS: Final[Dict[GeneratorName, FrozenSet[FeatureKey]]] = { + GeneratorName.NOISE: frozenset((FeatureKey.PITCH,)), } -def resting_reference(generator_name: GeneratorName) -> int: +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(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 +179,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 +201,46 @@ 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] + + +def reads_from_instrument(kind: GeneratorName, feature: FeatureKey) -> bool: + """Whether a channel of this kind reads a dimension of an instrument written by hand. + + A channel reads every dimension its generator offers. The noise channel also reads an + instrument's pitch envelope: each unit moves the period one step, the way an arpeggio step + does, which is how FamiTracker and Bitphase play a bend on noise. A hi-pitch unit moves the + period sixteen steps, a whole turn of the sixteen periods, so the noise channel hears nothing + of it. + + Args: + kind: The channel's generator. + feature: The instrument's dimension. + + Returns: + bool: Whether the channel reads that dimension. + """ + return supports(kind, feature) or feature in INSTRUMENT_ONLY_READINGS.get(kind, frozenset()) diff --git a/src/sampletones_core/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/cqt/transform.py b/src/sampletones_core/fft/cqt/transform.py index 7f70f38fb..791a33c81 100644 --- a/src/sampletones_core/fft/cqt/transform.py +++ b/src/sampletones_core/fft/cqt/transform.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Final, Optional import numpy as np @@ -13,29 +13,50 @@ from ..utils import calculate_n_bins from .kernel import CQTKernel, build_cqt_kernel +MAX_GATHERED_SAMPLES: Final[int] = 1 << 24 -def _framed_signal(audio: np.ndarray, frame_length: int, hop_length: int) -> Array: - """Stack centered frames of ``audio`` on the compute device, one column per hop. - ``audio`` is zero-padded by half a frame on each side so column ``t`` is centered on sample - ``t * hop_length``; the number of columns is ``1 + len(audio) // hop_length``. - """ +def _padded_signal(audio: np.ndarray, frame_length: int) -> Array: + """``audio`` on the compute device, zero-padded by half a frame on each side, which centers column + ``t`` on sample ``t * hop_length``.""" left = frame_length // 2 right = frame_length - left device_audio: Array = xp.asarray(audio, dtype=xp.complex64) padded: Array = xp.concatenate( [xp.zeros(left, dtype=xp.complex64), device_audio, xp.zeros(right, dtype=xp.complex64)] ) - frame_count = 1 + len(audio) // hop_length - starts = xp.arange(frame_count) * hop_length + return padded + + +def _framed_columns( + padded: Array, + frame_length: int, + hop_length: int, + first: int, + count: int, +) -> Array: + """Stack ``count`` centered frames of the padded signal, one column per hop from column ``first``.""" + starts = (xp.arange(count) + first) * hop_length indices = starts[:, None] + xp.arange(frame_length)[None, :] framed: Array = padded[indices].T return framed def _transform(audio: np.ndarray, kernel: CQTKernel, hop_length: int) -> np.ndarray: - frames = _framed_signal(audio, kernel.frame_length, hop_length) - coefficients: Array = kernel.matrix @ frames + """Correlates the kernel with every frame of ``audio``, ``1 + len(audio) // hop_length`` columns. + + Every frame spans the lowest bin's whole wavelet, so the frames are gathered a block of columns + at a time, each block holding at most ``MAX_GATHERED_SAMPLES`` samples. That keeps the memory a + recording takes to one block, however long the recording runs. + """ + padded = _padded_signal(audio, kernel.frame_length) + frame_count = 1 + len(audio) // hop_length + block = max(1, MAX_GATHERED_SAMPLES // kernel.frame_length) + columns = [ + kernel.matrix @ _framed_columns(padded, kernel.frame_length, hop_length, first, min(block, frame_count - first)) + for first in range(0, frame_count, block) + ] + coefficients: Array = xp.concatenate(columns, axis=1) return to_numpy(coefficients) diff --git a/src/sampletones_core/fft/features/base.py b/src/sampletones_core/fft/features/base.py index ad96a2233..815fbbd7b 100644 --- a/src/sampletones_core/fft/features/base.py +++ b/src/sampletones_core/fft/features/base.py @@ -20,11 +20,10 @@ class FeatureExtractor(ABC): method. A single extractor produces the matching target's per-frame features - (`extract`), a stationary candidate's reference feature (`reference_feature`), - and the residual feature left after removing a candidate (`subtract`). Routing - the target and the library through the same extractor is what keeps the two - directly comparable, and confines all spectrum-method branching to the extractor - chosen for the configuration. + (`extract`) and a stationary candidate's reference feature (`reference_feature`). + Routing the target and the library through the same extractor is what keeps the + two directly comparable, and confines all spectrum-method branching to the + extractor chosen for the configuration. """ def __init__(self, config: Config, window: Window) -> None: @@ -42,64 +41,27 @@ def sample_rate(self) -> int: def extract(self, audio: np.ndarray) -> List[Fragment]: """ - Build one `Fragment` per frame of `audio` (its central slice, its analysis - window, and its spectral feature). + Build one `Fragment` per whole frame of `audio`: its slice and its spectral feature. """ frame_length = self.window.frame_length count = audio.shape[0] // frame_length if count == 0: return [] - windowed_frames = [self.window.get_windowed_frame(audio, frame_id * frame_length) for frame_id in range(count)] - features = self._frame_features(audio, windowed_frames) + features = self._frame_features(audio, count) return [ Fragment( - audio=self.window.get_frame_from_window(windowed_audio), + audio=audio[frame_id * frame_length : (frame_id + 1) * frame_length], feature=feature, - windowed_audio=windowed_audio, config=self.config, ) - for windowed_audio, feature in zip(windowed_frames, features) + for frame_id, feature in enumerate(features) ] - def subtract(self, target: Fragment, approximation: Fragment) -> Fragment: - """Residual fragment after removing `approximation` from `target`.""" - if target.audio.shape != approximation.audio.shape: - raise ValueError("Fragments must have the same shape to be subtracted") - - if ( - target.config.library != approximation.config.library - or target.config.generation.calculation != approximation.config.generation.calculation - ): - raise ValueError("Both fragments must have the same config to be subtracted") - - windowed_audio = target.windowed_audio - approximation.windowed_audio - audio = target.audio - approximation.audio - feature = self._residual_feature(target, approximation, windowed_audio) - return Fragment( - audio=audio, - feature=feature, - windowed_audio=windowed_audio, - config=self.config, - ) - @abstractmethod - def _frame_features( - self, - audio: np.ndarray, - windowed_frames: List[np.ndarray], - ) -> List[Histogram]: - """Per-frame features; `windowed_frames` are the frame-centered analysis windows.""" + def _frame_features(self, audio: np.ndarray, count: int) -> List[Histogram]: + """The features of the first `count` frames of `audio`, each read around its own frame.""" @abstractmethod def reference_feature(self, sample: CyclicArray) -> Histogram: """Steady-state feature of a stationary, periodic candidate sample.""" - - @abstractmethod - def _residual_feature( - self, - target: Fragment, - approximation: Fragment, - windowed_audio: np.ndarray, - ) -> Histogram: - """Feature of the residual, given the already-differenced window.""" diff --git a/src/sampletones_core/fft/features/cqt.py b/src/sampletones_core/fft/features/cqt.py index fc950a297..081377902 100644 --- a/src/sampletones_core/fft/features/cqt.py +++ b/src/sampletones_core/fft/features/cqt.py @@ -10,7 +10,6 @@ ) from sampletones_core.structures.histogram import Histogram -from ..fragment.fragment import Fragment from ..spectrum.cqt import calculate_cqt_spectrum_columns from ..window.cyclic import CyclicArray from .base import FeatureExtractor @@ -20,13 +19,12 @@ class CQTFeatureExtractor(FeatureExtractor): """ Whole-signal extraction for the `cqt` method. The constant-Q window spans several frames, so the transform runs once over the whole signal, yielding one column per - frame centered on its hop position. Each frame is aligned to its own time position, - and `windowed_frames` supplies the frame count. + frame centered on its hop position. Each frame is aligned to its own time position. """ - def _frame_features(self, audio: np.ndarray, windowed_frames: List[np.ndarray]) -> List[Histogram]: + def _frame_features(self, audio: np.ndarray, count: int) -> List[Histogram]: spectra = calculate_cqt_spectrum_columns(audio, self.sample_rate, self.window.frame_length) - return [self.transformer.forward(spectrum) for spectrum in spectra[: len(windowed_frames)]] + return [self.transformer.forward(spectrum) for spectrum in spectra[:count]] def reference_feature(self, sample: CyclicArray) -> Histogram: buffer = sample.get_fragment(0, CQT_REFERENCE_CONTEXT_FACTOR * self.window.size) @@ -36,11 +34,3 @@ def reference_feature(self, sample: CyclicArray) -> Histogram: mean_values = np.mean([histogram.values for histogram in interior], axis=0) spectrum = Histogram(edges=interior[0].edges, values=mean_values.astype(np.float32)) return self.transformer.forward(spectrum) - - def _residual_feature( - self, - target: Fragment, - approximation: Fragment, - windowed_audio: np.ndarray, - ) -> Histogram: - return self.transformer.subtract(target.feature, approximation.feature) diff --git a/src/sampletones_core/fft/features/windowed.py b/src/sampletones_core/fft/features/windowed.py index 0b8e38ca9..abeebcefb 100644 --- a/src/sampletones_core/fft/features/windowed.py +++ b/src/sampletones_core/fft/features/windowed.py @@ -7,7 +7,6 @@ from sampletones_core.constants.algorithm import LIBRARY_PHASES_PER_SAMPLE from sampletones_core.structures.histogram import Histogram -from ..fragment.fragment import Fragment from ..window.cyclic import CyclicArray from .base import FeatureExtractor @@ -21,28 +20,26 @@ class WindowedFeatureExtractor(FeatureExtractor): so one class serves both.) """ - def _frame_features(self, audio: np.ndarray, windowed_frames: List[np.ndarray]) -> List[Histogram]: - return [self._windowed_feature(windowed_audio) for windowed_audio in windowed_frames] + def _frame_features(self, audio: np.ndarray, count: int) -> List[Histogram]: + frame_length = self.window.frame_length + return [ + self._windowed_feature(self.window.get_windowed_frame(audio, frame_id * frame_length)) + for frame_id in range(count) + ] def reference_feature(self, sample: CyclicArray) -> Histogram: - features: List[Histogram] = [] - for phase_id in range(LIBRARY_PHASES_PER_SAMPLE): - phase = phase_id / LIBRARY_PHASES_PER_SAMPLE - windowed_audio = sample.get_windowed_fragment(phase, self.window) - features.append(self._windowed_feature(windowed_audio)) + """The feature of the spectrum the sample averages to over the phases a frame starts on. - return self.transformer.mean(features) - - def _residual_feature( - self, - target: Fragment, - approximation: Fragment, - windowed_audio: np.ndarray, - ) -> Histogram: - if self.config.generation.calculation.fast_difference: - return self.transformer.subtract(target.feature, approximation.feature) - - return self._windowed_feature(windowed_audio) + The spectra are averaged as power and the average transformed once, in double precision, so + the feature is the one of the mean power at every gamma, down to the quietest bins. + """ + spectra = [ + self._normalized_spectrum(sample.get_windowed_fragment(phase_id / LIBRARY_PHASES_PER_SAMPLE, self.window)) + for phase_id in range(LIBRARY_PHASES_PER_SAMPLE) + ] + mean_values = np.mean([spectrum.values for spectrum in spectra], axis=0, dtype=np.float64) + mean_spectrum = Histogram(edges=spectra[0].edges.astype(np.float64), values=mean_values) + return self.transformer.forward(mean_spectrum).astype(np.float32) def _windowed_feature(self, windowed_audio: np.ndarray) -> Histogram: """ @@ -53,7 +50,10 @@ def _windowed_feature(self, windowed_audio: np.ndarray) -> Histogram: signal produce the same feature at every NES frequency, keeping the spectral scale, the spectrum floor, and the divergence semantics frame-rate stable. """ + return self.transformer.forward(self._normalized_spectrum(windowed_audio)) + + def _normalized_spectrum(self, windowed_audio: np.ndarray) -> Histogram: + """The power spectrum of one analysis window, divided by the envelope energy gain.""" gain = self.window.energy_gain spectrum = self.transformer.calculate_spectrum(windowed_audio, self.sample_rate) - normalized = spectrum.apply_with(lambda values: values / gain) - return self.transformer.forward(normalized) + return spectrum.apply_with(lambda values: values / gain) diff --git a/src/sampletones_core/fft/fragment/fragment.py b/src/sampletones_core/fft/fragment/fragment.py index 1fb29eb0e..efccdabf4 100644 --- a/src/sampletones_core/fft/fragment/fragment.py +++ b/src/sampletones_core/fft/fragment/fragment.py @@ -1,92 +1,19 @@ from __future__ import annotations from dataclasses import dataclass -from typing import List, Self from sampletones_core.configs import Config from sampletones_core.structures.histogram import Histogram -from sampletones_shared.array import xp -from sampletones_shared.types.array import Array, get_array_module +from sampletones_shared.types.array import Array @dataclass(frozen=True) class Fragment: """ - A single analysis frame: its central time-domain slice, the larger analysis - window it was taken from, and its spectral feature. A data holder — features and - residuals are produced by a `FeatureExtractor`, not here. + A single analysis frame: its time-domain slice and its spectral feature. A data + holder — features are produced by a `FeatureExtractor`. """ audio: Array feature: Histogram - windowed_audio: Array config: Config - - @classmethod - def stack(cls, fragments: List[Self]) -> Self: - if not fragments: - raise ValueError("The fragments list cannot be empty") - - first_fragment = fragments[0] - assert all( - fragment.config.library == first_fragment.config.library - and fragment.config.generation.calculation == first_fragment.config.generation.calculation - for fragment in fragments - ), "All fragments must have the same config to be concatenated" - - assert all( - fragment.ndim == first_fragment.ndim for fragment in fragments - ), "All fragments must have the same number of dimensions to be concatenated" - - module = get_array_module(first_fragment.audio) - concatenated_audio = module.stack([fragment.audio for fragment in fragments]) - concatenated_windowed_audio = module.stack([fragment.windowed_audio for fragment in fragments]) - concatenated_feature = module.stack([fragment.feature.values for fragment in fragments]) - - dimensions = ( - array.ndim - for array in [ - concatenated_audio, - concatenated_windowed_audio, - concatenated_feature, - ] - ) - assert all(ndim == 2 for ndim in dimensions), "All concatenated arrays must be 2-dimensional" - - feature: Histogram = Histogram( - edges=first_fragment.feature.edges, - values=concatenated_feature, - ) - - return cls( - audio=concatenated_audio, - feature=feature, - windowed_audio=concatenated_windowed_audio, - config=first_fragment.config, - ) - - def __mul__(self, scalar: float) -> Self: - audio = self.audio * scalar - windowed_audio = self.windowed_audio * scalar - feature = self.feature * scalar - return self.__class__( - audio=audio, - feature=feature, - windowed_audio=windowed_audio, - config=self.config, - ) - - def to_cupy(self) -> Self: - audio = xp.asarray(self.audio) - windowed_audio = xp.asarray(self.windowed_audio) - feature = self.feature.to_cupy() - return self.__class__( - audio=audio, - feature=feature, - windowed_audio=windowed_audio, - config=self.config, - ) - - @property - def ndim(self) -> int: - return int(self.audio.ndim) diff --git a/src/sampletones_core/fft/instantaneous.py b/src/sampletones_core/fft/instantaneous.py new file mode 100644 index 000000000..9d74c6866 --- /dev/null +++ b/src/sampletones_core/fft/instantaneous.py @@ -0,0 +1,243 @@ +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 sampletones_shared.constants.music import OCTAVE_SEMITONES + +from .cqt.frequencies import calculate_cqt_frequencies +from .cqt.normalization import normalize_cqt_energy +from .cqt.transform import calculate_cqt_frames + +HARMONIC_COUNT: Final[int] = 5 +HARMONIC_ROOM: Final[float] = 2.0 ** (1.0 / (2 * OCTAVE_SEMITONES)) +NEIGHBOR_NOTE: Final[float] = 2.0 ** (1.0 / OCTAVE_SEMITONES) +MINIMUM_COLUMN_ENERGY: Final[float] = 1e-20 +SHORT_LAG_DIVISOR: Final[int] = 16 + + +@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. A later harmonic counts where its partial + stands within half a semitone of where that fundamental puts it, the room a note owns, so a + partial another voice sounds beside it stays out of the reading. + + The first harmonic read has the note alone to be settled against. From about 1 kHz up a note's + room is wider than the whole turn the reading repeats over, so there a second reading, over a lag + a fraction of a hop long, names the turn the partial stands on. + + The confidence measures every bin on the scale the features use, its energy divided by the + length of its wavelet. A bass then takes the share of a frame its level gives it in every + register, and a melody above it keeps the rest. + """ + + 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._bin_energies: np.ndarray = normalize_cqt_energy( + self._magnitudes**2, + self._frequencies, + sample_rate, + bins_per_octave, + ) + self._column_energies: np.ndarray = np.sum(self._bin_energies, 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 + self._short_lag_first_bin: int = int(np.searchsorted(self._frequencies, self._fold_frequency())) + self._short_lag_partials: np.ndarray = self._read_short_lag(audio, sample_rate, hop_length, bins_per_octave) + + @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 + share = 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 + + expected = running * harmonic + if weight <= 0.0: + expected = self._first_guide(bin_index, opening, expected) + + partial = self._partial_frequency(bin_index, opening, closing, expected) + if weight > 0.0 and not expected / HARMONIC_ROOM <= partial <= expected * HARMONIC_ROOM: + continue + + energy = float(self._magnitudes[bin_index, opening]) ** 2 + weighted += energy * partial / harmonic + weight += energy + share += float(self._bin_energies[bin_index, opening]) + running = weighted / weight + + if weight <= 0.0: + return None + + return FundamentalReading( + frequency=weighted / weight, + confidence=share / max(float(self._column_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 _fold_frequency(self) -> float: + """The frequency from which half the span a reading repeats over is narrower than a note's room.""" + return self._ambiguity / 2.0 / (HARMONIC_ROOM - 1.0) + + def _read_short_lag( + self, + audio: np.ndarray, + sample_rate: int, + hop_length: int, + bins_per_octave: int, + ) -> np.ndarray: + """Each bin's partial from the fold frequency up, read over a short lag at the middle of every hop. + + The lag is ``hop / SHORT_LAG_DIVISOR`` long, so this reading repeats that many times wider than + the one-hop reading and spans a note's room up to the top of the range. It sits at the middle of + the hop the one-hop reading spans, so a moving pitch shows in both alike. Both of its columns come + from one transform, so the kernel's centering cancels out of the phase they turn. + + Returns: + np.ndarray: One frequency in Hz per bin from ``_short_lag_first_bin`` up and per hop. + """ + first = self._short_lag_first_bin + if first >= len(self._frequencies): + return np.empty((0, 0)) + + lag = hop_length // SHORT_LAG_DIVISOR + middle = hop_length // 2 + cutoff = float(self._frequencies[first]) + n_bins = len(self._frequencies) - first + opening = calculate_cqt_frames(audio[middle:], sample_rate, hop_length, cutoff, n_bins, bins_per_octave) + lagged = calculate_cqt_frames(audio[middle + lag :], sample_rate, hop_length, cutoff, n_bins, bins_per_octave) + count = min(opening.shape[1], lagged.shape[1]) + frequencies = self._frequencies[first:, None] + turned = np.angle(lagged[:, :count]) - np.angle(opening[:, :count]) + deviation = np.angle(np.exp(1j * (turned - 2.0 * np.pi * frequencies * lag / sample_rate))) + partials: np.ndarray = frequencies + deviation * sample_rate / (2.0 * np.pi * lag) + return partials + + def _first_guide(self, bin_index: int, column: int, expected: float) -> float: + """Where the first harmonic a frame reads is looked for. + + The short-lag reading guides it where the bin has one and that reading stands between the + note's neighbors. The decoder chose the note nearest the sound, so the partial lies there, even + where the note's own divider stands off equal temperament. The note itself guides it elsewhere. + """ + row = bin_index - self._short_lag_first_bin + if row < 0 or column >= self._short_lag_partials.shape[1]: + return expected + + guide = float(self._short_lag_partials[row, column]) + if expected / NEIGHBOR_NOTE < guide < expected * NEIGHBOR_NOTE: + return guide + + return expected + + 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/fft/spectrum/fft.py b/src/sampletones_core/fft/spectrum/fft.py index ebdc8d540..0ec9547e7 100644 --- a/src/sampletones_core/fft/spectrum/fft.py +++ b/src/sampletones_core/fft/spectrum/fft.py @@ -5,7 +5,7 @@ from sampletones_core.audio import validate_audio_array from sampletones_core.constants.spectrum import ( BINS_PER_OCTAVE, - CQT_CUTOFF_FREQUENCY, + LOG_FFT_CUTOFF_FREQUENCY, ) from sampletones_core.structures.histogram import Histogram @@ -48,7 +48,7 @@ def calculate_log_spaced_fft_spectrum( audio: np.ndarray, sample_rate: int, fft_size: Optional[int] = None, - cutoff: float = CQT_CUTOFF_FREQUENCY, + cutoff: float = LOG_FFT_CUTOFF_FREQUENCY, bins_per_octave: int = BINS_PER_OCTAVE, ) -> Histogram: """ @@ -57,7 +57,8 @@ def calculate_log_spaced_fft_spectrum( Computes the linear FFT spectrum and rebins it onto a logarithmic axis whose bin widths respect the FFT resolution: bins widen to the linear spacing where a musical interval falls below it, so low tones stay compact and adjacent bins - aggregate independent FFT measurements at every frequency. + aggregate independent FFT measurements at every frequency. The axis starts at the + pulse's lowest note, the lowest frequency the FFT window spans two cycles of. Args: audio: Input audio as array. diff --git a/src/sampletones_core/fft/spectrum/spectrum.py b/src/sampletones_core/fft/spectrum/spectrum.py index 1f6b4fe19..b11ee4e93 100644 --- a/src/sampletones_core/fft/spectrum/spectrum.py +++ b/src/sampletones_core/fft/spectrum/spectrum.py @@ -2,10 +2,7 @@ import numpy as np -from sampletones_core.constants.spectrum import ( - BINS_PER_OCTAVE, - CQT_CUTOFF_FREQUENCY, -) +from sampletones_core.constants.spectrum import BINS_PER_OCTAVE from sampletones_core.structures.histogram import Histogram from .cqt import calculate_cqt_spectrum @@ -18,20 +15,20 @@ def calculate_spectrum( audio: np.ndarray, sample_rate: int, fft_size: Optional[int] = None, - cutoff: float = CQT_CUTOFF_FREQUENCY, bins_per_octave: int = BINS_PER_OCTAVE, n_bins: Optional[int] = None, ) -> Histogram: """ Compute the spectrum of the given audio data, for given FFT size and sample rate, - depending on the selected spectrum calculation method. + depending on the selected spectrum calculation method. Each method starts its axis at its + own floor: the constant-Q transform at the triangle's lowest note, and the log-spaced FFT at + the lowest frequency its window spans two cycles of. Args: method: Spectrum calculation method. audio: Input audio data. sample_rate: Sample rate of the audio data. fft_size: Size of the FFT to be used. If None, uses the length of the audio array. - cutoff: Cutoff frequency. All frequencies below this value will be discarded. bins_per_octave: Number of bins per octave. Only used if n_bins is None. n_bins: Number of constant-Q components. Only used by the constant-Q method. @@ -55,16 +52,14 @@ def calculate_spectrum( audio, sample_rate, fft_size, - cutoff, - bins_per_octave, + bins_per_octave=bins_per_octave, ) case SpectrumMethod.CQT: spectrum = calculate_cqt_spectrum( audio, sample_rate, - cutoff, - bins_per_octave, - n_bins, + bins_per_octave=bins_per_octave, + n_bins=n_bins, ) case _: raise ValueError(f"Unsupported spectrum method: {method}") diff --git a/src/sampletones_core/fft/transformer.py b/src/sampletones_core/fft/transformer.py index 795596b91..673a4a6e9 100644 --- a/src/sampletones_core/fft/transformer.py +++ b/src/sampletones_core/fft/transformer.py @@ -5,8 +5,9 @@ import numpy as np from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.constants.algorithm import MAX_TRANSFORMATION_GAMMA, SPECTRUM_FLOOR +from sampletones_core.constants.algorithm import MAX_TRANSFORMATION_GAMMA from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE +from sampletones_core.constants.spectrum import SPECTRUM_FLOOR from sampletones_core.structures.histogram import Histogram from sampletones_shared.types.array import ( Array, @@ -422,31 +423,3 @@ def divide( """ feature1, feature2 = self.to_features((feature_or_scalar1, feature_or_scalar2)) return self.apply(lambda feature1, feature2: feature1 / feature2, feature1, feature2) - - def mean( - self, - features: Sequence[Histogram], - ) -> Histogram: - """ - Calculate the mean of multiple FFT features with transformations. - - `[ mean(feature₁ ^ (1 / a), feature₂ ^ (1 / a), ..., featureₙ ^ (1 / a)) ] ^ a` - - Requires all edges to be consistent. - - Args: - features (Sequence[Histogram]): Input FFT features. - - Returns: - Histogram: Mean FFT feature. - - Raises: - ValueError: If no features are provided. - ValueError: If Histogram features have inconsistent edges. - TypeError: If any of the features is not a Histogram. - """ - if not features: - raise ValueError("At least one feature is required to compute the mean") - - divisor = self.forward(len(features)) - return self.reduce(np.add, *features).apply_with(lambda x: np.divide(x, divisor, out=x, casting="unsafe")) diff --git a/src/sampletones_core/fft/window/cyclic.py b/src/sampletones_core/fft/window/cyclic.py index e836591d9..d82f8c9d5 100644 --- a/src/sampletones_core/fft/window/cyclic.py +++ b/src/sampletones_core/fft/window/cyclic.py @@ -56,10 +56,30 @@ def get_windowed_fragment(self, phase: float, window: Window) -> np.ndarray: windowed_fragment: np.ndarray = fragment * window.envelope return windowed_fragment + def get_frame(self, phase: float, window: Window) -> np.ndarray: + """The frame at the middle of the windowed fragment ``phase`` reads, under the envelope there. + + It reads the frame's own samples, so its cost follows the frame length however wide the + window spans. + """ + envelope = window.get_frame_from_window(window.envelope, copy=False) + frame: np.ndarray = self.get_fragment(phase, window.frame_length) * envelope + return frame + @property def length(self) -> int: return len(self.array) + @property + def mean(self) -> float: + """The level a shift of the sample averages to, since a shift reaches every sample alike.""" + return float(np.mean(self.array, dtype=np.float64)) + + @property + def variance(self) -> float: + """The spread of the sample about its mean, which any shift of it carries.""" + return float(np.var(self.array, dtype=np.float64)) + @field_serializer("array") def _serialize_array(self, array: np.ndarray) -> SerializedData: return serialize_array(array) diff --git a/src/sampletones_core/formats/binary.py b/src/sampletones_core/formats/binary.py new file mode 100644 index 000000000..cbfc4f97b --- /dev/null +++ b/src/sampletones_core/formats/binary.py @@ -0,0 +1,133 @@ +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 skip(self, count: int) -> None: + """Moves past the next ``count`` bytes, for a field the reader has no use for. + + Raises: + TruncatedDataError: If the buffer holds fewer than ``count`` bytes from here. + """ + self.read_bytes(count) + + def read_uint8(self) -> int: + return self._read(" int: + return self._read(" int: + return self._read(" int: + return self._read(" 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..328d250c4 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -1,10 +1,14 @@ import math -from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Final, FrozenSet, List, Mapping, 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.rows.levels import RowPlace, cell_volume, full_level_notes +from sampletones_core.exporters.rows.transpose import PitchWalk, unreached_start +from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows +from sampletones_core.exporters.slices import iterate_voice_slices +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.request import InstrumentExport, SampleExport from sampletones_core.formats.bitphase.envelopes import ( ChannelEnvelopes, features_to_envelopes, @@ -21,30 +25,22 @@ from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.model.song import BitphaseSong from sampletones_core.formats.bitphase.model.table import BitphaseTable -from sampletones_core.formats.bitphase.notes import ( - noise_period_to_note_index, - note_index_to_note_cell, - pitch_to_note_index, -) +from sampletones_core.formats.bitphase.notes import note_index_to_note_cell from sampletones_core.formats.bitphase.specification.channels import ( CHANNEL_LABELS, - GENERATOR_NAME_TO_CHANNEL_INDEX, + CHANNEL_TO_INDEX, ChannelIndex, ) from sampletones_core.formats.bitphase.specification.chip import ( - CPU_FREQUENCIES, - DEFAULT_A4_TUNING, - DEFAULT_CHIP_VARIANT, + DEFAULT_CPU_FREQUENCY, MAX_INITIAL_SPEED, MIN_INITIAL_SPEED, ) from sampletones_core.formats.bitphase.specification.effects import ( - NO_EFFECT_PARAMETER, SPEED_EFFECT_DELAY, EffectId, ) from sampletones_core.formats.bitphase.specification.instruments import ( - LOOP_FROM_START, MAX_INSTRUMENT_ID, MAX_TABLE_ID, MIN_INSTRUMENT_ID, @@ -60,110 +56,80 @@ VOLUME_OFF, 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.formats.bitphase.transposes import TransposeCell, TransposePlan +from sampletones_core.formats.bitphase.truncation import document_truncation +from sampletones_core.formats.bitphase.tuning import concert_frequency, generate_tuning_table +from sampletones_core.formats.bitphase.voices import SliceVoice, SliceVoiceTable from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project -from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove -from sampletones_core.trackers.request import InstrumentExport, SampleExport +from sampletones_core.project.tuning import tuning_from_project +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 VoiceLookup +from sampletones_core.timing import SongTiming, TickBounds from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED PREVIEW_SPEED = DEFAULT_SPEED +PREVIEW_TRANSPOSE = 0 PREVIEW_TRIGGER_ROW = 0 PREVIEW_REST_PATTERN_ID = FIRST_PATTERN_ID + 1 NO_AUTHOR = "" GROOVE_CHANNEL = ChannelIndex.DPCM -GROOVE_TRIGGER_ROW = 0 -GROOVE_TABLE_NAME = "Groove" -GROOVE_TABLE_COUNT = 1 - - -@dataclass(frozen=True) -class Voice: - """One built instrument together with the table and the note that triggers it. - - Attributes: - number: Value a pattern's instrument column carries to play the instrument. - instrument: The per-tick rows the channel takes on. - table: The per-tick semitone contour that moves the note. - generator: The NES channel the slice was reconstructed for. - initial_pitch: Pitch the slice's contour is measured against. - ticks: How many ticks the instrument runs before it loops. - """ - - number: int - instrument: BitphaseInstrument - table: BitphaseTable - generator: GeneratorName - initial_pitch: int - ticks: int - +FIRST_FRAME = 0 +FIRST_ROW = 0 +BITPHASE_TICK_BOUNDS: Final[TickBounds] = TickBounds(minimum=MIN_INITIAL_SPEED, maximum=MAX_INITIAL_SPEED) -VoiceTable = Dict[Tuple[str, GeneratorName], Voice] - -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 - that carries a groove holds one table of its own above the slices. + in both columns. Raises: ValueError: If the position runs past what a pattern column can name, or past the - table ids the document leaves to its slices. + table ids a document holds. """ number = index + MIN_INSTRUMENT_ID if number > MAX_INSTRUMENT_ID: raise ValueError(f"Document exceeds the Bitphase limit of {MAX_INSTRUMENT_ID} instruments") table_id = index + MIN_TABLE_ID - if table_id > maximum_table_id: - raise ValueError(f"Document holds room for {maximum_table_id + 1} slice tables") + if table_id > MAX_TABLE_ID: + raise ValueError(f"Document holds room for {MAX_TABLE_ID + 1} slice tables") - return Voice( + return SliceVoice( number=number, instrument=BitphaseInstrument( id=format_instrument_id(number), - rows=envelopes.rows, - loop=envelopes.loop, + macros=envelopes.macros, name=name, ), table=BitphaseTable( id=table_id, rows=envelopes.table_rows, - loop=envelopes.loop, + loop=envelopes.table_loop, name=name, ), - generator=generator, + channel=channel, initial_pitch=initial_pitch, - ticks=len(envelopes.rows), + ticks=envelopes.ticks, ) -def _note_cell(channel_generator: GeneratorName, pitch: int) -> NoteCell: - """Resolves a pitch to the note column of the channel the row sits on. - - The noise channel reads its note as a period selector, so its pitch takes the - mapping that reproduces that period; every other channel reads the tuning table. - """ - if channel_generator == GeneratorName.NOISE: - return note_index_to_note_cell(noise_period_to_note_index(pitch)) +def _note_cell(voice: SliceVoice, transpose: int) -> NoteCell: + """Resolves a slice moved by a row's transpose to the note column that triggers it.""" + return note_index_to_note_cell(voice.note_index(transpose)) - 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, @@ -196,21 +162,27 @@ def _build_song( *, speed: int, nes_frequency: int, + pattern_length: int, + a4_tuning: float, + tuning_table: Tuple[int, ...], ) -> BitphaseSong: - chip_frequency = CPU_FREQUENCIES[DEFAULT_CHIP_VARIANT] + """Gathers the patterns into a song played at the tuning its instruments were built for. + + The song states its concert pitch beside the table built from it, which is the pair Bitphase + reads a tuning from. + """ return BitphaseSong( patterns=patterns, - tuning_table=generate_tuning_table( - chip_frequency, - a4_tuning=DEFAULT_A4_TUNING, - ), + tuning_table=tuning_table, initial_speed=speed, - chip_frequency=chip_frequency, + default_pattern_length=pattern_length, + chip_frequency=DEFAULT_CPU_FREQUENCY, interrupt_frequency=nes_frequency, + a4_tuning_hz=a4_tuning, ) -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 +191,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 +203,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, PREVIEW_TRANSPOSE) channel_rows[channel][PREVIEW_TRIGGER_ROW] = _trigger_row( voice, note, @@ -257,9 +229,10 @@ 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. + for, so opening the document and pressing play sounds the reconstruction. The song + plays at the tuning the reconstruction was built against. Args: request: The reconstruction's slices. @@ -268,20 +241,22 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: BitphaseProject: The document to serialize. Raises: - ValueError: If the reconstruction holds more slices than Bitphase has room for. + ValueError: If the reconstruction holds more slices than Bitphase has room for, or its + concert pitch lies outside the range a Bitphase song takes. """ + a4_tuning = concert_frequency(request.tuning) + tuning_table = generate_tuning_table(DEFAULT_CPU_FREQUENCY, a4_tuning=a4_tuning) 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, + tuning_table=tuning_table, ), - maximum_table_id=MAX_TABLE_ID, ) for index, instrument in enumerate(request.instruments) ] @@ -298,6 +273,9 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: patterns, speed=PREVIEW_SPEED, nes_frequency=request.nes_frequency, + pattern_length=length, + a4_tuning=a4_tuning, + tuning_table=tuning_table, ), ), pattern_order=order, @@ -307,18 +285,24 @@ 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. + + The song plays at the tuning the slice's reconstruction was built against. Args: request: The slice to write. Returns: BitphaseProject: The document to serialize. + + Raises: + ValueError: If the slice's concert pitch lies outside the range a Bitphase song takes. """ sample = SampleExport( name=request.name, instruments=(request,), nes_frequency=request.nes_frequency, + tuning=request.tuning, ) return sample_to_bitphase(sample) @@ -326,40 +310,42 @@ def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: def _build_voice_table( project: Project, *, - maximum_table_id: int, -) -> Tuple[List[Voice], VoiceTable]: - voices: List[Voice] = [] - by_reference: VoiceTable = {} + tuning_table: Tuple[int, ...], +) -> Tuple[ + List[SliceVoice], + SliceVoiceTable, + Optional[EnvelopeTruncation], +]: + """Builds an instrument and a table for every voice slice, with what the macros shortened. + + Each slice becomes an instrument of its own, so the report counts the slices a macro shortened. + """ + voices: List[SliceVoice] = [] + by_reference: SliceVoiceTable = {} + truncations: List[Optional[EnvelopeTruncation]] = [] - for sample_slice in iterate_sample_slices(project): + for index, voice_slice in enumerate(iterate_voice_slices(project)): + truncations.append(document_truncation(voice_slice.features)) envelopes = features_to_envelopes( - sample_slice.features, - sample_slice.generator, - loop=sample_slice.sample.loop, + voice_slice.features, + voice_slice.channel, + tuning_table=tuning_table, ) - 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 - - return voices, by_reference - + by_reference[voice_slice.key] = voice -def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice: - voice = voices.get((reference.sample_id, reference.generator_name)) - if voice is None: - raise ValueError( - f"Row references sample '{reference.sample_id}' slice " - f"'{reference.generator_name}' that has no instrument" - ) - - return voice + return ( + voices, + by_reference, + EnvelopeTruncation.summarize(truncations), + ) def _volume_column(volume: Optional[int]) -> int: @@ -381,31 +367,50 @@ def _volume_column(volume: Optional[int]) -> int: def _row_cell( row: Row, - channel_generator: GeneratorName, - voices: VoiceTable, + channel_generator: ChannelName, + voices: SliceVoiceTable, + transpose: Optional[TransposeCell], + start: Optional[int], + *, + full_level: bool, ) -> 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. + A note-on naming a voice with no instrument on this channel plays nothing in the song, so it + becomes the note cut that silences the channel. A note-on starts the voice at the step ``start`` + the song's walk gave it, so an instrument placed without a pitch writes the note the channel was + sounding; a note-on that started nothing writes an empty cell, since an instrument cell alone + would restart the slice. The volume column states what :func:`cell_volume` gives the row, which + is the full level on a note Bitphase would otherwise start at the level the channel carries. A + pitch row moving the note sounding writes the table and the effect its ``transpose`` cell + names — see :class:`TransposePlan`. """ - volume = _volume_column(row.volume) + volume = _volume_column(cell_volume(row, channel_generator, full_level=full_level)) cell = BitphaseRow(volume=volume) + note_cut = BitphaseRow( + note=NoteCell(name=int(NoteName.OFF)), + volume=volume, + ) match row.command: case NoteOff(): + cell = note_cut + case NoteOn() as reference: + voice = voices.get((reference.voice_id, channel_generator)) + if voice is None: + cell = note_cut + elif start is not None: + cell = _trigger_row( + voice, + _note_cell(voice, start), + volume, + ) + case None if transpose is not None: cell = BitphaseRow( - note=NoteCell(name=int(NoteName.OFF)), + table=transpose.table, + effects=transpose.effects, volume=volume, ) - case Instrument() as reference: - voice = _resolve_voice(reference, voices) - pitch = voice.initial_pitch + (row.transpose or 0) - cell = _trigger_row( - voice, - _note_cell(channel_generator, pitch), - volume, - ) case None: pass @@ -415,133 +420,168 @@ def _row_cell( def _channel_rows( rows: Sequence[Row], length: int, - generator: GeneratorName, - voices: VoiceTable, + channel: ChannelName, + voices: SliceVoiceTable, + full_rows: FrozenSet[int], + transposes: Mapping[int, TransposeCell], + starts: Mapping[int, Optional[int]], + lookup: VoiceLookup, ) -> List[BitphaseRow]: - cells = [_row_cell(row, generator, voices) for row in rows[:length]] + """Converts one channel's pattern within a frame, writing the full level, the transposes and the starts.""" + cells = [ + _row_cell( + row, + channel, + voices, + transposes.get(row_index), + starts.get(row_index, unreached_start(row, channel, lookup)), + full_level=row_index in full_rows, + ) + for row_index, row in enumerate(rows[:length]) + ] cells.extend(BitphaseRow() for _ in range(length - len(cells))) return cells -def _project_groove(project: Project) -> Groove: - """Spreads the tempo a project states across the rows of one pattern. - - A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is - carried by a groove: whole tick counts that vary from row to row and average out to the - rate, placed by the metre 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), - minimum_ticks=MIN_INITIAL_SPEED, - maximum_ticks=MAX_INITIAL_SPEED, - ) +def _frame_rows(places: FrozenSet[RowPlace], position: int) -> FrozenSet[int]: + """The rows among ``places`` that the order frame at ``position`` plays.""" + return frozenset(place.row_index for place in places if place.order_position == position) -def _maximum_slice_table_id(groove: Groove) -> int: - """The last table id the document leaves to its slices. +def _project_timing(project: Project) -> SongTiming: + """How many ticks every row of the project's song lasts, held within Bitphase's speed range. - A groove whose rows differ occupies the table above the last slice, so the slices reach - one id less far; a groove whose rows last alike is carried by the song's initial speed - and leaves the whole column to them. + A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is carried by + rows whose tick counts vary, placed the way in-app playback places them. The engine's own speed + range bounds them. """ - if groove.is_uniform: - return MAX_TABLE_ID - - return MAX_TABLE_ID - GROOVE_TABLE_COUNT + return SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS) -def _groove_table(groove: Groove, table_id: int) -> BitphaseTable: - """Writes the groove as the table a speed effect reads one entry per pattern row from.""" - return BitphaseTable( - id=table_id, - rows=groove.ticks, - loop=LOOP_FROM_START, - name=GROOVE_TABLE_NAME, - ) - +def _speed_effect(speed: int) -> EffectCell: + """Sets the speed, the ticks a row lasts, from the row carrying it on. -def _speed_effect(table_id: int) -> EffectCell: - """Names the table a row takes its own duration from. - - The parameter states a speed directly where an effect carries no table, so an effect - that names one leaves it empty; the delay stays at zero, which is what Bitphase reads - on a speed effect. + The delay stays at zero, which is what Bitphase reads on a speed effect, and the parameter + states the speed itself. """ return EffectCell( effect=int(EffectId.SPEED), delay=SPEED_EFFECT_DELAY, - parameter=NO_EFFECT_PARAMETER, - table_index=table_id, + parameter=speed, ) -def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]: - """Rests a channel for a whole pattern beyond the groove trigger its first row carries. +def _speed_rows( + timing: SongTiming, + position: int, + frames: int, + length: int, +) -> Optional[List[BitphaseRow]]: + """Carries the speed changes one frame's rows make, on a channel that otherwise rests. - A speed effect applies from whichever channel holds it, so the groove rides the silent - DPCM channel and leaves every sounding channel its own effect column. The table then - advances one entry per row from where the trigger placed it, and triggering it again on - each pattern's first row keeps every row on the entry that describes it. + A row whose length differs from the row played before it states its own speed. The order + returns to its first frame after the last, so the song's first row follows the last one, and + the song's initial speed covers the first pass. A speed effect applies from whichever channel + holds it, so the speeds ride the silent DPCM channel and every sounding channel keeps its own + effect column. Bitphase finds the speed of a row it starts playing from by reading back to the + last speed effect, so a song started anywhere plays every row at its length. + + Args: + timing: How many ticks every row of the song lasts. + position: The order frame the rows belong to. + frames: How many frames the order plays. + length: The rows a pattern holds. + + Returns: + Optional[List[BitphaseRow]]: The channel's rows, or ``None`` where no row of the frame changes + the speed. """ rows = [BitphaseRow() for _ in range(length)] - rows[GROOVE_TRIGGER_ROW] = BitphaseRow(effects=(_speed_effect(table_id),)) - return rows + changed = False + for row_index, ticks in enumerate(timing.groove(position).ticks): + if ticks != _previous_row_ticks(timing, position, row_index, frames=frames, length=length): + rows[row_index] = BitphaseRow(effects=(_speed_effect(ticks),)) + changed = True + + return rows if changed else None + + +def _previous_row_ticks( + timing: SongTiming, + position: int, + row_index: int, + *, + frames: int, + length: int, +) -> int: + """The ticks the row played just before a row lasts, the song's last row before its first.""" + if row_index != FIRST_ROW: + return timing.row_ticks(position, row_index - 1) + + previous_frame = position - 1 if position != FIRST_FRAME else frames - 1 + return timing.row_ticks(previous_frame, length - 1) def _document_tables( - voices: Sequence[Voice], - groove_table: Optional[BitphaseTable], + voices: Sequence[SliceVoice], + transposes: TransposePlan, ) -> Tuple[BitphaseTable, ...]: - """Gathers the tables a document holds: one per slice, and the groove where it takes one.""" - tables = tuple(voice.table for voice in voices) - if groove_table is None: - return tables + """Gathers the tables a document holds: one per slice, then the moved tables.""" + return tuple(voice.table for voice in voices) + transposes.tables + - return tables + (groove_table,) +def _moved_table_id(voices: Sequence[SliceVoice]) -> int: + """The id the first table a transpose row moves a note to takes, above the slices.""" + return len(voices) + MIN_TABLE_ID def _project_patterns( project: Project, - voices: VoiceTable, - groove_table: Optional[BitphaseTable], + voices: SliceVoiceTable, + timing: SongTiming, + transposes: TransposePlan, + walks: Mapping[ChannelName, PitchWalk], ) -> Tuple[BitphasePattern, ...]: """Flattens the song's per-channel arrangement into whole-pattern order positions. A SampleToNES order frame points every channel at its own pattern, where a Bitphase order position names one pattern that spans all channels, so each frame becomes a - pattern of its own carrying that frame's channels side by side. Every pattern triggers - the groove table it is given, so the tempo holds wherever the order jumps. + pattern of its own carrying that frame's channels side by side, with the speed changes its + rows make beside them, so every row lasts what the song's timing gives it. The order plays + the frames in turn and returns to the first, which is the walk :func:`full_level_notes` + follows to find the notes writing the full level. Each frame is a pattern of its own, so a + transpose row writes the cell the frame reaching it needs. """ song = project.song length = song.rows_per_pattern patterns: List[BitphasePattern] = [] + full_levels = {channel_name: full_level_notes(song, channel_name) for channel_name in ChannelName.items()} for position, frame in enumerate(song.order): channel_rows = _empty_channels(length) - if groove_table is not None: - channel_rows[int(GROOVE_CHANNEL)] = _groove_channel_rows( - length, - groove_table.id, - ) + speeds = _speed_rows(timing, position, song.order_length(), length) + if speeds is not None: + channel_rows[int(GROOVE_CHANNEL)] = speeds - for 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, + _frame_rows(full_levels[channel_name], position), + transposes.frame_cells(channel_name, position), + walks[channel_name].frame_starts(position), + project.voice, ) patterns.append(_to_pattern(position, length, channel_rows)) @@ -550,49 +590,74 @@ def _project_patterns( def project_to_bitphase(project: Project) -> BitphaseProject: - """Maps a project's samples, song and tempo onto the Bitphase document IR. + """Maps a project's samples, song and tempo onto the Bitphase document IR.""" + return build_bitphase(project).document + + +def build_bitphase(project: Project) -> BuiltDocument[BitphaseProject]: + """Maps a project onto the Bitphase document IR and lists what it had to leave out. - The song carries the project's tempo as a groove, which is the initial speed on its own - where every row lasts alike and a table the patterns trigger where the rows differ. + The song carries the project's tempo in its row lengths: the initial speed where every row lasts + alike, and a speed effect on every row whose length differs from the row before it. It plays + at the tuning the project's samples were reconstructed at, and at concert pitch where the + project holds no sample. A row naming a voice on a channel the voice has no instrument for + plays nothing in the song, so the document holds a note cut there and the row is listed + beside it. A dimension longer than a macro holds keeps its opening values, and the slices + shortened that way are reported beside the rows. A row moving the transpose of a note already + sounding switches the channel to a moved copy of the note's table — see :class:`TransposePlan`. Args: project: The project to write. Returns: - BitphaseProject: The document to serialize. + BuiltDocument[BitphaseProject]: The document to serialize, the rows left silent and the + slices shortened. Raises: - ValueError: If the project holds more than Bitphase has room for, or a row - references a sample slice that has no instrument. + ValueError: If the project holds more than Bitphase has room for, its transpose rows' + tables included, if its samples were reconstructed at different tunings, or if their + concert pitch lies outside the range a Bitphase song takes. """ - groove = _project_groove(project) - voices, by_reference = _build_voice_table( + a4_tuning = concert_frequency(tuning_from_project(project)) + tuning_table = generate_tuning_table(DEFAULT_CPU_FREQUENCY, a4_tuning=a4_tuning) + timing = _project_timing(project) + voices, by_reference, truncation = _build_voice_table( project, - maximum_table_id=_maximum_slice_table_id(groove), + tuning_table=tuning_table, ) - groove_table = ( - None - if groove.is_uniform - else _groove_table( - groove, - len(voices) + MIN_TABLE_ID, - ) + walks = { + channel_name: PitchWalk.walk(project.song, channel_name, by_reference, project.voice) + for channel_name in ChannelName.items() + } + transposes = TransposePlan.build( + walks, + by_reference, + timing, + frames=project.song.order_length(), + first_table_id=_moved_table_id(voices), ) - patterns = _project_patterns(project, by_reference, groove_table) + patterns = _project_patterns(project, by_reference, timing, transposes, walks) settings = project.settings info = project.info - return BitphaseProject( - name=info.title, - author=info.author, - songs=( - _build_song( - patterns, - speed=groove.ticks[GROOVE_TRIGGER_ROW], - nes_frequency=settings.nes_frequency, + return BuiltDocument( + document=BitphaseProject( + name=info.title, + author=info.author, + songs=( + _build_song( + patterns, + speed=timing.row_ticks(FIRST_FRAME, FIRST_ROW), + nes_frequency=settings.nes_frequency, + pattern_length=project.song.rows_per_pattern, + a4_tuning=a4_tuning, + tuning_table=tuning_table, + ), ), + pattern_order=tuple(pattern.id for pattern in patterns), + tables=_document_tables(voices, transposes), + instruments=tuple(voice.instrument for voice in voices), ), - pattern_order=tuple(pattern.id for pattern in patterns), - tables=_document_tables(voices, groove_table), - instruments=tuple(voice.instrument for voice in voices), + skipped_rows=find_skipped_rows(project.song, by_reference), + truncation=truncation, ) diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index abea08b43..8fd4e135c 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -1,13 +1,14 @@ from dataclasses import dataclass -from typing import Dict, Final, Optional, Tuple +from typing import Dict, Sequence, Tuple -import numpy as np - -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName, FeatureKey +from sampletones_core.exporters.bend import bend_envelope from sampletones_core.exporters.feature import Features -from sampletones_core.exporters.lengths import equalize_lengths -from sampletones_core.formats.bitphase.model.instrument import NesInstrumentRow -from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.macros import macro, stored_envelope +from sampletones_core.formats.bitphase.model.instrument import InstrumentMacro +from sampletones_core.formats.bitphase.notes import noise_arpeggio_to_table_offset, pitch_to_note_index +from sampletones_core.formats.bitphase.pitch import contour_period, sounding_offset from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, @@ -17,134 +18,248 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField -SILENT_ROW: Final[NesInstrumentRow] = NesInstrumentRow( - pulse_width=FLAT_PULSE_WIDTH, - volume_or_rate=SILENT_VOLUME, -) +SILENT_VOLUME_ENVELOPE = Envelope[int](items=(SILENT_VOLUME,)) +HELD_VOLUME_ENVELOPE = Envelope[int](items=(MAX_VOLUME_OR_RATE,)) +FLAT_CONTOUR = Envelope[int](items=(NO_TABLE_OFFSET,)) + +MacroBag = Dict[NesMacroField, InstrumentMacro] @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 - sounds. + An instrument's macros and its table advance on per-tick counters of their own, so each + carries the length and the repeat point its own dimension asks for. Attributes: - rows: Instrument rows, one per engine tick. + macros: One macro per instrument field the slice decides. table_rows: Semitone offsets, one per engine tick. - loop: Row both lists return to once they run off the end. + table_loop: Step the contour circles from once it runs off the end. + ticks: Ticks the longest dimension describes. """ - rows: Tuple[NesInstrumentRow, ...] + macros: MacroBag table_rows: Tuple[int, ...] - loop: int - + table_loop: int + ticks: 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. + A square channel takes it as the duty itself; the noise channel takes its lowest bit as the + short LFSR mode; the triangle channel plays one fixed waveform. """ - match generator: - case GeneratorName.PULSE1 | GeneratorName.PULSE2: + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: return duty_cycle - case GeneratorName.NOISE: - return NOISE_MODE_SHORT if duty_cycle else NOISE_MODE_LONG - case GeneratorName.TRIANGLE: + case ChannelName.NOISE: + return NOISE_MODE_SHORT if duty_cycle & NOISE_MODE_SHORT else NOISE_MODE_LONG + 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 -def _held_volume(frames: int) -> Tuple[int, ...]: - """The volume envelope of a slice whose level the channel governs. +def _volume_envelope(features: Features) -> Envelope[int]: + """The levels an instrument writes, which a slice leaving its volume alone holds full. + + Bitphase combines each row's level with the pattern's volume column, and a full level + comes out at the column's own level, so an instrument holding one sounds at whatever + level the channel carries. + """ + volume = features.envelopes.get(FeatureKey.VOLUME, Envelope[int]()) + if volume.written: + return stored_envelope(FeatureKey.VOLUME, volume) + + return HELD_VOLUME_ENVELOPE + + +def _waveform_envelope(features: Features, channel: ChannelName) -> Envelope[int]: + """The waveform values a channel takes, which a slice leaving its duty alone holds flat.""" + duty_cycle = stored_envelope( + FeatureKey.DUTY_CYCLE, + features.envelopes.get(FeatureKey.DUTY_CYCLE, Envelope[int]()), + ) + items = duty_cycle.items or (FLAT_PULSE_WIDTH,) + return duty_cycle.with_items(tuple(_pulse_width(channel, item) for item in items)) + + +def _contour(features: Features, channel: ChannelName) -> Envelope[int]: + """The semitone steps the table moves the note by, flat where the slice states none.""" + arpeggio = features.envelopes.get(FeatureKey.ARPEGGIO, Envelope[int]()) + if not arpeggio.written: + return FLAT_CONTOUR + + return arpeggio.with_items(tuple(_table_offset(channel, step) for step in arpeggio.items)) + + +def _silent_slice( + features: Features, + channel: ChannelName, + *, + tuning_table: Sequence[int], +) -> ChannelEnvelopes: + """The smallest instrument Bitphase plays, which is what a slice describing no frame writes.""" + return ChannelEnvelopes( + macros=_macro_bag( + SILENT_VOLUME_ENVELOPE, + features, + channel, + FLAT_CONTOUR, + tuning_table=tuning_table, + ), + table_rows=FLAT_CONTOUR.items, + table_loop=LOOP_FROM_START, + ticks=0, + ) + + +def _macro_bag( + volume: Envelope[int], + features: Features, + channel: ChannelName, + contour: Envelope[int], + *, + tuning_table: Sequence[int], +) -> MacroBag: + """The fields the slice decides, the triangle channel leaving its one waveform alone.""" + macros = {NesMacroField.VOLUME_OR_RATE: macro(volume)} + if channel != ChannelName.TRIANGLE: + macros[NesMacroField.PULSE_WIDTH] = macro(_waveform_envelope(features, channel)) + + bend = ( + _bend_envelope(features, contour, tuning_table=tuning_table) + if channel in TONE_CHANNELS + else noise_bend(features) + ) + if bend.written: + macros[NesMacroField.TONE_ADD] = macro(bend) + + return macros + - Bitphase combines each row's level with the pattern's volume column, and a full-level - row comes out at the column's own level, so an instrument holding one for every frame - it describes sounds at whatever level the channel carries. +def noise_bend(features: Features) -> Envelope[int]: + """The period steps a slice's bend moves the noise channel by on each tick. + + Bitphase adds a tone offset to the noise channel's note, and the note index carries the + period itself (see :func:`noise_period_to_note_index`), so each step of the bend is one step + of the period, as the slice reads it. Args: - frames: The frames the slice describes. + features: The per-dimension envelopes describing the slice. Returns: - Tuple[int, ...]: One full-level item per frame. + Envelope[int]: The steps per tick, empty where the slice bends nothing. """ - return (MAX_VOLUME_OR_RATE,) * frames + bend = stored_envelope(FeatureKey.PITCH, bend_envelope(features.pitch, features.hi_pitch)) + if not any(bend.items): + return Envelope[int]() + + return bend + + +def _bend_envelope( + features: Features, + contour: Envelope[int], + *, + tuning_table: Sequence[int], +) -> Envelope[int]: + """The period offsets a slice's bend asks of each tick, measured from the note it sounds on. + + The table moves the note before Bitphase reads its period, and the tone offset is added to that + period, so each tick is measured from the note its own contour step reaches, in the song's own + tuning table. A slice that sounds every tick on its note asks for no offset at all. + + Args: + features: The per-dimension envelopes describing the slice. + contour: The semitone steps the table moves the note by. + tuning_table: The period the song gives each note index. + + Returns: + Envelope[int]: The offset per tick, empty where the slice sounds on its note throughout. + """ + bend = stored_envelope(FeatureKey.PITCH, bend_envelope(features.pitch, features.hi_pitch)) + if not any(bend.items): + return Envelope[int]() + + base_index = pitch_to_note_index(features.initial_pitch) + offsets = tuple( + sounding_offset(contour_period(tuning_table, base_index, _step(contour, tick)), steps) + for tick, steps in enumerate(bend.items) + ) + return bend.with_items(offsets) + + +def _step(contour: Envelope[int], tick: int) -> int: + """The semitone step the contour stands at on a tick, which is flat where it states none.""" + step = contour.at(tick) + return NO_TABLE_OFFSET if step is None else step def features_to_envelopes( features: Features, - generator: GeneratorName, + channel: ChannelName, *, - loop: bool, + tuning_table: Sequence[int], ) -> ChannelEnvelopes: - """Converts one generator slice's envelopes into Bitphase instrument and table rows. + """Converts one channel slice's envelopes into the macros and table Bitphase reads it from. 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. - - A slice describing no frame comes back as the one silent row that is the smallest + waveform field, the arpeggio becomes the table contour that moves the note, and a bend + becomes the offset each tick adds to the period that note resolves to in the tuning table + the instrument plays under. Each keeps the length and the repeat point it was written at, + so a dimension holding one value all through costs that one value. + + A slice describing no frame comes back as the one silent value 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. + tuning_table: The period each note index resolves to where the instrument plays, which + bounds every tick's tone offset. Returns: - ChannelEnvelopes: The rows, contour, and loop point describing the slice. + ChannelEnvelopes: The macros, contour and repeat points describing the slice. """ - arrays: Dict[FeatureKey, Optional[np.ndarray]] = { - FeatureKey.VOLUME: features.volume, - FeatureKey.ARPEGGIO: features.arpeggio, - FeatureKey.DUTY_CYCLE: features.duty_cycle, - } - items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop) - frames = max(len(values) for values in items.values()) - - if not frames: - return ChannelEnvelopes( - rows=(SILENT_ROW,), - table_rows=(NO_TABLE_OFFSET,), - loop=LOOP_FROM_START, - ) - - volumes = items[FeatureKey.VOLUME] or _held_volume(frames) - arpeggios = items[FeatureKey.ARPEGGIO] - duty_cycles = items[FeatureKey.DUTY_CYCLE] - - rows = tuple( - NesInstrumentRow( - pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), - volume_or_rate=volume, - ) - for frame, volume in enumerate(volumes) + if not features.frame_count: + return _silent_slice(features, channel, tuning_table=tuning_table) + + contour = _contour(features, channel) + macros = _macro_bag( + _volume_envelope(features), + features, + channel, + contour, + tuning_table=tuning_table, ) - contour = arpeggios or (NO_TABLE_OFFSET,) * len(volumes) - table_rows = tuple(_table_offset(generator, arpeggio) for arpeggio in contour) return ChannelEnvelopes( - rows=rows, - table_rows=table_rows, - loop=LOOP_FROM_START if loop else len(rows) - 1, + macros=macros, + table_rows=contour.items, + table_loop=_repeat_point(contour), + ticks=_ticks(macros, contour), ) + + +def _repeat_point(envelope: Envelope[int]) -> int: + """The step a dimension circles from, which is its last where it states no point of its own.""" + if envelope.loop_point is not None: + return envelope.loop_point + + return len(envelope.items) - 1 + + +def _ticks(macros: MacroBag, contour: Envelope[int]) -> int: + """How long the instrument runs before every dimension it writes stands at its end.""" + lengths = [len(written.values) for written in macros.values()] + return max(lengths + [len(contour.items)]) diff --git a/src/sampletones_core/formats/bitphase/macros.py b/src/sampletones_core/formats/bitphase/macros.py new file mode 100644 index 000000000..6c61ac5d1 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/macros.py @@ -0,0 +1,42 @@ +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.limits import within_limit +from sampletones_core.formats.bitphase.model.instrument import InstrumentMacro +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH + + +def stored_envelope(feature_key: FeatureKey, envelope: Envelope[int]) -> Envelope[int]: + """One dimension as a Bitphase instrument holds it, within the values a macro stores. + + The value limit belongs to the tracker, and :func:`within_limit` is the rule every format + shortens a dimension by. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + Envelope[int]: The dimension within the values a macro holds. + """ + return within_limit(feature_key, envelope, MAX_MACRO_LENGTH) + + +def macro(envelope: Envelope[int]) -> InstrumentMacro: + """One dimension as the macro Bitphase reads it from. + + Playback circles from the macro's loop index once the values run out, so a dimension + holding its final value states that value's own index, and one circling from a point + states the point. A dimension longer than a macro stores keeps its opening values. + + Args: + envelope: The dimension to write. + + Returns: + InstrumentMacro: The macro carrying that dimension. + + Raises: + ValueError: If the dimension writes no value at all. + """ + stored = envelope.limited(MAX_MACRO_LENGTH) + loop = stored.loop_point if stored.loop_point is not None else len(stored.items) - 1 + return InstrumentMacro(values=stored.items, loop=loop) diff --git a/src/sampletones_core/formats/bitphase/model/instrument.py b/src/sampletones_core/formats/bitphase/model/instrument.py index 37cb71aff..eef128ba7 100644 --- a/src/sampletones_core/formats/bitphase/model/instrument.py +++ b/src/sampletones_core/formats/bitphase/model/instrument.py @@ -1,154 +1,99 @@ -from typing import Tuple +from __future__ import annotations -from pydantic import BaseModel, Field +from typing import Dict, Tuple + +from pydantic import BaseModel, Field, model_validator from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES -from sampletones_core.formats.bitphase.specification.instruments import ( - ABSOLUTE_TONE, - CONSTANT_VOLUME, - KEEP_PHASE, - LOOP_FROM_START, - MAX_PULSE_WIDTH, - MAX_SOUND_LENGTH, - MAX_SWEEP_RATE, - MAX_SWEEP_SHIFT, - MAX_TONE_ADD, - MAX_VOLUME_OR_RATE, - MIN_PULSE_WIDTH, - MIN_SOUND_LENGTH, - MIN_SWEEP_RATE, - MIN_SWEEP_SHIFT, - MIN_TONE_ADD, - MIN_VOLUME_OR_RATE, - NO_SWEEP, - NO_SWEEP_RATE, - NO_SWEEP_SHIFT, - NO_TONE_OFFSET, - SUSTAINED_SOUND_LENGTH, +from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START +from sampletones_core.formats.bitphase.specification.macros import ( + MAX_MACRO_LENGTH, + MIN_MACRO_LENGTH, + NES_MACRO_FIELDS, + NesMacroField, ) -class NesInstrumentRow(BaseModel): - """One tick of a Bitphase NES instrument. +class InstrumentMacro(BaseModel): + """One field of an instrument, read a value per engine tick. - An instrument advances one row per engine tick, so a row carries every register - value the channel takes for that tick. ``pulse_width`` selects the duty on a square - channel and the LFSR mode on the noise channel; ``volume_or_rate`` is a literal - volume while ``envelope`` stays off. The remaining fields hold the settings a - reconstruction leaves alone: the note sustains, the pitch comes from the tuning - table, and the hardware sweep stays disabled. + Every field advances on a counter of its own, so a macro carries the length and the + repeat point that field alone asks for. ``loop`` indexes the values, and playback + circles from it once the values run out — which makes the whole macro circle where it + stands at the first value, and the last value hold where it stands at the last. """ model_config = BITPHASE_MODEL_CONFIG - pulse_width: int = Field( - ..., - ge=MIN_PULSE_WIDTH, - le=MAX_PULSE_WIDTH, - description="Square duty cycle, or the noise channel's LFSR mode.", - ) - volume_or_rate: int = Field( + values: Tuple[int, ...] = Field( ..., - ge=MIN_VOLUME_OR_RATE, - le=MAX_VOLUME_OR_RATE, - description="Channel volume while the hardware envelope stays off.", - ) - retrigger: bool = Field( - default=KEEP_PHASE, - description="Restarts the waveform phase this tick.", - ) - sound_length: int = Field( - default=SUSTAINED_SOUND_LENGTH, - ge=MIN_SOUND_LENGTH, - le=MAX_SOUND_LENGTH, - description="Length counter in ticks; zero holds the note for as long as the envelope runs.", - ) - envelope: bool = Field( - default=CONSTANT_VOLUME, - description="Reads volume_or_rate as a decay rate.", - ) - tone_add: int = Field( - default=NO_TONE_OFFSET, - ge=MIN_TONE_ADD, - le=MAX_TONE_ADD, - description="Offset added to the tuning-table period on a square or triangle channel.", + min_length=MIN_MACRO_LENGTH, + max_length=MAX_MACRO_LENGTH, + description="Value the field takes on each tick.", ) - tone_accumulation: bool = Field( - default=ABSOLUTE_TONE, - description="Sums tone_add across ticks.", - ) - sweep: bool = Field( - default=NO_SWEEP, - description="Enables the square channel's sweep unit.", - ) - sweep_rate: int = Field( - default=NO_SWEEP_RATE, - ge=MIN_SWEEP_RATE, - le=MAX_SWEEP_RATE, - ) - sweep_shift: int = Field( - default=NO_SWEEP_SHIFT, - ge=MIN_SWEEP_SHIFT, - le=MAX_SWEEP_SHIFT, + loop: int = Field( + default=LOOP_FROM_START, + ge=0, + description="Index among the values playback circles from.", ) + @model_validator(mode="after") + def _check_loop(self) -> InstrumentMacro: + if self.loop >= len(self.values): + raise ValueError(f"loop {self.loop} stands past the {len(self.values)} values written") -class BitphaseInstrument(BaseModel): - """A named row list one pattern cell triggers, held in a project's instrument list. + return self - ``id`` is the base-36 text a pattern's instrument column matches on, and ``loop`` - is the row playback returns to once it runs off the end. + +class MacroInstrument(BaseModel): + """The fields every stored Bitphase instrument carries. + + An instrument states a macro for each field whose values it decides, and Bitphase reads + every other field it offers at that field's own default. """ model_config = BITPHASE_MODEL_CONFIG - id: str = Field( - ..., - description="Base-36 identifier a pattern row references.", - ) chip_type: str = Field( default=CHIP_TYPE_NES, - description="Chip whose row layout the instrument uses.", - ) - rows: Tuple[NesInstrumentRow, ...] = Field( - ..., - description="One row per engine tick.", - ) - loop: int = Field( - default=LOOP_FROM_START, - ge=0, - description="Row playback returns to after the last row.", + description="Chip whose fields the macros drive.", ) name: str = Field( ..., description="Name shown in the instrument list.", ) + macros: Dict[NesMacroField, InstrumentMacro] = Field( + ..., + description="One macro per field the instrument decides.", + ) + @model_validator(mode="after") + def _check_values(self) -> MacroInstrument: + for field, macro in self.macros.items(): + spec = NES_MACRO_FIELDS[field] + for value in macro.values: + if not spec.minimum <= value <= spec.maximum: + raise ValueError(f"{field} holds {value}, outside the {spec.minimum}..{spec.maximum} it takes") -class BitphaseInstrumentPreset(BaseModel): - """A single instrument as Bitphase's instruments panel loads and saves it. + return self - The panel writes the loaded rows into the instrument slot the user has selected, - which supplies the id and leaves this file carrying the rows alone. - """ - model_config = BITPHASE_MODEL_CONFIG +class BitphaseInstrument(MacroInstrument): + """A named instrument one pattern cell triggers, held in a project's instrument list. - chip_type: str = Field( - default=CHIP_TYPE_NES, - description="Chip whose row layout the preset uses.", - ) - name: str = Field( - ..., - description="Name the preset offers for the instrument.", - ) - loop: int = Field( - default=LOOP_FROM_START, - ge=0, - description="Row playback returns to after the last row.", - ) - rows: Tuple[NesInstrumentRow, ...] = Field( + ``id`` is the base-36 text a pattern's instrument column matches on. + """ + + id: str = Field( ..., - description="One row per engine tick.", + description="Base-36 identifier a pattern row references.", ) + + +class BitphaseInstrumentPreset(MacroInstrument): + """A single instrument as Bitphase's instruments panel loads and saves it. + + The panel writes the loaded macros into the instrument slot the user has selected, + which supplies the id and the chip and leaves this file carrying the macros alone. + """ diff --git a/src/sampletones_core/formats/bitphase/model/pattern.py b/src/sampletones_core/formats/bitphase/model/pattern.py index 98a61c2c6..c2f4792ed 100644 --- a/src/sampletones_core/formats/bitphase/model/pattern.py +++ b/src/sampletones_core/formats/bitphase/model/pattern.py @@ -1,8 +1,12 @@ from typing import Dict, Optional, Tuple -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG +from sampletones_core.formats.bitphase.specification.effects import ( + MIN_EFFECT_COLUMNS, + NO_EFFECT_TABLE, +) from sampletones_core.formats.bitphase.specification.patterns import ( EMPTY_OCTAVE, FULL_VOLUME, @@ -35,16 +39,22 @@ class NoteCell(BaseModel): class EffectCell(BaseModel): - """One effect column of a pattern row.""" + """One effect column of a pattern row. + + Bitphase reads an effect from a table wherever the cell names an index of zero or + above, and from the cell's own parameter otherwise, so a parameter-driven effect + states ``NO_EFFECT_TABLE``. + """ model_config = BITPHASE_MODEL_CONFIG effect: int = Field(..., description="Effect identifier.") delay: int = Field(default=0, description="Ticks the effect waits before it applies.") parameter: int = Field(default=0, description="Effect argument.") - table_index: Optional[int] = Field( - default=None, - description="Table the effect drives, where it takes one.", + table_index: int = Field( + default=NO_EFFECT_TABLE, + ge=NO_EFFECT_TABLE, + description="Table the effect drives, or NO_EFFECT_TABLE where its parameter drives it.", ) @@ -77,13 +87,23 @@ class BitphaseRow(BaseModel): class BitphaseChannel(BaseModel): - """One channel's lines within a pattern.""" + """One channel's lines within a pattern. + + A channel lays out as many effect columns as its widest line carries, and Bitphase + holds that width across every pattern the channel appears in. + """ model_config = BITPHASE_MODEL_CONFIG rows: Tuple[BitphaseRow, ...] = Field(..., description="One row per pattern line.") label: str = Field(..., description="Name of the channel the lines drive.") + @computed_field # type: ignore[prop-decorator] + @property + def effect_column_count(self) -> int: + """How many effect columns the channel's lines fill.""" + return max((len(row.effects) for row in self.rows), default=MIN_EFFECT_COLUMNS) + class BitphasePattern(BaseModel): """One block of tracker lines across every channel. 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..b95f037ea 100644 --- a/src/sampletones_core/formats/bitphase/model/song.py +++ b/src/sampletones_core/formats/bitphase/model/song.py @@ -6,12 +6,18 @@ from sampletones_core.formats.bitphase.model.pattern import BitphasePattern from sampletones_core.formats.bitphase.specification.chip import ( CHIP_TYPE_NES, - DEFAULT_A4_TUNING, DEFAULT_CHIP_VARIANT, + MAX_A4_TUNING, MAX_INITIAL_SPEED, + MIN_A4_TUNING, MIN_INITIAL_SPEED, + SPEED_CLOCK_TEMPO, ChipVariant, ) +from sampletones_core.formats.bitphase.specification.patterns import ( + MAX_PATTERN_LENGTH, + MIN_PATTERN_LENGTH, +) class BitphaseSong(BaseModel): @@ -20,6 +26,14 @@ class BitphaseSong(BaseModel): ``interrupt_frequency`` is the engine tick rate in Hz, so it carries the rate a reconstruction's envelopes were measured at; ``initial_speed`` is how many of those ticks each pattern line lasts. + + ``tempo`` stays at the value that lets the speed alone time each line. A tempo above it + spreads the ticks of a line by FamiTracker's accumulator, so the speed clock keeps every + line at the tick count the speed effects of the groove state. + + ``a4_tuning_hz`` and ``tuning_table`` state one tuning together. Bitphase builds the table + again from that frequency and the chip clock when it loads a song, so the two are written in + step, and the frequency stays within the range the tracker's settings offer. """ model_config = BITPHASE_MODEL_CONFIG @@ -38,6 +52,16 @@ class BitphaseSong(BaseModel): le=MAX_INITIAL_SPEED, description="Engine ticks per pattern line.", ) + tempo: int = Field( + default=SPEED_CLOCK_TEMPO, + description="Tempo spreading a line's ticks, at the value leaving the speed to time each line.", + ) + default_pattern_length: int = Field( + ..., + ge=MIN_PATTERN_LENGTH, + le=MAX_PATTERN_LENGTH, + description="Line count a pattern added to the song takes.", + ) chip_type: str = Field( default=CHIP_TYPE_NES, description="Chip the song drives.", @@ -55,8 +79,10 @@ class BitphaseSong(BaseModel): description="Engine tick rate in Hz.", ) a4_tuning_hz: float = Field( - default=DEFAULT_A4_TUNING, - description="Concert pitch the tuning table centres on.", + ..., + ge=MIN_A4_TUNING, + le=MAX_A4_TUNING, + 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/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py index 65243d4e8..811809e6b 100644 --- a/src/sampletones_core/formats/bitphase/model/table.py +++ b/src/sampletones_core/formats/bitphase/model/table.py @@ -1,9 +1,13 @@ +from __future__ import annotations + from typing import Tuple from pydantic import BaseModel, Field from sampletones_core.formats.bitphase.model.config import BITPHASE_MODEL_CONFIG from sampletones_core.formats.bitphase.specification.instruments import ( + ABSOLUTE_TABLE, + FIRST_TABLE_STEP, LOOP_FROM_START, MAX_TABLE_ID, MIN_TABLE_ID, @@ -42,3 +46,74 @@ class BitphaseTable(BaseModel): ..., description="Name shown in the table list.", ) + additive: bool = Field( + default=ABSOLUTE_TABLE, + description="Whether each step adds to the value the table has reached, rather than to the note.", + ) + + @property + def circling_step(self) -> int: + """The step playback returns to past the last one, which is the first where ``loop`` stands outside the steps. + + Read from ``processTables`` of Bitphase's ``tracker-pattern-processor.js``, which takes ``loop`` + only where it stands above the first step and below the length. + """ + if FIRST_TABLE_STEP < self.loop < len(self.rows): + return self.loop + + return FIRST_TABLE_STEP + + def position_at(self, tick: int) -> int: + """The step playback stands at ``tick`` ticks after the table started, one step per tick. + + Args: + tick: Ticks since the table started, at least zero. + + Returns: + int: The step playback reads on that tick. + """ + length = len(self.rows) + if tick < length: + return tick + + start = self.circling_step + return start + (tick - length) % (length - start) + + def moved( + self, + *, + table_id: int, + shift: int, + start: int, + name: str, + ) -> BitphaseTable: + """This table with every step moved by ``shift``, playing from the step ``start`` on. + + A table starting at a later step lays the steps from there first, then circles over the + steps this one circles over, so it plays what this one plays from ``start`` on. + + Args: + table_id: The identifier the new table takes. + shift: The semitones every step moves by. + start: The step of this table the new one opens on. + name: The name the new table is listed by. + + Returns: + BitphaseTable: The moved table. + """ + rows = tuple(step + shift for step in self.rows) + circling = self.circling_step + if start >= circling: + laid = rows[start:] + rows[circling:start] + loop = LOOP_FROM_START + else: + laid = rows[start:] + loop = circling - start + + return BitphaseTable( + id=table_id, + rows=laid, + loop=loop, + name=name, + additive=self.additive, + ) diff --git a/src/sampletones_core/formats/bitphase/notes.py b/src/sampletones_core/formats/bitphase/notes.py index 0bc9e8735..675e77bc6 100644 --- a/src/sampletones_core/formats/bitphase/notes.py +++ b/src/sampletones_core/formats/bitphase/notes.py @@ -44,27 +44,28 @@ def note_index_to_note_cell(index: int) -> NoteCell: def noise_period_to_note_index(period: int) -> int: """Converts a noise period index to the note index that selects it. - Playback reads a noise note as ``15 - (index mod 16)``, so every period repeats once - per sixteen note indices and any of those indices selects it. The base index sits - far enough below the top of the tuning table that a whole cycle of table offsets - stays in range. + The project counts noise periods from the slowest and the register counts them from the + fastest, so a period reaches the register as ``15 - period``. Playback writes + ``15 - (index mod 16)`` to the register for a noise note, so the note index carries the + period itself, wrapped into the sixteen the channel has. Every period repeats once per + sixteen note indices, and the base index sits far enough below the top of the tuning + table that a whole cycle of table offsets stays in range. Args: - period: Noise period index the reconstruction chose. + period: Noise period index the reconstruction chose, or one a transpose moved. Returns: - int: Note index whose noise period equals ``period``. + int: Note index that sounds ``period``. """ - offset = (NUM_PERIODS - 1 - period) % NUM_PERIODS - return NOISE_BASE_NOTE_INDEX + offset + return NOISE_BASE_NOTE_INDEX + period % NUM_PERIODS def noise_arpeggio_to_table_offset(step: int) -> int: """Converts a noise arpeggio step to the semitone offset a table row carries. - A rising noise period is a falling note index, so the step is negated and wrapped - into one period cycle, which keeps every note the table reaches inside the tuning - table. + The note index carries the period itself (see :func:`noise_period_to_note_index`), so a + step moves the index by as much, wrapped into one period cycle, which keeps every note + the table reaches inside the tuning table. Args: step: Period offset from the reconstruction's initial noise period. @@ -72,4 +73,4 @@ def noise_arpeggio_to_table_offset(step: int) -> int: Returns: int: Semitone offset that moves the noise period by ``step``. """ - return (-step) % NUM_PERIODS + return step % NUM_PERIODS diff --git a/src/sampletones_core/formats/bitphase/pitch.py b/src/sampletones_core/formats/bitphase/pitch.py new file mode 100644 index 000000000..65f8b82c5 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/pitch.py @@ -0,0 +1,45 @@ +from typing import Sequence + +from sampletones_core.formats.bitphase.specification.chip import PERIOD_OVER_TIMER +from sampletones_core.formats.bitphase.specification.instruments import MAX_TONE_ADD, MIN_TONE_ADD +from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX +from sampletones_core.timers.arithmetic import bent_timer +from sampletones_shared.utils.arrays import clamp + + +def contour_period( + tuning_table: Sequence[int], + base_index: int, + step: int, +) -> int: + """The period the note a contour step moves to sounds at. + + Args: + tuning_table: The period the song gives each note index. + base_index: Note index the slice was reconstructed at. + step: Semitones the contour moves the note by. + + Returns: + int: The period of the note the step reaches, held inside the tuning table. + """ + index = min(max(base_index + step, MIN_NOTE_INDEX), MAX_NOTE_INDEX) + return tuning_table[index] + + +def sounding_offset(base_period: int, offset: int) -> int: + """The tone offset that bends a period by as much as the channel goes on sounding. + + Bitphase adds the offset to the period its note resolves to and loads the timer with that + period less one, so the offset follows ``bent_timer`` on the timer the period stands for. The + bent note then sounds the divider in-app playback sounds, the longest one the register holds + included. + + Args: + base_period: The period the tick's note resolves to. + offset: The timer steps the tick stands away from that note. + + Returns: + int: The offset to write, within both the timer's range and the field's own. + """ + period = bent_timer(base_period - PERIOD_OVER_TIMER, offset) + PERIOD_OVER_TIMER + return clamp(period - base_period, MIN_TONE_ADD, MAX_TONE_ADD) diff --git a/src/sampletones_core/formats/bitphase/preset.py b/src/sampletones_core/formats/bitphase/preset.py index fbd1a0482..33d140604 100644 --- a/src/sampletones_core/formats/bitphase/preset.py +++ b/src/sampletones_core/formats/bitphase/preset.py @@ -2,52 +2,50 @@ from pathlib import Path from typing import Final, Sequence, Tuple -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.formats.bitphase.envelopes import features_to_envelopes -from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset, NesInstrumentRow +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.bend import bend_envelope +from sampletones_core.exporters.feature import Features +from sampletones_core.exports.request import InstrumentExport +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes, noise_bend +from sampletones_core.formats.bitphase.macros import macro, stored_envelope +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset from sampletones_core.formats.bitphase.notes import pitch_to_note_index -from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY -from sampletones_core.formats.bitphase.specification.instruments import ( - MAX_TONE_ADD, - MIN_TONE_ADD, - NO_TONE_OFFSET, -) -from sampletones_core.formats.bitphase.specification.patterns import MAX_NOTE_INDEX, MIN_NOTE_INDEX -from sampletones_core.formats.bitphase.tuning import generate_tuning_table -from sampletones_core.trackers.request import InstrumentExport - -PRESET_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( - DEFAULT_CPU_FREQUENCY, - a4_tuning=DEFAULT_A4_TUNING, -) +from sampletones_core.formats.bitphase.pitch import contour_period, sounding_offset +from sampletones_core.formats.bitphase.specification.instruments import NO_TONE_OFFSET +from sampletones_core.formats.bitphase.specification.macros import NesMacroField +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE + PRESET_JSON_INDENT: Final[int] = 2 def _tone_offsets( - generator: GeneratorName, - initial_pitch: int, + features: Features, + channel: ChannelName, contour: Sequence[int], ) -> Tuple[int, ...]: - """Expresses a semitone contour as the per-tick period offsets a preset carries. - - A preset holds rows alone, so its pitch movement rides in each row's tone offset. - The offsets are measured against the pitch the slice was reconstructed at, under the - tuning the NTSC system gives at concert pitch, which is what a freshly created - Bitphase document plays. The noise channel takes its period from the note rather - than from a period offset, so its rows hold a flat offset and the note carries the - pitch. + """Expresses a slice's pitch movement as the per-tick period offsets a preset carries. + + A preset holds macros alone, so the movement a table would drive rides in the tone offset + each tick takes, beside the bend the slice sounds. The offsets are measured against the + pitch the slice was reconstructed at, under the tuning the NTSC system gives at concert + pitch, which is what a freshly created Bitphase document plays. A preset loads into a + document that keeps its own tuning, so the offsets stay at that tuning whatever the slice was + tuned at. On the noise channel an offset moves the note, which carries the period itself, so + each tick's offset is its contour step and its bend, in period steps. """ - if generator == GeneratorName.NOISE: - return (NO_TONE_OFFSET,) * len(contour) + if channel == ChannelName.NOISE: + bend = noise_bend(features) + return tuple(semitones + _value(bend, tick) for tick, semitones in enumerate(contour)) - base_index = pitch_to_note_index(initial_pitch) - base_period = PRESET_TUNING_TABLE[base_index] + base_index = pitch_to_note_index(features.initial_pitch) + base_period = DEFAULT_TUNING_TABLE[base_index] + bend = stored_envelope(FeatureKey.PITCH, bend_envelope(features.pitch, features.hi_pitch)) offsets = [] - for semitones in contour: - index = max(MIN_NOTE_INDEX, min(MAX_NOTE_INDEX, base_index + semitones)) - offset = PRESET_TUNING_TABLE[index] - base_period - offsets.append(max(MIN_TONE_ADD, min(MAX_TONE_ADD, offset))) + for tick, semitones in enumerate(contour): + moved = contour_period(DEFAULT_TUNING_TABLE, base_index, semitones) - base_period + offsets.append(sounding_offset(base_period, moved + _value(bend, tick))) return tuple(offsets) @@ -56,36 +54,48 @@ 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, + tuning_table=DEFAULT_TUNING_TABLE, ) offsets = _tone_offsets( - request.generator, - request.features.initial_pitch, - envelopes.table_rows, - ) - rows: Tuple[NesInstrumentRow, ...] = tuple( - row.model_copy(update={"tone_add": offset}) - for row, offset in zip( - envelopes.rows, - offsets, - ) + request.features, + request.channel, + _contour(envelopes), ) return BitphaseInstrumentPreset( name=request.name, - loop=envelopes.loop, - rows=rows, + macros={ + **envelopes.macros, + NesMacroField.TONE_ADD: macro(Envelope[int](items=offsets, loop_point=envelopes.table_loop)), + }, ) +def _contour(envelopes: ChannelEnvelopes) -> Tuple[int, ...]: + """The semitone steps a preset states an offset for, one per tick the instrument runs. + + A document moves the note through a table that advances on a counter of its own, where a + preset carries the movement in the offsets themselves, so the contour is read out to the + ticks the slice describes. + """ + contour = Envelope[int](items=envelopes.table_rows, loop_point=envelopes.table_loop) + return tuple(_value(contour, tick) for tick in range(envelopes.ticks)) + + +def _value(envelope: Envelope[int], tick: int) -> int: + """The value a dimension stands at on a tick, which is nothing where it writes none.""" + value = envelope.at(tick) + return NO_TONE_OFFSET if value is None else value + + def write_preset(destination: Path, preset: BitphaseInstrumentPreset) -> None: """Writes a Bitphase instrument preset to disk. 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..d9fd950b7 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" @@ -28,8 +29,12 @@ class ChipVariant(StrEnum): TUNING_PERIOD_DIVISOR: Final[int] = 16 MIN_TUNING_PERIOD: Final[int] = 1 MAX_TUNING_PERIOD: Final[int] = 2047 +PERIOD_OVER_TIMER: Final[int] = 1 DEFAULT_A4_TUNING: Final[float] = A4_FREQUENCY +MIN_A4_TUNING: Final[float] = 220.0 +MAX_A4_TUNING: Final[float] = 880.0 MIN_INITIAL_SPEED: Final[int] = 1 MAX_INITIAL_SPEED: Final[int] = 255 +SPEED_CLOCK_TEMPO: Final[int] = 0 diff --git a/src/sampletones_core/formats/bitphase/specification/effects.py b/src/sampletones_core/formats/bitphase/specification/effects.py index 04404c644..57321cade 100644 --- a/src/sampletones_core/formats/bitphase/specification/effects.py +++ b/src/sampletones_core/formats/bitphase/specification/effects.py @@ -3,14 +3,22 @@ class EffectId(IntEnum): - """Identifier an effect column carries, as the code point of the letter Bitphase prints. + """Identifier an effect column carries: the code point of the letter Bitphase prints, or the digit itself. ``SPEED`` states how many engine ticks the row it sits on lasts, taken from the effect's own parameter or, where the effect names a table, from one table entry per pattern row. + ``ORNAMENT_POSITION`` places the channel's table at the step its parameter names, on the + row's own first tick. """ + ORNAMENT_POSITION = 5 SPEED = ord("S") SPEED_EFFECT_DELAY: Final[int] = 0 -NO_EFFECT_PARAMETER: Final[int] = 0 +ORNAMENT_POSITION_DELAY: Final[int] = 0 +MAX_ORNAMENT_POSITION: Final[int] = 0xFF +NO_EFFECT_TABLE: Final[int] = -1 + +MIN_EFFECT_COLUMNS: Final[int] = 1 +MAX_EFFECT_COLUMNS: Final[int] = 4 diff --git a/src/sampletones_core/formats/bitphase/specification/instruments.py b/src/sampletones_core/formats/bitphase/specification/instruments.py index 2dfb06911..bc8505dc3 100644 --- a/src/sampletones_core/formats/bitphase/specification/instruments.py +++ b/src/sampletones_core/formats/bitphase/specification/instruments.py @@ -47,4 +47,6 @@ NO_SWEEP: Final[bool] = False LOOP_FROM_START: Final[int] = 0 +FIRST_TABLE_STEP: Final[int] = 0 NO_TABLE_OFFSET: Final[int] = 0 +ABSOLUTE_TABLE: Final[bool] = False diff --git a/src/sampletones_core/formats/bitphase/specification/macros.py b/src/sampletones_core/formats/bitphase/specification/macros.py new file mode 100644 index 000000000..529e2853d --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/macros.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Final, Mapping + +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_PULSE_WIDTH, + MAX_TONE_ADD, + MAX_VOLUME_OR_RATE, + MIN_PULSE_WIDTH, + MIN_TONE_ADD, + MIN_VOLUME_OR_RATE, +) + + +class NesMacroField(StrEnum): + """Instrument field a Bitphase NES macro drives, named as the instrument keys it. + + Bitphase reads each field it offers from the macro carrying that name, and gives a field + the instrument leaves out a default of its own, so an instrument states the fields whose + values it decides. These are the three a reconstruction decides. + """ + + VOLUME_OR_RATE = "volumeOrRate" + PULSE_WIDTH = "pulseWidth" + TONE_ADD = "toneAdd" + + +@dataclass(frozen=True) +class MacroFieldSpec: + """The values a field accepts. + + Attributes: + minimum: Lowest value the field takes. + maximum: Highest value the field takes. + """ + + minimum: int + maximum: int + + +NES_MACRO_FIELDS: Final[Mapping[NesMacroField, MacroFieldSpec]] = { + NesMacroField.VOLUME_OR_RATE: MacroFieldSpec(MIN_VOLUME_OR_RATE, MAX_VOLUME_OR_RATE), + NesMacroField.PULSE_WIDTH: MacroFieldSpec(MIN_PULSE_WIDTH, MAX_PULSE_WIDTH), + NesMacroField.TONE_ADD: MacroFieldSpec(MIN_TONE_ADD, MAX_TONE_ADD), +} + +MIN_MACRO_LENGTH: Final[int] = 1 +MAX_MACRO_LENGTH: Final[int] = 512 diff --git a/src/sampletones_core/formats/bitphase/transposes.py b/src/sampletones_core/formats/bitphase/transposes.py new file mode 100644 index 000000000..8c7d7173c --- /dev/null +++ b/src/sampletones_core/formats/bitphase/transposes.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Final, Mapping, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.transpose import PitchWalk, SoundingNote +from sampletones_core.formats.bitphase.model.pattern import EffectCell +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.specification.effects import ( + MAX_ORNAMENT_POSITION, + NO_EFFECT_TABLE, + ORNAMENT_POSITION_DELAY, + EffectId, +) +from sampletones_core.formats.bitphase.specification.instruments import FIRST_TABLE_STEP, MAX_TABLE_ID +from sampletones_core.formats.bitphase.specification.patterns import TABLE_COLUMN_OFFSET +from sampletones_core.formats.bitphase.voices import SliceVoice, SliceVoiceTable +from sampletones_core.timing import SongTiming + +NOTE_SHIFT: Final[int] = 0 +MOVED_TABLE_NAME: Final[str] = "{name} {shift:+d}" +STARTED_TABLE_NAME: Final[str] = "{name} {shift:+d} from {start}" +NO_EFFECTS: Final[Tuple[Optional[EffectCell], ...]] = (None,) + +TableKey = Tuple[int, int, int] + + +@dataclass(frozen=True) +class TransposeCell: + """The columns a transpose row writes: the table that moves the note, and where it starts. + + Attributes: + table: The table column naming the moved table. + effects: The row's effect columns, placing the table at the step the note has reached. + """ + + table: int + effects: Tuple[Optional[EffectCell], ...] + + +@dataclass +class _TableShelf: + """The moved tables a document holds, numbered from the first id its other tables leave free. + + Attributes: + first_id: The id the first moved table takes. + tables: The moved tables, by the slice, the shift and the step each opens on. + """ + + first_id: int + tables: Dict[TableKey, BitphaseTable] = field(default_factory=dict) + + def table(self, voice: SliceVoice, shift: int, start: int) -> BitphaseTable: + """The slice's table moved by ``shift`` and opening on ``start``, shared by every row asking for it. + + A shift of none opening on the first step is the slice's own table. + + Raises: + ValueError: If the table would take an id past the ones the table column names. + """ + if shift == NOTE_SHIFT and start == FIRST_TABLE_STEP: + return voice.table + + key = (voice.number, shift, start) + if key not in self.tables: + table_id = self.first_id + len(self.tables) + if table_id > MAX_TABLE_ID: + raise ValueError( + f"Document exceeds the Bitphase limit of {MAX_TABLE_ID + 1} tables " + f"once its transpose rows take tables of their own" + ) + + self.tables[key] = voice.table.moved( + table_id=table_id, + shift=shift, + start=start, + name=self._name(voice, shift, start), + ) + + return self.tables[key] + + @staticmethod + def _name(voice: SliceVoice, shift: int, start: int) -> str: + if start == FIRST_TABLE_STEP: + return MOVED_TABLE_NAME.format(name=voice.table.name, shift=shift) + + return STARTED_TABLE_NAME.format(name=voice.table.name, shift=shift, start=start) + + +def _ornament_position(position: int) -> Tuple[Optional[EffectCell], ...]: + """The effect columns placing a newly attached table at ``position``, which the first step needs none of.""" + if position == FIRST_TABLE_STEP: + return NO_EFFECTS + + return ( + EffectCell( + effect=int(EffectId.ORNAMENT_POSITION), + delay=ORNAMENT_POSITION_DELAY, + parameter=position, + table_index=NO_EFFECT_TABLE, + ), + ) + + +@dataclass(frozen=True) +class TransposePlan: + """The cells a document's transpose rows write, and the moved tables those cells name. + + Bitphase restarts the instrument on a note or an instrument cell, and a table cell alone attaches + a table at its first step while the instrument plays on. A transpose row therefore names a copy + of the note's table moved by the distance between the note its transpose would write and the + note already written, and an ornament-position effect places that copy at the step the note's + own table has reached. From that tick the channel sounds what a note-on at the new transpose + would sound, with the instrument's envelopes going on where they stood. + + Attributes: + cells: Per channel, the cell each transpose row writes, keyed by its frame and row. + tables: The moved tables the cells name, in id order. + """ + + cells: Dict[ChannelName, Dict[Tuple[int, int], TransposeCell]] + tables: Tuple[BitphaseTable, ...] + + @classmethod + def build( + cls, + walks: Mapping[ChannelName, PitchWalk], + voices: SliceVoiceTable, + timing: SongTiming, + *, + frames: int, + first_table_id: int, + ) -> TransposePlan: + """Plans every transpose row of the song, following the notes the order sounds. + + A note's table advances a step every tick, so the step a transpose row reaches is read from + the ticks the song's timing gives every row since the note, across frames. An ornament position + names a step up to ``MAX_ORNAMENT_POSITION``, so a row reaching a later step names a copy of + the table opening on that step. A row keeping the shift the channel already carries + writes nothing. + + Args: + walks: Each channel's pass through the song, as playback makes it. + voices: The slices a row's note-on reaches, by voice and channel. + timing: How many ticks every row of the song lasts in the document. + frames: How many frames the order holds. + first_table_id: The id the first moved table takes, above every other table. + + Returns: + TransposePlan: The cells and the moved tables they name. + + Raises: + ValueError: If the moved tables run past the ids the table column names. + """ + shelf = _TableShelf(first_id=first_table_id) + cells: Dict[ChannelName, Dict[Tuple[int, int], TransposeCell]] = {} + for channel_name in ChannelName.items(): + channel_cells: Dict[Tuple[int, int], TransposeCell] = {} + for note in walks[channel_name].notes: + channel_cells.update( + cls._note_cells( + note, + voices[(note.voice_id, channel_name)], + timing, + frames, + shelf, + ) + ) + + cells[channel_name] = channel_cells + + return cls(cells=cells, tables=tuple(shelf.tables.values())) + + def frame_cells(self, channel_name: ChannelName, order_position: int) -> Dict[int, TransposeCell]: + """The cells one frame's transpose rows on a channel write, keyed by row.""" + return { + row_index: cell + for (position, row_index), cell in self.cells[channel_name].items() + if position == order_position + } + + @staticmethod + def _note_cells( + note: SoundingNote, + voice: SliceVoice, + timing: SongTiming, + frames: int, + shelf: _TableShelf, + ) -> Dict[Tuple[int, int], TransposeCell]: + """The cells the transpose rows reaching one note write.""" + written = voice.note_index(note.step) + carried = NOTE_SHIFT + cells: Dict[Tuple[int, int], TransposeCell] = {} + for repitch in note.repitches: + shift = voice.note_index(repitch.step) - written + if shift == carried: + continue + + carried = shift + position = voice.table.position_at( + timing.ticks_across( + note.place.order_position, + note.place.row_index, + repitch.rows, + frames=frames, + ) + ) + cells[(repitch.place.order_position, repitch.place.row_index)] = TransposePlan._attached( + voice, + shift, + position, + shelf, + ) + + return cells + + @staticmethod + def _attached( + voice: SliceVoice, + shift: int, + position: int, + shelf: _TableShelf, + ) -> TransposeCell: + """The cell attaching the slice's table moved by ``shift`` at the step ``position``. + + An ornament position places the moved table where it names a step, and a table opening on + the step itself carries a later one. + """ + if position <= MAX_ORNAMENT_POSITION: + table = shelf.table(voice, shift, FIRST_TABLE_STEP) + return TransposeCell(table=table.id + TABLE_COLUMN_OFFSET, effects=_ornament_position(position)) + + table = shelf.table(voice, shift, position) + return TransposeCell(table=table.id + TABLE_COLUMN_OFFSET, effects=NO_EFFECTS) diff --git a/src/sampletones_core/formats/bitphase/truncation.py b/src/sampletones_core/formats/bitphase/truncation.py new file mode 100644 index 000000000..e06237b02 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/truncation.py @@ -0,0 +1,68 @@ +from typing import Optional + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.truncation import EnvelopeTruncation, instrument_truncation +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.macros import stored_envelope + + +def document_stored_length(feature_key: FeatureKey, envelope: Envelope[int]) -> int: + """The values a ``.btp`` document holds of one dimension. + + The arpeggio rides a table, which holds a contour of any length, and every other dimension + rides a macro, which holds its opening values. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + int: The values the document stores. + """ + if feature_key is FeatureKey.ARPEGGIO: + return len(envelope.items) + + return len(stored_envelope(feature_key, envelope).items) + + +def preset_stored_length(feature_key: FeatureKey, envelope: Envelope[int]) -> int: + """The values an instrument preset holds of one dimension. + + A preset carries macros alone, and the contour folds into the tone offset beside the bend, so + every dimension keeps the opening values a macro holds. + + Args: + feature_key: The dimension being written. + envelope: The dimension as the instrument carries it. + + Returns: + int: The values the preset stores. + """ + return len(stored_envelope(feature_key, envelope).items) + + +def document_truncation(features: Features) -> Optional[EnvelopeTruncation]: + """What a ``.btp`` document leaves out of one instrument's envelopes. + + Args: + features: The per-dimension envelopes describing the instrument. + + Returns: + Optional[EnvelopeTruncation]: The shortening the document imposes, and ``None`` where + every dimension is held whole. + """ + return instrument_truncation(features, document_stored_length) + + +def preset_truncation(features: Features) -> Optional[EnvelopeTruncation]: + """What an instrument preset leaves out of one instrument's envelopes. + + Args: + features: The per-dimension envelopes describing the instrument. + + Returns: + Optional[EnvelopeTruncation]: The shortening the preset imposes, and ``None`` where every + dimension is held whole. + """ + return instrument_truncation(features, preset_stored_length) diff --git a/src/sampletones_core/formats/bitphase/tuning.py b/src/sampletones_core/formats/bitphase/tuning.py index 7c907759d..ebe11c88e 100644 --- a/src/sampletones_core/formats/bitphase/tuning.py +++ b/src/sampletones_core/formats/bitphase/tuning.py @@ -1,14 +1,17 @@ import math -from typing import Tuple +from typing import Final, Tuple from sampletones_core.formats.bitphase.specification.chip import ( + DEFAULT_A4_TUNING, + DEFAULT_CPU_FREQUENCY, MAX_TUNING_PERIOD, MIN_TUNING_PERIOD, TUNING_A4_INDEX, TUNING_PERIOD_DIVISOR, TUNING_TABLE_LENGTH, ) -from sampletones_core.formats.bitphase.specification.patterns import NOTE_RANGE +from sampletones_core.formats.bitphase.specification.patterns import NOTE_INDEX_PITCH_OFFSET, NOTE_RANGE +from sampletones_shared.music import Tuning def generate_tuning_table( @@ -39,3 +42,24 @@ def generate_tuning_table( periods.append(max(MIN_TUNING_PERIOD, min(max_period, period))) return tuple(periods) + + +def concert_frequency(tuning: Tuning) -> float: + """The frequency a song's concert-pitch index sounds at under a tuning. + + A Bitphase song states its tuning as the frequency of the note at index 45, which is pitch 69, + so a tuning whose reference names another pitch is read out at that one. + + Args: + tuning: Where concert pitch sits for the work being written. + + Returns: + float: The frequency in Hz the song's ``a4TuningHz`` carries. + """ + return tuning.frequency(TUNING_A4_INDEX + NOTE_INDEX_PITCH_OFFSET) + + +DEFAULT_TUNING_TABLE: Final[Tuple[int, ...]] = generate_tuning_table( + DEFAULT_CPU_FREQUENCY, + a4_tuning=DEFAULT_A4_TUNING, +) diff --git a/src/sampletones_core/formats/bitphase/voices.py b/src/sampletones_core/formats/bitphase/voices.py new file mode 100644 index 000000000..fa03c70b9 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/voices.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Dict, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.pitch import highest_step, written_pitch +from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.notes import noise_period_to_note_index, pitch_to_note_index + + +@dataclass(frozen=True) +class SliceVoice: + """One built instrument together with the table and the note that triggers it. + + Attributes: + number: Value a pattern's instrument column carries to play the instrument. + instrument: The macros the channel reads a value per tick from. + table: The per-tick semitone contour that moves the note. + channel: The NES channel the slice was reconstructed for. + initial_pitch: Pitch the slice's contour is measured against. + ticks: How many ticks the instrument runs before every dimension stands at its end. + """ + + number: int + instrument: BitphaseInstrument + table: BitphaseTable + channel: ChannelName + initial_pitch: int + ticks: int + + @property + def contour_top(self) -> int: + """The highest semitone step the slice's table moves its note by.""" + return highest_step(self.table.rows) + + def note_index(self, transpose: int) -> int: + """The note index a row triggering the slice at ``transpose`` writes. + + The noise channel reads its note as a period selector, so its transposed period takes the + index that sounds that period. Every other channel reads the tuning table at the note its + table moves, so the transposed pitch is written at the note that keeps the contour where the + song plays it — see :func:`written_pitch`. + + Args: + transpose: The semitone offset the row moves the slice by, or the period offset on noise. + + Returns: + int: The index the row's note column names. + """ + pitch = self.initial_pitch + transpose + if self.channel == ChannelName.NOISE: + return noise_period_to_note_index(pitch) + + return pitch_to_note_index(written_pitch(pitch, self.contour_top)) + + +SliceVoiceTable = Dict[Tuple[str, ChannelName], SliceVoice] diff --git a/src/sampletones_core/formats/famitracker/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..82d987713 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -1,12 +1,16 @@ -from typing import List, Optional, Tuple +from typing import Final, FrozenSet, List, Mapping, Optional, Sequence, 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.rows.levels import cell_volume, full_level_notes +from sampletones_core.exporters.rows.transpose import PatternCell, PitchWalk, unreached_start +from sampletones_core.exporters.skipped import BuiltDocument, find_skipped_rows, in_song_order from sampletones_core.exporters.slices import ( - InstrumentSlot, + InstrumentEntry, InstrumentTable, - iterate_sample_slices, + iterate_instrument_entries, ) +from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 from sampletones_core.formats.famitracker.model.module import ( FamiTrackerModule, @@ -16,24 +20,21 @@ Track, ) from sampletones_core.formats.famitracker.model.pattern import PatternData, RowCell -from sampletones_core.formats.famitracker.notes import ( - period_to_note_cell, - pitch_to_note_cell, - resolve_machine, -) +from sampletones_core.formats.famitracker.notes import resolve_machine from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, + features_truncation, ) +from sampletones_core.formats.famitracker.slides import SlidePlan, slide_effect 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,12 +54,18 @@ MIN_OCTAVE, NoteValue, ) -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.formats.famitracker.starts import StartPlan +from sampletones_core.formats.famitracker.targets import RowTargets, row_targets from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project 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.voice import VoiceLookup +from sampletones_shared.application import SAMPLETONES_COPYRIGHT + +NO_REPITCHED_INSTRUMENTS: Final[FrozenSet[int]] = frozenset() def build_instrument( @@ -66,30 +73,23 @@ def build_instrument( name: str, features: Features, *, - loop: bool, + repitched: 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. + repitched: Whether a transpose row's note slide reaches the instrument in a module. Returns: The instrument the envelopes describe. """ - sequences = features_to_instrument_sequences( - 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, repitched=repitched) return Instrument2A03( index=index, @@ -99,77 +99,95 @@ 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; an instrument contributes one, which every channel it sounds on reaches, each + against that channel's own root. Instruments are numbered in voice order, then channel order. + + Raises: + ValueError: If the project holds more instruments than FamiTracker has room for. + """ + return _instrument_table(tuple(iterate_instrument_entries(project)), NO_REPITCHED_INSTRUMENTS) + - 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. +def _instrument_table( + entries: Sequence[InstrumentEntry], + repitched: FrozenSet[int], +) -> Tuple[List[Instrument2A03], InstrumentTable]: + """Builds the instruments the entries describe and the table a pattern row resolves through. + + Args: + entries: The instruments to build, in the order the module numbers them. + repitched: The instruments a transpose row's note slide reaches. + + Raises: + ValueError: If the entries hold more instruments than FamiTracker has room for. """ instruments: List[Instrument2A03] = [] slots: InstrumentTable = {} - for sample_slice in iterate_sample_slices(project): - if sample_slice.index >= MAX_INSTRUMENTS: + for entry in entries: + 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, + repitched=entry.index in repitched, ) ) - 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, - slot: InstrumentSlot, -) -> Tuple[int, int]: - base_pitch = slot.initial_pitch + transpose - if channel_generator == GeneratorName.NOISE: - cell = period_to_note_cell(base_pitch) - else: - cell = pitch_to_note_cell(base_pitch) - - return cell.note, cell.octave - - def _row_cell( row: Row, row_number: int, - channel_generator: GeneratorName, - slots: InstrumentTable, + channel_generator: ChannelName, + targets: RowTargets, + slide: Optional[int], + start: Optional[int], + *, + full_level: bool, ) -> Optional[RowCell]: + """Converts one tracker line to the cell that plays it, and ``None`` where the line is empty. + + A note-on naming a voice with no instrument on this channel plays nothing in the song, so it + becomes the note cut that silences the channel. A note-on starts the voice at the step ``start`` + the song's walk gave it, so an instrument placed without a pitch writes the note the channel was + sounding, which FamiTracker would otherwise leave standing; a note-on that started nothing + writes an empty note. The volume column states what :func:`cell_volume` gives the row, which is + the full level on a note FamiTracker would otherwise start at the level the channel carries. A + pitch row moving the note sounding writes the note slide ``slide`` names — see :class:`SlidePlan`. + """ note = EMPTY_NOTE octave = MIN_OCTAVE instrument = EMPTY_INSTRUMENT - volume = row.volume if row.volume is not None else EMPTY_VOLUME + stated_volume = cell_volume(row, channel_generator, full_level=full_level) + volume = stated_volume if stated_volume is not None else EMPTY_VOLUME match row.command: case NoteOff(): note = int(NoteValue.HALT) - case Instrument() as reference: - slot = slots.get((reference.sample_id, reference.generator_name)) - if slot is None: - raise ValueError( - f"Row references sample '{reference.sample_id}' slice " - f"'{reference.generator_name}' that has no instrument" - ) - instrument = slot.index - note, octave = _note_and_octave( - row.transpose or 0, - channel_generator, - slot, - ) + case NoteOn() as reference: + target = targets.get((reference.voice_id, channel_generator)) + if target is None: + note = int(NoteValue.HALT) + elif start is not None: + instrument = target.slot.index + cell_note = target.note_cell(start, channel_generator) + note, octave = cell_note.note, cell_note.octave case None: pass effects = tuple((EMPTY_EFFECT, EMPTY_EFFECT_PARAM) for _ in range(DEFAULT_EFFECT_COLUMNS)) + if slide is not None: + effects = (slide_effect(slide),) + effects[1:] cell = RowCell( row_number=row_number, note=note, @@ -190,12 +208,31 @@ def _has_data(cell: RowCell) -> bool: ) +def _full_level_cells(song: Song, channel_name: ChannelName) -> FrozenSet[PatternCell]: + """The pattern cells of one channel that write the full level, by pattern index and row. + + A module stores a pattern once for every frame that plays it, so a cell writes the full level + wherever any of those frames reaches its note carrying another, which FamiTracker plays as the + frames come and again from the first once the order ends. + """ + return frozenset((place.pattern_index, place.row_index) for place in full_level_notes(song, channel_name)) + + def _channel_patterns( - generator: GeneratorName, + name: ChannelName, channel: Channel, - slots: InstrumentTable, + targets: RowTargets, + full_level_cells: FrozenSet[PatternCell], + slides: Mapping[PatternCell, int], + starts: Mapping[PatternCell, Optional[int]], + voices: VoiceLookup, ) -> List[PatternData]: - channel_id = GENERATOR_NAME_TO_CHANNEL_ID[generator] + """Converts one channel's pattern pool, writing the full level, the note slides and the starts on the cells named. + + A cell the order never reaches starts its note as the song would from silence — see + :func:`unreached_start`. + """ + channel_id = CHANNEL_TO_ID[name] patterns: List[PatternData] = [] for index in sorted(channel.patterns): @@ -209,8 +246,11 @@ def _channel_patterns( _row_cell( row, row_number, - generator, - slots, + name, + targets, + slides.get((index, row_number)), + starts.get((index, row_number), unreached_start(row, name, voices)), + full_level=(index, row_number) in full_level_cells, ) for row_number, row in enumerate(pattern.rows) ) @@ -239,17 +279,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)) @@ -259,8 +299,33 @@ def _build_order(song: Song) -> Tuple[OrderFrame, ...]: def project_to_module(project: Project) -> FamiTrackerModule: """Maps a project's samples and song onto the FamiTracker module IR.""" - instruments, slots = build_instrument_table(project) + return build_module(project).document + + +def build_module(project: Project) -> BuiltDocument[FamiTrackerModule]: + """Maps a project onto the FamiTracker module IR and lists what it had to leave out. + + A row naming a voice on a channel the voice has no instrument for plays nothing in the song, + so the module holds a note cut there and the row is listed beside it. A dimension longer than a + sequence holds keeps its opening items, and the instruments shortened that way are reported + beside the rows. The rows write their notes and levels so FamiTracker plays them where the + song's own walk does. A row moving the transpose of a note already sounding writes a note slide, + and a row the module has no slide for is listed beside the others — see :class:`SlidePlan`. An + instrument placed without a pitch writes the note the channel was sounding where the first frame + reaching its cell sounds it, and a frame sounding another there is listed too — see + :class:`StartPlan`. + + Raises: + ValueError: If the project holds more than FamiTracker has room for. + """ + entries = tuple(iterate_instrument_entries(project)) song = project.song + plain_instruments, slots = _instrument_table(entries, NO_REPITCHED_INSTRUMENTS) + targets = row_targets(plain_instruments, slots) + walks = {channel: PitchWalk.walk(song, channel, targets, project.voice) for channel in ChannelName.items()} + slides = SlidePlan.build(walks, targets) + starts = StartPlan.build(walks) + instruments, _ = _instrument_table(entries, slides.repitched) settings = project.settings info = project.info @@ -278,16 +343,20 @@ 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], - slots, + channel, + song.channels[channel], + targets, + _full_level_cells(song, channel), + slides.slides[channel], + starts.starts[channel], + project.voice, ), ) @@ -301,10 +370,14 @@ def project_to_module(project: Project) -> FamiTrackerModule: effect_columns={channel_id: DEFAULT_EFFECT_COLUMNS for channel_id in ChannelId}, ) - return FamiTrackerModule( - parameters=parameters, - information=information, - instruments=tuple(instruments), - track=track, - comment=info.comment, + return BuiltDocument( + document=FamiTrackerModule( + parameters=parameters, + information=information, + instruments=tuple(instruments), + track=track, + comment=info.comment, + ), + skipped_rows=in_song_order(find_skipped_rows(song, slots) + slides.skipped_rows + starts.skipped_rows), + truncation=EnvelopeTruncation.summarize([features_truncation(entry.features) for entry in entries]), ) diff --git a/src/sampletones_core/formats/famitracker/export.py b/src/sampletones_core/formats/famitracker/export.py index 16a18538a..e083a1e97 100644 --- a/src/sampletones_core/formats/famitracker/export.py +++ b/src/sampletones_core/formats/famitracker/export.py @@ -1,11 +1,20 @@ -from sampletones_core.formats.famitracker.builder import project_to_module +from sampletones_core.exporters.skipped import BuiltDocument +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule from sampletones_core.formats.famitracker.module import module_to_ftm_bytes from sampletones_core.project.project import Project from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import save_binary -def write_ftm(filepath: Pathlike, project: Project) -> None: - """Exports a project to a FamiTracker ``.ftm`` module file.""" - module = project_to_module(project) - save_binary(filepath, module_to_ftm_bytes(module)) +def write_ftm(filepath: Pathlike, project: Project) -> BuiltDocument[FamiTrackerModule]: + """Exports a project to a FamiTracker ``.ftm`` module file. + + Returns: + BuiltDocument[FamiTrackerModule]: The module written, the rows written as a note cut + because their voice has no instrument on the channel, and the instruments a sequence + shortened. + """ + built = build_module(project) + save_binary(filepath, module_to_ftm_bytes(built.document)) + return built diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index 481ad4941..d29c60058 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -1,13 +1,9 @@ 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 -from sampletones_core.formats.famitracker.sequences.features import ( - features_to_instrument_sequences, -) +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.specification.memory import ( INSTRUMENT_DEFINITION_BYTES, SEQUENCE_HEADER_BYTES, @@ -19,12 +15,13 @@ @dataclass(frozen=True) class InstrumentFootprint: - """The bytes an instrument occupies once FamiTracker compiles it into an NSF. + """The raw bytes an instrument's envelopes take, laid out the way FamiTracker's driver lays them out. The two fields are the two regions the driver keeps an instrument in, which FamiTracker's own export log reports side by side: the instrument list and body under ``instrument_bytes``, - the sequence chunks the body points at under ``sequence_bytes``. See - `docs/formats/famitracker.md` for the layout each figure counts. + the sequence chunks the body points at under ``sequence_bytes``. Every item an envelope + carries is counted, so the figure is the size of the sound's data before any compression. + See `docs/formats/famitracker.md` for the layout each figure counts. Attributes: instrument_bytes: Bytes the instrument's table entry and body occupy. @@ -40,66 +37,33 @@ def total_bytes(self) -> int: return self.instrument_bytes + self.sequence_bytes -def sequence_footprint(sequence: InstrumentSequence) -> int: - """Measures the bytes one sequence chunk occupies: its four-field header and its items.""" - return SEQUENCE_HEADER_BYTES + SEQUENCE_ITEM_BYTES * len(sequence.items) - - -def sequences_footprint( - sequences: Iterable[InstrumentSequence], -) -> InstrumentFootprint: - """Measures the instrument the given sequences make up. - - A populated sequence earns the instrument a pointer to its chunk and contributes the chunk - itself; an empty one is written as a disabled slot the driver stores nothing for, so the - populated sequences alone decide both figures. - """ - populated = [sequence for sequence in sequences if sequence.enabled] - return InstrumentFootprint( - instrument_bytes=INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES * len(populated), - sequence_bytes=sum(sequence_footprint(sequence) for sequence in populated), - ) - +def envelope_footprint(envelope: Envelope[int]) -> int: + """Measures the bytes one envelope occupies as a sequence chunk: its four-field header and its items.""" + return SEQUENCE_HEADER_BYTES + SEQUENCE_ITEM_BYTES * len(envelope.items) -def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint: - """Measures one built instrument, the form an export writes.""" - return sequences_footprint(instrument.sequences.values()) +def features_footprint(features: Features) -> InstrumentFootprint: + """Measures the instrument a channel slice's envelopes make up, every item they carry counted. -def features_footprint( - features: Features, - *, - loop: bool, -) -> InstrumentFootprint: - """Measures the instrument a generator 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 - sequence holds. + A written envelope earns the instrument a pointer to its chunk and contributes the chunk at + its whole length; a dimension left to the channel is a disabled slot the driver stores + nothing for. The figure describes the envelopes as the application plays them, and an export + to FamiTracker shapes them to its own sequences (see `docs/formats/famitracker.md`). 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, + written = [envelope for envelope in features.envelopes.values() if envelope.written] + return InstrumentFootprint( + instrument_bytes=INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES * len(written), + sequence_bytes=sum(envelope_footprint(envelope) for envelope in written), ) - 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 +72,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..cc75d43c2 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) @@ -153,15 +153,15 @@ def _write_patterns_block(writer: BinaryWriter, patterns: Sequence[PatternData]) body.write_int8(row.volume) for effect, param in row.effects: body.write_int8(effect) - body.write_int8(param) + body.write_uint8(param) -def _write_dpcm_samples_block(writer: 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..db43eec8e 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -1,77 +1,219 @@ -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, + instrument_truncation, +) +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.limits import within_limit 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, ) +NO_ARPEGGIO_STEP: Final[int] = 0 +NO_BEND_STEP: Final[int] = 0 +FLAT_RUNNING_ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(NO_ARPEGGIO_STEP,), loop_point=LOOP_FROM_START) + + +def features_to_instrument_sequences( + features: Features, + *, + repitched: bool, +) -> Dict[SequenceKind, InstrumentSequence]: + """Builds the five 2A03 sequences from a channel slice's envelopes. -def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: - if array is None: - return () - return tuple(int(value) for value in array) + 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:`_pinned`. An instrument a note slide reaches keeps its arpeggio and + its bend running for as long as the note sounds — see :func:`_running`. -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: + features: The per-dimension envelopes describing the slice. + repitched: Whether a transpose row's note slide reaches the instrument in a module. - 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, InstrumentSequence]: The sequences, one per dimension FamiTracker holds. """ - 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) + stored = _pinned(_stored_envelopes(features)) + if repitched: + stored = _running(stored) - return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS) + return {kind: _sequence(kind, stored.get(kind, Envelope[int]())) for kind in SequenceKind} -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. +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, and :func:`within_limit` is the rule every format shortens + a dimension by. + + 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. + """ + return within_limit(feature_key, envelope, MAX_SEQUENCE_ITEMS) + + +def stored_length(feature_key: FeatureKey, envelope: Envelope[int]) -> int: + """The items a FamiTracker file holds of one dimension. + + A reader watching an envelope grow and an export reporting what it wrote read this one 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: + int: The items its sequence stores. + """ + return len(stored_envelope(feature_key, 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. """ - arrays: Dict[SequenceKind, Optional[np.ndarray]] = { - SequenceKind.VOLUME: volume, - SequenceKind.ARPEGGIO: arpeggio, - SequenceKind.PITCH: pitch, - SequenceKind.HI_PITCH: hi_pitch, - SequenceKind.DUTY: duty_cycle, + return instrument_truncation(features, stored_length) + + +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 acts. + + FamiTracker walks an instrument's sequences in slot order, and an arpeggio in absolute mode + reloads the period from the note before the bend sequences add to it. So while the arpeggio + 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. + + A bend that repeats, or ends on an offset it then holds, acts for as long as the note sounds, + so the arpeggio and the bend both run on (see :func:`_running`). A bend ending with no offset + acts until its last item, which an arpeggio reaching its length covers. + + Args: + stored: The sequences as the file holds them. + + Returns: + Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio covering the bend. + """ + bends = [bend for kind in BEND_SEQUENCE_KINDS if (bend := stored.get(kind, Envelope[int]())).items] + if not bends: + return stored + + if any(_acts_throughout(bend) for bend in bends): + return _running(stored) + + bend_length = max(len(bend.items) for bend in bends) + return {**stored, SequenceKind.ARPEGGIO: _pinning_arpeggio(stored, bend_length)} + + +def _acts_throughout(bend: Envelope[int]) -> bool: + """Whether a bend offsets the note for as long as it sounds: it repeats, or it holds an offset when it ends.""" + return bend.loops or bend.items[-1] != NO_BEND_STEP + + +def _pinning_arpeggio(stored: Dict[SequenceKind, Envelope[int]], bend_length: int) -> Envelope[int]: + """The arpeggio that reloads the note for every tick a bend ending with no offset states one for. + + An arpeggio circling from a point runs for as long as the note sounds and needs nothing; one + playing its items once holds its final note over the remaining ticks, which is the note it + 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. + """ + arpeggio = stored.get(SequenceKind.ARPEGGIO, Envelope[int]()) + if arpeggio.loops: + return arpeggio + + if not arpeggio.items: + return FLAT_RUNNING_ARPEGGIO + + return arpeggio.resized(max(len(arpeggio.items), bend_length)) + + +def _running(stored: Dict[SequenceKind, Envelope[int]]) -> Dict[SequenceKind, Envelope[int]]: + """These sequences with the arpeggio and the bend circling on their last item, so each runs as long as the note. + + A note slide moves the channel's note, and an arpeggio in absolute mode reloads the period from the + note every tick it runs, so the moved note sounds from the slide's own tick. A halted arpeggio + reloads nothing and the slide's glide is heard instead, so the arpeggio circles on its last item, + which is the note it rests on anyway, and an instrument writing none takes one item at its own + note. The bend is added to the period the arpeggio reloads, and a halted bend adds nothing, so a + bend circles on its last item too and holds the offset it ends on. + + Args: + stored: The sequences as the file holds them, the arpeggio already covering the bend. + + Returns: + Dict[SequenceKind, Envelope[int]]: Those sequences, the arpeggio and the bend running. + """ + arpeggio = stored.get(SequenceKind.ARPEGGIO, Envelope[int]()) + running = { + **stored, + SequenceKind.ARPEGGIO: _circling(arpeggio) if arpeggio.items else FLAT_RUNNING_ARPEGGIO, } + for kind in BEND_SEQUENCE_KINDS: + bend = stored.get(kind, Envelope[int]()) + if bend.items: + running[kind] = _circling(bend) - items_by_kind = _sequence_items(arrays, loop) + return running + + +def _circling(envelope: Envelope[int]) -> Envelope[int]: + """The envelope repeating from its own point, or from its last item where it plays once and holds it.""" + if envelope.loops: + return envelope + + return Envelope[int](items=envelope.items, loop_point=len(envelope.items) - 1) + + +def _stored_envelopes(features: Features) -> Dict[SequenceKind, Envelope[int]]: + """Each dimension the slice offers, as the file holds it.""" + return { + FEATURE_KEY_TO_SEQUENCE_KIND[feature_key]: stored_envelope(feature_key, envelope) + for feature_key, envelope in features.envelopes.items() + } - 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, - ) - 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/slides.py b/src/sampletones_core/formats/famitracker/slides.py new file mode 100644 index 000000000..c7434365c --- /dev/null +++ b/src/sampletones_core/formats/famitracker/slides.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Final, FrozenSet, List, Mapping, Set, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.transpose import PatternCell, PitchWalk, Repitch, SoundingNote +from sampletones_core.exporters.skipped import SkippedRow, SkipReason +from sampletones_core.formats.famitracker.specification.patterns import ( + FASTEST_SLIDE_SPEED, + MAX_SLIDE_SEMITONES, + SLIDE_SPEED_SHIFT, + EffectId, +) +from sampletones_core.formats.famitracker.targets import RowTargets + +NO_SLIDE: Final[int] = 0 + +SlideEffect = Tuple[int, int] + + +def slide_effect(semitones: int) -> SlideEffect: + """The effect column moving a channel's note by ``semitones`` at the highest speed: ``Qxy`` up, ``Rxy`` down. + + Args: + semitones: How far the note moves, within ``MAX_SLIDE_SEMITONES`` either way. + + Returns: + SlideEffect: The effect number and its parameter. + """ + effect = EffectId.SLIDE_UP if semitones > NO_SLIDE else EffectId.SLIDE_DOWN + return int(effect), (FASTEST_SLIDE_SPEED << SLIDE_SPEED_SHIFT) | abs(semitones) + + +@dataclass +class _ChannelSlides: + """One channel's pass through the notes transpose rows move, deciding each pattern cell once. + + A module stores a pattern once for every frame that plays it, so a cell carries one slide + wherever the order reaches it. The first frame reaching a cell while a note sounds decides it, + and a later frame needing another slide there is reported. + """ + + channel_name: ChannelName + targets: RowTargets + decided: Dict[PatternCell, int] = field(default_factory=dict) + skipped: List[SkippedRow] = field(default_factory=list) + repitched: Set[int] = field(default_factory=set) + + def follow(self, note: SoundingNote) -> None: + """Moves the note the channel holds through each transpose row reaching it. + + FamiTracker keeps the note a slide leaves, so each row's slide is measured from the note the + channel holds once the slides before it applied, and a row the module leaves without its + slide leaves the note where it was for the rows after it. + """ + target = self.targets[(note.voice_id, self.channel_name)] + held = target.cell_pitch(note.step, self.channel_name) + for repitch in note.repitches: + needed = target.cell_pitch(repitch.step, self.channel_name) - held + applied = self._decide(repitch, needed) + if applied != needed: + self._report(note, repitch) + + if applied != NO_SLIDE: + self.repitched.add(target.slot.index) + + held += applied + + def _decide(self, repitch: Repitch, needed: int) -> int: + """The slide the row's cell carries, decided by the first frame that reaches it with a note.""" + cell = (repitch.place.pattern_index, repitch.place.row_index) + if cell not in self.decided: + self.decided[cell] = needed if abs(needed) <= MAX_SLIDE_SEMITONES else NO_SLIDE + + return self.decided[cell] + + def _report(self, note: SoundingNote, repitch: Repitch) -> None: + self.skipped.append( + SkippedRow( + voice_id=note.voice_id, + channel=self.channel_name, + order_position=repitch.place.order_position, + row_index=repitch.place.row_index, + reason=SkipReason.UNREACHED_TRANSPOSE, + ) + ) + + @property + def slides(self) -> Dict[PatternCell, int]: + """The cells that move the note, by pattern index and row.""" + return {cell: semitones for cell, semitones in self.decided.items() if semitones != NO_SLIDE} + + +@dataclass(frozen=True) +class SlidePlan: + """The note slides a module's transpose rows write, the rows it leaves without one, and the instruments they reach. + + FamiTracker's ``Qxy`` and ``Rxy`` move the channel's note by up to fifteen semitones at once and glide + the period toward it. While an instrument's arpeggio in absolute mode runs, it reloads the period + from the note every tick, which makes the move instant and exact. A transpose row therefore writes + the slide from the note the channel holds to the note a note-on at the row's transpose would write, + at the highest speed, and every instrument a slide reaches keeps its arpeggio running — see + :func:`features_to_instrument_sequences`. On noise the note is the period, and both notes lie within + the sixteen periods, so every noise slide is within reach. + + Attributes: + slides: Per channel, the semitones each pattern cell moves the note by, keyed by pattern index + and row. + skipped_rows: The transpose rows the module plays without their slide, once for every frame + that plays one so. + repitched: The instruments a slide reaches. + """ + + slides: Dict[ChannelName, Dict[PatternCell, int]] + skipped_rows: Tuple[SkippedRow, ...] + repitched: FrozenSet[int] + + @classmethod + def build(cls, walks: Mapping[ChannelName, PitchWalk], targets: RowTargets) -> SlidePlan: + """Plans every transpose row of the song, following the notes the order sounds. + + A row moving the note further than a slide reaches, or a cell of a pattern several frames play + needing another slide than the frame that decided it, is written without it and reported. + + Args: + walks: Each channel's pass through the song, as playback makes it. + targets: What a row naming a voice on a channel triggers. + + Returns: + SlidePlan: The slides, the rows left without one, and the instruments the slides reach. + """ + slides: Dict[ChannelName, Dict[PatternCell, int]] = {} + skipped: List[SkippedRow] = [] + repitched: Set[int] = set() + for channel_name in ChannelName.items(): + channel = _ChannelSlides(channel_name=channel_name, targets=targets) + for note in walks[channel_name].notes: + channel.follow(note) + + slides[channel_name] = channel.slides + skipped.extend(channel.skipped) + repitched |= channel.repitched + + return cls( + slides=slides, + skipped_rows=tuple(skipped), + repitched=frozenset(repitched), + ) diff --git a/src/sampletones_core/formats/famitracker/specification/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/patterns.py b/src/sampletones_core/formats/famitracker/specification/patterns.py index 6ec10ea7f..bcbd4c1a3 100644 --- a/src/sampletones_core/formats/famitracker/specification/patterns.py +++ b/src/sampletones_core/formats/famitracker/specification/patterns.py @@ -19,6 +19,25 @@ class NoteValue(IntEnum): HALT = 14 +class EffectId(IntEnum): + """Effect number a pattern cell stores, as vanilla FamiTracker 0.4.6 numbers its effects. + + ``SLIDE_UP`` is ``Qxy`` and ``SLIDE_DOWN`` is ``Rxy``: each moves the channel's note by ``y`` + semitones at once and glides the period toward it at ``2x + 1`` units a tick. A module below the + 0CC version stores these numbers verbatim (``EF_SLIDE_UP``, ``EF_SLIDE_DOWN``). ``DAC`` is + ``Zxx`` (``EF_DAC``): it loads ``xx``, at most ``MAX_DAC_LEVEL``, into the DMC's output level. + """ + + DAC = 15 + SLIDE_UP = 20 + SLIDE_DOWN = 21 + + +MAX_SLIDE_SEMITONES: Final[int] = 0x0F +MAX_DAC_LEVEL: Final[int] = 0x7F +FASTEST_SLIDE_SPEED: Final[int] = 0x0F +SLIDE_SPEED_SHIFT: Final[int] = 4 + EMPTY_NOTE: Final[int] = 0 EMPTY_INSTRUMENT: Final[int] = 0x40 EMPTY_VOLUME: Final[int] = 0x10 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/starts.py b/src/sampletones_core/formats/famitracker/starts.py new file mode 100644 index 000000000..aeec53b47 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/starts.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Mapping, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.rows.levels import RowPlace +from sampletones_core.exporters.rows.transpose import NoteStart, PatternCell, PitchWalk +from sampletones_core.exporters.skipped import SkippedRow, SkipReason + + +@dataclass +class _ChannelStarts: + """One channel's pass through the note-ons the order reaches, deciding each pattern cell once.""" + + channel_name: ChannelName + decided: Dict[PatternCell, Optional[int]] = field(default_factory=dict) + skipped: List[SkippedRow] = field(default_factory=list) + + def read(self, place: RowPlace, start: NoteStart) -> None: + """Takes what a note-on started in one frame, the first frame reaching its cell deciding it.""" + cell = (place.pattern_index, place.row_index) + if cell not in self.decided: + self.decided[cell] = start.step + return + + if self.decided[cell] != start.step: + self.skipped.append( + SkippedRow( + voice_id=start.voice_id, + channel=self.channel_name, + order_position=place.order_position, + row_index=place.row_index, + reason=SkipReason.CARRIED_PITCH, + ) + ) + + +@dataclass(frozen=True) +class StartPlan: + """The note every note-on cell of a module starts at, and the frames that reach a cell needing another. + + A module stores a pattern once for every frame that plays it, so a note-on cell writes one note. A + row stating a pitch starts at it in every frame, and a sample stating none starts as recorded, so + every frame agrees on those cells. An instrument stating no pitch starts on the pitch its channel + is sounding, which the frames before decide, so a pattern the order plays after different pitches + needs a different note in that cell per frame. The first frame reaching the cell decides its + note, and every later frame needing another is reported. + + Attributes: + starts: Per channel, the step each note-on cell starts at, keyed by pattern index and row, + and ``None`` where the cell starts nothing. + skipped_rows: The note-ons the module plays at another pitch than the song, once for every + frame that plays one so. + """ + + starts: Dict[ChannelName, Dict[PatternCell, Optional[int]]] + skipped_rows: Tuple[SkippedRow, ...] + + @classmethod + def build(cls, walks: Mapping[ChannelName, PitchWalk]) -> StartPlan: + """Decides every note-on cell of the song from the frames the order plays it in. + + Args: + walks: Each channel's pass through the song, as playback makes it. + + Returns: + StartPlan: The step each cell starts at, and the frames written at another. + """ + starts: Dict[ChannelName, Dict[PatternCell, Optional[int]]] = {} + skipped: List[SkippedRow] = [] + for channel_name in ChannelName.items(): + channel = _ChannelStarts(channel_name=channel_name) + for place, start in walks[channel_name].starts.items(): + channel.read(place, start) + + starts[channel_name] = channel.decided + skipped.extend(channel.skipped) + + return cls(starts=starts, skipped_rows=tuple(skipped)) diff --git a/src/sampletones_core/formats/famitracker/targets.py b/src/sampletones_core/formats/famitracker/targets.py new file mode 100644 index 000000000..1b2862545 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/targets.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass +from typing import Dict, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.exporters.rows.pitch import highest_step, written_pitch +from sampletones_core.exporters.slices import InstrumentSlot, InstrumentTable +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.pattern import NoteCell +from sampletones_core.formats.famitracker.notes import period_to_note_cell, pitch_to_note_cell +from sampletones_core.formats.famitracker.specification.patterns import FT_MAX_PITCH, FT_MIN_PITCH +from sampletones_core.formats.famitracker.specification.sequences import SequenceKind + + +@dataclass(frozen=True) +class RowTarget: + """The instrument a row naming a voice on one channel triggers, with what places its note. + + Attributes: + slot: The instrument's position in the module and the reference a row's transpose steps from. + contour_top: The highest semitone step the instrument's arpeggio moves the note by. + """ + + slot: InstrumentSlot + contour_top: int + + def cell_pitch(self, transpose: int, channel_name: ChannelName) -> int: + """The note a cell triggering the instrument at ``transpose`` names, which the channel then holds. + + The noise channel reads its note as a period, wrapped into the sixteen it has. Every other + channel reads the note its arpeggio moves, so the transposed pitch is written at the note that + keeps the contour where the song plays it — see :func:`written_pitch` — within the notes a + cell names. + + Args: + transpose: The row's transpose, measured from the instrument's reference. + channel_name: The channel the row stands on. + + Returns: + int: The pitch the cell names, or the period on noise. + """ + pitch = self.slot.initial_pitch + transpose + if channel_name == ChannelName.NOISE: + return pitch % NUM_PERIODS + + return max(FT_MIN_PITCH, min(FT_MAX_PITCH, written_pitch(pitch, self.contour_top))) + + def note_cell(self, transpose: int, channel_name: ChannelName) -> NoteCell: + """The note column a row triggering the instrument at ``transpose`` writes.""" + pitch = self.cell_pitch(transpose, channel_name) + if channel_name == ChannelName.NOISE: + return period_to_note_cell(pitch) + + return pitch_to_note_cell(pitch) + + +RowTargets = Dict[Tuple[str, ChannelName], RowTarget] + + +def row_targets( + instruments: Sequence[Instrument2A03], + slots: InstrumentTable, +) -> RowTargets: + """Pairs every slot a row resolves through with the highest step its instrument's arpeggio reaches. + + The arpeggio is read as the module stores it, so the step is one FamiTracker plays. + + Args: + instruments: The module's instruments. + slots: The slot a row naming a voice on a channel resolves through. + + Returns: + RowTargets: What each row naming a voice on a channel triggers. + """ + contour_tops = { + instrument.index: highest_step(instrument.sequences[SequenceKind.ARPEGGIO].items) for instrument in instruments + } + return { + key: RowTarget( + slot=slot, + contour_top=contour_tops[slot.index], + ) + for key, slot in slots.items() + } diff --git a/src/sampletones_core/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..715cef847 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, + FULL_SCALE_RMS_LEVELS, 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", + "CLASS_NAME_TO_GENERATOR_MAP", + "FULL_SCALE_RMS_LEVELS", "GENERATOR_CLASS_MAP", + "GENERATOR_TO_CLASS_NAME_MAP", "GENERATOR_TO_INSTRUCTION_MAP", "INSTRUCTION_TO_GENERATOR_MAP", - "LIBRARY_GENERATOR_CLASS_MAP", "MIXER_LEVELS", "Generator", "GeneratorClass", @@ -39,9 +44,11 @@ "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..99364772f 100644 --- a/src/sampletones_core/generators/implementation/pulse.py +++ b/src/sampletones_core/generators/implementation/pulse.py @@ -3,25 +3,32 @@ 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, MIN_PITCH, + MIN_SOUNDING_PULSE_TIMER, MIXER_PULSE, ) from sampletones_core.instructions import InstructionTypeUnion, PulseInstruction 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]): + """The square channel, one of the chip's two pulses. + + A timer below ``MIN_SOUNDING_PULSE_TIMER`` renders silence while the timer runs on. The sweep unit + mutes the channel there whatever the sweep's own setting, and the waveform keeps stepping. + """ + def __init__( self, config: Config, - name: str = GeneratorName.PULSE1, + name: str = ChannelName.PULSE1, ) -> None: super().__init__(config, name) self.timer = PhaseTimer( @@ -52,13 +59,10 @@ def __call__( self.save_state(save, pulse_instruction) - return output + if self.timer.timer < MIN_SOUNDING_PULSE_TIMER: + return np.zeros(self.frame_length, dtype=np.float32) - def set_timer(self, instruction: PulseInstruction) -> None: - if instruction.on: - self.timer.frequency = self.get_frequency(instruction.pitch) - else: - self.timer.frequency = 0.0 + return output def apply(self, output: np.ndarray, instruction: PulseInstruction) -> np.ndarray: duty_cycle = DUTY_CYCLES[instruction.duty_cycle] diff --git a/src/sampletones_core/generators/implementation/triangle.py b/src/sampletones_core/generators/implementation/triangle.py index 463ea4763..38d5f253d 100644 --- a/src/sampletones_core/generators/implementation/triangle.py +++ b/src/sampletones_core/generators/implementation/triangle.py @@ -3,11 +3,13 @@ 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, + MIN_SOUNDING_TRIANGLE_TIMER, MIXER_TRIANGLE, TRIANGLE_OFFSET, + TRIANGLE_PHASE_INCREMENT, ) from sampletones_core.instructions import ( InstructionTypeUnion, @@ -16,21 +18,27 @@ 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]): + """The triangle channel, a fixed-volume wave an octave below a pulse at the same divider. + + A timer below ``MIN_SOUNDING_TRIANGLE_TIMER`` renders the middle of the wave while the timer runs + on. The sequencer steps above hearing there, and the console's output settles at its mean level. + """ + def __init__( self, config: Config, - name: str = GeneratorName.TRIANGLE, + name: str = ChannelName.TRIANGLE, ) -> None: super().__init__(config, name) self.timer = PhaseTimer( sample_rate=config.library.sample_rate, nes_frequency=config.library.nes_frequency, reset_phase=config.generation.reset_phase, - phase_increment=0.5, + phase_increment=TRIANGLE_PHASE_INCREMENT, ) def __call__( @@ -54,13 +62,10 @@ def __call__( self.save_state(save, triangle_instruction) - return output + if self.timer.timer < MIN_SOUNDING_TRIANGLE_TIMER: + return np.zeros(self.frame_length, dtype=np.float32) - def set_timer(self, instruction: TriangleInstruction) -> None: - if instruction.on: - self.timer.frequency = self.get_frequency(instruction.pitch) - else: - self.timer.frequency = 0.0 + return output 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 diff --git a/src/sampletones_core/generators/maps.py b/src/sampletones_core/generators/maps.py index abc838b94..9bc35ed39 100644 --- a/src/sampletones_core/generators/maps.py +++ b/src/sampletones_core/generators/maps.py @@ -1,9 +1,10 @@ +import math 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 +23,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, } @@ -62,3 +68,9 @@ GeneratorClassName.TRIANGLE_GENERATOR: MIXER_TRIANGLE, GeneratorClassName.NOISE_GENERATOR: MIXER_NOISE, } + +FULL_SCALE_RMS_LEVELS: Final[Dict[GeneratorClassName, float]] = { + GeneratorClassName.PULSE_GENERATOR: MIXER_PULSE, + GeneratorClassName.TRIANGLE_GENERATOR: MIXER_TRIANGLE / math.sqrt(3.0), + GeneratorClassName.NOISE_GENERATOR: MIXER_NOISE, +} 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..9ec09aab1 --- /dev/null +++ b/src/sampletones_core/generators/tonal.py @@ -0,0 +1,115 @@ +from abc import ABC +from typing import Dict, Tuple, TypeVar + +from sampletones_core.configs import Config +from sampletones_core.instructions import TonalInstruction +from sampletones_core.timers import PhaseTimer, frequency_to_timer, timer_to_frequency +from sampletones_core.timers.arithmetic import bent_timer +from sampletones_core.timers.utils import get_timer_table +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 a bend reaches settled by :func:`bent_timer`. + """ + + 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() + } + self.played_timer_table: Dict[int, int] = { + **get_timer_table(config.library.tuning), + **self.timer_table, + } + + 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. + + A song plays notes below the range the library holds, down to C-0, and each sounds the divider + the tuning gives it, which below A-0 is the longest the register holds. + + 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 bent_timer(self.played_timer_table[pitch], offset) + + 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_toward(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..17c6b09ea 100644 --- a/src/sampletones_core/generators/utils.py +++ b/src/sampletones_core/generators/utils.py @@ -1,49 +1,28 @@ from typing import Dict, List from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName -from sampletones_core.instructions import ( - INSTRUCTION_CLASS_MAP, - InstructionUnion, -) +from sampletones_core.constants.enums import ChannelName, GeneratorClassName 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( config: Config, ) -> Dict[GeneratorClassName, GeneratorUnion]: return {name: generator_class(config, name) for name, generator_class in GENERATOR_CLASS_MAP.items()} - - -def get_remaining_generator_classes( - remaining_generators: Dict[GeneratorName, GeneratorUnion], -) -> Dict[GeneratorClassName, GeneratorUnion]: - return {generator.class_name(): generator for generator in reversed(remaining_generators.values())} - - -def get_generator_by_instruction( - instruction: InstructionUnion, - remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], -) -> GeneratorUnion: - instruction_class_name = instruction.class_name() - instruction_class = INSTRUCTION_CLASS_MAP[instruction_class_name] - generator_class = INSTRUCTION_TO_GENERATOR_MAP[instruction_class] - return remaining_generator_classes[generator_class.class_name()] diff --git a/src/sampletones_core/headless/__init__.py b/src/sampletones_core/headless/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/headless/config.py b/src/sampletones_core/headless/config.py new file mode 100644 index 000000000..e1d807512 --- /dev/null +++ b/src/sampletones_core/headless/config.py @@ -0,0 +1,9 @@ +from pathlib import Path +from typing import Optional + +from sampletones_core.configs import Config + + +def load_config(path: Optional[Path]) -> Config: + """The configuration a headless run uses: the file named, or the saved one with the defaults behind it.""" + return Config.load(path) if path is not None else Config.default() diff --git a/src/sampletones_core/headless/conversion/__init__.py b/src/sampletones_core/headless/conversion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/headless/conversion/pairing.py b/src/sampletones_core/headless/conversion/pairing.py new file mode 100644 index 000000000..f9ab970dd --- /dev/null +++ b/src/sampletones_core/headless/conversion/pairing.py @@ -0,0 +1,47 @@ +from typing import List + +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.headless.conversion.request import ConversionRequest +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def describe_stem(entry: StemEntry) -> str: + """One stem as the pairing names it: its id, the channels it occupies and how it plays them. + + The channels it bends, a count below the channels it holds and a channel driven off unit each + add a clause, so a line states what a reader chose and leaves the usual unsaid. + """ + settings = entry.settings + channels = ", ".join(channel.value for channel in settings.channels) + parts = [f"stem {entry.id} on {channels}"] + bends = ", ".join(channel.value for channel in settings.bends) + if bends: + parts.append(f"bending {bends}") + + if settings.channel_cap < len(settings.channels): + parts.append(f"{settings.channel_cap} at once") + + driven = _driven_channels(settings) + if driven: + parts.append(f"driving {driven}") + + return ", ".join(parts) + + +def _driven_channels(settings: StemSettings) -> str: + """The channels the stem plays off unit drive, each with the level it gives them.""" + return ", ".join( + f"{channel.value} at {settings.drives[channel]:.2f}" + for channel in settings.channels + if settings.drives[channel] != UNIT_DRIVE + ) + + +def pairing_lines(request: ConversionRequest) -> List[str]: + """One line per source naming the stem it plays under, in the order they pair.""" + directory = request.directory + if directory is not None: + return [f"{directory.name}/: every recording under {describe_stem(request.stems.entries[0])}"] + + return [f"{source.name}: {describe_stem(entry)}" for source, entry in zip(request.sources, request.stems.entries)] diff --git a/src/sampletones_core/headless/conversion/request.py b/src/sampletones_core/headless/conversion/request.py new file mode 100644 index 000000000..673c5cf3a --- /dev/null +++ b/src/sampletones_core/headless/conversion/request.py @@ -0,0 +1,144 @@ +from functools import cached_property +from pathlib import Path +from typing import List, Optional, Self, Sequence, Tuple + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + ordered_channels, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import ( + StemsConfig, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO, is_audio_file +from sampletones_shared.utils.serialization import load_json +from sampletones_shared.utils.text import listed_items + + +def channels_named(stated: Optional[str]) -> List[ChannelName]: + """The channels a run hands out: the ones named, comma separated, or the usual three. + + Raises: + ValueError: If a name is none of the channels the hardware has. + """ + if stated is None: + return list(DEFAULT_CHANNELS) + + channels: List[ChannelName] = [] + for name in listed_items(stated): + try: + channels.append(ChannelName(name)) + except ValueError as error: + known = ", ".join(channel.value for channel in ChannelName) + raise ValueError(f"Unknown channel {name!r}; the channels are {known}.") from error + + return channels + + +def classic_setup(channels: Sequence[ChannelName]) -> StemsConfig: + """The setup a single-source conversion runs under: one stem over the channels it was given.""" + return StemsConfig.single_entry(StemSettings.covering(ordered_channels(frozenset(channels)))) + + +def load_stems(path: Path) -> StemsConfig: + """The stems setup a JSON file holds, validated the way the ``.stn`` record is. + + Raises: + ValueError: If no file stands at the path, the file holds anything other than a JSON + mapping, or the mapping is no stems setup. + """ + if not path.is_file(): + raise ValueError(f"No stems file at {path}.") + + loaded = load_json(path) + if not isinstance(loaded, dict): + raise ValueError(f"Stems file {path} must hold a mapping, got {type(loaded).__name__}.") # noqa: TRY004 + + return StemsConfig.model_validate(loaded) + + +class ConversionRequest(BaseModel): + """What a headless conversion is asked for: the sources, the stems setup and where the result goes. + + The i-th source plays under the i-th entry of the setup. One directory stands for every + recording under it, each converted alone under the setup's one stem, into the + configuration's reconstructions directory. + + Attributes: + sources: The recordings, or one directory of them. + stems: The setup handing the channels out. + output_path: The file the reconstruction of the recordings is written to, or ``None`` + for the configuration's own directory. + """ + + model_config = ConfigDict(frozen=True) + + sources: Tuple[Path, ...] = Field(min_length=1) + stems: StemsConfig + output_path: Optional[Path] + + @cached_property + def directory(self) -> Optional[Path]: + """The one directory the request converts file by file, or ``None`` for recordings. + + The sources are classified once, when the request is validated, and every later reading + answers from that. + """ + if len(self.sources) == 1 and self.sources[0].is_dir(): + return self.sources[0] + + return None + + @model_validator(mode="after") + def _sources_are_recordings_or_one_directory(self) -> Self: + """Raises: + ValueError: If a directory stands among several sources, or a source names nothing or + a file other than a recording. + """ + if self.directory is not None: + return self + + for source in self.sources: + if source.is_dir(): + raise ValueError("Sources are recordings, or one directory alone.") + + if not source.exists(): + raise ValueError(f"No file at {source}.") + + if not is_audio_file(source): + raise ValueError(f"{source} is no recording; a source is a {', '.join(EXT_FILES_AUDIO)} file.") + + return self + + @model_validator(mode="after") + def _entries_pair_with_sources(self) -> Self: + """Raises: + ValueError: If the setup holds a different number of stems than there are sources. + """ + entries = len(self.stems.entries) + if self.directory is not None and entries != 1: + raise ValueError(f"A directory is converted file by file under one stem; the setup holds {entries}.") + + if self.directory is None and entries != len(self.sources): + raise ValueError( + f"{len(self.sources)} sources for {entries} stems; a setup pairs one stem with each source, in order." + ) + + return self + + @model_validator(mode="after") + def _output_names_the_one_file(self) -> Self: + """Raises: + ValueError: If an output path is given for a directory, whose reconstructions land in + the configuration's directory. + """ + if self.directory is not None and self.output_path is not None: + raise ValueError( + "A directory's reconstructions land in the configuration's reconstructions directory; " + "an output path names the one file recordings are mixed into." + ) + + return self diff --git a/src/sampletones_core/headless/conversion/runners.py b/src/sampletones_core/headless/conversion/runners.py new file mode 100644 index 000000000..77454b670 --- /dev/null +++ b/src/sampletones_core/headless/conversion/runners.py @@ -0,0 +1,155 @@ +from pathlib import Path +from typing import Final, Optional, Tuple + +from tqdm import tqdm + +from sampletones_core.configs import Config +from sampletones_core.headless.conversion.request import ConversionRequest +from sampletones_core.headless.library import ensure_library +from sampletones_core.parallelization import TaskProgress, TaskStatus +from sampletones_core.reconstructions import Reconstructor +from sampletones_core.reconstructions.converter import ( + ConversionJob, + DirectoryConversion, + ReconstructionConverter, + reconstruct_job, +) +from sampletones_core.reconstructions.converter.paths import group_output_path +from sampletones_core.reconstructions.progress import ReconstructionProgress +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_shared.logger import logger, null_logger + +BAR_STEPS: Final[int] = 1000 + + +def reconstruct(request: ConversionRequest, config: Config) -> None: + """Builds what the request asks for: one reconstruction of the recordings, or one per file of the directory.""" + directory = request.directory + if directory is not None: + reconstruct_directory(directory, config, request.stems) + return + + reconstruct_sources(request.sources, config, request.stems, request.output_path) + + +def reconstruct_sources( + sources: Tuple[Path, ...], + config: Config, + stems: StemsConfig, + output_path: Optional[Path], +) -> None: + """Mixes the recordings into one reconstruction and writes it, showing the progress as a bar. + + A file already standing at the output path is kept, and the run says so. The library the + configuration names is prepared first. + + Args: + sources: The recordings, one per stem of the setup. + config: The configuration selecting the library and the matching settings. + stems: The setup handing the channels out. + output_path: The file written, or ``None`` for the configuration's own directory. + """ + if output_path is None: + output_path = group_output_path(config, sources, stems.covered_channels) + + if output_path.exists(): + logger.info(f"Reconstruction {output_path} exists, skipping") + return + + ensure_library(config) + names = ", ".join(source.name for source in sources) + logger.info(f"Starting reconstruction of {names}") + job = ConversionJob(sources=sources, stems=stems, output_path=output_path) + progress_bar = tqdm(total=BAR_STEPS, desc=f"Reconstructing {output_path.stem}", 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, stems.covered_channels), job, on_progress)) + finally: + progress_bar.close() + + logger.info(f"Reconstruction file saved to {output_path}") + + +def reconstruct_directory( + directory: Path, + config: Config, + stems: StemsConfig, +) -> None: + """Reconstructs every recording under the directory, each alone under the setup's stem. + + The library the configuration names is prepared first. The results mirror the directory's + tree inside the configuration's reconstructions directory. + + Args: + directory: The directory of recordings. + config: The configuration selecting the library and the matching settings. + stems: The one-stem setup every recording is converted under. + """ + ensure_library(config) + progress_bar = tqdm(total=0, desc=f"Reconstructing {directory.name}", unit="file") + + def on_start() -> None: + progress_bar.disable = False + logger.info(f"Starting reconstruction for directory {directory}") + + def on_completed(written: Tuple[Path, ...]) -> None: + logger.info(f"Reconstructed {len(written)} files from {directory}") + progress_bar.close() + + def on_progress( + task_status: TaskStatus, + task_progress: TaskProgress, + ) -> None: + progress_bar.disable = False + total = task_progress.total + if total and total != progress_bar.total: + progress_bar.total = total + progress_bar.refresh() + + delta = int(task_progress.completed) - int(progress_bar.n) + if delta > 0: + progress_bar.update(delta) + + if task_progress.current_item: + progress_bar.set_description(f"{directory.name}: {task_progress.current_item}") + + if task_status in ( + TaskStatus.COMPLETED, + TaskStatus.CANCELED, + TaskStatus.FAILED, + ): + progress_bar.close() + + def on_canceled() -> None: + logger.info("Reconstruction canceled by user") + progress_bar.close() + + def on_error(_exception: Exception) -> None: + progress_bar.close() + + converter = ReconstructionConverter( + config, + DirectoryConversion(directory=directory, stems=stems), + logger=null_logger, + ) + + converter.set_callbacks( + on_start=on_start, + on_completed=on_completed, + on_progress=on_progress, + on_canceled=on_canceled, + on_error=on_error, + ) + + try: + converter.start() + converter.wait() + except KeyboardInterrupt: + logger.info("Reconstruction interrupted by user") + finally: + progress_bar.close() diff --git a/src/sampletones_core/headless/library.py b/src/sampletones_core/headless/library.py new file mode 100644 index 000000000..e19bdf19f --- /dev/null +++ b/src/sampletones_core/headless/library.py @@ -0,0 +1,114 @@ +from typing import Tuple + +from tqdm import tqdm + +from sampletones_core.configs import Config +from sampletones_core.fft import Window +from sampletones_core.library import ( + InstructionLibrary, + InstructionLibraryData, + InstructionLibraryKey, + LibraryState, +) +from sampletones_core.library.creator import InstructionsLibraryCreator +from sampletones_core.parallelization import TaskProgress, TaskStatus +from sampletones_shared.logger import logger, null_logger + + +def ensure_library(config: Config) -> None: + """Prepares the instruction library a conversion under ``config`` searches. + + A library this build reads is used as it stands. A library built by another version is rebuilt + in its place, and a missing one is generated. + + Args: + config: The configuration selecting the library. + + Raises: + KeyboardInterrupt: If the generation is interrupted, so the conversion stops with it. + """ + window = Window.from_config(config) + library = InstructionLibrary.from_config(config) + key = library.create_key(config, window) + match library.state(key): + case LibraryState.CURRENT: + return + case LibraryState.OUTDATED: + logger.info(f"Library {key.filename} was built by another version, rebuilding it") + case LibraryState.MISSING: + logger.info(f"Library {key.filename} is missing, generating it") + + generate_library(config) + + +def generate_library(config: Config) -> None: + """Generates the instruction library for ``config`` and writes it over any file in its place. + + Args: + config: The configuration the library is built for. + + Raises: + KeyboardInterrupt: If the generation is interrupted. + """ + window = Window.from_config(config) + library = InstructionLibrary.from_config(config) + key = library.create_key(config, window) + + creator = InstructionsLibraryCreator(config, window=window, logger=null_logger) + + progress_bar = tqdm(total=0, desc="Generating library", unit="instruction", disable=False) + + def on_start() -> None: + logger.info(f"Starting library generation for key {key}") + + def on_completed( + result: Tuple[InstructionLibraryKey, InstructionLibraryData], + ) -> None: + key, library_data = result + library.save_data(key, library_data) + logger.info(f"Library {key.filename} generated successfully") + progress_bar.close() + + def on_progress( + task_status: TaskStatus, + _task_progress: TaskProgress, + ) -> None: + total = creator.total_instructions + if total and total != progress_bar.total: + progress_bar.total = total + progress_bar.refresh() + + delta = int(creator.completed_instructions) - int(progress_bar.n) + if delta > 0: + progress_bar.update(delta) + + if task_status in ( + TaskStatus.COMPLETED, + TaskStatus.CANCELED, + TaskStatus.FAILED, + ): + progress_bar.close() + + def on_canceled() -> None: + logger.info("Library generation canceled by user") + progress_bar.close() + + def on_error(_exception: Exception) -> None: + progress_bar.close() + + creator.set_callbacks( + on_start=on_start, + on_completed=on_completed, + on_progress=on_progress, + on_canceled=on_canceled, + on_error=on_error, + ) + + try: + creator.start() + creator.wait() + except KeyboardInterrupt: + logger.info("Library generation interrupted by user") + raise + finally: + progress_bar.close() diff --git a/src/sampletones_core/instructions/__init__.py b/src/sampletones_core/instructions/__init__.py index 7360d224f..47b482daf 100644 --- a/src/sampletones_core/instructions/__init__.py +++ b/src/sampletones_core/instructions/__init__.py @@ -4,12 +4,15 @@ from .implementation.triangle import TriangleInstruction from .instruction import Instruction from .maps import INSTRUCTION_CLASS_MAP +from .stream import sounds +from .tonal import TonalInstruction from .types import ( InstructionClass, InstructionFields, InstructionT, InstructionTypeUnion, InstructionUnion, + TonalInstructionUnion, ) from .utils import get_instruction_by_type @@ -24,6 +27,9 @@ "InstructionUnion", "NoiseInstruction", "PulseInstruction", + "TonalInstruction", + "TonalInstructionUnion", "TriangleInstruction", "get_instruction_by_type", + "sounds", ] 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/stream.py b/src/sampletones_core/instructions/stream.py new file mode 100644 index 000000000..739de4df2 --- /dev/null +++ b/src/sampletones_core/instructions/stream.py @@ -0,0 +1,18 @@ +from typing import Iterable + +from .instruction import Instruction + + +def sounds(stream: Iterable[Instruction]) -> bool: + """Whether a channel's stream sounds in any of its frames. + + A channel whose every frame rests plays nothing, so this is what tells a channel in play from + one standing by, whatever left it silent. + + Args: + stream: The instructions a channel plays, one per frame. + + Returns: + bool: True where some frame sounds. + """ + return any(instruction.on for instruction in stream) diff --git a/src/sampletones_core/instructions/tonal.py b/src/sampletones_core/instructions/tonal.py new file mode 100644 index 000000000..5c3f165f4 --- /dev/null +++ b/src/sampletones_core/instructions/tonal.py @@ -0,0 +1,67 @@ +from abc import ABC + +from pydantic import Field + +from sampletones_core.constants.general import ( + HI_PITCH_FACTOR, + MAX_PITCH, + MIN_PLAYED_PITCH, + PITCH_BEND_MAX, + PITCH_BEND_MIN, +) + +from .instruction import Instruction + + +def bend_steps(detune: int, coarse_detune: int) -> int: + """The timer steps a frame's two bend dimensions move its note by together. + + Args: + detune: Timer steps, one per unit. + coarse_detune: Timer steps, sixteen per unit. + + Returns: + int: The steps both dimensions reach together. + """ + return detune + HI_PITCH_FACTOR * coarse_detune + + +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_PLAYED_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 bend_steps(self.detune, 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/__init__.py b/src/sampletones_core/library/__init__.py index 735c2497a..d422a476b 100644 --- a/src/sampletones_core/library/__init__.py +++ b/src/sampletones_core/library/__init__.py @@ -3,12 +3,16 @@ from .fragment import InstructionLibraryFragment from .key import InstructionLibraryKey from .library import InstructionLibrary +from .state import LibraryHeader, LibraryState, library_state __all__ = [ "InstructionLibrary", "InstructionLibraryData", "InstructionLibraryFragment", "InstructionLibraryKey", + "LibraryHeader", + "LibraryState", "create_key_from_filename", "get_display_name_from_key", + "library_state", ] diff --git a/src/sampletones_core/library/creator/__init__.py b/src/sampletones_core/library/creator/__init__.py index 50ca67c77..05928c282 100644 --- a/src/sampletones_core/library/creator/__init__.py +++ b/src/sampletones_core/library/creator/__init__.py @@ -1,7 +1,5 @@ from .creation import ( generate_instruction, - generate_instruction_batch, - generate_instructions, generate_single_instruction_task, ) from .creator import InstructionsLibraryCreator @@ -9,7 +7,5 @@ __all__ = [ "InstructionsLibraryCreator", "generate_instruction", - "generate_instruction_batch", - "generate_instructions", "generate_single_instruction_task", ] diff --git a/src/sampletones_core/library/creator/creation.py b/src/sampletones_core/library/creator/creation.py index a42e057e0..a51c6c487 100644 --- a/src/sampletones_core/library/creator/creation.py +++ b/src/sampletones_core/library/creator/creation.py @@ -1,10 +1,10 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, Tuple 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 FeatureExtractor, get_feature_extractor -from sampletones_core.generators import GeneratorUnion, get_generators_map +from sampletones_core.generators import GeneratorUnion from sampletones_core.generators.maps import GENERATOR_CLASS_MAP from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibraryFragment @@ -25,28 +25,6 @@ def generate_instruction( return instruction, fragment -def generate_instructions( - instructions_batch: List[Tuple[GeneratorClassName, InstructionUnion]], - config: Config, - window: Window, - generators: Dict[GeneratorClassName, GeneratorUnion], -) -> List[Tuple[InstructionUnion, InstructionLibraryFragment[Any]]]: - extractor = get_feature_extractor(config, window) - return [ - generate_instruction(generators, generator_class_name, instruction, extractor) - for generator_class_name, instruction in instructions_batch - ] - - -def generate_instruction_batch( - task: Tuple[List[Tuple[GeneratorClassName, InstructionUnion]], Config, Window], -) -> List[Tuple[InstructionUnion, InstructionLibraryFragment[Any]]]: - instructions_batch, config, window = task - - generators: Dict[GeneratorClassName, GeneratorUnion] = get_generators_map(config) - return generate_instructions(instructions_batch, config, window, generators) - - def generate_single_instruction_task( task: Tuple[Tuple[GeneratorClassName, InstructionUnion], Config, Window], ) -> Tuple[InstructionUnion, InstructionLibraryFragment[Any]]: diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py index c62d07e63..7e0b390a4 100644 --- a/src/sampletones_core/library/data.py +++ b/src/sampletones_core/library/data.py @@ -9,6 +9,7 @@ from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.constants.enums import GeneratorClassName from sampletones_core.data import DataModel, Metadata, MetadataContract +from sampletones_core.data.document import decompress_document from sampletones_core.generators import GeneratorClassNames from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION @@ -117,7 +118,7 @@ def load(cls, path: Pathlike, fast: bool = True) -> InstructionLibraryData: try: return InstructionLibraryData.deserialize( - binary, + decompress_document(binary), validation=cls.validate_metadata, fast=fast, ) diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py index 0733a5326..27cb47fcd 100644 --- a/src/sampletones_core/library/filename/fields.py +++ b/src/sampletones_core/library/filename/fields.py @@ -9,7 +9,7 @@ from sampletones_core.constants.field_aliases import ALIASES from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.serialization import HASH_PATTERN +from sampletones_shared.utils.hashing import HASH_PATTERN from sampletones_shared.utils.system.paths import get_filename FILENAME_SEPARATOR: Final[str] = "_" diff --git a/src/sampletones_core/library/fragment.py b/src/sampletones_core/library/fragment.py index 980d7eac8..b72faf8d3 100644 --- a/src/sampletones_core/library/fragment.py +++ b/src/sampletones_core/library/fragment.py @@ -20,7 +20,6 @@ from sampletones_core.structures.histogram import Histogram from sampletones_shared.exceptions import InstructionTypeMismatchError from sampletones_shared.types.data import ( - Initials, ReducedObject, SerializedData, ) @@ -74,25 +73,28 @@ def instruction(self) -> InstructionT: return instruction def get_fragment(self, shift: int, config: Config, window: Window) -> Fragment: - windowed_audio = self.sample.get_windowed_fragment(shift, window) - audio = window.get_frame_from_window(windowed_audio) return Fragment( - audio=audio, + audio=self.sample.get_frame(shift, window), feature=self.feature, - windowed_audio=windowed_audio, config=config, ) - def get( + def frames_share_shape( self, - generator: Generator[InstructionT, Any], - config: Config, - window: Window, - initials: Initials = None, - ) -> Fragment: - generator.set_timer(self.instruction) - shift = generator.timer.calculate_offset(initials) - return self.get_fragment(shift, config, window) + *, + frame_length: int, + sample_rate: int, + ) -> bool: + """Whether every frame this entry plays repeats one waveform shape at some phase. + + A note repeats one period of its waveform. Noise repeats one shape while the register's + whole cycle, `sample_rate / frequency` samples, fits inside a frame; a longer cycle shows each + frame a different stretch of the sequence. + """ + if self.generator_class != GeneratorClassName.NOISE_GENERATOR: + return True + + return self.frequency * frame_length >= sample_rate @property def data(self) -> np.ndarray: diff --git a/src/sampletones_core/library/key.py b/src/sampletones_core/library/key.py index f431918b5..88655de55 100644 --- a/src/sampletones_core/library/key.py +++ b/src/sampletones_core/library/key.py @@ -3,13 +3,12 @@ from pydantic import BaseModel, ConfigDict, Field from sampletones_core.configs import InstructionsLibraryConfig -from sampletones_core.configs.config import Config from sampletones_core.constants.algorithm import MAX_TRANSFORMATION_GAMMA from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.fft import Window from sampletones_core.library.filename.fields import InstructionsFilenameFields -from sampletones_shared.utils.serialization import hash_model +from sampletones_shared.utils.hashing import hash_model class InstructionLibraryKey(BaseModel): @@ -36,11 +35,6 @@ class InstructionLibraryKey(BaseModel): config_hash: str = Field(..., description="Hash of the configuration") filename: str = Field(..., description="Filename representing the key") - @classmethod - def from_config(cls, config: Config) -> InstructionLibraryKey: - window = Window.from_config(config) - return cls.create(config.library, window) - @classmethod def create(cls, config: InstructionsLibraryConfig, window: Window) -> InstructionLibraryKey: config_hash = hash_model(config) diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py index 76b0b5eec..4450f7cf7 100644 --- a/src/sampletones_core/library/library.py +++ b/src/sampletones_core/library/library.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Dict, ItemsView, KeysView, Optional, Self, Union, ValuesView +from typing import Dict, Optional, Self from pydantic import BaseModel, ConfigDict, Field @@ -12,6 +12,7 @@ from .data import InstructionLibraryData from .key import InstructionLibraryKey +from .state import LibraryState, library_state class InstructionLibrary(BaseModel): @@ -42,9 +43,6 @@ class InstructionLibrary(BaseModel): description="Cached instruction library data, keyed by configuration.", ) - def __getitem__(self, key: InstructionLibraryKey) -> InstructionLibraryData: - return self.data[key] - @classmethod def from_config(cls, config: Config) -> Self: """Builds a library rooted at the directory named in the configuration. @@ -90,41 +88,23 @@ def get(self, config: Config, window: Optional[Window] = None) -> Optional[Instr if key in self.data: return self.data[key] - if self.exists(key): + if self.get_path(key).exists(): self.load_data(key) return self.data[key] logger.warning(f"Library data for key {key} does not exist") return None - def exists(self, config_or_key: Union[Config, InstructionLibraryKey]) -> bool: - """Reports whether a library file exists on disk for the given key. + def state(self, key: InstructionLibraryKey) -> LibraryState: + """Where the library file for ``key`` stands for this build. Args: - config_or_key: A configuration (whose key is derived) or a key directly. + key: The key identifying the library. Returns: - bool: True when the corresponding library file is present. + LibraryState: Whether the file is missing, out of date or current. """ - if isinstance(config_or_key, Config): - key = self.create_key(config_or_key, Window.from_config(config_or_key)) - else: - key = config_or_key - - return self.get_path(key).exists() - - def purge(self) -> None: - """Empties the in-memory cache, so the next request reloads from disk.""" - self.data.clear() - - def keys(self) -> KeysView[InstructionLibraryKey]: - return self.data.keys() - - def items(self) -> ItemsView[InstructionLibraryKey, InstructionLibraryData]: - return self.data.items() - - def values(self) -> ValuesView[InstructionLibraryData]: - return self.data.values() + return library_state(self.get_path(key)) def get_path(self, key: InstructionLibraryKey) -> Path: """The file path a library key maps to under the library directory. @@ -138,15 +118,26 @@ def get_path(self, key: InstructionLibraryKey) -> Path: return Path(self.directory) / key.filename def save_data(self, key: InstructionLibraryKey, library_data: InstructionLibraryData) -> None: - """Caches a library and writes it to its file on disk. + """Writes a library to its file on disk and caches it once the file is written. Args: key: The key identifying the library. library_data: The library to cache and persist. """ - path = self.get_path(key) - path.parent.mkdir(parents=True, exist_ok=True) + self.write_data(key, library_data) self.data[key] = library_data + + def write_data(self, key: InstructionLibraryKey, library_data: InstructionLibraryData) -> None: + """Writes a library to its file on disk, creating the library directory where it is missing. + + A directory named through a link creates the folder the link points to. + + Args: + key: The key identifying the library. + library_data: The library to persist. + """ + path = self.get_path(key) + path.parent.resolve().mkdir(parents=True, exist_ok=True) library_data.save(path) def load_data(self, key: InstructionLibraryKey) -> None: diff --git a/src/sampletones_core/library/state.py b/src/sampletones_core/library/state.py new file mode 100644 index 000000000..8550824cf --- /dev/null +++ b/src/sampletones_core/library/state.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path +from typing import Any, Final, FrozenSet, Optional, Self + +from sampletones_core.configs import InstructionsLibraryConfig +from sampletones_core.data import Metadata +from sampletones_core.data.stored import read_leading_fields +from sampletones_shared.exceptions import ( + IncompatibleLibraryDataVersionError, + InvalidMetadataError, +) + +from .data import LIBRARY_DATA_CONTRACT + +METADATA_FIELD: Final[str] = "metadata" +CONFIG_FIELD: Final[str] = "config" +HEADER_FIELDS: Final[FrozenSet[str]] = frozenset({METADATA_FIELD, CONFIG_FIELD}) +VERSION_FIELD: Final[str] = "library_data_version" + + +class LibraryState(Enum): + """Where a library file stands for the build about to use it. + + A current library is one this build loads. An outdated one is any other file standing there, + most often one another build wrote, and it is rebuilt before use. A missing one is generated. + """ + + MISSING = auto() + OUTDATED = auto() + CURRENT = auto() + + +@dataclass(frozen=True) +class LibraryHeader: + """What a library file states about itself at its front: the build that wrote it, and the + settings it was built for. + + A library stores both ahead of its entries, so they read from the first bytes of the file + whatever its size. Each is ``None`` where the file states it in no form this build reads, a + file the system refuses to read included. + """ + + metadata: Optional[Metadata] + config: Optional[InstructionsLibraryConfig] + + @classmethod + def read(cls, path: Path) -> Self: + """The header of the library stored at ``path``. + + Raises: + FileNotFoundError: If no file stands at ``path``. + """ + try: + fields = read_leading_fields(path, HEADER_FIELDS) + except FileNotFoundError: + raise + except OSError: + fields = {} + + return cls( + metadata=cls._stored_metadata(fields.get(METADATA_FIELD)), + config=cls._stored_config(fields.get(CONFIG_FIELD)), + ) + + @property + def is_current(self) -> bool: + """Whether this build loads the library, held to the contract a library's load applies.""" + if self.metadata is None: + return False + + try: + LIBRARY_DATA_CONTRACT.validate(self.metadata, self.metadata.library_data_version) + except (InvalidMetadataError, IncompatibleLibraryDataVersionError, ValueError): + return False + + return True + + @staticmethod + def _stored_metadata(stored: Any) -> Optional[Metadata]: + """The metadata a file states, taken from a file naming the library data version it was + written at. + + Metadata fills a field a file leaves out with the reading build's own version, so a file is + held to the version it states itself. + """ + if not isinstance(stored, dict) or not isinstance(stored.get(VERSION_FIELD), str): + return None + + try: + return Metadata.deserialize_inner(stored, fast=False) + except (ValueError, TypeError): + return None + + @staticmethod + def _stored_config(stored: Any) -> Optional[InstructionsLibraryConfig]: + """The settings a file states it was built for, where this build reads them.""" + if not isinstance(stored, dict): + return None + + try: + return InstructionsLibraryConfig.deserialize_inner(stored, fast=False) + except (ValueError, TypeError): + return None + + +def library_state(path: Path) -> LibraryState: + """Where the library stored at ``path`` stands for this build. + + Args: + path: Where the library file is expected. + + Returns: + LibraryState: ``CURRENT`` for a file this build loads, ``OUTDATED`` for any other file, + one the system refuses to read included, and ``MISSING`` where there is no file. + """ + try: + header = LibraryHeader.read(path) + except FileNotFoundError: + return LibraryState.MISSING + + return LibraryState.CURRENT if header.is_current else LibraryState.OUTDATED 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..bf4426466 --- /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/outcome.py b/src/sampletones_core/parallelization/outcome.py new file mode 100644 index 000000000..b85b833a0 --- /dev/null +++ b/src/sampletones_core/parallelization/outcome.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Generic, List, TypeVar, Union + +T = TypeVar("T") + + +@dataclass(frozen=True) +class RunCompleted(Generic[T]): + """A run whose every task answered, with the answers in the order the tasks were given.""" + + results: List[T] + + +@dataclass(frozen=True) +class RunCanceled: + """A run withdrawn before every task answered.""" + + +@dataclass(frozen=True) +class RunFailed: + """A run ended by the exception a task or the building of the tasks raised.""" + + exception: Exception + + +RunOutcome = Union[RunCompleted[T], RunCanceled, RunFailed] diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index a377bf3ce..5081c6513 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -2,15 +2,21 @@ import threading from abc import ABC, abstractmethod from concurrent.futures._base import CancelledError -from typing import Any, Callable, Final, Generic, List, Optional, TypeVar, Union +from typing import Any, Callable, Final, Generic, List, Optional, TypeVar 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.outcome import RunCanceled, RunCompleted, RunFailed, RunOutcome +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 @@ -18,40 +24,50 @@ T = TypeVar("T") -CANCEL_TIMEOUT: Final[float] = 5.0 -STOP_TIMEOUT: Final[float] = 2.0 +SPAWN_CONTEXT: Final[str] = "spawn" class TaskProcessor(ABC, CallbackMixin, Generic[T]): + """Runs a list of tasks on a process pool that its monitor thread alone owns. + + The monitor thread builds the tasks, creates the pool, gathers the answers and, however the run + ends, ends the pool and the progress channel before it announces the outcome. Whoever reacts to + ``on_completed``, ``on_canceled`` or ``on_error`` therefore meets a run with no worker left. + """ + def __init__( self, max_workers: Optional[int] = None, logger: LoggerProtocol = default_logger, ) -> None: self.max_workers: int = max_workers or MAX_WORKERS - self.pool: Optional[ProcessPool] = None self.future: Optional[ProcessMapFuture] = None self.monitor_thread: Optional[threading.Thread] = None self.logger = logger self.status: TaskStatus = TaskStatus.PENDING self.running = False - self.cancelling = False + self.canceling = False self.total_tasks = 0 self.completed_tasks = 0 self.current_item: Optional[str] = None self._notify_progress_lock = threading.Lock() - 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._reset_status() self.monitor_thread = threading.Thread( target=self._run_tasks, daemon=True, @@ -67,58 +83,34 @@ def wait(self, timeout: Optional[float] = None) -> None: if self._exception is not None: raise self._exception - def cleanup(self) -> None: - self.status = TaskStatus.CLEANING_UP - self.running = False - self.cancelling = True - - self._notify_progress() - self._cleanup() - def cancel(self) -> None: - self.status = TaskStatus.CANCELLING - self.cancelling = True + """Withdraws the run; the monitor thread answers by stopping the pool and announcing it.""" + self.status = TaskStatus.CANCELING + self.canceling = True + self._withdraw() self._notify_progress() - self._cleanup() + future = self.future + if future is not None: + future.cancel() def shutdown(self) -> None: - """Stops the pool and reaps its workers on the calling thread before returning. - - Cancelling from the interface tears the pool down on a background thread to keep - the interface responsive. At application exit the process is about to release the - shared resources the pool's spawned workers rely on, so the teardown runs inline - here and returns only once the pool has stopped.""" - self.status = TaskStatus.CLEANING_UP - self.running = False - self.cancelling = True + """Ends the run and returns once its pool and its channel have ended. - self._notify_progress() - if self.future is not None: - self.future.cancel() + A run still in progress is canceled and its monitor thread joined; a run whose monitor has + already announced its outcome returns at once. A run whose tasks were built and never + started holds a channel all the same, which ends here. + """ + monitor = self.monitor_thread + if monitor is not None and monitor.is_alive(): + self.cancel() + monitor.join() - self._stop_pool() - self._join_thread() - self._reset_status() + self._release_channel() def is_running(self) -> bool: return self.running - def is_completed(self) -> bool: - return self.status == TaskStatus.COMPLETED - - def is_cancelled(self) -> bool: - return self.status == TaskStatus.CANCELLED - - def is_cancelling(self) -> bool: - return self.status == TaskStatus.CANCELLING - - def is_failed(self) -> bool: - return self.status == TaskStatus.FAILED - - def get_status(self) -> TaskStatus: - return self.status - @abstractmethod def _create_tasks(self) -> List[Any]: ... @@ -128,22 +120,83 @@ 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 workers have ended. + + The channel's manager is a process of its own that the workers hold proxies to, so it ends + after the pool, and every ending reaches here, including a run that built its tasks and + never started. + """ + 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 - self.cancelling = False + self.canceling = False self.total_tasks = 0 self.completed_tasks = 0 self.current_item = None def _run_tasks(self) -> None: - self._reset_status() + """Gathers the run, ends the channel once the pool has ended, then announces the outcome.""" + try: + outcome = self._gather() + finally: + self._release_channel() + self._announce(outcome) + + def _gather(self) -> RunOutcome[T]: + """Builds the tasks and runs them on a pool that has ended by the time this returns.""" try: tasks = self._create_tasks() except Exception as exception: - self._stop_with_error(exception) - return + return RunFailed(exception) + + if self.canceling: + return RunCanceled() self.total_tasks = len(tasks) self.completed_tasks = 0 @@ -151,42 +204,72 @@ def _run_tasks(self) -> None: self.logger.info("Starting processing tasks...") self._notify_progress() - workers = self.max_workers - context = multiprocessing.get_context("spawn") - self.pool = ProcessPool(max_workers=workers, context=context) - task_function = self._get_task_function() - self.future = self.pool.map(task_function, tasks, timeout=None) + pool = ProcessPool( + max_workers=self.max_workers, + context=multiprocessing.get_context(SPAWN_CONTEXT), + ) + outcome: Optional[RunOutcome[T]] = None + try: + outcome = self._drain(pool, tasks) + return outcome + finally: + self._end_pool(pool, outcome) + + def _drain( + self, + pool: ProcessPool, + tasks: List[Any], + ) -> RunOutcome[T]: + """Hands the tasks to ``pool`` and collects their answers until they are all in or withdrawn.""" + self.future = pool.map(self._get_task_function(), tasks, timeout=None) + self._start_pump() self.call(self.on_start) - results = [] + results: List[T] = [] + self.running = True + self.status = TaskStatus.RUNNING + self._notify_progress() try: - self.running = True - self.status = TaskStatus.RUNNING - self._notify_progress() iterator = self.future.result() - - while True: - if self.cancelling: - raise CancelledError() - - result = next(iterator) - results.append(result) + while not self.canceling: + results.append(next(iterator)) + 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 CancelledError: - self._finalize_cancellation() - return + return RunCompleted(results) + except (CancelledError, OperationCanceled): + return RunCanceled() except Exception as exception: - self._stop_with_error(exception) - return + return RunFailed(exception) finally: self.running = False - self._complete_process(results) + return RunCanceled() + + def _end_pool( + self, + pool: ProcessPool, + outcome: Optional[RunOutcome[T]], + ) -> None: + """Lets a pool whose every task answered wind down, stops any other, and reaps its workers.""" + if isinstance(outcome, RunCompleted): + self.logger.info("Closing the task manager pool...") + pool.close() # type: ignore[no-untyped-call] + else: + self.logger.info("Stopping the task manager pool...") + pool.stop() # type: ignore[no-untyped-call] + + pool.join() + + def _announce(self, outcome: RunOutcome[T]) -> None: + match outcome: + case RunCompleted(results=results): + self._finalize_completion(results) + case RunCanceled(): + self._finalize_cancellation() + case RunFailed(exception=exception): + self._stop_with_error(exception) def _notify_progress(self) -> None: with self._notify_progress_lock: @@ -197,27 +280,23 @@ 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) self.logger.debug(f"Status: {self.status}; progress: {progress}") def _finalize_cancellation(self) -> None: - if not self.cancelling: - return - - self.logger.info("Task processing was cancelled.") - self.status = TaskStatus.CANCELLED - self.cancelling = False - self.running = False + self.logger.info("Task processing was canceled.") + self.status = TaskStatus.CANCELED + self.canceling = 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") self.status = TaskStatus.COMPLETED - self.running = False self._notify_progress() processed_result = self._process_results(results) @@ -228,73 +307,5 @@ def _stop_with_error(self, exception: Exception) -> None: self._exception = exception self.status = TaskStatus.FAILED - self.running = False self._notify_progress() self.call(self.on_error, exception) - - def _cleanup(self) -> None: - if self.future: - self.future.cancel() - - cleanup_thread = threading.Thread( - target=self._wait_for_cleanup, - daemon=True, - name="TaskProcessorCleanup", - ) - cleanup_thread.start() - - def _is_thread_alive(self) -> bool: - return self.monitor_thread is not None and self.monitor_thread.is_alive() - - def _join_thread(self) -> None: - if self._is_thread_alive(): - assert self.monitor_thread is not None, "Monitor thread expected to be alive" - self.monitor_thread.join(timeout=CANCEL_TIMEOUT) - - def _wait_for_cleanup(self) -> None: - self._stop_pool() - self._join_thread() - self._reset_status() - - def _complete_process(self, results: List[T]) -> None: - self._finalize_completion(results) - self._cleanup_pool() - self._reset_status() - - def _join_pool(self, timeout: Optional[Union[int, float]] = None) -> None: - try: - if self.pool is not None: - if timeout is not None: - self.pool.join(timeout=timeout) - else: - self.pool.join() - except OSError as exception: - self.logger.error_with_traceback(exception, f"Error while joining the pool: {exception}") - - def _cleanup_pool(self) -> None: - self._notify_progress() - if self.pool is None: - return - - with self._pool_lock: - self.logger.info("Cleaning the task manager pool...") - try: - self.pool.close() # type: ignore[no-untyped-call] - except RuntimeError as exception: - self.logger.error_with_traceback(exception, f"Error while closing the pool: {exception}") - finally: - self._join_pool() - - def _stop_pool(self, timeout: float = STOP_TIMEOUT) -> None: - self._notify_progress() - if self.pool is None: - return - - with self._pool_lock: - self.logger.info("Stopping the task manager pool...") - try: - self.pool.stop() # type: ignore[no-untyped-call] - except RuntimeError as exception: - self.logger.error_with_traceback(exception, f"Error while stopping the pool: {exception}") - finally: - self._join_pool(timeout=timeout) diff --git a/src/sampletones_core/parallelization/progress.py b/src/sampletones_core/parallelization/progress.py index f95759630..7fb426697 100644 --- a/src/sampletones_core/parallelization/progress.py +++ b/src/sampletones_core/parallelization/progress.py @@ -1,77 +1,63 @@ -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 + +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: 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: 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) + + 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..04c668355 100644 --- a/src/sampletones_core/parallelization/task.py +++ b/src/sampletones_core/parallelization/task.py @@ -1,9 +1,10 @@ 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 +SINGLE_TASK: Final[int] = 1 class TaskStatus(Enum): @@ -12,20 +13,60 @@ class TaskStatus(Enum): RUNNING = "RUNNING" COMPLETED = "COMPLETED" FAILED = "FAILED" - CANCELLING = "CANCELLING" - CANCELLED = "CANCELLED" - CLEANING_UP = "CLEANING_UP" + CANCELING = "CANCELING" + CANCELED = "CANCELED" + + +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 is_single(self) -> bool: + """The run is one task, whose own step is the whole of what the run has to report.""" + return self.total <= SINGLE_TASK + + @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..6ec4b18c6 --- /dev/null +++ b/src/sampletones_core/performance/__init__.py @@ -0,0 +1,29 @@ +from .audition import audition_audio +from .modifiers import apply_modifiers +from .progress import ( + WalkProgress, + WalkReporter, + announce, +) +from .rows import apply_row, note_step, resolve_row, sounding_pitch, sounding_voice +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", + "note_step", + "resolve_row", + "song_instructions", + "sound_tick", + "sounding_pitch", + "sounding_voice", +] diff --git a/src/sampletones_core/performance/audition.py b/src/sampletones_core/performance/audition.py new file mode 100644 index 000000000..b8b5202b6 --- /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 an instrument 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_core/performance/modifiers.py b/src/sampletones_core/performance/modifiers.py new file mode 100644 index 000000000..06b180763 --- /dev/null +++ b/src/sampletones_core/performance/modifiers.py @@ -0,0 +1,104 @@ +from typing import Final + +from sampletones_core.constants.general import MAX_VOLUME, MIN_VOLUME, SILENT_VOLUME +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.utils.frequencies import transpose_period, transpose_pitch + +TRIANGLE_LOUDEST_SILENT_VOLUME: Final[int] = MAX_VOLUME // 2 + + +def triangle_sounds_at(row_volume: int) -> bool: + """Whether the triangle sounds at the level a pattern has reached. + + The triangle plays at one fixed loudness, so a pattern's level decides only whether it sounds, + and it sounds while the row asks for more than half volume. A tracker export writes the + triangle's volume column by this rule, so the tracker gates the triangle where the song does. + + Args: + row_volume: The level the pattern has reached. + + Returns: + bool: Whether the triangle sounds at that level. + """ + return row_volume > TRIANGLE_LOUDEST_SILENT_VOLUME + + +def pulse_volume( + volume: int, + row_volume: int, +) -> int: + """The level a pulse channel sounds at: the instruction's level scaled by the row's, to the nearest step. + + Args: + volume: The level the instruction holds. + row_volume: The level the pattern has reached. + + Returns: + int: The level the channel sounds at. + """ + return max(SILENT_VOLUME, min(MAX_VOLUME, round(volume * row_volume / MAX_VOLUME))) + + +def noise_volume( + volume: int, + row_volume: int, +) -> int: + """The level the noise channel sounds at: the instruction's level scaled by the row's, rounded down. + + A product that rounds down to silence while both levels sound is raised to the quietest level, so a + quiet row keeps the noise audible. FamiTracker and Bitphase both set the noise level by this rule, + so an exported song's noise plays there at the level it plays here. + + Args: + volume: The level the instruction holds. + row_volume: The level the pattern has reached. + + Returns: + int: The level the channel sounds at. + """ + scaled = max(SILENT_VOLUME, min(MAX_VOLUME, volume * row_volume // MAX_VOLUME)) + if scaled == SILENT_VOLUME and volume > SILENT_VOLUME and row_volume > SILENT_VOLUME: + return MIN_VOLUME + + return scaled + + +def apply_modifiers( + instruction: InstructionUnion, + transpose: int, + row_volume: int, +) -> InstructionUnion: + """Bends one tick's instruction by the transpose and volume the pattern has reached. + + A sample carries the instructions it was reconstructed from; a pattern states how loud and how + high it is played. Each channel takes both in the terms it understands: the pulse channels + scale their volume by :func:`pulse_volume` and shift their pitch, the triangle shifts its pitch + and sounds while the row asks for more than half volume, and the noise channel scales its + volume by :func:`noise_volume` and walks its period around the sixteen the hardware offers. + + Args: + instruction: The tick's instruction as the sample holds it. + transpose: The semitone offset the pattern has reached, held within the pitch range. + row_volume: The level the pattern has reached, scaling the instruction's own. + + Returns: + InstructionUnion: A copy of the instruction as the channel sounds it. + """ + match instruction: + case PulseInstruction(): + scaled_volume = pulse_volume(instruction.volume, row_volume) + effective_pitch = transpose_pitch(instruction.pitch, transpose) + return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) + case TriangleInstruction(): + effective_pitch = transpose_pitch(instruction.pitch, transpose) + on = instruction.on and triangle_sounds_at(row_volume) + return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) + case NoiseInstruction(): + scaled_volume = noise_volume(instruction.volume, row_volume) + effective_period = transpose_period(instruction.period, transpose) + return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) diff --git a/src/sampletones_core/performance/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..b0db0ef73 --- /dev/null +++ b/src/sampletones_core/performance/rows.py @@ -0,0 +1,222 @@ +from typing import Optional + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import transposed_reference +from sampletones_core.performance.state import ChannelPerformance +from sampletones_core.project.patterns.pitch import RowPitch, step_of +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.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 ( + VoiceLookup, + VoiceUnion, + voice_channels, + voice_reference, +) + + +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 sounding_voice( + performance: ChannelPerformance, + channel_name: ChannelName, + voices: VoiceLookup, +) -> Optional[VoiceUnion]: + """The voice a channel is sounding, and ``None`` while the channel is silent. + + A channel sounds while it carries a voice the project holds that has frames on the channel. A + voice that has played its frames out is still the channel's note, since no row has ended it, so + the channel goes on sounding it in these terms until a note-off or another note. + + Args: + performance: What the channel carries. + channel_name: The channel being read. + voices: Where a voice id resolves to the voice it names. + + Returns: + Optional[VoiceUnion]: The voice sounding, or ``None`` on a silent channel. + """ + if performance.voice_id is None: + return None + + voice = voices(performance.voice_id) + if voice is None or channel_name not in voice_channels(voice): + return None + + return voice + + +def sounding_pitch( + performance: ChannelPerformance, + channel_name: ChannelName, + voices: VoiceLookup, +) -> Optional[int]: + """The pitch, or the period, a channel is sounding, and ``None`` while it is silent. + + The voice sounds where its reference and the transpose in force put it, which is the note the + channel plays before any arpeggio the voice writes. + + Args: + performance: What the channel carries. + channel_name: The channel being read. + voices: Where a voice id resolves to the voice it names. + + Returns: + Optional[int]: The pitch the channel sounds, or ``None`` on a silent channel. + """ + voice = sounding_voice(performance, channel_name, voices) + if voice is None: + return None + + return transposed_reference( + channel_name, + voice_reference(voice, channel_name), + performance.transpose, + ) + + +def note_step( + pitch: Optional[RowPitch], + voice: VoiceUnion, + channel_name: ChannelName, + *, + sounding: Optional[int], +) -> Optional[int]: + """The step a note-on starts a voice at, and ``None`` where the row starts nothing. + + A row stating a pitch starts the voice there. A sample stating none plays as it was recorded. + An instrument stating none goes on from the pitch the channel is sounding, whichever voice + sounded it, which is what lets a row restart the envelopes under a note already standing; on + a silent channel it has no pitch to sound and starts nothing. + + Args: + pitch: The pitch the row states, or ``None`` for an empty cell. + voice: The voice the row names. + channel_name: The channel the row stands on. + sounding: The pitch the channel is sounding, or ``None`` while it is silent. + + Returns: + Optional[int]: The step from the voice's reference, or ``None`` where nothing starts. + """ + reference = voice_reference(voice, channel_name) + if pitch is not None: + return step_of(pitch, reference=reference) + + match voice: + case Sample(): + return 0 + case Instrument(): + if sounding is None: + return None + + return sounding - reference + + +def apply_row( + performance: ChannelPerformance, + row: Row, + channel_name: ChannelName, + voices: VoiceLookup, +) -> 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 at the step :func:`note_step` gives the + row, taking the volume the row states or the full level where it states none, with every + envelope dimension at the value a song starts on. A row naming no note leaves the voice playing + and changes only the columns it fills in: a pitch moves the note already sounding, measured + from that voice's own reference, and a volume sets its level. A voice the project no longer + holds starts as a silent note, so the rows that named it rest while the rows around them play. + + Args: + performance: What the channel carries; updated in place. + row: The row the channel reached. + channel_name: The channel the row stands on. + voices: Where a voice id resolves to the voice it names. + + Returns: + bool: Whether the channel starts over, which is where a phase-continuous voice resets. + """ + match row.command: + case NoteOn() as note_on: + voice = voices(note_on.voice_id) + step = ( + 0 + if voice is None + else note_step( + row.pitch, + voice, + channel_name, + sounding=sounding_pitch(performance, channel_name, voices), + ) + ) + if step is None: + return False + + performance.start_note( + note_on.voice_id, + transpose=step, + 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.pitch is not None: + _bend(performance, row.pitch, channel_name, voices) + + if row.volume is not None: + performance.volume = row.volume + + return False + + +def _bend( + performance: ChannelPerformance, + pitch: RowPitch, + channel_name: ChannelName, + voices: VoiceLookup, +) -> None: + """Moves the note a channel is sounding to a pitch, leaving a silent channel as it is.""" + voice = sounding_voice(performance, channel_name, voices) + if voice is None: + return + + performance.transpose = step_of(pitch, reference=voice_reference(voice, channel_name)) diff --git a/src/sampletones_core/performance/song.py b/src/sampletones_core/performance/song.py new file mode 100644 index 000000000..04ad7479b --- /dev/null +++ b/src/sampletones_core/performance/song.py @@ -0,0 +1,109 @@ +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.bounds import SONG_TICK_BOUNDS +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 + timing gives its place in the song. Every channel answers for each of those ticks, so the four + streams share one length and a tick's index into them is the same moment of the song — which is + what an engine consuming one instruction per tick plays from. + + The timing states the ticks the whole order lasts before a row is played, so a walk of a song + of minutes says how far along it is and answers a caller who no longer wants it. + + Args: + 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 + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + total = timing.frame_tick(song.order_length()) + performances = {channel_name: ChannelPerformance() for channel_name in ChannelName.items()} + streams: Dict[ChannelName, List[InstructionUnion]] = {channel_name: [] for channel_name in ChannelName.items()} + + position = SongPosition() + walked = 0 + while position.order_position < song.order_length(): + ticks = timing.row_ticks(position.order_position, position.row_index) + for channel_name in ChannelName.items(): + performance = performances[channel_name] + row = resolve_row(song, position, channel_name) + if row is not None: + apply_row(performance, row, channel_name, project.voice) + + 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..9b4f87253 --- /dev/null +++ b/src/sampletones_core/performance/state.py @@ -0,0 +1,72 @@ +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. Every note starts them from the values a song starts on, + and a frame the instrument writes hands its value over, so the channel keeps the last one + written for as long as the note sounds. + + Attributes: + voice_id: The voice the channel is sounding, or ``None`` while it is silent. + 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 start_note( + self, + voice_id: str, + *, + transpose: int, + volume: int, + ) -> None: + """Begins a note of ``voice_id`` from its first tick, at the transpose and volume given. + + The envelope dimensions start from the values a song starts on: full volume, no arpeggio + offset, no bend and the first timbre. A dimension the voice leaves empty therefore sounds + the same on every note, wherever the note stands in the song, which is how FamiTracker and + Bitphase start a note and what keeps a tracker export playing it as the song does. + + Args: + voice_id: The voice the note sounds. + transpose: The semitone offset the note plays at. + volume: The level the note plays at. + """ + self.voice_id = voice_id + self.tick_index = 0 + self.transpose = transpose + self.volume = volume + self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy() + + def reset(self) -> None: + """Returns the channel to silence at full volume, as a song starts it. + + 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..659c8ef8f --- /dev/null +++ b/src/sampletones_core/performance/voice.py @@ -0,0 +1,142 @@ +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 every note starts + where a song starts it. That is what clearing an envelope in the instruments panel means once + the voice is played in a song. + + Reading a voice on a channel answers all a channel needs of it — the frames, the reference its + arpeggio is measured against, the dimensions it leaves behind, and what it sounds once the + 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..13bb15b4d 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -1,21 +1,24 @@ +import json import zipfile from pathlib import Path -from typing import Dict +from typing import Dict, Set, Tuple, TypeAlias from pydantic import ValidationError -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.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import read_version, upgrade_json +from sampletones_core.project.document import PROJECT_DATA_CONTRACT, ProjectDocument 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, samples from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection -from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from sampletones_shared.constants.project import ( PROJECT_DOCUMENT_NAME, RECONSTRUCTIONS_DIRECTORY, ) -from sampletones_shared.deployment.version import compare_versions from sampletones_shared.exceptions import ( DeserializationError, IncompatibleProjectVersionError, @@ -31,15 +34,17 @@ from sampletones_shared.utils.serialization import JSON_INDENT from sampletones_shared.utils.system.paths import get_filename +StoredReconstructions: TypeAlias = Dict[int, Tuple[str, Reconstruction]] + class ProjectContainer: """Reads and writes a project as a compressed archive. 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. + validated :class:`ProjectDocument` -- plus one ``reconstructions/.stn`` per + distinct 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 @@ -47,15 +52,18 @@ class ProjectContainer: """ @staticmethod - def save(project: Project, path: Pathlike) -> None: - document = ProjectContainer._build_document(project) + def save( + project: Project, + path: Pathlike, + ) -> None: + stored = ProjectContainer._stored_reconstructions(project) + document = ProjectContainer._build_document(project, stored) payload = document.model_dump_json(indent=JSON_INDENT).encode("utf-8") - reconstructions = ProjectContainer._unique_reconstructions(project) with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: archive.writestr(PROJECT_DOCUMENT_NAME, payload) - for reconstruction_id, reconstruction in reconstructions.items(): - filename = get_filename(reconstruction_id, EXT_FILE_RECONSTRUCTION) + for key, reconstruction in stored.values(): + filename = get_filename(key, EXT_FILE_RECONSTRUCTION) name = f"{RECONSTRUCTIONS_DIRECTORY}/{filename}" archive.writestr(name, reconstruction.serialize()) @@ -63,8 +71,7 @@ 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)) - ProjectContainer._validate_document(document) + document = ProjectContainer._read_document(archive.read(PROJECT_DOCUMENT_NAME)) reconstructions = ProjectContainer._read_reconstructions(archive) return ProjectContainer._build_project(document, reconstructions) except zipfile.BadZipFile as exception: @@ -77,7 +84,7 @@ def load(path: Pathlike) -> Project: raise MissingProjectDataFileError( f'The project "{Path(path)}" is incomplete: missing {exception}' ) from exception - except ValidationError as exception: + except (ValidationError, json.JSONDecodeError, UnicodeDecodeError) as exception: raise InvalidProjectDataValuesError( f'Failed to load project data from "{Path(path)}" due to validation error: {exception}', exception, @@ -92,30 +99,36 @@ def load(path: Pathlike) -> Project: ) from exception @staticmethod - def _validate_document(document: ProjectDocument) -> None: - format_version = document.format_version - if compare_versions(format_version, SAMPLETONES_PROJECT_DATA_VERSION) != 0: - raise IncompatibleProjectVersionError( - f"Project data version mismatch: expected " - f"{SAMPLETONES_PROJECT_DATA_VERSION}, got {format_version}.", - expected_version=SAMPLETONES_PROJECT_DATA_VERSION, - actual_version=format_version, - ) + def _read_document(raw: bytes) -> ProjectDocument: + """The document an archive stores, upgraded, with its version checked before its shape. + + A document no upgrade reaches keeps the shape of the build that wrote it, so the version is + checked first and the refusal names both versions. A document stating no version is read at + the version its model gives it. + + Raises: + IncompatibleProjectVersionError: If the document states a version no upgrade reaches. + json.JSONDecodeError: If the document is not JSON. + UnicodeDecodeError: If the document is not text. + ValidationError: If the document's shape departs from the one this build reads. + """ + payload = json.loads(upgrade_json(ObjectKind.PROJECT, raw)) + stated_version = read_version(ObjectKind.PROJECT, payload) + if stated_version is not None: + PROJECT_DATA_CONTRACT.validate_version(stated_version) + + return ProjectDocument.model_validate(payload) @staticmethod - def _build_document(project: Project) -> ProjectDocument: + def _build_document( + project: Project, + stored: StoredReconstructions, + ) -> ProjectDocument: return 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, stored) for voice in project.voices], song=project.song, ) @@ -124,32 +137,77 @@ 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, + stored: StoredReconstructions, + ) -> VoiceRecord: + """The record a voice is written as: a reference for a sample, the whole of it for an instrument.""" + match voice: + case Sample(): + key, _ = stored[id(voice.reconstruction)] + return SampleRecord( + id=voice.id, + name=voice.name, + reconstruction_id=key, + ) + case Instrument(): + return voice @staticmethod - def _unique_reconstructions(project: Project) -> Dict[str, Reconstruction]: - reconstructions: Dict[str, Reconstruction] = {} - for sample in project.samples: - reconstructions[sample.reconstruction.id] = sample.reconstruction + 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 - return reconstructions + @staticmethod + def _stored_reconstructions(project: Project) -> StoredReconstructions: + """The key each distinct reconstruction is stored under, with the reconstruction itself. + + Samples sharing one reconstruction object store it once. A reconstruction keeps the key + its own id gives it, and a distinct object whose id an earlier one already took is stored + under the id of the sample holding it, so every document the samples play reaches the + archive whole. + """ + stored: StoredReconstructions = {} + taken: Set[str] = set() + for sample in samples(project.voices): + reconstruction = sample.reconstruction + if id(reconstruction) in stored: + continue + + key = reconstruction.id if reconstruction.id not in taken else sample.id + taken.add(key) + stored[id(reconstruction)] = (key, reconstruction) + + return stored @staticmethod def _read_reconstructions(archive: zipfile.ZipFile) -> Dict[str, Reconstruction]: diff --git a/src/sampletones_core/project/document.py b/src/sampletones_core/project/document.py index 63dfcc0b3..6869df409 100644 --- a/src/sampletones_core/project/document.py +++ b/src/sampletones_core/project/document.py @@ -1,21 +1,28 @@ -from typing import List +from typing import Final, List from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.data import Metadata -from sampletones_core.project.instruments.record import SampleRecord +from sampletones_core.data import Metadata, MetadataContract +from sampletones_core.project.voices.record import VoiceRecord from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION +from sampletones_shared.exceptions import IncompatibleProjectVersionError from .info import ProjectInfo from .settings import ProjectSettings from .song import Song +PROJECT_DATA_CONTRACT: Final[MetadataContract] = MetadataContract( + label="Project data", + expected_version=SAMPLETONES_PROJECT_DATA_VERSION, + error=IncompatibleProjectVersionError, +) + 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 +37,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/instruments/record.py b/src/sampletones_core/project/instruments/record.py deleted file mode 100644 index 03f5722b1..000000000 --- a/src/sampletones_core/project/instruments/record.py +++ /dev/null @@ -1,13 +0,0 @@ -from pydantic import BaseModel, Field - - -class SampleRecord(BaseModel): - """The on-disk form of a sample: its identity plus a reference to the - reconstruction stored separately in the archive.""" - - 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.", - ) diff --git a/src/sampletones_core/project/instruments/sample.py b/src/sampletones_core/project/instruments/sample.py deleted file mode 100644 index 672abb09b..000000000 --- a/src/sampletones_core/project/instruments/sample.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Self -from uuid import uuid4 - -from sampletones_core.reconstructions import Reconstruction - - -class Sample: - 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. - """ - return type(self)( - name=self.name, - reconstruction=self.reconstruction.model_copy(deep=True), - loop=self.loop, - ) - - def __hash__(self) -> int: - return hash(self.id) - - def __eq__(self, other: object) -> bool: - return isinstance(other, Sample) and self.id == other.id - - def __repr__(self) -> str: - return f"Sample(id={self.id!r}, name={self.name!r})" diff --git a/src/sampletones_core/project/patterns/__init__.py b/src/sampletones_core/project/patterns/__init__.py index 8ac21a2e7..585ae2c1a 100644 --- a/src/sampletones_core/project/patterns/__init__.py +++ b/src/sampletones_core/project/patterns/__init__.py @@ -1,9 +1,13 @@ from .channel import Channel from .pattern import Pattern +from .pitch import Note, RowPitch, Step from .row import Row __all__ = [ "Channel", + "Note", "Pattern", "Row", + "RowPitch", + "Step", ] diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py index eb4f81df2..8411b8b85 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) @@ -35,7 +35,7 @@ def _next_index(self, reserved_indices: AbstractSet[int] = frozenset()) -> int: """Returns a free index above every pool key and ``reserved_indices``. The pool alone does not reveal indices an order slot references before its - pattern is materialised, so a caller aware of those passes them as + pattern is materialized, so a caller aware of those passes them as ``reserved_indices`` to keep the new index from taking one of them. """ return max(self.patterns.keys() | reserved_indices, default=-1) + 1 @@ -55,7 +55,7 @@ def ensure_pattern(self, index: int, length: int) -> Pattern: """Returns the pattern at ``index``, creating an empty one if absent. Lets an order position reference an index before its pattern exists; the - pattern is materialised on first write (once the slot gains content). + pattern is materialized on first write (once the slot gains content). """ if index not in self.patterns: self.patterns[index] = Pattern.empty(length) @@ -67,11 +67,10 @@ def clone_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = froz ``reserved_indices`` are extra indices the clone must avoid beyond the pool's own keys, so a caller that knows of indices referenced elsewhere (order slots - whose patterns are not yet materialised) keeps the clone from taking one of them. + whose patterns are not yet materialized) keeps the clone from taking one of them. """ - source = self.patterns[index] clone_index = self._next_index(reserved_indices) - self.patterns[clone_index] = Pattern(name=source.name, rows=list(source.rows)) + self.patterns[clone_index] = self.patterns[index].model_copy() return clone_index def remove_pattern(self, index: int) -> None: @@ -81,7 +80,13 @@ def get_row(self, index: int, row_index: int) -> Row: return self.patterns[index].rows[row_index] def set_row(self, index: int, row_index: int, row: Row) -> None: - self.patterns[index].rows[row_index] = row + """Puts the pattern holding ``row`` at ``row_index`` in place of the one at ``index``.""" + self.patterns[index] = self.patterns[index].with_row(row_index, row) + + def snapshot(self) -> Channel: + """A pool of its own holding the very patterns this one holds, which an edit of either replaces.""" + copied: Channel = self.model_copy(update={"patterns": dict(self.patterns)}) + return copied 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/pattern.py b/src/sampletones_core/project/patterns/pattern.py index 538e8f1d9..32b928b78 100644 --- a/src/sampletones_core/project/patterns/pattern.py +++ b/src/sampletones_core/project/patterns/pattern.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import List, Optional +from typing import Mapping, Optional, Tuple -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .row import Row @@ -10,18 +10,24 @@ class Pattern(BaseModel): """A reusable block of rows belonging to a single channel. - Mutable so its rows and name can be edited in place. A pattern's identity is - its integer key within the owning channel's pattern dictionary, so the model - itself carries no id; the same key may be referenced from several order - positions, and editing the pattern there changes every position at once. + A pattern is a value: an edit makes a new pattern holding the very rows it leaves alone, and the + channel's pool puts it at the edited index. A pattern's identity is its integer key within the + owning channel's pattern dictionary, so the model itself carries no id; the same key may be + referenced from several order positions, and a pattern installed there is heard at every one. """ + model_config = ConfigDict(frozen=True) + name: Optional[str] = Field(default=None, description="Optional human-readable name.") - rows: List[Row] = Field(..., description="Tracker lines, in order.") + rows: Tuple[Row, ...] = Field(..., description="Tracker lines, in order.") @classmethod - def empty(cls, length: int, name: Optional[str] = None) -> Pattern: - return cls(rows=[Row() for _ in range(length)], name=name) + def empty( + cls, + length: int, + name: Optional[str] = None, + ) -> Pattern: + return cls(rows=(Row(),) * length, name=name) @property def length(self) -> int: @@ -30,5 +36,42 @@ def length(self) -> int: def is_empty(self) -> bool: return all(row.is_empty() for row in self.rows) + def with_row( + self, + index: int, + row: Row, + ) -> Pattern: + """The pattern holding ``row`` at ``index`` and every other row as it stands.""" + return self.with_rows({index: row}) + + def with_rows(self, rows: Mapping[int, Row]) -> Pattern: + """The pattern holding each of ``rows`` at its index and every other row as it stands.""" + updated = list(self.rows) + for index, row in rows.items(): + updated[index] = row + + written: Pattern = self.model_copy(update={"rows": tuple(updated)}) + return written + + def resized(self, length: int) -> Pattern: + """The pattern cut to ``length`` rows, or carried there with empty rows, the rows it keeps shared.""" + if length == len(self.rows): + return self + + kept = self.rows[:length] + resized: Pattern = self.model_copy(update={"rows": kept + (Row(),) * (length - len(kept))}) + return resized + + def without_voice(self, voice_id: str) -> Pattern: + """The pattern with every note naming ``voice_id`` cleared, the very pattern where none does.""" + if not any(row.references_voice(voice_id) for row in self.rows): + return self + + cleared = tuple( + row.model_copy(update={"command": None}) if row.references_voice(voice_id) else row for row in self.rows + ) + released: Pattern = self.model_copy(update={"rows": cleared}) + return released + def __repr__(self) -> str: return f"Pattern(name={self.name!r}, length={self.length})" diff --git a/src/sampletones_core/project/patterns/pitch.py b/src/sampletones_core/project/patterns/pitch.py new file mode 100644 index 000000000..31cda015a --- /dev/null +++ b/src/sampletones_core/project/patterns/pitch.py @@ -0,0 +1,132 @@ +from typing import Annotated, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PITCH, MAX_TRANSPOSE, MIN_TRANSPOSE, NUM_PERIODS +from sampletones_core.features import speaks_in_periods, transposed_reference +from sampletones_core.utils.pitch_kind import PLAYED_PITCH_VALUE_KIND, note_value_kind +from sampletones_shared.utils.arrays import clamp + + +class Note(BaseModel): + """A pitch cell naming the note the channel sounds. + + The value is the pitch on a tonal channel and the period on noise, the two ranges standing + apart, so a note reads the same whichever voice the row names and keeps its pitch when the + voice changes. + + Attributes: + value: The pitch, or the period, the channel sounds. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["note"] = "note" + value: int = Field(..., ge=0, le=MAX_PITCH) + + +class Step(BaseModel): + """A pitch cell naming the step from the voice's own reference. + + A sample measures the step from the pitch it was recorded at, and an instrument from its initial + pitch, so the same step moves every voice by the same interval from where it rests. + + Attributes: + value: The semitones, or the periods on noise, the row moves the voice by. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["step"] = "step" + value: int = Field(..., ge=MIN_TRANSPOSE, le=MAX_TRANSPOSE) + + +RowPitch = Annotated[Union[Note, Step], Field(discriminator="kind")] + + +def step_of(pitch: RowPitch, *, reference: int) -> int: + """The step from a voice's reference that a pitch cell asks for. + + A step states it outright, and a note is measured from the reference, so both faces reach the + channel as the one offset it sounds a voice at. + + Args: + pitch: The pitch the row states. + reference: The value the voice rests at on the channel. + + Returns: + int: The semitones, or the periods on noise, from the reference. + """ + match pitch: + case Step(): + return pitch.value + case Note(): + return pitch.value - reference + + +def sounded_pitch( + channel_name: ChannelName, + pitch: RowPitch, + *, + reference: int, +) -> int: + """Where a voice sounds on a channel once a pitch cell has moved it. + + Args: + channel_name: The channel sounding the voice. + pitch: The pitch the row states. + reference: The value the voice rests at on the channel. + + Returns: + int: The pitch, or the period, the channel sounds. + """ + return transposed_reference(channel_name, reference, step_of(pitch, reference=reference)) + + +def clamped_note(channel_name: ChannelName, value: int) -> Note: + """A note held inside the range a channel plays: its notes from C-0 up, or its sixteen periods.""" + return Note(value=note_value_kind(channel_name).clamp(value)) + + +def played_note(pitch: int) -> Note: + """A pitch as a note cell names it, held within the notes a tonal channel plays, C-0 to B-7.""" + return Note(value=PLAYED_PITCH_VALUE_KIND.clamp(pitch)) + + +def note_for_channel(channel_name: ChannelName, pitch: int) -> Note: + """The note a pitch names on a channel. + + A tonal channel names the pitch itself, held within the notes it plays. The noise channel names + the period ``pitch mod 16``, which is how FamiTracker reads a note on the noise channel, so a + piano key picks one of its sixteen periods there. + + Args: + channel_name: The channel the note is written on. + pitch: The pitch a piano key names. + + Returns: + Note: The note the channel takes. + """ + if speaks_in_periods(channel_name): + return Note(value=pitch % NUM_PERIODS) + + return played_note(pitch) + + +def clamped_step(value: int) -> Step: + """A step held inside the range a row accepts.""" + return Step(value=int(clamp(value, MIN_TRANSPOSE, MAX_TRANSPOSE))) + + +def shifted_pitch( + pitch: RowPitch, + delta: int, + channel_name: ChannelName, +) -> RowPitch: + """A pitch moved by ``delta`` on its own face: a note to another note, a step to another step.""" + match pitch: + case Step(): + return clamped_step(pitch.value + delta) + case Note(): + return clamped_note(channel_name, pitch.value + delta) diff --git a/src/sampletones_core/project/patterns/row.py b/src/sampletones_core/project/patterns/row.py index 54af3b1ee..d9b16c60b 100644 --- a/src/sampletones_core/project/patterns/row.py +++ b/src/sampletones_core/project/patterns/row.py @@ -2,37 +2,32 @@ from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.constants.general import ( - MAX_TRANSPOSE, - MAX_VOLUME, - MIN_TRANSPOSE, - SILENT_VOLUME, -) -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME +from sampletones_core.project.patterns.pitch import RowPitch +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 - :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. + The note column holds a :data:`NoteCommand`: a :class:`NoteOn` naming the voice to start, a + :class:`NoteOff`, or ``None`` for an empty cell. The pitch column holds a :class:`Note` the + channel sounds or a :class:`Step` from the voice's own reference, stored as it was written. A + fully empty row (no command, no pitch, no volume) is a blank line. """ model_config = ConfigDict(frozen=True) 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( + pitch: Optional[RowPitch] = Field( default=None, - ge=MIN_TRANSPOSE, - le=MAX_TRANSPOSE, - description="Note pitch, or None for an empty cell.", + description="The note the channel sounds or the step from the voice's reference, or None for an empty cell.", ) volume: Optional[int] = Field( default=None, @@ -42,8 +37,8 @@ class Row(BaseModel): ) def is_empty(self) -> bool: - return self.command is None and self.transpose is None and self.volume is None + return self.command is None and self.pitch 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..a48d947bd 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,31 @@ 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 snapshot(self) -> Project: + """An independent project holding the very values this one holds. + + The project is a tree of shells over values. A shell is changed in place by a gesture: the + info, the settings, the voices list and each voice, the song with its order and its + channels' pools. A value is never changed once made: the metadata, a sample's + reconstruction, an instrument's envelopes, a pattern and its rows. The snapshot copies the + shells and holds the values, so an edit of either project replaces a value in its own + shell and leaves the other as it was, and a history entry owns only the values its + gesture made. + """ + return Project( + metadata=self.metadata, + info=self.info.model_copy(), + settings=self.settings.model_copy(), + voices=IdentifiedCollection(voice.snapshot() for voice in self.voices), + song=self.song.snapshot(), + ) 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..bc605ab39 100644 --- a/src/sampletones_core/project/song.py +++ b/src/sampletones_core/project/song.py @@ -4,10 +4,9 @@ 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 from sampletones_shared.constants.project import ( MAX_ROWS_PER_PATTERN, MIN_ROWS_PER_PATTERN, @@ -23,7 +22,7 @@ class Song(BaseModel): automatically. ``rows_per_pattern`` is the invariant row count for every pattern in this song. - Changing it via :meth:`resize_patterns` resizes all existing patterns in place. + Changing it via :meth:`resize_patterns` puts every pattern, resized, in place of the old one. """ model_config = ConfigDict(validate_assignment=True) @@ -34,35 +33,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 +69,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 +79,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. + already reference, so it never aliases a slot whose pattern is unmaterialized. """ - 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: @@ -109,7 +108,7 @@ def duplicate_frame(self, position: int) -> None: pattern per channel and an edit to either is heard in both. The copy is a fresh mapping, so assigning a channel a different pattern in one frame leaves the other frame where it was. Silent slots stay silent, and an index whose pattern - is not yet materialised is carried across as the reference it is. + is not yet materialized is carried across as the reference it is. """ self.order.insert(position + 1, dict(self.order[position])) @@ -121,48 +120,45 @@ 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 - for row in pattern.rows - ] + for index, pattern in list(channel.patterns.items()): + channel.patterns[index] = pattern.without_voice(voice_id) def resize_patterns(self, rows_per_pattern: int) -> None: """Updates the row count for every pattern in the song. @@ -172,14 +168,23 @@ def resize_patterns(self, rows_per_pattern: int) -> None: holds after the call. """ self.rows_per_pattern = rows_per_pattern - empty_row = Row() for channel in self.channels.values(): - for pattern in channel.patterns.values(): - current = len(pattern.rows) - if current > rows_per_pattern: - pattern.rows = pattern.rows[:rows_per_pattern] - elif current < rows_per_pattern: - pattern.rows.extend([empty_row] * (rows_per_pattern - current)) + for index, pattern in list(channel.patterns.items()): + channel.patterns[index] = pattern.resized(rows_per_pattern) + + def snapshot(self) -> Song: + """A song of its own holding the very patterns this one holds. + + The order and each frame in it are copied, since a gesture writes them in place, and each + channel's pool is copied around the patterns it holds, which an edit replaces whole. + """ + copied: Song = self.model_copy( + update={ + "order": [dict(frame) for frame in self.order], + "channels": {name: channel.snapshot() for name, channel in self.channels.items()}, + } + ) + return copied def __repr__(self) -> str: return f"Song(order_length={len(self.order)}, rows_per_pattern={self.rows_per_pattern})" diff --git a/src/sampletones_core/project/tuning.py b/src/sampletones_core/project/tuning.py new file mode 100644 index 000000000..7afd772ed --- /dev/null +++ b/src/sampletones_core/project/tuning.py @@ -0,0 +1,41 @@ +from typing import Final, Set + +from sampletones_core.project.project import Project +from sampletones_core.project.voices.voice import samples +from sampletones_shared.exceptions import ProjectTuningError +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: + ProjectTuningError: 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 ProjectTuningError(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..95f85c175 --- /dev/null +++ b/src/sampletones_core/project/voices/envelopes.py @@ -0,0 +1,101 @@ +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, a pitch item bends a divider on the tonal channels and moves the noise period a step, + and a duty-cycle item selects a pulse waveform, its lowest bit the noise channel's short mode. An + empty envelope leaves that dimension to the channel, which sounds it at the value every note + starts on — the same record a reconstruction's held dimensions carry. + + 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..cfdb20f05 --- /dev/null +++ b/src/sampletones_core/project/voices/instrument.py @@ -0,0 +1,206 @@ +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, + speaks_in_periods, + supported_features, +) +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.spec import reads_from_instrument +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes + + +def _new_instrument_id() -> str: + return uuid4().hex + + +class Instrument(BaseModel): + """A voice made of envelopes alone, 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._writes(channel_name, feature_key) + ) + + def _writes(self, channel_name: ChannelName, feature_key: FeatureKey) -> bool: + """Whether the instrument decides a dimension on a channel. + + The noise channel plays a bend as steps of the period the arpeggio sets, so a frame carries + the two as one period, and an instrument writing a bend there decides the arpeggio too. + """ + if self.envelopes.envelope(feature_key).written: + return True + + return ( + feature_key is FeatureKey.ARPEGGIO + and speaks_in_periods(channel_name) + and self.envelopes.envelope(FeatureKey.PITCH).written + ) + + def features(self, channel_name: ChannelName) -> Features: + """The envelopes as this channel reads them, measured against the instrument's pitch. + + A channel takes the dimensions it reads of an instrument (see :func:`reads_from_instrument`) + and leaves the rest absent, which is what makes one set of envelopes serve every channel. + Each dimension travels with the item it repeats from, so a channel reads a loop the way the + instrument wrote it. + + Args: + channel_name: The channel reading the instrument. + + 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 reads of the instrument, as the instrument writes them.""" + kind = CHANNEL_GENERATOR_KIND[channel_name] + return { + feature_key: envelope + for feature_key, envelope in self.envelopes.envelope_map.items() + if reads_from_instrument(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 snapshot(self) -> Self: + """An instrument of its own with the same id, holding the very same envelopes.""" + return self.model_copy() + + 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/voices/record.py b/src/sampletones_core/project/voices/record.py new file mode 100644 index 000000000..0d0a67b3a --- /dev/null +++ b/src/sampletones_core/project/voices/record.py @@ -0,0 +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/voices/sample.py b/src/sampletones_core/project/voices/sample.py new file mode 100644 index 000000000..6f6fc4e5f --- /dev/null +++ b/src/sampletones_core/project/voices/sample.py @@ -0,0 +1,48 @@ +from typing import Self +from uuid import uuid4 + +from sampletones_core.reconstructions import Reconstruction + + +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, + ) -> None: + self.id: str = uuid4().hex + self.name: str = name + self.reconstruction: Reconstruction = reconstruction + + def clone(self) -> Self: + """Return a copy with a fresh id, holding the same reconstruction and carrying the name. + + A reconstruction never changes once made, and an edit installs a new one in the sample it + reaches, so the copy and the original share the document until one of them is edited. + """ + return type(self)( + name=self.name, + reconstruction=self.reconstruction, + ) + + def snapshot(self) -> Self: + """A sample of its own with the same id and name, holding the very same reconstruction.""" + copied = type(self)(name=self.name, reconstruction=self.reconstruction) + copied.id = self.id + return copied + + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other: object) -> bool: + return isinstance(other, Sample) and self.id == other.id + + def __repr__(self) -> str: + return f"Sample(id={self.id!r}, name={self.name!r})" diff --git a/src/sampletones_core/project/voices/voice.py b/src/sampletones_core/project/voices/voice.py new file mode 100644 index 000000000..b3cfa41e6 --- /dev/null +++ b/src/sampletones_core/project/voices/voice.py @@ -0,0 +1,62 @@ +from typing import Callable, Iterable, Iterator, Optional, 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] +VoiceLookup = Callable[[str], Optional[VoiceUnion]] + + +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..345cd560a 100644 --- a/src/sampletones_core/reconstructions/converter/__init__.py +++ b/src/sampletones_core/reconstructions/converter/__init__.py @@ -1,19 +1,21 @@ -from .conversion import reconstruct_file +from .conversion import reconstruct_job from .converter import ReconstructionConverter -from .paths.fields import ConfigDirectoryFields -from .paths.utils import ( - filter_files, - get_audio_files, - get_output_path, - get_relative_path, +from .job import ConversionJob +from .plan import ( + BatchConversion, + BatchEntry, + ConversionPlan, + DirectoryConversion, + GroupConversion, ) __all__ = [ - "ConfigDirectoryFields", + "BatchConversion", + "BatchEntry", + "ConversionJob", + "ConversionPlan", + "DirectoryConversion", + "GroupConversion", "ReconstructionConverter", - "filter_files", - "get_audio_files", - "get_output_path", - "get_relative_path", - "reconstruct_file", + "reconstruct_job", ] 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..dc4bc630d 100644 --- a/src/sampletones_core/reconstructions/converter/converter.py +++ b/src/sampletones_core/reconstructions/converter/converter.py @@ -1,37 +1,38 @@ from pathlib import Path -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, FrozenSet, List, Optional, Tuple from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName 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: @@ -41,39 +42,37 @@ def start(self) -> None: super().start() 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)) + """One task per job, sharing the reconstructor the whole run's channels are built for.""" + self.jobs = self.plan.jobs(self.config) + reconstructor = Reconstructor(self.config, self._covered_channels()) + return [(reconstructor, job, JobReporter(self._task_reporter(index))) for index, job in enumerate(self.jobs)] - if not arguments: - raise NoFilesToProcessError(f"No audio files found in {self.input_path}") - - return arguments + def _covered_channels(self) -> FrozenSet[ChannelName]: + """Every channel the jobs hand out, which is what the run builds generators for.""" + return frozenset(channel_name for job in self.jobs for channel_name in job.stems.covered_channels) 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 - + job = self.job_under_way(self.completed_tasks) + self.current_item = str(job.sources[0]) if job is not None else None super()._notify_progress() + + def job_under_way(self, completed: int) -> Optional[ConversionJob]: + """The job the run names itself by once ``completed`` jobs are counted. + + 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. A run that has yet to read its plan holds no job. + """ + if not self.jobs: + return None + + return self.jobs[min(completed, len(self.jobs) - 1)] 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..acb80aa35 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -2,16 +2,28 @@ ConfigDirectoryFields, ) from sampletones_core.reconstructions.converter.paths.utils import ( + config_directory_path, filter_files, get_audio_files, get_output_path, get_relative_path, + group_output_path, + named_output_path, + reconstructions_directory, + walk_audio_files, + walk_entries, ) __all__ = [ "ConfigDirectoryFields", + "config_directory_path", "filter_files", "get_audio_files", "get_output_path", "get_relative_path", + "group_output_path", + "named_output_path", + "reconstructions_directory", + "walk_audio_files", + "walk_entries", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index 9ff18843c..c2272b24e 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -1,4 +1,4 @@ -from typing import Final, Optional, Self, Tuple +from typing import AbstractSet, Final, Optional, Self, Tuple from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -9,14 +9,15 @@ 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, + ordered_channels, ) from sampletones_core.constants.field_aliases import ALIASES -from sampletones_shared.utils.serialization import HASH_PATTERN, hash_models +from sampletones_shared.utils.hashing import HASH_PATTERN, hash_models CONFIG_DIRECTORY_SEPARATOR: Final[str] = "_" @@ -37,24 +38,49 @@ 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: + def from_config(cls, config: Config, channels: AbstractSet[ChannelName]) -> Self: + """The fields a reconstruction's directory is named from: the settings, and the channels it was handed. + + The channels come from the setup, so the name is written in the order the application + states them however the caller gathered the set. + """ + return cls.from_hashed_config(config, channels, settings_hash=cls.settings_hash(config)) + + @classmethod + def from_hashed_config( + cls, + config: Config, + channels: AbstractSet[ChannelName], + *, + settings_hash: str, + ) -> Self: + """The fields :meth:`from_config` names, with the hash of the settings given. + + A caller naming the directories of several channel sets under one configuration hashes the + settings once with :meth:`settings_hash` and names each set from that hash. + """ return cls( sr=config.library.sample_rate, nf=config.library.nes_frequency, sm=config.library.spectrum_method, tg=config.library.transformation_gamma, - gn=abbreviate_generator_names(config.generation.generators), - ch=hash_models(config.library, config.generation), + gn=abbreviate_channel_names(ordered_channels(channels)), + ch=settings_hash, ) + @staticmethod + def settings_hash(config: Config) -> str: + """The hash a directory's name carries, folding in the library and generation settings.""" + return hash_models(config.library, config.generation) + @classmethod def from_directory_name(cls, name: str) -> Optional[Self]: """Parses a directory name, returning ``None`` when it is not a config directory. @@ -96,7 +122,3 @@ def display_name(self) -> str: self.gn, ] ) - - @classmethod - def generate_config_directory_name(cls, config: Config) -> str: - return cls.from_config(config).directory_name diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 3069f11b1..b73df7ac2 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -1,13 +1,18 @@ +import errno +import os from pathlib import Path -from typing import List, Tuple +from typing import AbstractSet, Dict, FrozenSet, Iterator, List, Tuple from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName 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, + is_audio_file, ) from sampletones_shared.utils.system.paths import to_path @@ -24,13 +29,68 @@ def get_relative_path( return Path(output_path.absolute()) +def reconstructions_directory(config: Config) -> Path: + """The directory holding the folder of every setting a run writes under.""" + return to_path(config.general.reconstructions_directory) + + +class ConfigDirectories: + """The directories one configuration writes its reconstructions into, one per channel set. + + A directory is named after the settings that shaped the library and the channels the + reconstruction was handed, so reconstructions that differ in either keep apart. The settings' + part of the name, with its hash, is read once when this is built, and each channel set names + its directory the first time it is asked for. A plan reads where its recordings are written + through one of these, so the reading costs one hash, however many recordings it names. + """ + + def __init__(self, config: Config) -> None: + self._config = config + self._root = reconstructions_directory(config) + self._settings_hash = ConfigDirectoryFields.settings_hash(config) + self._named: Dict[FrozenSet[ChannelName], Path] = {} + + @property + def root(self) -> Path: + """The directory holding the directory of every channel set.""" + return self._root + + def directory(self, channels: AbstractSet[ChannelName]) -> Path: + """The directory of the reconstructions handed ``channels``.""" + channel_set = frozenset(channels) + directory = self._named.get(channel_set) + if directory is None: + fields = ConfigDirectoryFields.from_hashed_config( + self._config, + channel_set, + settings_hash=self._settings_hash, + ) + directory = self._root / fields.directory_name + self._named[channel_set] = directory + + return directory + + +def config_directory_path( + config: Config, + channels: AbstractSet[ChannelName], +) -> Path: + """The directory a reconstruction handed ``channels`` is written into, as :class:`ConfigDirectories` names it.""" + return ConfigDirectories(config).directory(channels) + + def get_output_path( config: Config, input_path: Path, + channels: AbstractSet[ChannelName], suffix: str = EXT_FILE_RECONSTRUCTION, ) -> Path: - config_directory = ConfigDirectoryFields.generate_config_directory_name(config) - output_directory = to_path(config.general.reconstructions_directory) / config_directory + """Where the reconstruction of one recording, or the folder of them, is written. + + ``channels`` names what the run hands out, which the configuration's own directory is named + after alongside the settings that shaped the library. + """ + output_directory = config_directory_path(config, channels) if input_path.is_dir(): return output_directory / input_path.name @@ -43,12 +103,87 @@ def get_output_path( raise OSError(f"Invalid path: {input_path}") +def group_output_path( + config: Config, + sources: Tuple[Path, ...], + channels: AbstractSet[ChannelName], + 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`). + + ``channels`` names what the run hands out, which the configuration's own directory is named + after. + + Raises: + ValueError: If ``sources`` is empty. + """ + return named_output_path(config_directory_path(config, channels), sources, suffix) + + +def named_output_path( + directory: Path, + sources: Tuple[Path, ...], + suffix: str = EXT_FILE_RECONSTRUCTION, +) -> Path: + """Where the one reconstruction built from ``sources`` is written inside ``directory``. + + The file takes the name the source rules derive: one source names it after itself, and several + after what they share. + + Raises: + ValueError: If ``sources`` is empty. + """ + return Path((directory / f"{derive_name(sources)}{suffix}").absolute()) + + +def walk_entries(input_directory: Path) -> Iterator[Path]: + """Every path below a directory, reported as the walk meets it. + + A caller that has to answer between entries — one counting what it has found, or one a reader + may stop partway — reads the tree through this and decides for itself what each entry is. A + folder below the directory that the reader may not open is passed over with everything it + holds, so one locked folder leaves the rest of the tree to the walk. + + A POSIX directory opens its entries through its execute permission, so a directory may list its + names and keep its entries closed. ``os.access`` reads that permission, and it answers True on + Windows, whose folders carry none. + + Raises: + OSError: If the directory itself cannot be opened, which leaves the walk nothing to read. + PermissionError: If the directory lists its names and keeps its entries closed, which leaves + the walk nothing to read either. + """ + os.scandir(input_directory).close() + if not os.access(input_directory, os.X_OK): + raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(input_directory)) + + return input_directory.rglob("*") + + +def walk_audio_files( + input_directory: Path, + extensions: Tuple[str, ...] = EXT_FILES_AUDIO, +) -> Iterator[Path]: + """The recordings below a directory, reported as the walk meets them. + + A tree is read one entry at a time, so a caller reporting how far it has got hears from the + walk while it runs rather than once it ends. + """ + for path in walk_entries(input_directory): + if is_audio_file(path, extensions): + yield path + + def get_audio_files( input_directory: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO, sort: bool = False, ) -> List[Path]: - audio_files = [path for path in input_directory.rglob("*") if path.is_file() and path.suffix.lower() in extensions] + audio_files = list(walk_audio_files(input_directory, extensions)) if sort: audio_files.sort() 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..16e860bca --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/__init__.py @@ -0,0 +1,12 @@ +from .batch import BatchConversion, BatchEntry +from .directory import DirectoryConversion +from .group import GroupConversion +from .protocol import ConversionPlan + +__all__ = [ + "BatchConversion", + "BatchEntry", + "ConversionPlan", + "DirectoryConversion", + "GroupConversion", +] diff --git a/src/sampletones_core/reconstructions/converter/plan/batch.py b/src/sampletones_core/reconstructions/converter/plan/batch.py new file mode 100644 index 000000000..d79310f09 --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/batch.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.job import ConversionJob +from sampletones_core.reconstructions.converter.paths.utils import ( + ConfigDirectories, + get_relative_path, + named_output_path, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_shared.exceptions import NoFilesToProcessError + + +@dataclass(frozen=True) +class BatchEntry: + """One recording a batch converts on its own, under the setup handing out its channels. + + The reconstruction lands in the folder named after the channels this recording is handed, so + a folder holds reconstructions made with the channels its name states. ``base_directory`` + names the folder the recording was gathered from, whose tree the written reconstructions + mirror. A recording gathered by name carries none, and its reconstruction sits directly in its + channels' folder. + """ + + source: Path + stems: StemsConfig + base_directory: Optional[Path] + + @property + def is_named(self) -> bool: + """The reader named this recording itself, rather than the folder holding it.""" + return self.base_directory is None + + def directory(self, directories: ConfigDirectories) -> Path: + """The folder of the channels this recording is handed, which its reconstruction lands in.""" + return directories.directory(self.stems.covered_channels) + + def output_path(self, directories: ConfigDirectories) -> Path: + """The reconstruction this recording is written to.""" + directory = self.directory(directories) + if self.base_directory is None: + return named_output_path(directory, (self.source,)) + + return get_relative_path(self.base_directory, self.source, directory / self.base_directory.name) + + +@dataclass(frozen=True) +class BatchConversion: + """One reconstruction per recording gathered, each built from that recording alone. + + Every recording carries the channels its own row holds and is written into the folder named + after them, so one batch writes as many folders as the setups the reader worked out. A + recording named by the reader is written whenever the batch runs; one gathered from a folder is + left as it stands where its own reconstruction is already written, so a repeated run over a + folder picks up where the last one stopped and writes again the recordings whose channels + changed since. + """ + + entries: Tuple[BatchEntry, ...] + + def destination(self, config: Config) -> Path: + """The reconstruction a batch of one writes, or the folder holding everything a larger batch writes. + + Recordings sharing their channels share one folder, which is that folder; recordings with + different channels fill one folder apiece, all held in the reconstructions directory. + """ + directories = ConfigDirectories(config) + if len(self.entries) == 1: + return self.entries[0].output_path(directories) + + named = {entry.directory(directories) for entry in self.entries} + if len(named) == 1: + return named.pop() + + return directories.root + + def jobs(self, config: Config) -> List[ConversionJob]: + """The single-source jobs this batch writes. + + Raises: + NoFilesToProcessError: If every gathered recording is reconstructed already. + """ + directories = ConfigDirectories(config) + targets = ((entry, entry.output_path(directories)) for entry in self.entries) + jobs = [ + ConversionJob(sources=(entry.source,), stems=entry.stems, output_path=output_path) + for entry, output_path in targets + if entry.is_named or not output_path.exists() + ] + if not jobs: + raise NoFilesToProcessError("Every gathered recording is reconstructed already") + + return jobs + + def existing_targets(self, config: Config) -> Tuple[Path, ...]: + """The reconstructions standing where a recording the reader named would be written.""" + directories = ConfigDirectories(config) + named = (entry.output_path(directories) for entry in self.entries if entry.is_named) + return tuple(output_path for output_path in named if output_path.is_file()) 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..8f18b69d1 --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/directory.py @@ -0,0 +1,59 @@ +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 = self.destination(config) + 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 destination(self, config: Config) -> Path: + """The folder mirroring the directory, which every reconstruction this conversion writes sits in. + + Raises: + FileNotFoundError: If the directory does not exist. + """ + return get_output_path(config, self.directory, self.stems.covered_channels) + + 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..ed0589d7a --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/group.py @@ -0,0 +1,38 @@ +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.destination(config), + ) + ] + + def existing_targets(self, config: Config) -> Tuple[Path, ...]: + """The one file this conversion writes, where it already stands.""" + output_path = self.destination(config) + return (output_path,) if output_path.is_file() else () + + def destination(self, config: Config) -> Path: + """The one reconstruction this conversion writes.""" + return group_output_path(config, self.sources, self.stems.covered_channels) 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..3b4893fd1 --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/protocol.py @@ -0,0 +1,32 @@ +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 destination(self, config: Config) -> Path: + """Where this plan writes: the one reconstruction a plan of one makes, or the folder the rest fill. + + A reader is shown this path before the run, so every reconstruction the plan's jobs write + is this file or lies inside this folder. + """ + + 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..bc5dfb610 100644 --- a/src/sampletones_core/reconstructions/criterion/criterion.py +++ b/src/sampletones_core/reconstructions/criterion/criterion.py @@ -1,13 +1,13 @@ from typing import Tuple, Union from sampletones_core.configs import Config -from sampletones_core.constants.enums import SpectralDistance from sampletones_core.fft import Window from sampletones_core.structures.histogram import Histogram from sampletones_shared.array import xp -from .spectral import calculate_spectral_loss -from .temporal import calculate_temporal_loss +from .metric import SpectralMetric +from .spectral import calculate_spectral_loss, weighted_reference_energy +from .temporal import calculate_expected_temporal_loss, calculate_temporal_loss from .weights import calculate_spectral_weights @@ -27,8 +27,7 @@ def __init__(self, config: Config, window: Window, signal_length: int) -> None: self.alpha, self.beta = _validate_loss_blend(config) metric = config.generation.metric - self.spectral_distance = SpectralDistance(metric.spectral_distance) - self.divergence_beta = float(metric.beta) + self.metric = SpectralMetric.from_config(metric) self.temporal_level_floor = float(metric.temporal_level_floor) self.weights = calculate_spectral_weights(config, window, signal_length) @@ -51,8 +50,23 @@ def spectral_loss( _feature_values(feature), _feature_values(approximation_feature), self.weights, - distance=self.spectral_distance, - divergence_beta=self.divergence_beta, + metric=self.metric, + ) + + 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.metric.distance, ) def temporal_loss( @@ -80,6 +94,33 @@ def temporal_loss( level_floor=self.temporal_level_floor, ) + def expected_temporal_loss( + self, + audio: xp.ndarray, + expectation: Union[float, xp.ndarray], + variance: Union[float, xp.ndarray], + ) -> xp.ndarray: + """ + Level-normalized RMS difference expected between the target and candidates known up to a + spread about the waveform they are expected to render. + + Args: + audio: Target waveform. + expectation: The expected waveforms, one candidate per row, or one waveform or level + every candidate shares. + variance: The per-sample variance of each candidate, or one variance every candidate + shares. + + Returns: + One loss per candidate. + """ + return calculate_expected_temporal_loss( + audio, + expectation, + variance, + level_floor=self.temporal_level_floor, + ) + def combine_losses( self, spectral_loss: Union[float, xp.ndarray], diff --git a/src/sampletones_core/reconstructions/criterion/metric.py b/src/sampletones_core/reconstructions/criterion/metric.py new file mode 100644 index 000000000..ab4ed2e86 --- /dev/null +++ b/src/sampletones_core/reconstructions/criterion/metric.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_core.configs.generation import MetricConfig +from sampletones_core.constants.enums import SpectralDistance + + +@dataclass(frozen=True) +class SpectralMetric: + """What the spectral term measures with: the distance family, and the scale it reads bins on. + + The scale states how far under a frame's loudest bin the comparison reaches and how loudly each + bin counts within that reach, so one frame's quiet detail and another's loud peaks are measured + the same way. + + Attributes: + distance: The family the per-bin distance belongs to. + divergence_beta: The beta of the beta-divergence distance. + silence_floor: The power a frame whose own bins lie under it is measured from. + dynamic_range_decibels: How far under the loudest bin the frame's floor sits. + """ + + distance: SpectralDistance + divergence_beta: float + silence_floor: float + dynamic_range_decibels: float + + @classmethod + def from_config(cls, metric: MetricConfig) -> Self: + """The metric the configured settings state.""" + return cls( + distance=SpectralDistance(metric.spectral_distance), + divergence_beta=float(metric.beta), + silence_floor=float(metric.silence_floor), + dynamic_range_decibels=float(metric.dynamic_range_decibels), + ) diff --git a/src/sampletones_core/reconstructions/criterion/spectral.py b/src/sampletones_core/reconstructions/criterion/spectral.py index 8324d3ba5..9768295c9 100644 --- a/src/sampletones_core/reconstructions/criterion/spectral.py +++ b/src/sampletones_core/reconstructions/criterion/spectral.py @@ -1,10 +1,10 @@ from typing import Tuple -from sampletones_core.constants.algorithm import SPECTRUM_FLOOR from sampletones_core.constants.enums import SpectralDistance from sampletones_shared.array import xp from .alignment import align_candidates +from .metric import SpectralMetric def calculate_spectral_loss( @@ -12,22 +12,20 @@ def calculate_spectral_loss( candidates: xp.ndarray, weights: xp.ndarray, *, - distance: SpectralDistance, - divergence_beta: float, + metric: SpectralMetric, ) -> xp.ndarray: """ Weighted spectral distance between a target feature and candidate features. - The per-bin distances are weighted and normalized by the target's own weighted - energy, so the score reflects spectral shape at every target level. The - `SPECTRUM_FLOOR` in the denominator keeps the ratio finite for silent targets. + The per-bin distances are weighted and divided by the target's own weighted energy, so a + score reflects spectral shape at every target level. Every side measures power above the + target's `spectral_floor`, which keeps the reading finite for silent targets. Args: reference: Target feature values, one dimension. candidates: Candidate feature values, one candidate per row. weights: Per-bin weights of the configuration. - distance: Per-bin distance family. - divergence_beta: Beta parameter of the beta-divergence distance. + metric: The distance family and the scale bins are measured on. Returns: One loss per candidate. @@ -38,8 +36,9 @@ def calculate_spectral_loss( ValueError: If the spectral distance is unsupported. """ reference, candidates, weights = _prepare(reference, candidates, weights) + floor = spectral_floor(reference, metric=metric) - match distance: + match metric.distance: case SpectralDistance.SQUARED: numerator = xp.sqrt( xp.sum( @@ -47,25 +46,78 @@ 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 * _beta_divergence( reference, candidates, - divergence_beta, + metric.divergence_beta, + floor, ), axis=-1, ) - denominator = xp.sum(weights * reference, axis=-1) case _: - raise ValueError(f"Unsupported spectral distance: {distance}") + raise ValueError(f"Unsupported spectral distance: {metric.distance}") + + denominator = weighted_reference_energy(reference, weights, distance=metric.distance) + return numerator / (denominator + floor) + + +def spectral_floor(reference: xp.ndarray, *, metric: SpectralMetric) -> xp.ndarray: + """ + The power a bin of the target's frame is measured above, set by that frame's loudest bin. + + The floor sits the metric's dynamic range under the loudest bin, so a candidate's addition is + charged wherever it stays audible beside what the frame sounds, at every frame loudness: quiet + noise under a loud tone costs what it adds, and noise filling a frame costs what it leaves out. + A frame whose loudest bin lies under the metric's silence floor is measured from that level, + which keeps the floor positive for a silent frame. + + Args: + reference: Target feature values. + metric: The scale the frame is measured on. + + Returns: + The floor, as a scalar on the active array backend. + """ + loudest = xp.maximum(xp.max(reference), metric.silence_floor) + return loudest * 10.0 ** (-metric.dynamic_range_decibels / 10.0) + + +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. - return numerator / (denominator + SPECTRUM_FLOOR) + 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( @@ -84,17 +136,35 @@ def _beta_divergence( reference: xp.ndarray, candidates: xp.ndarray, beta: float, + floor: xp.ndarray, ) -> xp.ndarray: - reference = reference + SPECTRUM_FLOOR - candidates = candidates + SPECTRUM_FLOOR + """ + Per-bin beta-divergence of the floored candidates from the floored reference. + + Every branch reads the divergence off the relative difference `u` of the two spectra, in double + precision, and returns it in the reference's precision. The divergence of a small `u` is about + `u² / 2` while its rounding stays near machine epsilon times `|u|`, so double precision measures + a candidate whose spectrum differs from the reference by a millionth as finely as a loud one. + """ + precision = reference.dtype + reference = reference.astype(xp.float64) + candidates = candidates.astype(xp.float64) + floor = xp.asarray(floor, dtype=xp.float64) + floored_reference = reference + floor + floored_candidates = candidates + floor if beta == 1.0: - return reference * (xp.log(reference) - xp.log(candidates)) + (candidates - reference) + divergence = floored_reference * _log_excess((candidates - reference) / floored_reference) + elif beta == 0.0: + divergence = _log_excess((reference - candidates) / floored_candidates) + else: + relative = (reference - candidates) / floored_candidates + excess = xp.expm1(beta * xp.log1p(relative)) - beta * relative + divergence = floored_candidates**beta * excess / (beta * (beta - 1.0)) + + return divergence.astype(precision) - if beta == 0.0: - ratio = reference / candidates - return ratio - xp.log(ratio) - 1.0 - return (reference**beta + (beta - 1.0) * candidates**beta - beta * reference * candidates ** (beta - 1.0)) / ( - beta * (beta - 1.0) - ) +def _log_excess(relative: xp.ndarray) -> xp.ndarray: + """`u - log(1 + u)`, the divergence of a ratio `1 + u` from one, which vanishes quadratically at `u = 0`.""" + return relative - xp.log1p(relative) diff --git a/src/sampletones_core/reconstructions/criterion/temporal.py b/src/sampletones_core/reconstructions/criterion/temporal.py index 5d7819fb3..a8305cade 100644 --- a/src/sampletones_core/reconstructions/criterion/temporal.py +++ b/src/sampletones_core/reconstructions/criterion/temporal.py @@ -1,3 +1,5 @@ +from typing import Union + from sampletones_shared.array import xp from .alignment import align_candidates @@ -34,3 +36,45 @@ def calculate_temporal_loss( rmse = xp.sqrt(xp.mean(xp.square(candidates - reference), axis=-1)) level = xp.sqrt(xp.mean(xp.square(reference))) return rmse / xp.maximum(level, level_floor) + + +def calculate_expected_temporal_loss( + audio: xp.ndarray, + expectation: Union[float, xp.ndarray], + variance: Union[float, xp.ndarray], + *, + level_floor: float, +) -> xp.ndarray: + """ + RMS difference expected between a waveform and candidates known up to a spread about a waveform. + + A candidate's expectation is the waveform it is expected to render: its rendering at a known + phase, or the mean level of a candidate standing at every phase alike. Its variance is its + spread about that expectation, so the expected squared difference is the squared difference + from the expectation plus the variance. The root of that expectation normalizes by the target's + level as `calculate_temporal_loss` does, so the two losses stand on one scale. + + Args: + audio: Target waveform, one dimension. + expectation: The expected waveforms, one candidate per row, or one waveform or level every + candidate shares. + variance: The per-sample variance of each candidate, or one variance every candidate shares. + level_floor: Lowest target level the loss normalizes by. + + Returns: + One loss per candidate. + + Raises: + ValueError: If the target has more than one dimension. + """ + reference = xp.asarray(audio) + if reference.ndim != 1: + raise ValueError("audio must be 1D") + + expected = xp.asarray(expectation) + if expected.ndim < 2: + expected = xp.reshape(expected, (1, -1)) + + mean_square = xp.mean(xp.square(reference[None, :] - expected), axis=-1) + xp.reshape(xp.asarray(variance), (-1,)) + level = xp.sqrt(xp.mean(xp.square(reference))) + return xp.sqrt(mean_square) / xp.maximum(level, level_floor) 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 deleted file mode 100644 index 8956a60d7..000000000 --- a/src/sampletones_core/reconstructions/reconstruction/approximations.py +++ /dev/null @@ -1,18 +0,0 @@ -import numpy as np -from pydantic import ConfigDict, Field, field_serializer - -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.data import DataModel -from sampletones_shared.types.data import SerializedData -from sampletones_shared.utils.serialization import serialize_array - - -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") - - @field_serializer("approximation") - def serialize_approximation(self, approximation: np.ndarray) -> SerializedData: - return serialize_array(approximation) diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index 7fe2fd894..d6626d4e8 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -1,58 +1,91 @@ from __future__ import annotations -from typing import Iterable, List +from typing import Iterable, List, Self, Tuple -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator -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 +from sampletones_core.instructions import InstructionData, InstructionUnion, sounds 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( + instructions: Tuple[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( + held_features: Tuple[FeatureKey, ...] = Field( ..., - description="Dimensions the channel governs, keeping the value it holds while the generator sounds", + description="Dimensions the channel governs, sounding at the value every note starts on", ) + @model_validator(mode="after") + def _validate_a_stream_sounds(self) -> Self: + """Holds a stored stream to sounding somewhere, since a channel resting throughout stands by. + + Raises: + ValueError: If the stream holds frames and none of them sounds. + """ + if self.instructions and not self.sounds: + raise ValueError(f"The {self.channel_name} stream rests through every frame it holds") + + return self + + @property + def sounds(self) -> bool: + """Whether the channel sounds in some frame, which is what puts it in play.""" + return sounds(data.instruction for data in self.instructions) + @classmethod def create( cls, - generator_name: GeneratorName, + channel_name: ChannelName, instructions: List[InstructionUnion], initial_pitch: int, held_features: Iterable[FeatureKey], ) -> InstructionsItem: + """The stream a channel plays, stored as no frame at all where it sounds in none. + + A channel resting through every frame stands by, whatever left it silent, so its stream + describes no frame. The reference and the held dimensions stay those handed in, so a + channel an edit or a removal silences keeps the base it was shaped from. + + Args: + channel_name: The channel the stream belongs to. + instructions: What the channel plays, one instruction per frame. + initial_pitch: The reference the channel's arpeggio envelope is measured against. + held_features: The dimensions the channel governs. + + Returns: + InstructionsItem: The stream the channel carries. + """ + stored = instructions if sounds(instructions) else [] return InstructionsItem( - generator_name=generator_name, - instructions=[ + channel_name=channel_name, + instructions=tuple( InstructionData( instruction_class=instruction.class_name(), instruction=instruction, ) - for instruction in instructions - ], + for instruction in stored + ), initial_pitch=initial_pitch, - held_features=list(held_features), + held_features=tuple(list(held_features)), ) @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 +95,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, - instructions=[], - initial_pitch=resting_reference(generator_name), - held_features=resting_held_features(generator_name), + return cls( + channel_name=channel_name, + instructions=tuple([]), + initial_pitch=resting_reference(channel_name), + held_features=tuple(list(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..8ee3bd9e2 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -1,13 +1,16 @@ from __future__ import annotations import struct +from contextlib import contextmanager from functools import cached_property from pathlib import Path from typing import ( - Any, + AbstractSet, Dict, Final, + FrozenSet, Iterable, + Iterator, List, Mapping, Optional, @@ -17,21 +20,31 @@ ) from uuid import uuid4 -import numpy as np -from pydantic import ConfigDict, Field, ValidationError, field_serializer +from pydantic import ConfigDict, Field, ValidationError, model_validator +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.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.data import DataModel, Metadata, MetadataContract +from sampletones_core.data.document import decompress_document 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.instructions import InstructionUnion +from sampletones_core.instructions import InstructionUnion, sounds +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +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 heard_instructions +from sampletones_core.reconstructions.reconstruction.stems.ownership import UNRECORDED_STEM_IDS, carried_edit +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection +from sampletones_core.reconstructions.reconstructor.state import ReconstructionState +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION from sampletones_shared.exceptions import ( IncompatibleReconstructionVersionError, @@ -41,14 +54,8 @@ ) from sampletones_shared.logger import logger 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 +from sampletones_shared.utils.serialization import load_binary RECONSTRUCTION_DATA_CONTRACT: Final[MetadataContract] = MetadataContract( label="Reconstruction data", @@ -58,7 +65,7 @@ class Reconstruction(DataModel): - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) metadata: Metadata = Field( default_factory=Metadata.default, @@ -68,38 +75,110 @@ class Reconstruction(DataModel): ..., description="Unique identifier for the reconstruction", ) - audio_filepath: Optional[Path] = Field( - ..., - description="Location of the source audio; None marks a reconstruction detached from its local origin", - ) config: Config = Field( ..., description="Configuration used for reconstruction", frozen=True, ) - approximation: np.ndarray = Field( + instructions_data: Tuple[InstructionsItem, ...] = Field( ..., - description="Audio approximation", + description="Instructions per channel", ) - approximations_data: List[ApproximationsItem] = Field( + stems_data: StemsData = Field( ..., - description="Approximations per generator", - ) - instructions_data: List[InstructionsItem] = Field( - ..., - description="Instructions per generator", + description="The stems setup and per-frame assignment recorded by the conversion", ) coefficient: float = Field( ..., description="Normalization coefficient used during reconstruction", ) - @cached_property - def approximations(self) -> Dict[GeneratorName, np.ndarray]: - return {item.generator_name: item.approximation for item in self.approximations_data} + @model_validator(mode="after") + def _validate_record_answers_for_the_channels(self) -> Self: + """Holds the per-frame record to the channels it describes. + + The record answers for the channels in play and for those alone, names one owner per + frame of each, and states silence and rest over the same frames. A frame a recording + holds lies on a channel that recording's settings occupy, while a frame the reader + wrote answers to no settings. + + Raises: + ValueError: If the record names channels other than those in play, if a channel's + owners and frames differ in number, if a frame's owner names neither a recorded + entry nor rest nor the reader, if rest and silence name different frames, or if + a recording holds a channel its settings leave out. + """ + entries = self.stems_data.config.entries_by_id + owned = self.stems_data.assignments_by_channel + playing = frozenset(self.playing_channels) + if frozenset(owned) != playing: + raise ValueError(f"The stems record names {sorted(owned)} where {sorted(playing)} play") + + for channel_name in playing: + stream = self.instructions[channel_name] + stem_ids = owned[channel_name] + if len(stem_ids) != len(stream): + raise ValueError( + f"The stems record gives {channel_name} {len(stem_ids)} owners for {len(stream)} frames" + ) + + for frame, (instruction, stem_id) in enumerate(zip(stream, stem_ids)): + self._validate_frame_owner(channel_name, frame, instruction, stem_id, entries) + + return self + + @model_validator(mode="after") + def _validate_every_recording_holds_a_frame(self) -> Self: + """Holds the record to naming the recordings behind its frames and those alone. + + A recording holding no frame leaves the document, so every entry holds a frame somewhere + once any of them does. A record where none does keeps them all. + + Raises: + ValueError: If one recording holds a frame while another holds none. + """ + holding = self.stems_data.holding_stem_ids + recorded = frozenset(self.stems_data.config.entries_by_id) + if holding and holding != recorded: + raise ValueError(f"The stems record names {sorted(recorded - holding)}, which hold no frame") + + return self + + @staticmethod + def _validate_frame_owner( + channel_name: ChannelName, + frame: int, + instruction: InstructionUnion, + stem_id: int, + entries: Mapping[int, StemEntry], + ) -> None: + """Holds one frame's owner to the record's rules. + + Raises: + ValueError: If the owner names nothing the setup knows, if rest and silence disagree, + or if a recording holds a channel its settings leave out. + """ + if stem_id not in entries and stem_id not in UNRECORDED_STEM_IDS: + raise ValueError(f"Frame {frame} of {channel_name} names stem {stem_id}, which the setup leaves out") + + if (stem_id == RESTING_STEM_ID) != (not instruction.on): + raise ValueError(f"Frame {frame} of {channel_name} states rest and silence over different frames") + + if stem_id in entries and channel_name not in entries[stem_id].settings.channel_set: + raise ValueError(f"Frame {frame} gives stem {stem_id} the {channel_name} its settings leave out") + + @property + def audio_filepath(self) -> Tuple[Path, ...]: + """Where the recordings behind this document live, in entry order. + + The record names each recording and the file it was read from, so this reads the + locations off it: one path per recording while every one of them is still located, and + none at all once the document is detached from its origin. + """ + return self.stems_data.paths @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 +186,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 governs, whose envelopes an export leaves empty. - 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. + An instrument writes the dimensions it describes and leaves the rest to the channel, + which sounds them at the value every note starts on for as long as the instrument sounds. + These are the dimensions it leaves. """ - 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, ...]: - """The channels whose instruction stream describes a frame. + def playing_channels(self) -> Tuple[ChannelName, ...]: + """The channels whose instruction stream sounds in some 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. + play: the rest stand by, exporting nothing and costing nothing, while sounding 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.sounds) @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: @@ -152,16 +231,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]) @@ -177,47 +256,106 @@ def _derive_initial_pitch(cls, instructions: List[InstructionUnion]) -> int: @classmethod def create( cls, - approximation: np.ndarray, - approximations: Mapping[GeneratorName, np.ndarray], - instructions: Mapping[GeneratorName, Sequence[InstructionUnion]], + 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], - ) - for generator_name in GeneratorName.items() - if generator_name in approximations - ] - - instructions_data: List[InstructionsItem] = [] - for generator_name in GeneratorName.items(): - channel_instructions = list(instructions.get(generator_name, ())) - if not channel_instructions: - instructions_data.append(InstructionsItem.resting(generator_name)) - continue - - instructions_data.append( - InstructionsItem.create( - generator_name=generator_name, - instructions=channel_instructions, - initial_pitch=cls._derive_initial_pitch(channel_instructions), - held_features=(), - ) - ) + """A fresh document holding the streams a conversion chose and the record it made. + + A channel whose stream sounds in no frame stands by, so it takes the stream of a channel + that describes none and the record lets go of it. A recording the conversion gave no frame + leaves the document, its path with it. The paths name the recordings in entry order, so + they reach the record before it settles. + + Args: + instructions: What each channel plays, one instruction per frame. + config: The configuration the conversion ran under. + coefficient: The working-level coefficient the recordings were scaled by. + audio_filepath: Where each recording was read from, one path per entry. + stems_data: The record of the recordings behind every frame. + Returns: + Self: The document the conversion made. + """ + streams = { + channel_name: cls._converted_stream(channel_name, list(instructions.get(channel_name, ()))) + for channel_name in ChannelName.items() + } + instructions_data, settled = cls._settled(streams, stems_data.with_sources(audio_filepath)) return cls( id=uuid4().hex, - approximation=approximation, - approximations_data=approximations_data, - instructions_data=instructions_data, + instructions_data=tuple(instructions_data), + stems_data=settled, config=config, coefficient=coefficient, - audio_filepath=audio_filepath, + ) + + @classmethod + def _converted_stream( + cls, + channel_name: ChannelName, + instructions: List[InstructionUnion], + ) -> InstructionsItem: + """The stream a conversion leaves one channel, anchored to the contour it plays. + + A channel that sounds nowhere stands by, resting at the reference its first envelope + will sound at. + """ + if not sounds(instructions): + return InstructionsItem.resting(channel_name) + + exporter_class = cls._get_exporter_class(instructions[0]) + return InstructionsItem.create( + channel_name=channel_name, + instructions=instructions, + initial_pitch=cls._derive_initial_pitch(instructions), + held_features=exporter_class.unstated_features(instructions), # type: ignore[arg-type] + ) + + @staticmethod + def _settled( + streams: Mapping[ChannelName, InstructionsItem], + stems_data: StemsData, + ) -> Tuple[List[InstructionsItem], StemsData]: + """Every channel's stream in channel order, and the record answering for the channels in play. + + Every change to a document passes through here, so a channel resting through every frame + stands by whatever silenced it: a conversion, an edit or a removal. + """ + ordered = [streams[channel_name] for channel_name in ChannelName.items()] + playing = frozenset(item.channel_name for item in ordered if item.sounds) + return ( + ordered, + stems_data.settled(playing), + ) + + def rewritten( + self, + streams: Mapping[ChannelName, InstructionsItem], + stems_data: StemsData, + ) -> Reconstruction: + """A fresh document holding ``streams`` and ``stems_data``, settled like any other change. + + The identifier, metadata, configuration and working level carry over, so the result is + the same document describing what the gesture left. + + Args: + streams: Every channel's stream, keyed by channel. + stems_data: The record of the recordings behind those streams. + + Returns: + Reconstruction: The rewritten document. + """ + instructions_data, settled = self._settled(streams, stems_data) + return Reconstruction( + metadata=self.metadata, + id=self.id, + config=self.config, + instructions_data=tuple(instructions_data), + stems_data=settled, + coefficient=self.coefficient, ) @classmethod @@ -226,90 +364,125 @@ 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()): + """The document a conversion's state describes, absent where no channel sounds anywhere.""" + if not any(sounds(stream) for stream in state.instructions.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())) - return cls.create( - approximation=approximation, - approximations=approximations, instructions=state.instructions, config=config, coefficient=coefficient, audio_filepath=path, + stems_data=stems_data, ) - def update_generator_data( + def with_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. + *, + heard: AbstractSet[int], + ) -> Reconstruction: + """The document with one channel's instructions, reference pitch, and held dimensions replaced. 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 dimensions travel with them for the same reason: the frames state a value for every dimension, and this is what says which of them the instrument itself wrote. - 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. + The record follows the frames: each of them keeps the recording that held it, one the + edit quiets rests, and one it brings into play is the reader's own. ``heard`` names the + recordings the edit reaches on this channel, so a frame of a recording left out of it + stands as it is. A channel the edit leaves resting through every frame stands by and + stays editable, keeping the reference and the held dimensions handed in; its audio is + read afresh from the stream it now carries. A recording the edit leaves holding no frame + on any channel leaves the document, together with its source and its place in the + hierarchy, unless no recording holds a frame at all. + + Every other channel's stream and owners are the very objects this document holds, so the + new document owns the one channel the edit wrote and shares the rest. + + Args: + channel_name: The channel the edit writes. + instructions: The stream the edit offers, one instruction per frame. + initial_pitch: The reference pitch the channel's arpeggio is measured against. + held_features: The dimensions the channel governs. + heard: The recordings the reader hears on this channel, which the edit reaches. + + Returns: + Reconstruction: The document the edit leaves. """ - partial_approximation = np.trim_zeros(partial_approximation, trim="b") - rendered = {name: audio for name, audio in self.approximations.items() if name != generator_name} - if partial_approximation.size: - rendered[generator_name] = partial_approximation - - max_length = max( - (len(np.trim_zeros(audio, trim="b")) for audio in rendered.values()), - default=0, + carried = carried_edit( + self.instructions[channel_name], + self.stems_data.assignments_by_channel.get(channel_name, ()), + instructions, + heard=heard, ) - self.approximations_data = self._build_approximations_data(rendered, max_length) - streams = dict(self.streams) - streams[generator_name] = InstructionsItem.create( - generator_name=generator_name, - instructions=instructions, + streams[channel_name] = InstructionsItem.create( + channel_name=channel_name, + instructions=carried.instructions, initial_pitch=initial_pitch, held_features=held_features, ) - self.instructions_data = [streams[name] for name in GeneratorName.items()] - self._invalidate_derived_caches(self) - self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data]) + return self.rewritten( + streams, + self.stems_data.with_assignments(self._assignments_with(channel_name, carried.stem_ids)), + ) - def get_generator_instructions( + def _assignments_with( self, - generator_name: GeneratorName, + channel_name: ChannelName, + stem_ids: List[int], + ) -> List[ChannelAssignment]: + """The per-channel record with one channel's owners replaced, in channel order.""" + replaced = {item.channel_name: item for item in self.stems_data.assignments} + replaced[channel_name] = ChannelAssignment(channel_name=channel_name, stem_ids=tuple(stem_ids)) + return [replaced[name] for name in ChannelName.items() if name in replaced] + + @property + def recorded_stem_ids(self) -> FrozenSet[int]: + """The recordings the document was built from, as a scope covering every one of them.""" + return frozenset(self.stems_data.config.entries_by_id) + + def get_channel_instructions( + self, + 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. + def detached(self) -> Reconstruction: + """The document with every recording's location let go of, 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. Letting each recording's + location go keeps everything the document describes — its instructions, its per-frame + record and the name of every recording behind it — so a saved project stays portable and + still says what played where. A document holding no location is returned as it stands, + so a project sample keeps the very document the Reconstructions tab edits. """ - self.audio_filepath = None + stems_data = self.stems_data.detached() + if stems_data is self.stems_data: + return self + + detached: Reconstruction = self.model_copy(update={"stems_data": stems_data}) + return detached def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: - """Returns a copy retuned to ``nes_frequency`` by re-rendering its audio. + """Returns a copy retuned to ``nes_frequency``, whose instructions sound at the new rate. 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 - frame length, re-timing the audio; the instructions and coefficient carry over. The - original instance is returned when it already runs at ``nes_frequency``. + project adopts that rate. The frozen ``config`` is rebuilt at the new rate, so each + channel's instructions sound over 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``. """ if self.config.nes_frequency == nes_frequency: return self @@ -317,96 +490,55 @@ 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``. + """Returns a copy running at ``config``, which re-times the audio it reads. - 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 - plain sum reproduces the stored approximation shape. Drive is left at unity to match the - regeneration path. + Each instruction spans ``config.frame_length`` samples, so the same instructions read at + a new frame length sound over a new span. The instructions, the record and the + coefficient carry over. """ - 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) - approximations_data = self._build_approximations_data( - rendered, - max_length, - ) - approximation = self._sum_approximations([item.approximation for item in approximations_data]) - - retuned: Reconstruction = self.model_copy( - update={ - "config": config, - "approximations_data": approximations_data, - "approximation": approximation, - } - ) + retuned: Reconstruction = self.model_copy(update={"config": config}) 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], - length: int, - ) -> List[ApproximationsItem]: - """Pads 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, - and a fixed order keeps a stored reconstruction reading the same however an edit reached it. - """ - return [ - ApproximationsItem( - generator_name=generator_name, - approximation=pad(rendered[generator_name], 0, length), - ) - for generator_name in GeneratorName.items() - if generator_name in rendered - ] - @staticmethod def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: - """Drops the memoized per-generator views so they recompute from their backing data.""" - reconstruction.__dict__.pop("approximations", None) + """Drops the memoized per-channel views so they recompute from their backing data.""" 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: - binary = load_binary(path) + def load( + cls, + path: Pathlike, + fast: bool = True, + ) -> Reconstruction: return cls.deserialize_data( - binary, + load_binary(path), source=Path(path), validation=cls.validate_metadata, fast=fast, ) + @classmethod + def read_stems_data(cls, path: Pathlike) -> StemsData: + """The recordings a document names and the setup they were converted under, read on their own. + + A listing that names many reconstructions asks each one what it is made of, which the whole + document costs far more to answer than the one field holding it. The payload travels through + the upgrade and the ``deserialize_inner`` a full load reads it with, so this answer and the + loaded document's describe the same recordings. + + Raises: + InvalidReconstructionValuesError: If the payload states values a stems record rejects. + UnhandledReconstructionError: If reading it fails for any other reason. + """ + with cls._reading(path): + document = cls.unpack(upgrade_binary(ObjectKind.RECONSTRUCTION, decompress_document(load_binary(path)))) + return StemsData.deserialize_inner(document["stems_data"]) + @classmethod def deserialize_data( cls, @@ -415,8 +547,16 @@ def deserialize_data( validation: Optional[Callback] = None, fast: bool = True, ) -> Reconstruction: - try: + with cls._reading(source): + binary = upgrade_binary(ObjectKind.RECONSTRUCTION, decompress_document(binary)) return cls.deserialize(binary, validation=validation, fast=fast) + + @staticmethod + @contextmanager + def _reading(source: Pathlike) -> Iterator[None]: + """States what a malformed document raises, so every reader of one answers the same errors.""" + try: + yield except (ValidationError, TypeError, ValueError, struct.error, IndexError) as exception: raise InvalidReconstructionValuesError( f'Failed to deserialize ReconstructionData from "{source}" due to validation error: {exception}', @@ -457,46 +597,49 @@ 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(): - instructions = self.instructions[name] - exporter_class = self._exporter_class(name, instructions) + return self._features_of(self.instructions) + + def export_heard(self, selection: StemSelection) -> Dict[ChannelName, Features]: + """The envelopes of the part each channel plays for the recordings a reader hears. + + The reading the waveform draws and the one an instrument is written from are the same + one, so what stands on screen is what an export writes. A channel every recording is + left out on describes no frame, which reads as standing by. + + Args: + selection: The recordings the reader hears, channel by channel. + + Returns: + Dict[ChannelName, Features]: The heard envelopes of each channel. + """ + return self._features_of(heard_instructions(self.stems_data, self.instructions, selection)) + + def _features_of( + self, + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + ) -> Dict[ChannelName, Features]: + """The envelopes a set of streams exports, each read through the exporter its type names.""" + features: Dict[ChannelName, Features] = {} + for name in ChannelName.items(): + stream = list(instructions[name]) + exporter_class = self._exporter_class(name, stream) exporter: ExporterUnion = exporter_class() - if instructions: - self._validate_instructions(exporter, instructions) + if stream: + self._validate_instructions(exporter, stream) features[name] = exporter.to_features( - instructions, # type: ignore[arg-type] + stream, # type: ignore[arg-type] self.initial_pitches[name], self.held_features[name], ) return features - - @field_serializer("approximation") - def _serialize_approximation( - self, - approximation: np.ndarray, - _info: Any, - ) -> SerializedData: - return serialize_array(approximation) - - @field_serializer("audio_filepath") - def _serialize_audio_filepath( - self, - audio_filepath: Optional[Path], - _info: Any, - ) -> Optional[str]: - if audio_filepath is None: - return None - - return str(audio_filepath) diff --git a/src/sampletones_core/reconstructions/reconstruction/recordings.py b/src/sampletones_core/reconstructions/reconstruction/recordings.py new file mode 100644 index 000000000..86335789e --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/recordings.py @@ -0,0 +1,36 @@ +from typing import Tuple + +import numpy as np + +from sampletones_core.audio import mix_scale, read_stems, scale_stems +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction + + +def load_recordings(reconstruction: Reconstruction) -> Tuple[np.ndarray, ...]: + """The recordings a document names, each at the level its conversion read it at. + + The conversion divided the whole set it read by one factor, which the record keeps, so a + recording stays at that level once another leaves the document and across a save and a reload. + A record stating no factor holds one recording, which its own peak scales the way its + conversion scaled it. + + Args: + reconstruction: The document whose recordings are read. + + Returns: + Tuple[np.ndarray, ...]: One recording per path the document names, in entry order. + + Raises: + FileNotFoundError: If a path names no file. + IsADirectoryError: If a path points at a directory. + """ + config = reconstruction.config + general = config.general + recordings = read_stems(reconstruction.audio_filepath, target_sample_rate=config.library.sample_rate) + scale = reconstruction.stems_data.scale + return scale_stems( + recordings, + scale=scale if scale is not None else mix_scale(recordings, normalize=general.normalize), + quantize=general.quantize, + quantization_levels=general.quantization_levels, + ) diff --git a/src/sampletones_core/reconstructions/reconstruction/renders.py b/src/sampletones_core/reconstructions/reconstruction/renders.py new file mode 100644 index 000000000..5b5403a2c --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/renders.py @@ -0,0 +1,29 @@ +from typing import Dict + +import numpy as np + +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.reconstructions.reconstruction.reconstruction import Reconstruction + + +def rendered_channels(reconstruction: Reconstruction) -> Dict[ChannelName, np.ndarray]: + """The audio each channel in play renders from the instructions it carries. + + A reconstruction records the instructions a channel plays, so its sound is read from those + each time it is asked for. A reader that asks again and again keeps the answer where it + governs how long the audio lives, such as the application's render cache. + """ + return render_channels(reconstruction.instructions, reconstruction.config) + + +def rendered_mix(reconstruction: Reconstruction) -> np.ndarray: + """The whole reconstruction, summed from the channels that play.""" + return mix(list(rendered_channels(reconstruction).values())) + + +def rendered_length(reconstruction: Reconstruction) -> int: + """How many samples the rendered reconstruction spans: its longest stream, frame by frame.""" + frames = max((len(stream) for stream in reconstruction.instructions.values()), default=0) + return frames * reconstruction.config.frame_length 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..6cdf049a8 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/channel_assignment.py @@ -0,0 +1,19 @@ +from typing import Tuple + +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: Tuple[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..5c4374494 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from functools import cached_property +from pathlib import Path +from typing import AbstractSet, Dict, FrozenSet, List, Optional, Self, Sequence, Tuple + +from pydantic import ConfigDict, Field, model_validator + +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.reconstruction.stems.source import StemSource +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +class StemsData(DataModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + config: StemsConfig = Field( + ..., + description="The stems setup the assignment was made under", + ) + sources: Tuple[StemSource, ...] = Field( + default_factory=tuple, + description="Where each recording came from and what it is called, one per entry", + ) + assignments: Tuple[ChannelAssignment, ...] = Field( + ..., + description="Per channel, the stem holding each frame", + ) + scale: Optional[float] = Field( + ..., + gt=0, + description="The factor the conversion divided every recording by, absent where it went unmeasured", + ) + + @classmethod + def single_entry( + cls, + settings: StemSettings, + assignments: List[ChannelAssignment], + scale: Optional[float], + ) -> StemsData: + """The record of one stem converted with ``settings``, holding the frames ``assignments`` name.""" + return cls( + config=StemsConfig.single_entry(settings), + assignments=tuple(assignments), + scale=scale, + ) + + @model_validator(mode="after") + def _validate_an_unmeasured_scale_holds_one_recording(self) -> Self: + """Holds a record stating no scale to the one recording such a record can carry. + + A conversion measures the scale of the whole set it reads. A document written before the + scale was recorded holds one recording, which its own peak scales exactly as the + conversion did, and a lone recording stays on the record for good. + + Raises: + ValueError: If a record stating no scale names other than one recording. + """ + if self.scale is None and len(self.config.entries) != 1: + raise ValueError(f"A record stating no scale names {len(self.config.entries)} recordings") + + return self + + @model_validator(mode="after") + def _validate_sources_name_the_entries(self) -> Self: + """Holds the recorded sources to the entries they belong to. + + Raises: + ValueError: If the sources name entries other than the recorded ones, or name one + of them twice. + """ + if not self.sources: + return self + + named = [source.stem_id for source in self.sources] + if sorted(named) != sorted(self.config.entries_by_id): + raise ValueError( + f"The recorded sources name {sorted(named)} where the setup holds {sorted(self.config.entries_by_id)}" + ) + + return self + + @cached_property + def assignments_by_channel(self) -> Dict[ChannelName, Tuple[int, ...]]: + """The per-frame stem ids each channel carries, keyed by channel.""" + return {item.channel_name: item.stem_ids for item in self.assignments} + + @cached_property + def holding_stem_ids(self) -> FrozenSet[int]: + """The recorded entries holding a frame on some channel. + + A rest and a frame the reader wrote answer to no entry, so neither counts toward any. + """ + held = frozenset(stem_id for item in self.assignments for stem_id in item.stem_ids) + return held & frozenset(self.config.entries_by_id) + + @cached_property + def sources_by_id(self) -> Dict[int, StemSource]: + """Where each recording came from, keyed by the entry it was converted as.""" + return {source.stem_id: source for source in self.sources} + + @property + def paths(self) -> Tuple[Path, ...]: + """Where the recordings live, in entry order, empty once any of them is let go of. + + One unreadable recording costs the whole original, so a set the document can offer whole + is the one every reader asks for. + """ + located = [self.sources_by_id[entry.id].path for entry in self.config.entries if entry.id in self.sources_by_id] + if len(located) != len(self.config.entries) or any(path is None for path in located): + return () + + return tuple(path for path in located if path is not None) + + def named(self, stem_id: int) -> Optional[str]: + """What one recording is called, absent where the record names none.""" + source = self.sources_by_id.get(stem_id) + return source.name if source is not None else None + + def with_sources(self, paths: Sequence[Path]) -> StemsData: + """The record naming where each entry's recording came from, in entry order. + + A conversion states one path per entry, which is what the sources are read from. Handing + it no path at all leaves the sources as they stand, so a record already naming its + recordings keeps them. + + Raises: + ValueError: If the paths number anything other than one per recorded entry. + """ + if not paths: + return self + + if len(paths) != len(self.config.entries): + raise ValueError( + f"The recorded sources number {len(paths)} where the setup holds {len(self.config.entries)}" + ) + + return self._with_sources( + [StemSource.of(entry.id, Path(source)) for entry, source in zip(self.config.entries, paths)] + ) + + def detached(self) -> StemsData: + """The record with every recording's location let go of, keeping the names it holds. + + A record holding no location is returned as it stands. + """ + if all(source.path is None for source in self.sources): + return self + + return self._with_sources([source.detached() for source in self.sources]) + + def with_assignments(self, assignments: List[ChannelAssignment]) -> StemsData: + """The record naming ``assignments`` as the owners of every frame, the setup and sources kept.""" + return self._rebuilt( + config=self.config, + sources=self.sources, + assignments=assignments, + ) + + def without_entries(self, stem_ids: AbstractSet[int]) -> StemsData: + """The record with the entries ``stem_ids`` names gone, together with their sources. + + A level the entries leave empty goes with them, and the ids of the entries that stay are + left alone, so the owners the record names keep naming the same recordings. + + Args: + stem_ids: The entries that leave. + + Returns: + StemsData: The record of the entries that stay. + """ + return self._rebuilt( + config=self.config.without_entries(stem_ids), + sources=[source for source in self.sources if source.stem_id not in stem_ids], + assignments=self.assignments, + ) + + def settled(self, playing: AbstractSet[ChannelName]) -> StemsData: + """The record answering for the channels in play, and naming the recordings that hold a frame. + + A channel resting through every frame stands by and describes no frame, so the record + lets go of the owners it named there, whatever left the channel silent. A recording holding + no frame on any channel then leaves the record, its source and its place in the hierarchy + with it. A record where no recording holds a frame keeps every one of them, since nothing + sounds that could tell them apart. + + Args: + playing: The channels whose streams sound somewhere. + + Returns: + StemsData: The record of the channels in play and the recordings behind them. + """ + record = self.with_assignments([item for item in self.assignments if item.channel_name in playing]) + holding = record.holding_stem_ids + if not holding: + return record + + return record.without_entries(frozenset(record.config.entries_by_id) - holding) + + def _with_sources(self, sources: List[StemSource]) -> StemsData: + """This record carrying ``sources`` in place of the ones it names.""" + return self._rebuilt( + config=self.config, + sources=sources, + assignments=self.assignments, + ) + + def _rebuilt( + self, + *, + config: StemsConfig, + sources: Sequence[StemSource], + assignments: Sequence[ChannelAssignment], + ) -> StemsData: + """A record built afresh from its parts, so its memoized views follow what it now holds. + + The scale belongs to the conversion and stays whatever the record now holds, so every + recording that stays is read at the level the conversion read it at. + """ + return StemsData( + config=config, + sources=tuple(sources), + assignments=tuple(assignments), + scale=self.scale, + ) 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..75d68d2b7 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/filter.py @@ -0,0 +1,102 @@ +from typing import AbstractSet, Dict, List, Mapping, Sequence + +import numpy as np + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, sounds +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstruction.stems.ownership import heard_frame +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 + + +def heard_instructions( + stems_data: StemsData, + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + selection: StemSelection, +) -> Dict[ChannelName, List[InstructionUnion]]: + """The part of each channel's stream the recordings a reader hears account for. + + A frame held by a recording the reader left out reads as its channel's silent instruction + at the index it stands on, so the reading lines up with the audio and the record frame for + frame. The reading ends at the last frame the reader hears, and a channel whose sound the + reader's choice took away reads as standing by — which is the count an export writes and a + footprint measures. + + Args: + stems_data: The record naming the stem holding each frame. + instructions: The stream each channel plays. + selection: The recordings the reader hears, channel by channel. + + Returns: + Dict[ChannelName, List[InstructionUnion]]: The heard part of each channel's stream. + """ + heard: Dict[ChannelName, List[InstructionUnion]] = {} + for channel_name, stream in instructions.items(): + stem_ids = stems_data.assignments_by_channel.get(channel_name, ()) + hearing = selection.stems_for(channel_name) + heard[channel_name] = _heard_stream(stream, stem_ids, hearing) + + return heard + + +def _heard_stream( + stream: Sequence[InstructionUnion], + stem_ids: Sequence[int], + heard: AbstractSet[int], +) -> List[InstructionUnion]: + """The stream a reader hears: silence where a recording is left out, cut where hearing ends. + + A rest answers to no recording, so every reader hears it and it stands where it is written. + A channel whose heard part sounds in no frame reads as standing by, however many rests it + holds, which is how the document itself stores a channel resting through every frame. + """ + if not stream: + return list(stream) + + null: InstructionUnion = type(stream[0]).null_instruction() + masked: List[InstructionUnion] = [] + last_heard = 0 + for frame, instruction in enumerate(stream): + if _is_heard(stem_ids, frame, heard): + masked.append(instruction) + last_heard = frame + 1 + else: + masked.append(null) + + return masked[:last_heard] if sounds(masked) else [] + + +def _is_heard(stem_ids: Sequence[int], frame: int, heard: AbstractSet[int]) -> bool: + """Whether the reader hears one frame, which a frame standing past the record always is.""" + if frame >= len(stem_ids): + return True + + return heard_frame(stem_ids[frame], heard) diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py b/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py new file mode 100644 index 000000000..231ee7583 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/ownership.py @@ -0,0 +1,186 @@ +from dataclasses import dataclass +from typing import AbstractSet, Final, FrozenSet, List, Sequence + +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.instructions import InstructionUnion + +UNRECORDED_STEM_IDS: Final[FrozenSet[int]] = frozenset({RESTING_STEM_ID, AUTHORED_STEM_ID}) + + +def heard_frame(stem_id: int, heard: AbstractSet[int]) -> bool: + """Whether the reader hears the frame one stem holds. + + A recording is heard where the reader keeps its box ticked, and the frames a reader wrote by + hand answer to a row of their own, so they follow that row's boxes like any other. A resting + frame belongs to no row, so it is always read and always within reach of an edit, which is + what lets a gesture write a note into silence. + + Args: + stem_id: The stem holding the frame. + heard: The recordings the reader hears on that channel. + + Returns: + bool: True where the frame is read, played and written. + """ + return stem_id in heard or stem_id == RESTING_STEM_ID + + +@dataclass(frozen=True) +class CarriedEdit: + """One channel as an edit leaves it: what each frame plays, and who plays it. + + Attributes: + instructions: The channel's stream, one instruction per frame. + stem_ids: The stem holding each of those frames. + """ + + instructions: List[InstructionUnion] + stem_ids: List[int] + + +@dataclass(frozen=True) +class OwnerRun: + """One stretch of a channel held by a single owner. + + Attributes: + start: The first frame of the stretch. + end: The frame the stretch runs up to, one past its last. + stem_id: The stem holding every frame of it. + """ + + start: int + end: int + stem_id: int + + +def owner_runs(stem_ids: Sequence[int]) -> List[OwnerRun]: + """The stretches a channel divides into, each held by one recording throughout. + + A reader follows a recording by the stretches it holds rather than by frames, so the record + is read as the runs it is made of. The runs cover the channel end to end and meet without + overlapping, which is what the one-owner-per-frame rule gives them. + + Args: + stem_ids: The stem holding each frame of the channel. + + Returns: + List[OwnerRun]: The stretches, in the order they play. + """ + runs: List[OwnerRun] = [] + start = 0 + for frame in range(1, len(stem_ids) + 1): + if frame == len(stem_ids) or stem_ids[frame] != stem_ids[start]: + runs.append(OwnerRun(start=start, end=frame, stem_id=stem_ids[start])) + start = frame + + return runs + + +def writes_reach(stem_ids: Sequence[int], heard: AbstractSet[int]) -> List[bool]: + """Whether an edit reaches each frame of a channel. + + A frame accepts a gesture where the reader hears it, so narrowing what is heard narrows what + is changed with it: what is drawn is what is written. + + Args: + stem_ids: The stem holding each frame of the channel. + heard: The recordings the reader hears on that channel. + + Returns: + List[bool]: One flag per frame, true where an edit writes it. + """ + return [heard_frame(stem_id, heard) for stem_id in stem_ids] + + +def carried_edit( + previous: Sequence[InstructionUnion], + stem_ids: Sequence[int], + proposed: Sequence[InstructionUnion], + *, + heard: AbstractSet[int], +) -> CarriedEdit: + """The channel an edit leaves, with every frame's owner carried through it. + + An edit rewrites what a frame plays and leaves who plays it, so a frame sounding before and + after keeps its owner, one the edit quiets rests, and one it brings into play is the + reader's own. A frame written past the end of the stream answers the same way. Frames + outside the reader's scope stand as they are, in what they play and in how far the stream + runs, which is what lets one recording's part be shaped while the recordings beside it + carry on. + + Args: + previous: The stream the channel played. + stem_ids: The stem holding each of those frames. + proposed: The stream the edit offers. + heard: The recordings the reader hears on this channel. + + Returns: + CarriedEdit: The stream the channel now plays, and the stem holding each of its frames. + """ + reach = writes_reach(stem_ids, heard) + instructions = _written_stream(previous, proposed, reach) + carried: List[int] = [] + for frame, instruction in enumerate(instructions): + stood = frame < len(previous) + owner = stem_ids[frame] if frame < len(stem_ids) else RESTING_STEM_ID + carried.append(_owner_of(instruction, owner if stood else RESTING_STEM_ID)) + + return CarriedEdit(instructions=instructions, stem_ids=carried) + + +def _written_stream( + previous: Sequence[InstructionUnion], + proposed: Sequence[InstructionUnion], + reach: Sequence[bool], +) -> List[InstructionUnion]: + """What every frame of the channel plays once the edit has been carried into it. + + The edit states each frame it reaches and the channel states the rest, so a frame held by a + recording the reader left out keeps what it played whether the edit rewrote it or dropped + it from the end. + """ + written = [instruction if _reaches(reach, frame) else previous[frame] for frame, instruction in enumerate(proposed)] + held = _held_beyond(previous, len(proposed), reach) + if held > len(proposed): + null: InstructionUnion = type(previous[0]).null_instruction() + written.extend(null if _reaches(reach, frame) else previous[frame] for frame in range(len(proposed), held)) + + return written + + +def _reaches(reach: Sequence[bool], frame: int) -> bool: + """Whether an edit writes one frame, which it does wherever the stream ran no further.""" + return frame >= len(reach) or reach[frame] + + +def _held_beyond( + previous: Sequence[InstructionUnion], + proposed_length: int, + reach: Sequence[bool], +) -> int: + """How far a channel runs once an edit has shortened it. + + An edit drops the frames it reaches, so the stream runs through the last frame standing + outside its scope and the frames it did reach rest along the way. A scope covering every + frame past the edit leaves the channel exactly as long as the edit states. + """ + for frame in reversed(range(proposed_length, len(previous))): + if not _reaches(reach, frame): + return frame + 1 + + return proposed_length + + +def _owner_of(instruction: InstructionUnion, owner: int) -> int: + """The stem a frame holds once the edit has settled what it plays. + + Rest and silence name the same frames, so a silent frame rests whoever held it, a sounding + frame keeps the owner it already had, and one coming into play is the reader's own. + """ + if not instruction.on: + return RESTING_STEM_ID + + if owner == RESTING_STEM_ID: + return AUTHORED_STEM_ID + + return owner 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..e9a0327fb --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/removal.py @@ -0,0 +1,128 @@ +from typing import Dict, List, Optional, Sequence + +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.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment + + +def can_remove_stem(reconstruction: Reconstruction, stem_id: int) -> bool: + """Whether a removal of ``stem_id`` applies to the reconstruction as it stands. + + A removal asked for a moment before it runs meets the document the steps before it left, which + may have let that recording go already or left it the last one standing. The answer follows + the guard :func:`without_stem` holds to, so a removal asked about first always succeeds. + + Args: + reconstruction: The reconstruction the recording would be taken out of. + stem_id: The stems entry to remove. + + Returns: + bool: True where ``stem_id`` names a recorded entry and another entry stands beside it. + """ + return _removal_refusal(reconstruction, stem_id) is None + + +def without_stem(reconstruction: Reconstruction, stem_id: int) -> Reconstruction: + """The reconstruction with one recording taken out, the frames it held left resting. + + A released frame states silence and takes ``RESTING_STEM_ID``, which is the shape a capped + run already records for a frame every stem passed over. A channel the removal leaves resting + through every frame stands by, describing no frame at all and keeping the reference it was + shaped from. The frames of the recordings that stay keep their instructions and their owners, + and the audio the document answers with is read afresh from what remains. + + The entry leaves the recorded setup, taking its level and its recorded source along with + it, once that level holds nothing else. The ids of the recordings that stay are left alone, + and the document settles the way every change does, so each recording it names still holds + a frame. 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. + """ + refusal = _removal_refusal(reconstruction, stem_id) + if refusal is not None: + raise ValueError(refusal) + + stems_data = reconstruction.stems_data + released = {item.channel_name: [held == stem_id for held in item.stem_ids] for item in stems_data.assignments} + return reconstruction.rewritten( + _released_streams(reconstruction, released), + stems_data.with_assignments( + [_released_assignment(item, released[item.channel_name]) for item in stems_data.assignments] + ).without_entries(frozenset({stem_id})), + ) + + +def _removal_refusal(reconstruction: Reconstruction, stem_id: int) -> Optional[str]: + """Why a removal of ``stem_id`` does not apply to the reconstruction, or ``None`` where it does.""" + config = reconstruction.stems_data.config + if stem_id not in config.entries_by_id: + return f"Stem {stem_id} names no entry of the recorded setup" + + if len(config.entries) == 1: + return "A reconstruction holds at least one stem" + + return None + + +def _released_assignment(item: ChannelAssignment, released: Sequence[bool]) -> ChannelAssignment: + """The channel's per-frame ownership with each released frame resting, the very record where none is.""" + if not any(released): + return item + + return ChannelAssignment( + channel_name=item.channel_name, + stem_ids=tuple( + RESTING_STEM_ID if frame_released else held for held, frame_released in zip(item.stem_ids, released) + ), + ) + + +def _released_streams( + reconstruction: Reconstruction, + released: Dict[ChannelName, List[bool]], +) -> Dict[ChannelName, InstructionsItem]: + """Every channel's stream as the removal leaves it, keyed by channel. + + A channel the removal reaches states silence where the recording held a frame, and one it + reaches none of stands exactly as it did. + """ + return { + channel_name: _released_stream(stream, released[channel_name]) if channel_name in released else stream + for channel_name, stream in reconstruction.streams.items() + } + + +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. The reference and the + held dimensions stay the stream's own, so a channel the removal silences keeps them. A stream + the removal releases no frame of is returned as it stands. + """ + instructions = [data.instruction for data in stream.instructions] + if not instructions or not any(released): + 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, + ) 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/reconstruction/stems/source.py b/src/sampletones_core/reconstructions/reconstruction/stems/source.py new file mode 100644 index 000000000..de7508b53 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstruction/stems/source.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Optional, Self + +from pydantic import ConfigDict, Field, field_serializer + +from sampletones_core.data import DataModel + + +class StemSource(DataModel): + """Where one recording came from, and what it was called. + + A recording's name belongs to the document that was built from it, while the file it lives + in belongs to the machine the conversion ran on. Keeping the two apart is what lets a + reconstruction embedded in a project still name the recordings behind its frames. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + stem_id: int = Field( + ..., + description="The stems entry this recording was converted as", + ) + name: str = Field( + ..., + description="What the recording is called", + ) + path: Optional[Path] = Field( + default=None, + description="Where the recording lives on this machine, absent once detached from its origin", + ) + + @classmethod + def of(cls, stem_id: int, path: Path) -> Self: + """The source a conversion records for a recording, named after the file it read.""" + return cls(stem_id=stem_id, name=path.stem, path=path) + + def detached(self) -> Self: + """The same recording with its location let go of, keeping the name it is known by.""" + return self.__class__(stem_id=self.stem_id, name=self.name, path=None) + + @field_serializer("path") + def _serialize_path(self, path: Optional[Path]) -> Optional[str]: + return str(path) if path is not None else None 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/candidates.py b/src/sampletones_core/reconstructions/reconstructor/candidates.py index b8a8b5ede..5286708c4 100644 --- a/src/sampletones_core/reconstructions/reconstructor/candidates.py +++ b/src/sampletones_core/reconstructions/reconstructor/candidates.py @@ -1,25 +1,27 @@ from dataclasses import dataclass, field -from functools import lru_cache -from typing import Callable, Dict, List, Tuple +from functools import cached_property, lru_cache +from typing import Callable, Dict, Final, Tuple + +import numpy as np from sampletones_core.configs import Config from sampletones_core.constants.enums import ( GeneratorClassName, InstructionClassName, ) -from sampletones_core.fft import Fragment, Window -from sampletones_core.generators import ( - GeneratorUnion, - get_generator_by_instruction, -) +from sampletones_core.fft import Window +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import GeneratorUnion from sampletones_core.instructions import ( INSTRUCTION_CLASS_MAP, InstructionUnion, ) from sampletones_core.library import InstructionLibraryData +from sampletones_shared.array import xp SerializedInstructions = Tuple[Tuple[InstructionClassName, bytes], ...] -CachedApproximations = Callable[[SerializedInstructions, Tuple[GeneratorUnion, ...]], Fragment] +CachedPowers = Callable[[SerializedInstructions], xp.ndarray] +START_OF_SAMPLE: Final[int] = 0 @lru_cache(maxsize=16) @@ -29,64 +31,88 @@ def serialize_instructions( return tuple((instruction.class_name(), instruction.serialize()) for instruction in instructions) +@dataclass(frozen=True) +class ClassCandidates: + """ + The candidates of some generator classes, with the power each adds to a frame. + + Attributes: + instructions: The candidates, in library order. + powers: One row per candidate: its power density per bin averaged over phase, as its + library sample plays, on the active array backend. + """ + + instructions: Tuple[InstructionUnion, ...] + powers: xp.ndarray + + @dataclass(frozen=True) class CandidateProvider: + """ + Serves the library's candidates in the forms the matching reads them in. + + Every quantity stands at the level the library sample plays, which the matching reads at + the drive the channel is asked for, so one set of rows serves every drive a run holds. A + candidate's power is read off its phase-averaged library feature once per library and kept + per generator class, so every frame mixes the same rows. Its waveform and its moments are + read where a shortlist asks for them. + """ + config: Config window: Window library_data: InstructionLibraryData + extractor: FeatureExtractor - _cached_approximations: CachedApproximations = field(init=False) + _cached_powers: CachedPowers = field(init=False) + _moments: Dict[InstructionUnion, Tuple[float, float]] = field(init=False, default_factory=dict) def __post_init__(self) -> None: - object.__setattr__(self, "_cached_approximations", self._build_cached_approximations()) - - def candidates( - self, - remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], - ) -> Tuple[Tuple[InstructionUnion, ...], Fragment]: - valid_instructions = tuple(self.library_data.filter(tuple(remaining_generator_classes)).keys()) - approximations = self._get_approximations(valid_instructions, remaining_generator_classes) - return valid_instructions, approximations - - def get_approximation(self, instruction: InstructionUnion, generator: GeneratorUnion) -> Fragment: - library_fragment = self.library_data[instruction] - fragment = library_fragment.get( - generator, - self.config, - self.window, - generator.initials, + object.__setattr__(self, "_cached_powers", self._build_cached_powers()) + + @cached_property + def widths(self) -> np.ndarray: + """The width of every bin of the feature axis the library is described on.""" + feature = next(iter(self.library_data.values())).feature + return np.asarray(np.diff(feature.edges), dtype=np.float64) + + def candidates(self, generator_classes: Dict[GeneratorClassName, GeneratorUnion]) -> ClassCandidates: + """Every candidate of ``generator_classes``, with the power its library sample plays at.""" + instructions = tuple(self.library_data.filter(tuple(generator_classes)).keys()) + return ClassCandidates( + instructions=instructions, powers=self._cached_powers(serialize_instructions(instructions)) ) - return fragment * self.config.generation.drive - - def _get_approximations( - self, - valid_instructions: Tuple[InstructionUnion, ...], - remaining_generator_classes: Dict[GeneratorClassName, GeneratorUnion], - ) -> Fragment: - serialized_instructions = serialize_instructions(valid_instructions) - remaining_generators = tuple(remaining_generator_classes.values()) - return self._cached_approximations(serialized_instructions, remaining_generators) - - def _build_cached_approximations(self) -> CachedApproximations: + + def features_of(self, powers: xp.ndarray) -> xp.ndarray: + """The feature values of power densities, for one row of them or a stack.""" + return self.extractor.transformer.forward(xp.asarray(powers)) * xp.asarray(self.widths) + + def power_of(self, instruction: InstructionUnion) -> np.ndarray: + """The candidate's power density per bin averaged over phase, as its library sample plays.""" + values = np.asarray(self.library_data[instruction].feature.values, dtype=np.float64) + return np.asarray(self.extractor.transformer.backward(values / self.widths)) + + def library_waveform(self, instruction: InstructionUnion) -> np.ndarray: + """The frame the candidate's library sample plays from its own start.""" + fragment = self.library_data[instruction].get_fragment(START_OF_SAMPLE, self.config, self.window) + return np.asarray(fragment.audio, dtype=np.float64) + + def expected_moments(self, instruction: InstructionUnion) -> Tuple[float, float]: + """The mean level and the per-sample variance of the candidate's library sample, read once per library.""" + moments = self._moments.get(instruction) + if moments is None: + sample = self.library_data[instruction].sample + moments = (sample.mean, sample.variance) + self._moments[instruction] = moments + + return moments + + def _build_cached_powers(self) -> CachedPowers: @lru_cache(maxsize=16) - def cached( - serialized_instructions: SerializedInstructions, - remaining_generators: Tuple[GeneratorUnion, ...], - ) -> Fragment: - remaining_generator_classes = dict( - zip( - (generator.class_name() for generator in remaining_generators), - remaining_generators, - ) - ) - approximations: List[Fragment] = [] - for instruction_class_name, serialized_instruction in serialized_instructions: - instruction_class = INSTRUCTION_CLASS_MAP[instruction_class_name] - instruction = instruction_class.deserialize(serialized_instruction) - generator = get_generator_by_instruction(instruction, remaining_generator_classes) - approximation = self.get_approximation(instruction, generator) - approximations.append(approximation) - - return Fragment.stack(approximations).to_cupy() + def cached(serialized_instructions: SerializedInstructions) -> xp.ndarray: + rows = [ + self.power_of(INSTRUCTION_CLASS_MAP[class_name].deserialize(serialized)) + for class_name, serialized in serialized_instructions + ] + return xp.asarray(np.stack(rows)) return cached diff --git a/src/sampletones_core/reconstructions/reconstructor/contribution.py b/src/sampletones_core/reconstructions/reconstructor/contribution.py new file mode 100644 index 000000000..c6bfc3c5b --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/contribution.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import Self + +import numpy as np + + +@dataclass(frozen=True) +class Contribution: + """ + What one channel's candidate adds to a frame it is scored in. + + The channel settles the phase it renders at after the matching, so the power a candidate adds + is its power averaged over the phases a frame starts on. The waveform it adds depends on what + its frames have in common: a candidate whose frames repeat one shape adds its rendering aligned + to what the frame still holds, and a candidate whose frames show different stretches of a + sequence adds its mean level, with its spread about that mean as the variance. + + Attributes: + power: The candidate's power density per bin, read at unit drive. + expectation: The waveform the candidate is expected to render over the frame. + variance: The per-sample variance about that waveform. + """ + + power: np.ndarray + expectation: np.ndarray + variance: float + + @classmethod + def silence(cls, bins: int, frame_length: int) -> Self: + """The contribution of a channel sounding nothing.""" + return cls( + power=np.zeros(bins, dtype=np.float64), + expectation=np.zeros(frame_length, dtype=np.float64), + variance=0.0, + ) 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/decoder/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py new file mode 100644 index 000000000..47f7ed721 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/decoder/viterbi.py @@ -0,0 +1,123 @@ +import itertools +from typing import List, Tuple + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.instructions import InstructionUnion + +from ..matching import ScoredCandidate +from .base import ChannelLattice, Decoder, Lattices, Streams + + +class ViterbiDecoder(Decoder): + """ + Plays the cheapest path through each channel's frames, cost and continuity together. + + 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 + + @property + def lattice_width(self) -> int: + return self.config.generation.decoder.top_k + + def decode(self, lattices: Lattices) -> Streams: + return {channel_name: self._decode_channel(frames) for channel_name, frames in lattices.items()} + + 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: + return [] + + backpointers, final_costs = self._forward_pass(frames) + return self._backtrack(backpointers, final_costs) + + def _forward_pass(self, frames: ChannelLattice) -> Tuple[List[List[int]], List[float]]: + costs = [state.cost for state in frames[0]] + backpointers: List[List[int]] = [] + + for previous_states, current_states in itertools.pairwise(frames): + layer_costs: List[float] = [] + layer_backpointers: List[int] = [] + for state in current_states: + predecessor, accumulated = self._best_predecessor(costs, previous_states, state.instruction) + layer_costs.append(accumulated + state.cost) + layer_backpointers.append(predecessor) + + costs = layer_costs + backpointers.append(layer_backpointers) + + return backpointers, costs + + def _best_predecessor( + self, + previous_costs: List[float], + previous_states: Tuple[ScoredCandidate, ...], + instruction: InstructionUnion, + ) -> Tuple[int, float]: + best_index = 0 + best_cost = float("inf") + for index, previous_state in enumerate(previous_states): + total = previous_costs[index] + self._transition_cost(previous_state.instruction, instruction) + if total < best_cost: + best_cost = total + best_index = index + + return best_index, best_cost + + def _backtrack(self, backpointers: List[List[int]], final_costs: List[float]) -> List[int]: + last = int(np.argmin(final_costs)) + path = [last] + for layer in reversed(backpointers): + last = layer[last] + path.append(last) + + path.reverse() + return path + + def _transition_cost(self, previous: InstructionUnion, current: InstructionUnion) -> float: + if not previous.on and not current.on: + return 0.0 + + if previous.on != current.on: + return self.on_off_weight + + cost = 0.0 + cost += self.pitch_weight * self._distance(previous, current, "pitch") + cost += self.pitch_weight * self._distance(previous, current, "period") + cost += self.volume_weight * self._distance(previous, current, "volume") + cost += self.timbre_weight * self._mismatch(previous, current, "duty_cycle") + cost += self.timbre_weight * self._mismatch(previous, current, "short") + return cost + + @staticmethod + def _distance(previous: InstructionUnion, current: InstructionUnion, field: str) -> float: + previous_value = getattr(previous, field, None) + current_value = getattr(current, field, None) + if previous_value is None or current_value is None: + return 0.0 + + return float(abs(int(previous_value) - int(current_value))) + + @staticmethod + def _mismatch(previous: InstructionUnion, current: InstructionUnion, field: str) -> float: + previous_value = getattr(previous, field, None) + current_value = getattr(current, field, None) + if previous_value is None or current_value is None: + return 0.0 + + return 0.0 if previous_value == current_value else 1.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..c828686d1 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/matching.py @@ -0,0 +1,225 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.fft import Fragment +from sampletones_core.generators import GeneratorUnion +from sampletones_core.instructions import InstructionUnion +from sampletones_shared.array import to_numpy, xp + +from .candidates import CandidateProvider, ClassCandidates +from .contribution import Contribution +from .mix import FrameMix +from .phase import PhaseAligner +from .scorer import Scorer + + +@dataclass(frozen=True) +class ScoredCandidate: + """ + One alternative for a channel in one frame. + + Attributes: + instruction: What the channel plays. + cost: The frame's cost with this alternative sounding beside the stem's other picks. + contribution: What this alternative adds to the frame. + """ + + instruction: InstructionUnion + cost: float + contribution: Contribution + + +Column = Tuple[ScoredCandidate, ...] + + +@dataclass(frozen=True) +class FrameMatcher: + """ + Scores one channel's candidates in a target fragment with the stem's other picks sounding. + + Carries the matching machinery the stems assignment works from: the two-stage criterion + scoring and the per-candidate contribution build. What the scoring produces is a column of + alternatives on one scale, the channel's silence among them, which the assignment turns into + ownership and the decoder into a stream. A drive lifts what a channel reaches for: every + candidate is read at unit drive, so the row fitting the target is the one sounding it at the + drive, and a channel is answered at the level its own recording is given. + """ + + 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_column( + self, + target: Fragment, + generator: GeneratorUnion, + mix: FrameMix, + *, + drive: float, + ) -> Column: + """ + Score one generator class's candidates in two stages, each added to ``mix``. + + Every candidate is read at unit drive: its waveform divides by the drive and its power + by the drive's square, so a rising drive reaches for a louder row and settles on the + loudest the class holds. Each candidate's power is added to the mix and the result + ranked by the spectral term, which compares phase-averaged powers and is therefore + immune to how the waveforms happen to be phased. The ``top_k`` best and the class's silent instruction + then receive the full cost of the frame with them sounding. A candidate whose frames + repeat one waveform shape adds its rendering at its best phase against what the mix + leaves of the target's waveform, or its library sample from the start when best-phase + search is off. A candidate whose frames show different stretches of a sequence adds its + mean level and its variance. + + Args: + target: Target fragment to match. + generator: The generator whose class is scored. + mix: What the stem's other picks sound in the frame. + drive: The level the channel reaches for, which every candidate is read against. + + Returns: + The shortlisted candidates with their frame costs, best first, silence ahead of an + equal cost. + + Raises: + ValueError: If the library lacks the class's silent instruction. + """ + candidates = self.candidate_provider.candidates({generator.class_name(): generator}) + unit_powers = _at_unit_drive(candidates.powers, drive) + mixed = self.candidate_provider.features_of(unit_powers + xp.asarray(mix.power)) + spectral_costs = self.scorer.spectral_costs(target, mixed) + shortlist = self._shortlist(spectral_costs, candidates, generator) + + residual = mix.residual_waveform(target) + powers = np.asarray(to_numpy(unit_powers[xp.asarray(shortlist)]), dtype=np.float64) + contributions = [ + self.contribution(candidates.instructions[index], residual, power, drive=drive) + for index, power in zip(shortlist, powers) + ] + costs = self.scorer.frame_costs( + target, + spectral_costs[shortlist], + np.stack([mix.expectation + contribution.expectation for contribution in contributions]), + np.array([mix.variance + contribution.variance for contribution in contributions]), + ) + scored = [ + ScoredCandidate(instruction=candidates.instructions[index], cost=float(cost), contribution=contribution) + for index, cost, contribution in zip(shortlist, costs, contributions) + ] + scored.sort(key=lambda candidate: (candidate.cost, candidate.instruction.on)) + return tuple(scored) + + def mix_cost(self, target: Fragment, mix: FrameMix) -> float: + """The frame's cost with ``mix`` sounding alone, on the scale columns are scored on.""" + features = self.candidate_provider.features_of(xp.asarray(mix.power)[None, :]) + spectral_costs = self.scorer.spectral_costs(target, features) + costs = self.scorer.frame_costs(target, spectral_costs, mix.expectation[None, :], np.array([mix.variance])) + return float(costs[0]) + + 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 contribution( + self, + instruction: InstructionUnion, + residual: np.ndarray, + power: np.ndarray, + *, + drive: float, + ) -> Contribution: + """ + What one candidate adds to a frame whose waveform the mix leaves as ``residual``. + + Args: + instruction: The candidate. + residual: What the frame's waveform holds beyond the stem's other picks. + power: The candidate's power density per bin, read at unit drive. + drive: The level the channel reaches for, which the candidate's waveform and mean + level divide by, and its variance the drive's square. + + Returns: + Contribution: The candidate's power, expected waveform and variance. + """ + library_fragment = self.candidate_provider.library_data[instruction] + if not library_fragment.frames_share_shape( + frame_length=self.config.library.frame_length, + sample_rate=self.config.library.sample_rate, + ): + mean, variance = self.candidate_provider.expected_moments(instruction) + return Contribution( + power=power, + expectation=np.full(residual.shape[0], mean / drive), + variance=variance / drive**2, + ) + + if self.config.generation.calculation.find_best_phase: + waveform = self.phase_aligner.align(residual, instruction, drive) + else: + waveform = self.candidate_provider.library_waveform(instruction) / drive + + return Contribution(power=power, expectation=waveform, variance=0.0) + + def _shortlist( + self, + spectral_costs: np.ndarray, + candidates: ClassCandidates, + generator: GeneratorUnion, + ) -> List[int]: + """The ``top_k`` best spectral costs, with the class's silence among them. + + Raises: + ValueError: If the library lacks the class's silent instruction. + """ + silence = generator.get_instruction_type().null_instruction() + if silence not in candidates.instructions: + raise ValueError(f"The library lacks the silent instruction of {generator.class_name()}") + + shortlist = [int(index) for index in Scorer.top_k(spectral_costs, self.top_k)] + silent_index = candidates.instructions.index(silence) + if silent_index not in shortlist: + shortlist.append(silent_index) + + return shortlist + + +def _at_unit_drive(powers: xp.ndarray, drive: float) -> xp.ndarray: + """The candidates' powers read at unit drive, which a power divides by the drive's square.""" + if drive == UNIT_DRIVE: + return powers + + return powers / drive**2 + + +def column_of(scored: Column, width: int) -> Column: + """ + The alternatives a decoder reading ``width`` of them chooses among. + + A column wider than one keeps the channel's silence, standing in for the last kept + alternative when the silence ranks below them, so a decoder can always rest the channel. + + Args: + scored: A column best first. + width: How many alternatives the decoder reads. + + Returns: + Column: The best ``width`` alternatives. + """ + kept = scored[:width] + if width == 1 or any(not candidate.instruction.on for candidate in kept): + return kept + + silence = next((candidate for candidate in scored if not candidate.instruction.on), None) + if silence is None: + return kept + + return (*kept[:-1], silence) diff --git a/src/sampletones_core/reconstructions/reconstructor/mix.py b/src/sampletones_core/reconstructions/reconstructor/mix.py new file mode 100644 index 000000000..477a0f2d8 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/mix.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Self, Sequence + +import numpy as np + +from sampletones_core.fft import Fragment + +from .contribution import Contribution + + +@dataclass(frozen=True) +class FrameMix: + """ + The sound a stem's picks add up to in one frame. + + Picks on different channels sound independently of each other's phase, so their powers add; + the waveforms they are expected to render add, and so do their variances. A candidate is scored + against its stem's frame with its own contribution added to this mix, which is what lets the + cost of adding a channel stand beside the cost of leaving it silent. + + Attributes: + power: The summed power density per bin. + expectation: The summed expected waveform. + variance: The summed per-sample variance. + """ + + power: np.ndarray + expectation: np.ndarray + variance: float + + @classmethod + def empty(cls, target: Fragment) -> Self: + """The mix of no picks, shaped to the frame it is scored against.""" + silence = Contribution.silence(len(target.feature.values), target.audio.shape[0]) + return cls(power=silence.power, expectation=silence.expectation, variance=silence.variance) + + @classmethod + def of(cls, target: Fragment, contributions: Sequence[Contribution]) -> Self: + """The mix of ``contributions``, summed afresh.""" + mix = cls.empty(target) + for contribution in contributions: + mix = mix.added(contribution) + + return mix + + def added(self, contribution: Contribution) -> Self: + """This mix with one more contribution sounding in it.""" + return self.__class__( + power=self.power + contribution.power, + expectation=self.expectation + contribution.expectation, + variance=self.variance + contribution.variance, + ) + + def residual_waveform(self, target: Fragment) -> np.ndarray: + """What the frame's waveform holds beyond the mix's expected waveform, which a candidate aligns to.""" + residual: np.ndarray = np.asarray(target.audio, dtype=np.float64) - self.expectation + return residual diff --git a/src/sampletones_core/reconstructions/reconstructor/phase.py b/src/sampletones_core/reconstructions/reconstructor/phase.py index cadda0456..f6d94bbda 100644 --- a/src/sampletones_core/reconstructions/reconstructor/phase.py +++ b/src/sampletones_core/reconstructions/reconstructor/phase.py @@ -7,7 +7,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import PhaseAlignerName -from sampletones_core.fft import Fragment, Window +from sampletones_core.fft import Window from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibraryData @@ -24,57 +24,70 @@ def __init__( self.library_data = library_data @abstractmethod - def align(self, fragment: Fragment, instruction: InstructionUnion) -> Fragment: ... + def align(self, waveform: np.ndarray, instruction: InstructionUnion, drive: float) -> np.ndarray: + """The frame the candidate renders at its best phase against ``waveform``, read at unit drive.""" + + def _rendering(self, instruction: InstructionUnion, shift: int, drive: float) -> np.ndarray: + """The candidate's frame starting ``shift`` samples into its library sample, read at unit drive.""" + fragment = self.library_data[instruction].get_fragment(shift, self.config, self.window) + return np.asarray(fragment.audio, dtype=np.float64) / drive + + def _cycle(self, instruction: InstructionUnion) -> np.ndarray: + """Two cycles of the candidate's library sample, which every shift of one frame reads from.""" + sample = self.library_data[instruction].sample + return np.asarray(sample.get_fragment(length=2 * sample.length), dtype=np.float64) class SlidingRmsePhaseAligner(PhaseAligner): """ - Finds the cyclic shift minimizing the RMSE between the target and the - drive-scaled candidate waveform, and returns the candidate at that shift, - scaled by the drive to match the amplitude the candidate competes at. + Finds the cyclic shift minimizing the RMSE between the waveform and the candidate read at + unit drive, and returns the candidate's frame at that shift. """ - def align(self, fragment: Fragment, instruction: InstructionUnion) -> Fragment: - drive = self.config.generation.drive - library_fragment = self.library_data[instruction] - array = library_fragment.sample.get_fragment(length=2 * library_fragment.sample.length) - - windows = sliding_window_view(array, self.config.library.frame_length) - remainder = fragment.audio - drive * windows + def align(self, waveform: np.ndarray, instruction: InstructionUnion, drive: float) -> np.ndarray: + windows = sliding_window_view(self._cycle(instruction), self.config.library.frame_length) + remainder = np.asarray(waveform, dtype=np.float64) - windows / drive rmse = np.sqrt((remainder**2).mean(axis=1)) - best_shift = int(np.argmin(rmse)) - return library_fragment.get_fragment(best_shift, self.config, self.window) * drive + return self._rendering(instruction, int(np.argmin(rmse)), drive) class CrossCorrelationPhaseAligner(PhaseAligner): """ - Finds the cyclic shift minimizing the squared error between the target and the - drive-scaled candidate via FFT cross-correlation, expanding - `||target - drive * candidate||^2` into a per-shift cost of - `drive * energy - 2 * correlation` (the constant target energy and the positive - drive factor drop out of the argmin). Returns the candidate at that shift, - scaled by the drive to match the amplitude the candidate competes at. + Finds the cyclic shift minimizing the squared error between the waveform and the candidate + read at unit drive via FFT cross-correlation, expanding + `||waveform - candidate / drive||^2` into a per-shift cost of + `energy / drive - 2 * correlation` (the constant waveform energy and the positive + drive factor drop out of the argmin), and returns the candidate's frame at that shift. + The sliding energy of a candidate depends on its library sample alone, so it is read once + per instruction. """ - def align(self, fragment: Fragment, instruction: InstructionUnion) -> Fragment: - drive = self.config.generation.drive - library_fragment = self.library_data[instruction] - frame_length = self.config.library.frame_length - array = library_fragment.sample.get_fragment(length=2 * library_fragment.sample.length) - target = np.asarray(fragment.audio, dtype=np.float64) - - correlation = fftconvolve(array.astype(np.float64), target[::-1], mode="valid") - window_energy = self._sliding_energy(array, frame_length) - cost = drive * window_energy - 2.0 * correlation - best_shift = int(np.argmin(cost)) - return library_fragment.get_fragment(best_shift, self.config, self.window) * drive - - @staticmethod - def _sliding_energy(array: np.ndarray, frame_length: int) -> np.ndarray: - squared = np.asarray(array, dtype=np.float64) ** 2 - cumulative = np.concatenate([np.zeros(1, dtype=np.float64), np.cumsum(squared)]) - energy: np.ndarray = np.asarray(cumulative[frame_length:] - cumulative[:-frame_length]) + def __init__( + self, + config: Config, + window: Window, + library_data: InstructionLibraryData, + ) -> None: + super().__init__(config, window, library_data) + self._energies: Dict[InstructionUnion, np.ndarray] = {} + + def align(self, waveform: np.ndarray, instruction: InstructionUnion, drive: float) -> np.ndarray: + cycle = self._cycle(instruction) + target = np.asarray(waveform, dtype=np.float64) + + correlation = fftconvolve(cycle, target[::-1], mode="valid") + cost = self._sliding_energy(instruction, cycle) / drive - 2.0 * correlation + return self._rendering(instruction, int(np.argmin(cost)), drive) + + def _sliding_energy(self, instruction: InstructionUnion, cycle: np.ndarray) -> np.ndarray: + energy = self._energies.get(instruction) + if energy is None: + frame_length = self.config.library.frame_length + cumulative = np.concatenate([np.zeros(1, dtype=np.float64), np.cumsum(cycle**2)]) + energy = np.asarray(cumulative[frame_length:] - cumulative[:-frame_length]) + self._energies[instruction] = energy + return energy diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 7cdad4d23..1d6849e41 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -1,59 +1,90 @@ +from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional +from typing import AbstractSet, 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, + mix, + mix_scale, + read_stems, + scale_stems, +) 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 ( + TONE_CHANNELS, + ChannelName, + ordered_channels, +) from sampletones_core.fft import FragmentedAudio, Window from sampletones_core.generators import ( - MIXER_LEVELS, - GeneratorUnion, - get_generators_by_names, + FULL_SCALE_RMS_LEVELS, + get_generators_by_channels, ) 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.stems.configs.settings import StemSettings +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 LoadedStems: + """Each stem's recording on the shared scale, and the factor that scale divided them by. + + The factor is measured on the whole set a conversion reads, so the document records it and + reads the recordings that stay at that same level once one of them leaves. + + Attributes: + recordings: Each stem's recording, in entry order. + scale: The factor every recording was divided 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: Tuple[np.ndarray, ...] + scale: float + + +@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. + """ + + recordings: Dict[int, np.ndarray] + frames: Dict[int, FragmentedAudio] + coefficient: float class Reconstructor: @@ -72,13 +103,15 @@ class Reconstructor: def __init__( self, config: Config, + channels: AbstractSet[ChannelName], library: Optional[InstructionLibrary] = None, ) -> None: """Builds a reconstructor for a configuration and loads its library. Args: - config: The reconstruction configuration selecting generators, window, and - matching settings. + config: The reconstruction configuration selecting the window and the matching + settings. + channels: The channels this run hands out, which it builds generators for. library: The instruction library to match against; a default library rooted at the configured directory is used when omitted. @@ -88,8 +121,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) + self.channel_names: List[ChannelName] = ordered_channels(channels) + self.channels = get_generators_by_channels(config, self.channel_names) self.window: Window = Window.from_config(self.config) self.library_data: InstructionLibraryData = self.load_library(library) @@ -97,8 +130,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 channel this reconstructor was built for, on one precedence level, + sounding all of them at once, so every one of them is assigned in every frame. Args: path: Path to the audio file to reconstruct. @@ -109,17 +143,226 @@ 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(StemSettings.covering(list(self.channel_names))) + 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 each stem's own count. A stem takes a channel where + its own recording sounds, so what the channel carries is that recording, at the drive that + stem's settings give the channel. 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 what each recording is converted with, and the + precedence hierarchy. + report: Hears each stage of the run and answers whether it is still wanted. - path = to_path(path) - audio = self.load_audio(path) + 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) + loaded = self._load_stem_recordings(checked_paths) + announce(report, ReconstructionStage.LOADING, RECORDINGS_LOADED, PREPARATIONS) + prepared = self._prepare_stem_frames(loaded.recordings, stems_config) + announce(report, ReconstructionStage.LOADING, FRAMES_PREPARED, PREPARATIONS) + worker = self._build_worker(common_length(loaded.recordings)) + assignment = self._assign_stem_frames(prepared.frames, stems_config, worker, report) + announce(report, ReconstructionStage.DECODING, STAGE_BEGUN, WHOLE_STAGE) + streams = worker.decoder.decode(assignment.lattices) + assignment.release_silent(streams) + streams = self._refiner(stems_config).refine(streams, assignment.stem_ids, prepared.recordings) + announce(report, ReconstructionStage.DECODING, WHOLE_STAGE, WHOLE_STAGE) + 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, loaded.scale), + ) + + def _refiner(self, stems_config: StemsConfig) -> PitchRefiner: + """The pass that carries each chosen note toward the fundamental the recording sounds.""" + return PitchRefiner(config=self.config, channels=self.channels, stems=stems_config) + + @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)}") + + 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]) -> LoadedStems: + """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. The scale is + measured on every recording the run reads, before any of them leaves the result. + """ + general = self.config.general + recordings = read_stems(checked_paths, target_sample_rate=self.config.library.sample_rate) + scale = mix_scale(recordings, normalize=general.normalize) + return LoadedStems( + recordings=scale_stems( + recordings, + scale=scale, + quantize=general.quantize, + quantization_levels=general.quantization_levels, + ), + scale=scale, + ) + + 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.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 _record_streams( + self, + streams: Streams, + report: ReconstructionReporter, + ) -> None: + """Reads the decoded streams into the state, one frame at a time. + + A reconstruction records the instructions its channels play, and reads its sound from + them, so this is where a run's answer is gathered. + """ + frames = self._frame_count(streams) + for position in range(frames): + announce(report, ReconstructionStage.GATHERING, position, frames) + for channel_name in self.state.channel_names: + self.state.append(channel_name, streams[channel_name][position].instruction) + + announce(report, ReconstructionStage.GATHERING, 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]], + scale: float, + ) -> StemsData: + """Assembles the per-channel per-frame stem record into serializable stems data.""" + return StemsData( + config=stems_config, + assignments=tuple( + ChannelAssignment(channel_name=channel, stem_ids=tuple(stem_ids)) + for channel, stem_ids in assignments.items() + ), + scale=scale, + ) def load_audio(self, path: Path) -> np.ndarray: """Loads and preconditions the audio at ``path`` for reconstruction. @@ -141,22 +384,30 @@ 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 brings the input's typical frame to what one channel renders. - 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. + The typical frame is the robust active-frame RMS level under the configured percentile + and audibility floor, floored at `MINIMUM_AUDIO_LEVEL` so a fully silent input yields a + finite coefficient. It is brought to the full-scale RMS level of the quietest tone + channel the setup covers, or of the quietest covered channel when the setup covers no + tone channel. A steady tone then plays at a level a single tone channel renders whole, + so one channel can answer it, and louder frames call on more channels. A pulse and the + noise swing between two levels, so their RMS level is their peak; a triangle's is its + peak over the square root of three. 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()) level = max( active_frame_level( audio, @@ -166,7 +417,13 @@ def get_coefficient(self, audio: np.ndarray) -> float: ), MINIMUM_AUDIO_LEVEL, ) - return float(level / total) + return float(level / self._working_level(stems_config)) + + def _working_level(self, stems_config: StemsConfig) -> float: + """The full-scale RMS level of the quietest covered tone channel, or covered channel without one.""" + covered = [name for name in self.channels if name in stems_config.covered_channels] + anchors = [name for name in covered if name in TONE_CHANNELS] or covered + return min(FULL_SCALE_RMS_LEVELS[self.channels[name].class_name()] for name in anchors) def get_fragments(self, audio: np.ndarray) -> FragmentedAudio: """Frames the audio into the fragments matched against the library. @@ -179,31 +436,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 +445,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 +461,11 @@ 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. - - Args: - fragment_approximation: The chosen approximation for one fragment and - generator. - """ - generator: GeneratorUnion = self.generators[fragment_approximation.generator_name] - if self.config.generation.final_regeneration: - instruction = fragment_approximation.instruction - initials = generator.initials - approximation = ( - generator( - instruction, # type: ignore[arg-type] - initials=initials, - save=True, - ) - * self.config.generation.drive - ) - else: - approximation = fragment_approximation.approximation.audio * self.config.generation.drive - - self.state.append(fragment_approximation, 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..99b8ba9a8 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py @@ -0,0 +1,209 @@ +from dataclasses import dataclass, replace +from typing import Dict, FrozenSet, 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 sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + +from .smoothing import smoothed + +StemRecordings = Dict[int, np.ndarray] +StemIds = Dict[ChannelName, List[int]] + + +@dataclass(frozen=True) +class PitchRefiner: + """Bends each chosen note toward 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. A bend counts divider steps from its own note, so each note's frames are + settled on their own, and a frame that reads nothing keeps a bend its own note read. + + Which recordings are carried, and on which channels, each stem entry states for itself, so a + channel a stem leaves alone keeps the note the matching chose. + + Attributes: + config: The settings the refinement and the render are run under. + channels: The generator each channel sounds through, which owns the divider geometry. + stems: The setup the run assigns channels under, which states the bends each stem asks for. + """ + + config: Config + channels: Dict[ChannelName, GeneratorUnion] + stems: StemsConfig + + @property + def active(self) -> bool: + """Whether this run bends its notes, which some stem asking for a bend decides.""" + return bool(self.stems.bent_channels) + + def refine( + self, + streams: Streams, + stem_ids: StemIds, + recordings: StemRecordings, + ) -> Streams: + """The decoded streams with every frame's note bent toward 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() if self._bends_of(stem_id) + } + return { + channel_name: self._refined(channel_name, stream, stem_ids.get(channel_name, []), readers) + for channel_name, stream in streams.items() + } + + def _bends_of(self, stem_id: int) -> FrozenSet[ChannelName]: + """The channels one stem carries toward its own recording.""" + entry = self.stems.entries_by_id.get(stem_id) + if entry is None: + return frozenset() + + return entry.settings.bend_set + + 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 + + if channel_name not in self.stems.bent_channels: + return stream + + settings = self.config.generation.refinement + proposals = [ + self._proposal(generator, channel_name, candidate, frame, stem_ids, readers) + for frame, candidate in enumerate(stream) + ] + bends: List[int] = [] + for run in _note_runs(stream): + bends.extend( + smoothed( + [proposals[frame] for frame in run], + 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, + channel_name: ChannelName, + 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(channel_name, 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_toward(instruction.pitch, reading.frequency) + + def _reader_at( + self, + channel_name: ChannelName, + frame: int, + stem_ids: List[int], + readers: Dict[int, InstantaneousPitch], + ) -> Optional[InstantaneousPitch]: + """The reading behind one frame, where the stem it took asks for this channel to bend.""" + if frame >= len(stem_ids): + return None + + stem_id = stem_ids[frame] + if stem_id == RESTING_STEM_ID or channel_name not in self._bends_of(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})) + + +def _note_runs(stream: List[ScoredCandidate]) -> List[range]: + """The stream's frames cut wherever the note they sound changes, a rest standing as a note of its own.""" + runs: List[range] = [] + start = 0 + for frame in range(1, len(stream) + 1): + if frame == len(stream) or _note(stream[frame]) != _note(stream[start]): + runs.append(range(start, frame)) + start = frame + + return runs + + +def _note(candidate: ScoredCandidate) -> Optional[int]: + """The note one frame sounds, which a bend counts its divider steps from, and nothing where it rests.""" + instruction = candidate.instruction + if not isinstance(instruction, (PulseInstruction, TriangleInstruction)) or not instruction.on: + return None + + return instruction.pitch 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..dc693c014 100644 --- a/src/sampletones_core/reconstructions/reconstructor/scorer.py +++ b/src/sampletones_core/reconstructions/reconstructor/scorer.py @@ -9,73 +9,83 @@ class Scorer: """ - Scores candidate approximations against a target fragment in two stages. + Scores what a frame sounds like against the target fragment, in two stages. - `spectral_costs` ranks the whole candidate stack by the phase-independent - spectral term, producing the shortlist. `aligned_cost` completes the criterion - for one shortlisted candidate, evaluating the temporal term on the candidate's - phase-aligned waveform so it reflects the waveform shape at its best phase alignment. + `spectral_costs` ranks a stack of mixed features by the phase-independent spectral term, + producing the shortlist. `frame_costs` completes the criterion for shortlisted mixes with the + temporal term their expected waveforms measure. """ def __init__(self, config: Config, window: Window, signal_length: int) -> None: self.criterion = Criterion(config, window, signal_length) - def spectral_costs(self, target: Fragment, candidates: Fragment) -> np.ndarray: + def spectral_costs(self, target: Fragment, features: xp.ndarray) -> np.ndarray: """ - Weighted spectral loss of every candidate against the target. + Weighted spectral loss of every mixed feature against the target. - Both sides are compared through their spectral features, which are averaged - over phase for library candidates, so the ranking is independent of how the - candidate waveforms are phased. + The mixed features sum powers averaged over phase, so the ranking is independent of how + the candidate waveforms are phased. Args: target: Target fragment to match. - candidates: Stacked candidate fragments. + features: Feature values of the mixes, one mix per row. Returns: - One spectral cost per candidate. + One spectral cost per mix. """ errors = None - target_gpu = None + target_values = None try: - target_gpu = target.to_cupy() - errors = self.criterion.spectral_loss( - target_gpu.feature, - candidates.feature, - ) - return to_numpy(errors) + target_values = xp.asarray(target.feature.values) + errors = self.criterion.spectral_loss(target_values, features) + return np.asarray(to_numpy(errors), dtype=np.float64) finally: - del errors, target_gpu + del errors, target_values if CUPY_AVAILABLE: xp.get_default_memory_pool().free_all_blocks() - def aligned_cost( + def frame_costs( self, target: Fragment, - spectral_cost: float, - approximation: Fragment, - ) -> float: + spectral_costs: np.ndarray, + expectations: np.ndarray, + variances: np.ndarray, + ) -> np.ndarray: """ - Full criterion cost of one candidate, with the temporal term evaluated on the - candidate's aligned waveform. - - Combines the already-computed spectral cost with the temporal loss between the - target and the aligned approximation, using the configured loss blend. + Full criterion cost of every mix, with the temporal term its expected waveform measures. Args: target: Target fragment to match. - spectral_cost: The candidate's spectral cost from `spectral_costs`. - approximation: The candidate fragment built at its best phase. + spectral_costs: The mixes' spectral costs from `spectral_costs`. + expectations: The waveform each mix is expected to render, one mix per row. + variances: The per-sample variance of each mix about its expected waveform. Returns: - The blended criterion cost. + One blended criterion cost per mix. """ - temporal = self.criterion.temporal_loss( - xp.asarray(target.audio), - xp.asarray(approximation.audio), + temporal = self.criterion.expected_temporal_loss( + xp.asarray(target.audio, dtype=xp.float64), + xp.asarray(expectations), + xp.asarray(variances), ) - combined = self.criterion.combine_losses(spectral_cost, temporal) - return float(to_numpy(combined)[0]) + combined = self.criterion.combine_losses(xp.asarray(spectral_costs), temporal) + return np.asarray(to_numpy(combined), dtype=np.float64).reshape(-1) + + 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]) @staticmethod def top_k(costs: np.ndarray, k: int) -> np.ndarray: 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/selector/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py deleted file mode 100644 index 8f4d76d94..000000000 --- a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py +++ /dev/null @@ -1,181 +0,0 @@ -import itertools -from typing import Dict, 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 - -ChannelLattice = List[List[ScoredCandidate]] -FrameCandidates = Dict[GeneratorName, List[ScoredCandidate]] - - -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, - ) - 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) - - return lattices - - 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(self, frames: ChannelLattice) -> List[int]: - if not frames: - return [] - - backpointers, final_costs = self._forward_pass(frames) - return self._backtrack(backpointers, final_costs) - - def _forward_pass(self, frames: ChannelLattice) -> Tuple[List[List[int]], List[float]]: - costs = [state.cost for state in frames[0]] - backpointers: List[List[int]] = [] - - for previous_states, current_states in itertools.pairwise(frames): - layer_costs: List[float] = [] - layer_backpointers: List[int] = [] - for state in current_states: - predecessor, accumulated = self._best_predecessor(costs, previous_states, state.instruction) - layer_costs.append(accumulated + state.cost) - layer_backpointers.append(predecessor) - - costs = layer_costs - backpointers.append(layer_backpointers) - - return backpointers, costs - - def _best_predecessor( - self, - previous_costs: List[float], - previous_states: List[ScoredCandidate], - instruction: InstructionUnion, - ) -> Tuple[int, float]: - best_index = 0 - best_cost = float("inf") - for index, previous_state in enumerate(previous_states): - total = previous_costs[index] + self._transition_cost(previous_state.instruction, instruction) - if total < best_cost: - best_cost = total - best_index = index - - return best_index, best_cost - - def _backtrack(self, backpointers: List[List[int]], final_costs: List[float]) -> List[int]: - last = int(np.argmin(final_costs)) - path = [last] - for layer in reversed(backpointers): - last = layer[last] - path.append(last) - - path.reverse() - return path - - def _transition_cost(self, previous: InstructionUnion, current: InstructionUnion) -> float: - if not previous.on and not current.on: - return 0.0 - - if previous.on != current.on: - return self.on_off_weight - - cost = 0.0 - cost += self.pitch_weight * self._distance(previous, current, "pitch") - cost += self.pitch_weight * self._distance(previous, current, "period") - cost += self.volume_weight * self._distance(previous, current, "volume") - cost += self.timbre_weight * self._mismatch(previous, current, "duty_cycle") - cost += self.timbre_weight * self._mismatch(previous, current, "short") - return cost - - @staticmethod - def _distance(previous: InstructionUnion, current: InstructionUnion, field: str) -> float: - previous_value = getattr(previous, field, None) - current_value = getattr(current, field, None) - if previous_value is None or current_value is None: - return 0.0 - - return float(abs(int(previous_value) - int(current_value))) - - @staticmethod - def _mismatch(previous: InstructionUnion, current: InstructionUnion, field: str) -> float: - previous_value = getattr(previous, field, None) - current_value = getattr(current, field, None) - if previous_value is None or current_value is None: - return 0.0 - - return 0.0 if previous_value == current_value else 1.0 diff --git a/src/sampletones_core/reconstructions/reconstructor/state.py b/src/sampletones_core/reconstructions/reconstructor/state.py index 43bdbdd3f..a42c348d6 100644 --- a/src/sampletones_core/reconstructions/reconstructor/state.py +++ b/src/sampletones_core/reconstructions/reconstructor/state.py @@ -1,14 +1,11 @@ from typing import Dict, List, Self -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 +17,20 @@ 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]] = {} @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}, ) def append( self, - fragment_approximation: ApproximationData, - approximation: np.ndarray, + channel_name: ChannelName, + instruction: InstructionUnion, ) -> 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.""" + self.instructions[channel_name].append(instruction) 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/columns.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/columns.py new file mode 100644 index 000000000..08fbafd5c --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/columns.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from typing import Dict, List, Mapping, Tuple + +from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.generators import GeneratorUnion + + +@dataclass(frozen=True) +class ColumnGroup: + """The channels one column of candidates answers, named by the channel standing for them. + + Attributes: + generator: The generator of the lowest channel in the group, which stands for it. + drive: The level every channel of the group reaches for. + """ + + generator: GeneratorUnion + drive: float + + +def column_groups( + remaining: Mapping[ChannelName, GeneratorUnion], + drives: Mapping[ChannelName, float], +) -> List[ColumnGroup]: + """One group per generator class and drive among ``remaining``, in channel order. + + Channels of one kind a stem drives alike share a candidate catalog and a scale, so one + scored column answers all of them and the lowest of them stands for the group; successive + picks over channels of one kind therefore reach the lowest free one. Channels of one kind at + different drives are groups of their own, each scored at the level its stem gives it. + + Args: + remaining: The channels still free that the stem may occupy, with their generators. + drives: The level the stem gives each of its channels. + + Returns: + The groups the stem offers, in the order their representative channels stand in. + """ + groups: Dict[Tuple[GeneratorClassName, float], ColumnGroup] = {} + for channel_name in ChannelName.items(): + generator = remaining.get(channel_name) + if generator is None: + continue + + drive = drives[channel_name] + groups.setdefault((generator.class_name(), drive), ColumnGroup(generator=generator, drive=drive)) + + return list(groups.values()) 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..7f0b24a0e --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/frame.py @@ -0,0 +1,61 @@ +from typing import Dict + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft import Fragment +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, + lattice_width: int, +) -> StemFrameAssignment: + """ + Assigns one frame's channels to stems, one pick at a time, for as long as a pick helps. + + Every pick scores each stem's candidates by the cost that stem's own frame reaches with the + candidate sounding beside the stem's earlier picks, silence among them, and takes the channel + whose best candidate lowers a frame's cost the most, weighted by the energy of that frame. + Picks stop when no candidate lowers any frame's cost, so a frame one channel renders whole + sounds one channel. Scoring each stem against its own recording is what makes the channel a + stem wins carry that recording's sound, and it carries it at the drive that stem gives the + channel. 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 count its own settings allow per frame. + + A channel no pick took goes to the first sounding stem that may hold it, headed by its silence, + and every choice is then scored once more with the stem's other choices sounding. The frame is + answered whole: every covered channel is held 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's feature space. + stems_config: The stems setup the assignment runs under. + channels: The enabled channels with their generators. + matcher: The candidate scoring machinery. + 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, + 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..7fd3e9c2d --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py @@ -0,0 +1,385 @@ +from dataclasses import dataclass +from typing import Dict, FrozenSet, List, Mapping, NamedTuple, Optional, Sequence, Set, Tuple + +import numpy as np + +from sampletones_core.constants.algorithm import ( + RESTING_FRAME_COST, + STEM_ACTIVITY_FLOOR, + UNIT_DRIVE, +) +from sampletones_core.constants.enums import ( + ChannelName, + GeneratorClassName, + HierarchyMode, +) +from sampletones_core.fft import Fragment +from sampletones_core.generators import GeneratorUnion +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +from sampletones_core.reconstructions.reconstructor.matching import ( + Column, + FrameMatcher, + ScoredCandidate, + column_of, +) +from sampletones_core.reconstructions.reconstructor.mix import FrameMix +from sampletones_core.reconstructions.reconstructor.stems.assignment.columns import column_groups +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 a channel: that channel's column in the stem's frame, and what it saves. + + The column arrives best first, so its head is the candidate the channel would play. + + Attributes: + stem_id: The stem making the offer. + generator: The generator standing for the channel the stem would take. + column: The channel's candidates scored in the stem's frame at the drive the stem gives + the channel, best first. + unit_cost: The cost the channel's own best candidate at unit drive reaches in the stem's + frame, which the offer is ranked on and the stem stands at once the channel is taken. + improvement: How far the channel at unit drive lowers the stem's frame cost, weighted by + the energy of the stem's frame. + """ + + stem_id: int + generator: GeneratorUnion + column: Column + unit_cost: float + improvement: float + + @property + def head(self) -> ScoredCandidate: + return self.column[0] + + +class ColumnKey(NamedTuple): + """What a scored column answers: one stem's kind of channel at one drive.""" + + stem_id: int + class_name: GeneratorClassName + drive: float + + +@dataclass(frozen=True) +class ScoredChoice: + """A choice together with the contributions sounding beside it when its column was scored.""" + + choice: StemChoice + context: Tuple[Contribution, ...] + + +class AssignmentSession: + """ + Carries one frame assignment's mutable progress: each stem's mix and frame cost, the free + channels, and how many channels each stem holds against the count its settings allow. + + A stem's mix is what its picks sound in its own frame, so a candidate is scored by the cost + that frame reaches with the candidate sounding beside them, and the channel's silence is one + of the candidates. Every candidate is read at the drive the stem gives the channel, so one + column answers the channels of a kind the stem drives alike and the channels it drives apart + hold columns of their own. A channel is taken where it lowers a frame's cost, by the stem + whose sound it covers most, measured at unit drive so a drive settles what a channel plays + and leaves the stems standing where they are. 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, + lattice_width: int, + ) -> None: + self.fragments = fragments + self.stems_config = stems_config + self.channels = channels + self.matcher = matcher + self.lattice_width = lattice_width + self.caps: Dict[int, int] = {entry.id: entry.settings.channel_cap for entry in stems_config.entries} + 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.mixes: Dict[int, FrameMix] = {stem_id: FrameMix.empty(fragments[stem_id]) for stem_id in self.sounding} + self.frame_costs: Dict[int, float] = { + stem_id: matcher.mix_cost(fragments[stem_id], mix) for stem_id, mix in self.mixes.items() + } + self.energies: Dict[int, float] = { + stem_id: matcher.reference_energy(fragments[stem_id]) for stem_id in self.sounding + } + self.columns: Dict[ColumnKey, Column] = {} + self.choices: List[ScoredChoice] = [] + + def run(self) -> StemFrameAssignment: + """Runs the frame's picks, settles the channels no pick took, and reports the frame whole.""" + match self.stems_config.hierarchy.mode: + case HierarchyMode.ROUND_ROBIN: + self._round_robin() + case HierarchyMode.STRICT: + self._strict() + + self._settle_declined() + self._sweep() + return StemFrameAssignment( + choices=tuple( + StemChoice( + stem_id=scored.choice.stem_id, + channel_name=scored.choice.channel_name, + column=column_of(scored.choice.column, self.lattice_width), + ) + for scored in self.choices + ), + rests=self._rests(), + ) + + def _round_robin(self) -> None: + """Gives every level one pick per round, for as many rounds as the largest count among the stems sounding.""" + rounds = max((self.caps[stem_id] for stem_id in self.sounding), default=0) + for _ in range(rounds): + 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._has_room(stem_id) 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 + + self._take(offer) + picked_this_visit.add(offer.stem_id) + + def _best_offer(self, stem_ids: Sequence[int]) -> Optional[StemOffer]: + """The offer lowering its stem's frame cost the most, weighted by that frame's energy. + + A cost is a fraction of its own frame's energy, so two stems' costs stand on different + scales; weighting a lowering by the energy behind it states it in absolute terms, so the + channel reaches the stem whose sound it covers most. The lowering is read from the + channel scored at unit drive while the driven column decides what the channel plays, so + the stems compete on how much of a frame a channel covers and a drive stays a property of + the sound its stem makes. An offer whose head is the channel's silence, or which lowers + nothing, stands aside. Equal offers leave the one already standing, which is earlier in + level order and then in channel order, so a rerun of the same frame assigns the same way. + """ + best: Optional[StemOffer] = None + for stem_id in stem_ids: + for group in column_groups(self._remaining_channels(stem_id), self._drives(stem_id)): + column = self._column(stem_id, group.generator, group.drive) + if not column[0].instruction.on: + continue + + unit_cost = self._column(stem_id, group.generator, UNIT_DRIVE)[0].cost + improvement = (self.frame_costs[stem_id] - unit_cost) * self.energies[stem_id] + if improvement <= 0.0: + continue + + if best is None or improvement > best.improvement: + best = StemOffer( + stem_id=stem_id, + generator=group.generator, + column=column, + unit_cost=unit_cost, + improvement=improvement, + ) + + return best + + def _take(self, offer: StemOffer) -> None: + """Gives the offer's channel to its stem, sounding the head in that stem's mix. + + The stem stands at the cost the channel reaches at unit drive, which is the scale the + next offer is measured against, so one drive-free lowering follows another. + """ + stem_id = offer.stem_id + self._record(stem_id, ChannelName(offer.generator.name), offer.column) + self.mixes[stem_id] = self.mixes[stem_id].added(offer.head.contribution) + self.frame_costs[stem_id] = offer.unit_cost + self._forget_columns(stem_id) + + def _settle_declined(self) -> None: + """Gives each channel no pick took to the first sounding stem that may still hold it. + + The channel sounds nothing there, and its column keeps the alternatives a decoder may + still sound when the frames around ask for the channel. A declined channel counts against + its stem's count, so no decoded frame sounds more channels than the stem allows. A channel + no stem may hold stays free for the rests. + """ + for channel_name in list(self.free_channels): + stem_id = self._settling_stem(channel_name) + if stem_id is None: + continue + + self._record( + stem_id, + channel_name, + self._column(stem_id, self.channels[channel_name], self._drive_of(stem_id, channel_name)), + ) + + def _sweep(self) -> None: + """Scores every choice once more with the stem's other choices sounding beside it. + + A pick is made before the picks after it, so the channels taken early answered a frame the + later channels had yet to join. Scoring each channel again against what the others settled + on lets its head move to what the whole frame asks of it: a channel may fall silent where + the others cover its sound, or sound where they leave room. A choice whose context is + unchanged keeps its column. + """ + for index, scored in enumerate(self.choices): + stem_id = scored.choice.stem_id + context = tuple( + other.choice.head.contribution + for other_index, other in enumerate(self.choices) + if other_index != index and other.choice.stem_id == stem_id and other.choice.sounding + ) + if len(context) == len(scored.context) and all( + current is previous for current, previous in zip(context, scored.context) + ): + continue + + fragment = self.fragments[stem_id] + column = self.matcher.score_column( + fragment, + self.channels[scored.choice.channel_name], + FrameMix.of(fragment, context), + drive=self._drive_of(stem_id, scored.choice.channel_name), + ) + self.choices[index] = ScoredChoice( + choice=StemChoice(stem_id=stem_id, channel_name=scored.choice.channel_name, column=column), + context=context, + ) + + def _record(self, stem_id: int, channel_name: ChannelName, column: Column) -> None: + context = tuple( + scored.choice.head.contribution + for scored in self.choices + if scored.choice.stem_id == stem_id and scored.choice.sounding + ) + self.choices.append( + ScoredChoice( + choice=StemChoice(stem_id=stem_id, channel_name=channel_name, column=column), + context=context, + ) + ) + self.used_channels[stem_id] += 1 + self.free_channels.remove(channel_name) + + def _column(self, stem_id: int, generator: GeneratorUnion, drive: float) -> Column: + """The column of ``generator``'s class at ``drive`` in the stem's frame, scored once per mix.""" + key = ColumnKey(stem_id=stem_id, class_name=generator.class_name(), drive=drive) + column = self.columns.get(key) + if column is None: + column = self.matcher.score_column( + self.fragments[stem_id], + generator, + self.mixes[stem_id], + drive=drive, + ) + self.columns[key] = column + + return column + + def _forget_columns(self, stem_id: int) -> None: + for key in [key for key in self.columns if key.stem_id == stem_id]: + del self.columns[key] + + def _settling_stem(self, channel_name: ChannelName) -> Optional[int]: + """The first sounding stem in hierarchy order that allows ``channel_name`` and has room left.""" + for level in self.stems_config.hierarchy.levels: + for stem_id in level: + allowed = self.stems_config.entries_by_id[stem_id].settings.channel_set + if stem_id in self.sounding and channel_name in allowed and self._has_room(stem_id): + return stem_id + + return None + + def _drives(self, stem_id: int) -> Mapping[ChannelName, float]: + """The level the stem gives each of the channels it occupies.""" + return self.stems_config.entries_by_id[stem_id].settings.drives + + def _drive_of(self, stem_id: int, channel_name: ChannelName) -> float: + """The level the stem gives one of the channels it occupies.""" + return self._drives(stem_id)[channel_name] + + def _has_room(self, stem_id: int) -> bool: + """Whether the stem holds fewer channels than the count its settings allow in one frame.""" + return self.used_channels[stem_id] < self.caps[stem_id] + + def _rests(self) -> Tuple[StemRest, ...]: + """The channels no stem holds, each holding its null instruction over a silent frame.""" + if not self.free_channels: + return () + + fragment = next(iter(self.fragments.values())) + silence = Contribution.silence(len(fragment.feature.values), fragment.audio.shape[0]) + return tuple( + StemRest( + channel_name=channel_name, + column=( + ScoredCandidate( + instruction=self.channels[channel_name].get_instruction_type().null_instruction(), + cost=RESTING_FRAME_COST, + contribution=silence, + ), + ), + ) + for channel_name in self.free_channels + ) + + 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].settings.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..be852a80b --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/track.py @@ -0,0 +1,51 @@ +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, Streams +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 release_silent(self, streams: Streams) -> None: + """Gives every frame a decoded stream plays silent to the resting stem. + + A stem owns a channel's frame to sound its recording there, so a frame the decoder settled + on an off instruction holds no stem's sound. Releasing it keeps the resting stem id and + the silent instruction naming the same frames, and a channel decoded silent throughout + reads as resting. + + Args: + streams: The decoded streams of the channels in play. + """ + for channel_name, stream in streams.items(): + stem_ids = self.stem_ids[channel_name] + for frame, candidate in enumerate(stream): + if not candidate.instruction.on: + stem_ids[frame] = RESTING_STEM_ID + + 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..849cea995 --- /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 run was built for. + + 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 run has no generator for. + """ + enabled = set(channels) + for entry in stems_config.entries: + foreign = entry.settings.channel_set - enabled + if foreign: + raise ValueError(f"Stem {entry.id} allows channels the run was not built for: {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..77669c0b9 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -0,0 +1,94 @@ +from functools import cached_property +from typing import AbstractSet, Dict, FrozenSet, Self, Tuple + +from pydantic import ConfigDict, Field, model_validator + +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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +class StemsConfig(DataModel): + """The setup a run hands its channels out under: the stems, and the order they pick in. + + Each entry states what its recording is converted with, the count and the drives included, so + the setup carries every per-recording choice and the hierarchy alone speaks for the run. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + entries: Tuple[StemEntry, ...] = Field( + default_factory=tuple, + description="The competing stems, each with what its recording is converted with", + ) + hierarchy: StemsHierarchy = Field( + default_factory=StemsHierarchy, + description="The precedence structure of the stems assignment", + ) + + @classmethod + def single_entry(cls, settings: StemSettings) -> Self: + """The setup of one stem converted with ``settings`` on a single precedence level. + + One entry covering every channel it is given, sounding all of them at once, reproduces the + classic greedy pick, so this setup describes both a single-file conversion and the stems + pipeline's simplest case. + """ + return cls( + entries=(StemEntry(id=0, settings=settings),), + hierarchy=StemsHierarchy(levels=((0,),)), + ) + + def without_entries(self, stem_ids: AbstractSet[int]) -> Self: + """The setup with the entries ``stem_ids`` names gone, and every level they empty gone too. + + The entries that stay keep their ids, their settings and their order, and the levels keep + the picking mode they were read in, so the setup still says how what remains was converted. + + Args: + stem_ids: The entries that leave. + + Returns: + Self: The setup of the entries that stay. + """ + levels = [tuple(stem_id for stem_id in level if stem_id not in stem_ids) for level in self.hierarchy.levels] + return self.__class__( + entries=tuple(entry for entry in self.entries if entry.id not in stem_ids), + hierarchy=StemsHierarchy( + levels=tuple(level for level in levels if level), + mode=self.hierarchy.mode, + ), + ) + + @cached_property + def bent_channels(self) -> FrozenSet[ChannelName]: + """Every channel some stem carries toward its own recording.""" + return frozenset(channel for entry in self.entries for channel in entry.settings.bends) + + @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.settings.channels) + + @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..c1b65e9db --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py @@ -0,0 +1,19 @@ +from pydantic import ConfigDict, Field + +from sampletones_core.data import DataModel +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +class StemEntry(DataModel): + """One stem a run competes for channels with: the id it is recorded under, and its settings.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + id: int = Field( + ..., + description="Identifier of the stem the hierarchy references", + ) + settings: StemSettings = Field( + ..., + description="What the recording behind this stem is converted with", + ) 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..d755faa71 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/hierarchy.py @@ -0,0 +1,22 @@ +from typing import Tuple + +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: Tuple[Tuple[int, ...], ...] = Field( + default_factory=tuple, + 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/configs/settings.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/settings.py new file mode 100644 index 000000000..1621ce32e --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/settings.py @@ -0,0 +1,186 @@ +from functools import cached_property +from typing import AbstractSet, Any, FrozenSet, List, Mapping, Self, Tuple + +from pydantic import ConfigDict, Field, model_validator + +from sampletones_core.constants.algorithm import ( + ALL_STEMS_CHANNEL_CAP, + MAX_DRIVE, + MIN_DRIVE, + MIN_STEMS_CHANNEL_CAP, + UNIT_DRIVE, +) +from sampletones_core.constants.enums import ( + TONE_CHANNELS, + ChannelName, + bending_channels, + ordered_channels, +) +from sampletones_core.data import DataModel + +CHANNELS_FIELD: str = "channels" +DRIVES_FIELD: str = "drives" + + +class StemSettings(DataModel): + """What one recording is converted with: the channels it may occupy, which of them it bends, + how hard it pushes each of them, and how many of them it sounds at once. + + A recording's settings are one value, so the list a reader sets a run up in, the entry that run + records, and whoever reads the record later all state the same thing. A further per-recording + choice is a field here, and each of those readers gains it in the same step. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + channels: Tuple[ChannelName, ...] = Field( + ..., + description="The channels the recording may occupy", + ) + bends: Tuple[ChannelName, ...] = Field( + ..., + description="The channels whose notes the recording carries to the divider it sounds", + ) + drives: Mapping[ChannelName, float] = Field( + default_factory=dict, + description="How hard the recording pushes each channel it may occupy", + ) + channel_cap: int = Field( + default=ALL_STEMS_CHANNEL_CAP, + ge=MIN_STEMS_CHANNEL_CAP, + le=ALL_STEMS_CHANNEL_CAP, + description="The most channels the recording sounds in one frame", + ) + + @classmethod + def covering(cls, channels: List[ChannelName]) -> Self: + """The usual settings over ``channels``: every tone channel bent, unit drive, every channel at once. + + This is what a recording is converted with until a reader says otherwise, and the shape a + single-file conversion runs under. + """ + return cls( + channels=tuple(channels), + bends=tuple(bending_channels(channels)), + drives={channel_name: UNIT_DRIVE for channel_name in channels}, + channel_cap=ALL_STEMS_CHANNEL_CAP, + ) + + @cached_property + def channel_set(self) -> FrozenSet[ChannelName]: + """The channels the recording may occupy, in the form an assignment tests membership against.""" + return frozenset(self.channels) + + @cached_property + def bend_set(self) -> FrozenSet[ChannelName]: + """The channels the recording bends, in the form the refinement tests membership against.""" + return frozenset(self.bends) + + def with_channels(self, channels: AbstractSet[ChannelName]) -> Self: + """The settings occupying exactly ``channels``, keeping what still reaches one of them. + + A bend and a drive belong to a channel the recording occupies, so narrowing the channels + narrows both along with them, and a channel newly occupied plays at unit drive. The count + stays the reader's choice. + """ + held = ordered_channels(channels) + return self.__class__( + channels=tuple(held), + bends=tuple(ordered_channels(self.bend_set & frozenset(held))), + drives={channel_name: self.drives.get(channel_name, UNIT_DRIVE) for channel_name in held}, + channel_cap=self.channel_cap, + ) + + def with_bends(self, bends: AbstractSet[ChannelName]) -> Self: + """The settings bending ``bends``, each held to an occupied channel whose hardware reads one.""" + reached = frozenset(bends) & self.channel_set & TONE_CHANNELS + return self.__class__( + channels=self.channels, + bends=tuple(ordered_channels(reached)), + drives=self.drives, + channel_cap=self.channel_cap, + ) + + def with_drive(self, channel_name: ChannelName, drive: float) -> Self: + """The settings pushing ``channel_name`` with ``drive``. + + Raises: + ValueError: If the recording leaves ``channel_name`` unoccupied, or ``drive`` lies + outside the bounds a drive has. + """ + if channel_name not in self.channel_set: + raise ValueError(f"The recording leaves {channel_name} unoccupied, so it carries no drive there") + + return self.__class__( + channels=self.channels, + bends=self.bends, + drives={**self.drives, channel_name: drive}, + channel_cap=self.channel_cap, + ) + + def with_channel_cap(self, channel_cap: int) -> Self: + """The settings sounding at most ``channel_cap`` channels in one frame.""" + return self.__class__( + channels=self.channels, + bends=self.bends, + drives=self.drives, + channel_cap=channel_cap, + ) + + @model_validator(mode="before") + @classmethod + def _every_channel_holds_a_drive(cls, data: Any) -> Any: + """Gives a channel stated without a drive the unit drive. + + A setup states a drive for the channels it pushes away from unit, and the rest play as + calibrated, so a stems file, a settings file and a record written before drives existed + each read as complete settings. + """ + if not isinstance(data, dict): + return data + + channels = data.get(CHANNELS_FIELD) + if not isinstance(channels, (list, tuple)): + return data + + drives = dict(data.get(DRIVES_FIELD) or {}) + for channel_name in channels: + drives.setdefault(channel_name, UNIT_DRIVE) + + return {**data, DRIVES_FIELD: drives} + + @model_validator(mode="after") + def _bends_reach_their_channels(self) -> Self: + """Holds a bend to a channel the recording occupies and whose hardware loads a divider. + + Raises: + ValueError: If a bent channel lies outside the recording's own channels, or names the + noise channel, whose sixteen periods stand at fixed distances from each other. + """ + unheld = self.bend_set - self.channel_set + if unheld: + raise ValueError(f"Bent channels lie outside the ones occupied: {sorted(unheld)}") + + toneless = self.bend_set - TONE_CHANNELS + if toneless: + raise ValueError(f"Bent channels read no bend: {sorted(toneless)}") + + return self + + @model_validator(mode="after") + def _drives_name_the_channels(self) -> Self: + """Holds a drive to each channel the recording occupies and to the bounds a drive has. + + Raises: + ValueError: If the drives name a channel outside the ones occupied, or a drive lies + below ``MIN_DRIVE`` or above ``MAX_DRIVE``. + """ + named = frozenset(self.drives) + if named != self.channel_set: + raise ValueError(f"Drives name {sorted(named)} where the recording occupies {sorted(self.channel_set)}") + + for channel_name, drive in self.drives.items(): + if not MIN_DRIVE <= drive <= MAX_DRIVE: + raise ValueError(f"The drive on {channel_name} lies outside [{MIN_DRIVE}, {MAX_DRIVE}]: {drive}") + + return self 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..2e7ea9b1b --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/models/choice.py @@ -0,0 +1,40 @@ +from typing import NamedTuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +from sampletones_core.reconstructions.reconstructor.matching import Column, ScoredCandidate + + +class StemChoice(NamedTuple): + """One stem's hold on one channel for one frame. + + The column holds the channel's alternatives scored in the stem's frame with the stem's other + choices sounding, best first. Its head is what the assignment settled on: an instruction that + sounds where the channel lowers the frame's cost, and the channel's silence where it does not. + """ + + stem_id: int + channel_name: ChannelName + column: Column + + @property + def head(self) -> ScoredCandidate: + return self.column[0] + + @property + def instruction(self) -> InstructionUnion: + return self.head.instruction + + @property + def contribution(self) -> Contribution: + return self.head.contribution + + @property + def cost(self) -> float: + return self.head.cost + + @property + def sounding(self) -> bool: + """Whether the channel sounds in the frame, which is what a mix adds up.""" + return self.head.instruction.on 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..d33a3d30a 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,43 +36,26 @@ 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) - candidate_provider = CandidateProvider(self.config, self.window, self.library_data) + feature_extractor = get_feature_extractor(self.config, self.window) + candidate_provider = CandidateProvider(self.config, self.window, self.library_data, feature_extractor) 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..349c16c20 --- /dev/null +++ b/src/sampletones_core/reconstructions/stage.py @@ -0,0 +1,67 @@ +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 gathers those streams into the document it + answers with. 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 the whole of what a run spends its + time on: measured over whole runs it takes some ninety-seven parts in a hundred, decoding two, + and the gathering a tenth of one. :data:`STAGE_WEIGHTS` turns those measurements into shares a + bar reads well. Loading takes eight, because a run opening a library of its own pays that + reading once and a short recording spends a quarter of itself on it — a bar standing still is + noticed, while one moving a little early is not. The gathering takes one so the last stage + moves visibly, decoding keeps the two it measures, and matching keeps the eighty-nine that + remain. + + The weights are counts, each stating what a stage is worth against the others, and a reading + divides once at the point of use — which is what lets the last stage arrive exactly at the + whole run. + """ + + LOADING = "loading" + MATCHING = "matching" + DECODING = "decoding" + GATHERING = "gathering" + + @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: 89, + ReconstructionStage.DECODING: 2, + ReconstructionStage.GATHERING: 1, +} + +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 deleted file mode 100644 index 9c2c44859..000000000 --- a/src/sampletones_core/scripts/library.py +++ /dev/null @@ -1,79 +0,0 @@ -from typing import Tuple - -from tqdm import tqdm - -from sampletones_core.configs import Config -from sampletones_core.fft import Window -from sampletones_core.library import ( - InstructionLibrary, - InstructionLibraryData, - InstructionLibraryKey, -) -from sampletones_core.library.creator import InstructionsLibraryCreator -from sampletones_core.parallelization import TaskProgress, TaskStatus -from sampletones_shared.logger import logger, null_logger - - -def generate_library(config: Config) -> None: - window = Window.from_config(config) - library = InstructionLibrary.from_config(config) - key = library.create_key(config, window) - - creator = InstructionsLibraryCreator(config, window=window, logger=null_logger) - - progress_bar = tqdm(total=0, desc="Generating library", unit="instruction", disable=False) - - def on_start() -> None: - progress_bar.disable = False - logger.info(f"Starting library generation for key {key}") - - def on_completed( - result: Tuple[InstructionLibraryKey, InstructionLibraryData], - ) -> None: - key, library_data = result - library.save_data(key, library_data) - logger.info(f"Library {key.filename} generated successfully") - progress_bar.close() - - def on_progress( - task_status: TaskStatus, - _task_progress: TaskProgress, - ) -> None: - total = creator.total_instructions - if total and total != progress_bar.total: - progress_bar.total = total - progress_bar.refresh() - - delta = int(creator.completed_instructions) - int(progress_bar.n) - if delta > 0: - progress_bar.update(delta) - - if task_status in ( - TaskStatus.COMPLETED, - TaskStatus.CANCELLED, - TaskStatus.FAILED, - ): - progress_bar.close() - - def on_cancelled() -> None: - logger.info("Library generation cancelled by user") - progress_bar.close() - - def on_error(_exception: Exception) -> None: - progress_bar.close() - - creator.set_callbacks( - on_start=on_start, - on_completed=on_completed, - on_progress=on_progress, - on_cancelled=on_cancelled, - on_error=on_error, - ) - - try: - creator.start() - creator.wait() - except KeyboardInterrupt: - logger.info("Reconstruction interrupted by user") - finally: - progress_bar.close() diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py deleted file mode 100644 index 8037aa256..000000000 --- a/src/sampletones_core/scripts/reconstruction.py +++ /dev/null @@ -1,118 +0,0 @@ -from pathlib import Path -from typing import Optional - -from tqdm import tqdm - -from sampletones_core.configs import Config -from sampletones_core.library import InstructionLibrary -from sampletones_core.parallelization import TaskProgress, TaskStatus -from sampletones_core.reconstructions import Reconstructor -from sampletones_core.reconstructions.converter import ( - ReconstructionConverter, - get_output_path, -) -from sampletones_core.reconstructions.converter import reconstruct_file as _reconstruct_file -from sampletones_core.scripts.library import generate_library -from sampletones_shared.logger import logger, null_logger - - -def reconstruct_file( - input_path: Path, - config: Config, - output_path: Optional[Path] = None, -) -> None: - if output_path is None: - output_path = get_output_path(config, input_path) - - if output_path.exists(): - logger.info(f"Reconstructing file {input_path} exists, skipping") - return - - if input_path.is_dir(): - 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)) - logger.info(f"Reconstruction file saved to {output_path}") - - -def reconstruct_directory( - input_path: Path, - config: Config, - output_path: Optional[Path] = None, -) -> None: - if output_path is None: - output_path = get_output_path(config, input_path) - - if not input_path.is_dir(): - raise NotADirectoryError(f"Expected a directory path, got file path: {input_path}") - - library = InstructionLibrary.from_config(config) - if not library.exists(config): - logger.warning("Library does not exist for the given configuration, generating a new library") - generate_library(config) - - progress_bar = tqdm(total=0, desc=f"Reconstructing {input_path.name}", unit="file") - - def on_start() -> None: - progress_bar.disable = False - logger.info(f"Starting reconstruction for directory {input_path}") - - def on_completed(_path: Path) -> None: - logger.info(f"Reconstruction directory saved to {output_path}") - progress_bar.close() - - def on_progress( - task_status: TaskStatus, - task_progress: TaskProgress, - ) -> None: - progress_bar.disable = False - total = task_progress.total - if total and total != progress_bar.total: - progress_bar.total = total - progress_bar.refresh() - - delta = int(task_progress.completed) - int(progress_bar.n) - if delta > 0: - progress_bar.update(delta) - - if task_progress.current_item: - progress_bar.set_description(f"{input_path.name}: {task_progress.current_item}") - - if task_status in ( - TaskStatus.COMPLETED, - TaskStatus.CANCELLED, - TaskStatus.FAILED, - ): - progress_bar.close() - - def on_cancelled() -> None: - logger.info("Reconstruction cancelled by user") - progress_bar.close() - - def on_error(_exception: Exception) -> None: - progress_bar.close() - - converter = ReconstructionConverter( - config, - input_path=input_path, - is_file=False, - logger=null_logger, - ) - - converter.set_callbacks( - on_start=on_start, - on_completed=on_completed, - on_progress=on_progress, - on_cancelled=on_cancelled, - on_error=on_error, - ) - - try: - converter.start() - converter.wait() - except KeyboardInterrupt: - logger.info("Reconstruction interrupted by user") - finally: - progress_bar.close() diff --git a/src/sampletones_core/structures/histogram/histogram.py b/src/sampletones_core/structures/histogram/histogram.py index 0b13c75ca..e6a708edf 100644 --- a/src/sampletones_core/structures/histogram/histogram.py +++ b/src/sampletones_core/structures/histogram/histogram.py @@ -6,7 +6,6 @@ from typing import ( Dict, Iterator, - List, Optional, Tuple, Union, @@ -723,8 +722,8 @@ def densities(self) -> Array: Returns: Array of densities (values / widths) for each bin. """ - densities: List[Float] = [self.density(i) for i in range(len(self))] - return self.xp.array(densities, dtype=self.values.dtype) + densities: Array = (self.values / self.widths).astype(self.values.dtype) + return densities @cached_property def total(self) -> Float: diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index 6176c4d2d..c5870c145 100644 --- a/src/sampletones_core/structures/tree/node.py +++ b/src/sampletones_core/structures/tree/node.py @@ -1,18 +1,41 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Optional, Tuple, TypeVar 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 +TreeNodeT = TypeVar("TreeNodeT", bound="TreeNode") + + +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. + + ``gathered_plain_name`` says that the node's name has taken in a name somebody chose — a + folder's, an audio file's — which a tree hands a row when it folds a heading into it. The + classes naming a configuration read it through :attr:`states_configuration`, so a reader of + configuration text meets the row as the plain name it has become. + """ + + name: str + parent: Optional[TreeNode] + children: Tuple[TreeNode, ...] + path: Tuple[TreeNode, ...] + root: TreeNode + ancestors: Tuple[TreeNode, ...] + descendants: Tuple[TreeNode, ...] -class TreeNode(Node): # type: ignore[misc] def __init__( self, name: str, @@ -21,9 +44,20 @@ def __init__( ) -> None: super().__init__(name, parent=parent) self.node_type = node_type + self.gathered_plain_name = False + + @property + def states_configuration(self) -> bool: + """Whether the node's name is the machine text a reconstruction configuration carries.""" + return False def copy(self, parent: Optional[TreeNode] = None) -> TreeNode: - return TreeNode(self.name, node_type=self.node_type, parent=parent) + return self._carrying(TreeNode(self.name, node_type=self.node_type, parent=parent)) + + def _carrying(self, node: TreeNodeT) -> TreeNodeT: + """The fresh node, given what its name has come to hold beside the fields it was built with.""" + node.gathered_plain_name = self.gathered_plain_name + return node class FileSystemNode(TreeNode): @@ -38,11 +72,13 @@ def __init__( self.filepath = filepath def copy(self, parent: Optional[TreeNode] = None) -> FileSystemNode: - return FileSystemNode( - self.name, - filepath=self.filepath, - node_type=self.node_type, - parent=parent, + return self._carrying( + FileSystemNode( + self.name, + filepath=self.filepath, + node_type=self.node_type, + parent=parent, + ) ) @@ -71,13 +107,19 @@ def __init__( ) self.config = config + @property + def states_configuration(self) -> bool: + return not self.gathered_plain_name + def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode: - return ConfigNode( - self.name, - node_type=self.node_type, - filepath=self.filepath, - config=self.config, - parent=parent, + return self._carrying( + ConfigNode( + self.name, + node_type=self.node_type, + filepath=self.filepath, + config=self.config, + parent=parent, + ) ) @@ -90,27 +132,43 @@ class ConfigGroupNode(TreeNode): tooltip, a font — reach it the way it reaches the configuration row below. """ + @property + def states_configuration(self) -> bool: + return not self.gathered_plain_name + def copy(self, parent: Optional[TreeNode] = None) -> ConfigGroupNode: - return ConfigGroupNode(self.name, node_type=self.node_type, parent=parent) + return self._carrying(ConfigGroupNode(self.name, node_type=self.node_type, parent=parent)) class LibraryNode(TreeNode): + """A library file of the catalog, marked ``outdated`` where another version built it.""" + def __init__( self, name: str, library_key: InstructionLibraryKey, + *, + outdated: bool, node_type: NodeType = NodeType.LIBRARY, parent: Optional[TreeNode] = None, ) -> None: super().__init__(name, node_type=node_type, parent=parent) self.library_key = library_key + self.outdated = outdated + + @property + def states_configuration(self) -> bool: + return not self.gathered_plain_name def copy(self, parent: Optional[TreeNode] = None) -> LibraryNode: - return LibraryNode( - self.name, - node_type=self.node_type, - library_key=self.library_key, - parent=parent, + return self._carrying( + LibraryNode( + self.name, + node_type=self.node_type, + library_key=self.library_key, + outdated=self.outdated, + parent=parent, + ) ) @@ -118,7 +176,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: @@ -126,9 +184,11 @@ def __init__( self.generator_name = generator_name def copy(self, parent: Optional[TreeNode] = None) -> GeneratorNode: - return GeneratorNode( - self.name, - node_type=self.node_type, - generator_name=self.generator_name, - parent=parent, + return self._carrying( + GeneratorNode( + self.name, + node_type=self.node_type, + generator_name=self.generator_name, + parent=parent, + ) ) diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py index 7c9fbbc1a..cfc3ccab9 100644 --- a/src/sampletones_core/structures/tree/visibility.py +++ b/src/sampletones_core/structures/tree/visibility.py @@ -11,7 +11,7 @@ class TreeVisibility: A named row stays, and so do the rows leading down to it and the rows it holds: a named file is read under the folders it sits in, and a named folder shows what it gathers. Keeping the named rows and their ancestors alone holds the memory to the size of what was found, and a row below a - match is answered from its own path upwards. + match is answered from its own path upward. """ matches: FrozenSet[TreeNode] 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..17837347c --- /dev/null +++ b/src/sampletones_core/timers/arithmetic.py @@ -0,0 +1,73 @@ +from sampletones_core.constants.general import ( + APU_CLOCK, + MAX_TIMER, + MIN_TIMER, + TIMER_CYCLE_DIVIDER, +) +from sampletones_shared.utils.arrays import clamp + + +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)) + + +def bent_timer(timer: int, offset: int) -> int: + """The divider a note sounds at once a bend has moved it off its own. + + The sum stays within the 11 bits the register offers and above the divider that stops the + waveform, which keeps a bend audible wherever it lands. + + Args: + timer: The divider the note sounds at unbent. + offset: The divider steps the bend moves it by. + + Returns: + int: The divider to sound, in ``[MIN_TIMER, MAX_TIMER]``. + """ + return clamp(timer + offset, MIN_TIMER, MAX_TIMER) diff --git a/src/sampletones_core/timers/implementation/lfsr.py b/src/sampletones_core/timers/implementation/lfsr.py index da4c88d56..0fed23538 100644 --- a/src/sampletones_core/timers/implementation/lfsr.py +++ b/src/sampletones_core/timers/implementation/lfsr.py @@ -3,7 +3,6 @@ import numpy as np -from sampletones_core.constants.algorithm import RESET_PHASE from sampletones_core.constants.general import ( APU_CLOCK, MAX_LFSR, @@ -79,7 +78,7 @@ def __init__( self, sample_rate: int, nes_frequency: int, - reset_phase: bool = RESET_PHASE, + reset_phase: bool, ) -> None: super().__init__(sample_rate, nes_frequency, reset_phase) @@ -134,11 +133,6 @@ def resolve_index(self, lfsr: int) -> int: index = int(self.lfsr_tables[self.short].lfsr_to_index[lfsr]) return index if index != OFF_CYCLE else CYCLE_START - def calculate_offset(self, initials: Initials = None) -> int: - lfsr, clock = initials if initials is not None else (1, 0.0) - index = self.resolve_index(lfsr) - return int(np.ceil(index / self._clocks_per_sample - clock)) - def generate_frame(self, save: bool = True) -> np.ndarray: tables = self.lfsr_tables[self.short] length = self.lfsr_period @@ -173,12 +167,24 @@ def period(self) -> float: @period.setter def period(self, value: int) -> None: self._clocks_per_sample = self.calculate_clocks_per_sample(value) - self._period = self.lfsr_period / self._clocks_per_sample + self._period = self.cycle_samples(self.short, value) self._real_frequency = self.sample_rate / self._period if self.reset_phase: self.reset() + def cycle_samples(self, short: bool, period: int) -> float: + """The samples one whole shift-register cycle spans at a period, a fractional count. + + Args: + short: Whether the register runs in its 93-step short mode. + period: The index into the noise period table. + + Returns: + float: The cycle's length in samples. + """ + return cycle_length(short) / self.calculate_clocks_per_sample(period) + def calculate_clocks_per_sample(self, period: int) -> float: apu_period = NOISE_PERIODS[period] lfsr_clock_hz = APU_CLOCK / float(apu_period) diff --git a/src/sampletones_core/timers/implementation/phase.py b/src/sampletones_core/timers/implementation/phase.py index 8d6e2f422..4bebf60cd 100644 --- a/src/sampletones_core/timers/implementation/phase.py +++ b/src/sampletones_core/timers/implementation/phase.py @@ -2,10 +2,10 @@ import numpy as np -from sampletones_core.constants.algorithm import RESET_PHASE 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 @@ -14,7 +14,7 @@ def __init__( self, sample_rate: int, nes_frequency: int, - reset_phase: bool = RESET_PHASE, + reset_phase: bool, phase_increment: float = 1.0, ) -> None: super().__init__(sample_rate, nes_frequency, reset_phase) @@ -45,10 +45,6 @@ def __call__( return self.generate_frame(save=save) - def calculate_offset(self, initials: Initials = None) -> int: - phase = initials[0] if initials is not None else 0.0 - return round(self.sample_rate / self._real_frequency * phase) - def generate_frame(self, save: bool = True) -> np.ndarray: indices = np.arange(self.frame_length, dtype=np.float32) + 1 delta = self.phase_increment / self._timer_ticks * self._cycles_per_sample @@ -64,20 +60,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 +70,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/nearest.py b/src/sampletones_core/timers/nearest.py new file mode 100644 index 000000000..92361960f --- /dev/null +++ b/src/sampletones_core/timers/nearest.py @@ -0,0 +1,87 @@ +from bisect import bisect_left +from typing import Dict, List, Mapping, NamedTuple, Tuple + +from sampletones_core.constants.general import MAX_TIMER + + +class NearestPitch(NamedTuple): + """A divider named as the pitch lying nearest it, beside the steps it stands away. + + Attributes: + pitch: The pitch whose own divider lies nearest. + offset: The divider steps from that pitch's own divider to the one named. + """ + + pitch: int + offset: int + + +class _Owners(NamedTuple): + """The distinct dividers a table holds, in ascending order, each beside the pitch standing for it.""" + + timers: List[int] + pitches: Dict[int, int] + + +def nearest_pitch( + timer_table: Mapping[int, int], + divider: int, +) -> NearestPitch: + """The pitch lying nearest one divider, beside the steps between them. + + A bent note sounds a divider that may belong to no pitch at all. Naming it by the pitch lying + nearest leaves the fewest steps over: within the table's span, half the widest gap between + neighboring pitches at most. Pitches sharing a divider sound alike, so the lowest of them + stands for the group. A divider halfway between two pitches goes to the smaller divider, the + higher pitch: the lowest pitches all share the clamped top divider, and this keeps a note bent + down toward it on its own pitch. + + Args: + timer_table: The divider each pitch sounds at. + divider: The divider to name. + + Returns: + NearestPitch: The pitch and the steps the divider stands away from it. + + Raises: + ValueError: If the table names no pitch. + """ + return _nearest(divider, _owners(timer_table)) + + +def nearest_pitches(timer_table: Mapping[int, int]) -> Tuple[NearestPitch, ...]: + """The pitch lying nearest every divider the register holds, by the rule of :func:`nearest_pitch`. + + Args: + timer_table: The divider each pitch sounds at. + + Returns: + Tuple[NearestPitch, ...]: One entry per divider from 0 to ``MAX_TIMER``, indexed by the + divider. + + Raises: + ValueError: If the table names no pitch. + """ + owners = _owners(timer_table) + return tuple(_nearest(divider, owners) for divider in range(MAX_TIMER + 1)) + + +def _owners(timer_table: Mapping[int, int]) -> _Owners: + pitches: Dict[int, int] = {} + for pitch in sorted(timer_table): + pitches.setdefault(timer_table[pitch], pitch) + + if not pitches: + raise ValueError("a timer table names at least one pitch to measure a divider from") + + return _Owners(timers=sorted(pitches), pitches=pitches) + + +def _nearest( + divider: int, + owners: _Owners, +) -> NearestPitch: + position = bisect_left(owners.timers, divider) + neighbors = owners.timers[max(position - 1, 0) : position + 1] + timer = min(neighbors, key=lambda neighbor: (abs(divider - neighbor), neighbor)) + return NearestPitch(pitch=owners.pitches[timer], offset=divider - timer) diff --git a/src/sampletones_core/timers/timer.py b/src/sampletones_core/timers/timer.py index a1fc88490..5a2ba1337 100644 --- a/src/sampletones_core/timers/timer.py +++ b/src/sampletones_core/timers/timer.py @@ -3,7 +3,7 @@ import numpy as np -from sampletones_core.constants.algorithm import MAX_SAMPLE_LENGTH, MIN_SAMPLE_LENGTH, RESET_PHASE +from sampletones_core.constants.algorithm import MAX_SAMPLE_LENGTH, MIN_SAMPLE_LENGTH from sampletones_core.fft import CyclicArray, Window from sampletones_shared.types.data import Initials @@ -13,7 +13,7 @@ def __init__( self, sample_rate: int, nes_frequency: int, - reset_phase: bool = RESET_PHASE, + reset_phase: bool, ): self._real_frequency: float = 0.0 self.sample_rate: int = sample_rate @@ -32,9 +32,6 @@ def __call__( @abstractmethod def initials(self) -> Tuple[Any, ...]: ... - @abstractmethod - def calculate_offset(self, initials: Initials = None) -> int: ... - def prepare_frame(self, window: Optional[Window] = None) -> np.ndarray: length = self.frame_length if window is None else window.size return np.zeros(length, dtype=np.float32) @@ -56,6 +53,16 @@ def calculate_base_length(self, minimum_length: int) -> int: return round(cycles * cycle_length) def generate_sample(self) -> CyclicArray: + """The library sample of the waveform the timer runs, a stretch that loops. + + A sample spans whole cycles, at least ``MIN_SAMPLE_LENGTH`` seconds, so it loops without a + seam. A cycle longer than ``MAX_SAMPLE_LENGTH``, the slow noise periods in the long mode, keeps + its middle ``MAX_SAMPLE_LENGTH`` seconds and loops with a seam there. That length covers the + stretch a constant-Q reference feature reads, so the reference stays clear of the seam. + + Returns: + CyclicArray: The sample, at the frequency the timer runs. + """ min_sample_length = round(MIN_SAMPLE_LENGTH * self.sample_rate) max_sample_length = round(MAX_SAMPLE_LENGTH * self.sample_rate) base_length = self.calculate_base_length(min_sample_length) diff --git a/src/sampletones_core/timers/utils.py b/src/sampletones_core/timers/utils.py index 2aed934a7..d9a24d512 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 @@ -10,16 +12,31 @@ def get_frequency_table(config: Config) -> Dict[int, float]: timer = PhaseTimer( sample_rate=config.library.sample_rate, nes_frequency=config.library.nes_frequency, + reset_phase=config.generation.reset_phase, ) + 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..273e75d85 100644 --- a/src/sampletones_core/timing/__init__.py +++ b/src/sampletones_core/timing/__init__.py @@ -1,15 +1,24 @@ +from .bounds import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW, SONG_TICK_BOUNDS, TickBounds from .clock import TickClock -from .distribution import distribute_by_halving, distribute_proportionally -from .groove import Groove, calculate_groove -from .metre import Metre +from .distribution import nearest, split_by_halving +from .groove import Groove, bar_line, plan_bar +from .meter import Meter from .rate import RowRate +from .song import BarSlot, SongTiming __all__ = [ + "MAX_TICKS_PER_ROW", + "MIN_TICKS_PER_ROW", + "SONG_TICK_BOUNDS", + "BarSlot", "Groove", - "Metre", + "Meter", "RowRate", + "SongTiming", + "TickBounds", "TickClock", - "calculate_groove", - "distribute_by_halving", - "distribute_proportionally", + "bar_line", + "nearest", + "plan_bar", + "split_by_halving", ] diff --git a/src/sampletones_core/timing/bounds.py b/src/sampletones_core/timing/bounds.py new file mode 100644 index 000000000..2454b4dcc --- /dev/null +++ b/src/sampletones_core/timing/bounds.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +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 +) + + +@dataclass(frozen=True) +class TickBounds: + """The shortest and the longest an engine holds a row for, in ticks. + + In-app playback and the NSF reach every row the settings ask for, while a tracker's speed column + states a narrower range, so each player names the range its rows are held within. + + Attributes: + minimum: The fewest ticks a row lasts. + maximum: The most ticks a row lasts. + """ + + minimum: int + maximum: int + + +SONG_TICK_BOUNDS: Final[TickBounds] = TickBounds( + minimum=MIN_TICKS_PER_ROW, + maximum=MAX_TICKS_PER_ROW, +) diff --git a/src/sampletones_core/timing/distribution.py b/src/sampletones_core/timing/distribution.py index 53d511ab7..a51c5e4dc 100644 --- a/src/sampletones_core/timing/distribution.py +++ b/src/sampletones_core/timing/distribution.py @@ -1,77 +1,74 @@ -from typing import List, Sequence, Tuple +from fractions import Fraction +from math import ceil, floor +from typing import Final, Tuple +HALF: Final[Fraction] = Fraction(1, 2) +SINGLE_ROW: Final[int] = 1 -def _divide_rounding_up(dividend: int, divisor: int) -> int: - """Divides two integers, carrying a fractional result up to the next integer.""" - return -(-dividend // divisor) +def nearest(value: Fraction) -> int: + """The whole number closest to ``value``, a half rounding up.""" + return floor(value + HALF) -def distribute_proportionally( + +def split_by_halving( total: int, - lengths: Sequence[int], + spans: Tuple[int, ...], + *, + row_ticks: Fraction, ) -> Tuple[int, ...]: - """Shares a tick total among consecutive spans in proportion to their row counts. + """Shares a tick total among the rows of consecutive spans by halving them down to single rows. - Each span ends at a boundary rounded up from its exact share, so where a share falls - between two integers the surplus tick goes to the earlier span. Over a pattern this - puts the longer rows on the earlier, metrically stronger positions. + The spans are cut in two, the larger part first, so three spans part as two and one. Each part + takes the share of the total nearest its exact length, and each part is then halved again. A + single span is cut between its own rows. The surplus ticks therefore settle where a listener hears + the strongest positions: the first half of a bar's beats, then the halves of those, and inside a + beat its first row, then its middle, then its quarters. - Only the floor and the ceiling of the average per row ever appear, which is what lets - a caller hold every row within an engine's speed range by bounding ``total`` alone. + Every row of the spans lasts ``row_ticks`` exactly, and each part's share is held to what its rows + can carry, so every row lasts the floor or the ceiling of ``row_ticks``. Args: - total: The tick count the spans share. - lengths: The row count of each span, in order, each at least 1. + total: The ticks the spans share, between the floor and the ceiling of their exact length. + spans: The row count of each consecutive span, in order, each at least 1. + row_ticks: The exact ticks one row lasts. Returns: - Tuple[int, ...]: One tick total per span, together summing to ``total``. + Tuple[int, ...]: One tick count per row of the spans, together summing to ``total``. Raises: - ValueError: If no span is given, or a span holds fewer than one row. + ValueError: If no span is given, a span holds no row, or ``total`` lies outside what the rows + carry at the floor and the ceiling of ``row_ticks``. """ - if not lengths: + if not spans: raise ValueError("At least one span is required to share a tick total") - if any(length < 1 for length in lengths): - raise ValueError(f"Every span must hold at least 1 row, got {tuple(lengths)}") - - rows = sum(lengths) - shares: List[int] = [] - cumulative = 0 - boundary = 0 - for length in lengths: - cumulative += length - previous, boundary = boundary, _divide_rounding_up(total * cumulative, rows) - shares.append(boundary - previous) - - return tuple(shares) - - -def distribute_by_halving(total: int, rows: int) -> Tuple[int, ...]: - """Shares a tick total among rows by halving the span down to single rows. - - The earlier half takes the extra row where the count is odd and the surplus tick - where the share is fractional, so within a beat the longer rows fall on the positions - a listener hears as strong: the first row, then the halfway row, then the quarters. - - Args: - total: The tick count the rows share. - rows: How many rows share it, at least 1. + if any(span < SINGLE_ROW for span in spans): + raise ValueError(f"Every span must hold at least 1 row, got {spans}") - Returns: - Tuple[int, ...]: One tick count per row, together summing to ``total``. + rows = sum(spans) + if not rows * floor(row_ticks) <= total <= rows * ceil(row_ticks): + raise ValueError(f"{rows} rows of {row_ticks} ticks each carry no total of {total}") - Raises: - ValueError: If fewer than one row is given. - """ - if rows < 1: - raise ValueError(f"rows must be at least 1, got {rows}") + return _halved(total, spans, row_ticks) - if rows == 1: - return (total,) - left = _divide_rounding_up(rows, 2) - right = rows - left - halves = distribute_proportionally(total, (left, right)) - - return distribute_by_halving(halves[0], left) + distribute_by_halving(halves[1], right) +def _halved( + total: int, + spans: Tuple[int, ...], + row_ticks: Fraction, +) -> Tuple[int, ...]: + if len(spans) == 1: + if spans[0] == SINGLE_ROW: + return (total,) + + spans = (SINGLE_ROW,) * spans[0] + + middle = ceil(len(spans) / 2) + first, second = spans[:middle], spans[middle:] + first_rows, second_rows = sum(first), sum(second) + share = nearest(total * Fraction(first_rows, first_rows + second_rows)) + lowest = max(first_rows * floor(row_ticks), total - second_rows * ceil(row_ticks)) + highest = min(first_rows * ceil(row_ticks), total - second_rows * floor(row_ticks)) + share = min(max(share, lowest), highest) + return _halved(share, first, row_ticks) + _halved(total - share, second, row_ticks) diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py index 368ff36f7..0cb9963c6 100644 --- a/src/sampletones_core/timing/groove.py +++ b/src/sampletones_core/timing/groove.py @@ -1,26 +1,21 @@ from dataclasses import dataclass from fractions import Fraction -from math import floor -from typing import Final, List, Tuple +from functools import lru_cache +from typing import Final, Tuple -from sampletones_core.timing.distribution import ( - distribute_by_halving, - distribute_proportionally, -) -from sampletones_core.timing.metre import Metre -from sampletones_core.timing.rate import RowRate +from sampletones_core.timing.distribution import nearest, split_by_halving -HALF: Final[Fraction] = Fraction(1, 2) +BAR_PLAN_CACHE_SIZE: Final[int] = 4096 @dataclass(frozen=True) class Groove: - """The engine ticks each row of a pattern lasts. + """The engine ticks each row of one pattern lasts, where an order frame plays it. An engine that takes one speed value per row reaches a fractional row rate by varying that value from row to row, which is how a tempo its speed column alone cannot state - still comes out right on average. The variation is placed by metre, so the longer rows - land on the bar, then the beat, then the subdivisions inside a beat. + still comes out right on average. Every bar starts on the tick nearest its exact start, + and inside a bar the meter places the longer rows on the strongest positions first. Attributes: ticks: One tick count per pattern row, in order. @@ -28,98 +23,37 @@ class Groove: ticks: Tuple[int, ...] - @property - def total_ticks(self) -> int: - """How many engine ticks the whole pattern lasts.""" - return sum(self.ticks) - @property - def mean_ticks_per_row(self) -> Fraction: - """The row rate the groove realizes, which states what a bounded groove reached.""" - return Fraction(self.total_ticks, len(self.ticks)) - - @property - def is_uniform(self) -> bool: - """Whether every row lasts alike, so a single speed value carries the tempo.""" - return len(set(self.ticks)) == 1 - - -def _pattern_ticks( - rate: RowRate, - rows: int, - *, - minimum_ticks: int, - maximum_ticks: int, -) -> int: - """Rounds a pattern's exact tick count to the nearest integer within the engine's speed range. - - Rounding once, on the pattern, is what makes the pattern's duration the closest the - engine reaches; the metre then decides which rows carry the difference. Bounding the - pattern total rather than each row keeps every row inside the range as a consequence, - since a proportional split yields only the floor and the ceiling of the average. +def bar_line(row: int, row_ticks: Fraction) -> int: + """The tick a bar starting on ``row`` of the song starts on: the one nearest its exact start. Args: - rate: The exact ticks one row lasts. - rows: The pattern's row count. - minimum_ticks: The fewest ticks the engine holds a row for. - maximum_ticks: The most ticks the engine holds a row for. + row: The row the bar starts on, counted from the song's first row. + row_ticks: The exact ticks one row lasts. Returns: - int: The tick count the pattern's rows share. + int: The tick, counted from the song's first. """ - exact = rate.ticks_per_row * rows - return min( - max(floor(exact + HALF), rows * minimum_ticks), - rows * maximum_ticks, - ) - - -def calculate_groove( - rate: RowRate, - metre: Metre, - *, - minimum_ticks: int, - maximum_ticks: int, -) -> Groove: - """Builds the per-row tick counts that carry a row rate across one pattern. - - The pattern's tick total is shared among its bars, each bar's among its beats, and - each beat's among its rows by halving — one rule applied at three levels, so the - surplus ticks settle on the strongest position each level offers. + return nearest(row * row_ticks) + + +@lru_cache(maxsize=BAR_PLAN_CACHE_SIZE) +def plan_bar( + total: int, + beats: Tuple[int, ...], + row_ticks: Fraction, +) -> Tuple[int, ...]: + """The ticks each row of a bar lasts, the bar's total halved down its beats and rows. + + A bar lasts one of two totals wherever it falls in a song, the floor or the ceiling of its exact + length, so each bar of a pattern has at most two plans, and they are kept once computed. Args: - rate: The exact ticks one row lasts. - metre: The pattern's length and its beat and bar grouping. - minimum_ticks: The fewest ticks the engine holds a row for. - maximum_ticks: The most ticks the engine holds a row for. + total: The ticks the bar lasts. + beats: The row count of each of the bar's beats. + row_ticks: The exact ticks one row lasts. Returns: - Groove: One tick count per row of the pattern. + Tuple[int, ...]: One tick count per row of the bar. """ - total = _pattern_ticks( - rate, - metre.rows, - minimum_ticks=minimum_ticks, - maximum_ticks=maximum_ticks, - ) - bars = metre.spans - bar_lengths = tuple(sum(beats) for beats in bars) - - ticks: List[int] = [] - for beats, bar_ticks in zip( - bars, - distribute_proportionally( - total, - bar_lengths, - ), - ): - for beat_rows, beat_ticks in zip( - beats, - distribute_proportionally( - bar_ticks, - beats, - ), - ): - ticks.extend(distribute_by_halving(beat_ticks, beat_rows)) - - return Groove(ticks=tuple(ticks)) + return split_by_halving(total, beats, row_ticks=row_ticks) diff --git a/src/sampletones_core/timing/meter.py b/src/sampletones_core/timing/meter.py new file mode 100644 index 000000000..b2e5a287c --- /dev/null +++ b/src/sampletones_core/timing/meter.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Tuple + +from sampletones_core.project.settings import ProjectSettings + + +@dataclass(frozen=True) +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 + ``second_highlight`` gathers beats into a bar. The bar is what organizes emphases + where the beat divides the pattern unevenly; where both divide it cleanly the bar + grouping agrees with the beats on their own. + + The pattern length is the hard limit, so a span reaching past the last row ends + there and counts as the shorter span it is. This holds at both levels: a pattern of + 60 rows against a 16-row bar carries three whole bars and a 12-row one, and a bar + shorter than its beat carries a single beat of the rows that remain. + + Attributes: + rows: The pattern's row count. + first_highlight: The rows one beat spans. + second_highlight: The rows one bar spans. + """ + + rows: int + first_highlight: int + second_highlight: int + + def __post_init__(self) -> None: + if self.rows < 1: + raise ValueError(f"rows must be at least 1, got {self.rows}") + + if self.first_highlight < 1: + raise ValueError(f"first_highlight must be at least 1, got {self.first_highlight}") + + if self.second_highlight < 1: + raise ValueError(f"second_highlight must be at least 1, got {self.second_highlight}") + + @classmethod + 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. + """ + return cls( + rows=rows, + first_highlight=settings.first_highlight, + second_highlight=settings.second_highlight, + ) + + @property + def spans(self) -> Tuple[Tuple[int, ...], ...]: + """The whole grouping, as the beat row counts of each consecutive bar. + + Returns: + Tuple[Tuple[int, ...], ...]: One entry per bar, each holding that bar's beat + row counts in order, so the entries flattened come to ``rows``. + """ + return tuple( + self._divide(bar_rows, self.first_highlight) + for bar_rows in self._divide( + self.rows, + self.second_highlight, + ) + ) + + @staticmethod + def _divide(rows: int, unit: int) -> Tuple[int, ...]: + """Cuts a row span into consecutive units, the final one holding what remains.""" + whole, remainder = divmod(rows, unit) + spans = (unit,) * whole + return spans + (remainder,) if remainder else spans diff --git a/src/sampletones_core/timing/metre.py b/src/sampletones_core/timing/metre.py deleted file mode 100644 index 8b80eee91..000000000 --- a/src/sampletones_core/timing/metre.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Tuple - -from sampletones_core.project.settings import ProjectSettings - - -@dataclass(frozen=True) -class Metre: - """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 - ``second_highlight`` gathers beats into a bar. The bar is what organizes emphases - where the beat divides the pattern unevenly; where both divide it cleanly the bar - grouping agrees with the beats on their own. - - The pattern length is the hard limit, so a span reaching past the last row ends - there and counts as the shorter span it is. This holds at both levels: a pattern of - 60 rows against a 16-row bar carries three whole bars and a 12-row one, and a bar - shorter than its beat carries a single beat of the rows that remain. - - Attributes: - rows: The pattern's row count. - first_highlight: The rows one beat spans. - second_highlight: The rows one bar spans. - """ - - rows: int - first_highlight: int - second_highlight: int - - def __post_init__(self) -> None: - if self.rows < 1: - raise ValueError(f"rows must be at least 1, got {self.rows}") - - if self.first_highlight < 1: - raise ValueError(f"first_highlight must be at least 1, got {self.first_highlight}") - - if self.second_highlight < 1: - 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. - - The project holds the two highlights while the song holds the pattern length, so - the row count arrives beside the settings. - """ - return cls( - rows=rows, - first_highlight=settings.first_highlight, - second_highlight=settings.second_highlight, - ) - - @property - def spans(self) -> Tuple[Tuple[int, ...], ...]: - """The whole grouping, as the beat row counts of each consecutive bar. - - Returns: - Tuple[Tuple[int, ...], ...]: One entry per bar, each holding that bar's beat - row counts in order, so the entries flattened come to ``rows``. - """ - return tuple( - self._divide(bar_rows, self.first_highlight) - for bar_rows in self._divide( - self.rows, - self.second_highlight, - ) - ) - - @staticmethod - def _divide(rows: int, unit: int) -> Tuple[int, ...]: - """Cuts a row span into consecutive units, the final one holding what remains.""" - whole, remainder = divmod(rows, unit) - spans = (unit,) * whole - return spans + (remainder,) if remainder else spans diff --git a/src/sampletones_core/timing/rate.py b/src/sampletones_core/timing/rate.py index f02103858..0f06c60e9 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) @@ -75,3 +75,29 @@ def from_settings(cls, settings: ProjectSettings) -> RowRate: speed=settings.speed, nes_frequency=settings.nes_frequency, ) + + def bounded( + self, + *, + minimum_ticks: int, + maximum_ticks: int, + ) -> RowRate: + """The rate an engine plays this one at, every row lasting between its shortest and longest. + + A tempo asking for rows shorter than the engine's shortest plays every row at that shortest + length, so the song runs slower than its tempo states. FamiTracker and Bitphase play such a + tempo the same way. + + Args: + minimum_ticks: The fewest ticks the engine holds a row for. + maximum_ticks: The most ticks the engine holds a row for. + + Returns: + RowRate: The rate, held between the two. + """ + return RowRate( + ticks_per_row=min( + max(self.ticks_per_row, Fraction(minimum_ticks)), + Fraction(maximum_ticks), + ), + ) diff --git a/src/sampletones_core/timing/song.py b/src/sampletones_core/timing/song.py new file mode 100644 index 000000000..ffc3250c6 --- /dev/null +++ b/src/sampletones_core/timing/song.py @@ -0,0 +1,197 @@ +from dataclasses import dataclass +from fractions import Fraction +from functools import cached_property +from typing import Dict, Self, Tuple + +from sampletones_core.project import Project +from sampletones_core.timing.bounds import TickBounds +from sampletones_core.timing.groove import Groove, bar_line, plan_bar +from sampletones_core.timing.meter import Meter +from sampletones_core.timing.rate import RowRate + + +@dataclass(frozen=True) +class BarSlot: + """One bar of a pattern: the row it starts on and the row count of each of its beats. + + Attributes: + first_row: The pattern row the bar starts on. + beats: The row count of each of the bar's beats, in order. + """ + + first_row: int + beats: Tuple[int, ...] + + @property + def rows(self) -> int: + """How many rows the bar spans.""" + return sum(self.beats) + + +@dataclass(frozen=True) +class SongTiming: + """How many engine ticks every row of a song lasts, wherever the order plays it. + + Every bar starts on the tick nearest its exact start, counted from the song's first row, so + the rounding never adds up and the song keeps its tempo however long it plays. Inside a bar, + the bar's ticks are halved down its beats and rows, so the surplus settles on the strongest + positions. The meter restarts at every pattern, as a tracker's highlights do, so every order + frame starts a bar. Where the bar lines fall depends on how far into the song a frame plays, + so two frames can differ by a tick in a bar. + + Each answer follows from the row's place alone. A player reaching a row, a seek and an export + asking where a frame starts all read it directly. Once the exact lengths of a run of patterns add + up to whole ticks, every frame after it plays the groove of the frame that many before, so each + groove is planned once and kept. + + Attributes: + rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. + meter: The pattern length and the beat and bar grouping the ticks are spread over. + bounds: The shortest and the longest the player holds a row for. + """ + + rate: RowRate + meter: Meter + bounds: TickBounds + + @classmethod + def from_project(cls, project: Project, *, bounds: TickBounds) -> Self: + """Reads the timing a project plays at, taking the pattern length from its song. + + Args: + project: The project whose settings and song set the timing. + bounds: The shortest and the longest the player holds a row for. + + Returns: + Self: The project's timing. + """ + return cls( + rate=RowRate.from_settings(project.settings), + meter=Meter.from_settings( + project.settings, + rows=project.song.rows_per_pattern, + ), + bounds=bounds, + ) + + @cached_property + def exact_row_ticks(self) -> Fraction: + """The exact ticks one row lasts, held within what the player plays. + + A tempo asking for rows shorter than the shortest the player holds plays every row at that + shortest length, so the song runs slower than its tempo states. + """ + return self.rate.bounded( + minimum_ticks=self.bounds.minimum, + maximum_ticks=self.bounds.maximum, + ).ticks_per_row + + @cached_property + def bars(self) -> Tuple[BarSlot, ...]: + """Every bar of a pattern, in order.""" + slots = [] + first_row = 0 + for beats in self.meter.spans: + slots.append(BarSlot(first_row=first_row, beats=beats)) + first_row += sum(beats) + + return tuple(slots) + + @cached_property + def period(self) -> int: + """How many frames pass before the grooves repeat: the patterns whose exact lengths add up to whole ticks.""" + return (self.exact_row_ticks * self.meter.rows).denominator + + @cached_property + def planned(self) -> Dict[int, Groove]: + """The grooves planned so far, by their frame's place within the period.""" + return {} + + def groove(self, frame: int) -> Groove: + """The ticks each row of the pattern lasts where order frame ``frame`` plays it. + + Args: + frame: The order position, counted from 0. + + Returns: + Groove: One tick count per pattern row. + """ + phase = frame % self.period + groove = self.planned.get(phase) + if groove is None: + groove = Groove(ticks=tuple(ticks for bar in self.bars for ticks in self._bar_plan(phase, bar))) + self.planned[phase] = groove + + return groove + + def row_ticks(self, frame: int, row: int) -> int: + """The ticks one row lasts where order frame ``frame`` plays it. + + Args: + frame: The order position, counted from 0. + row: The row within the pattern. + + Returns: + int: The ticks the row lasts. + """ + return self.groove(frame).ticks[row] + + def frame_tick(self, frame: int) -> int: + """The engine tick order frame ``frame`` starts on. + + A frame starts a bar, so it starts on the tick nearest its exact start. The frame one past + the order's last is where the song ends, so its tick is the length of the whole song: the + whole number of ticks nearest its exact length. + + Args: + frame: The order position, counted from 0. + + Returns: + int: The ticks the order plays before ``frame`` begins. + """ + return bar_line(frame * self.meter.rows, self.exact_row_ticks) + + def tick_at(self, frame: int, row: int) -> int: + """The engine tick a row starts on, counted from the song's first tick. + + Args: + frame: The order position, counted from 0. + row: The row within the pattern. + + Returns: + int: The ticks the song plays before the row begins. + """ + return self.frame_tick(frame) + sum(self.groove(frame).ticks[:row]) + + def ticks_across( + self, + frame: int, + row: int, + rows: int, + *, + frames: int, + ) -> int: + """How many engine ticks pass over ``rows`` rows starting at a row of the song. + + The order plays its frames in turn and returns to the first after the last, and every pass + through it plays alike, so a span running past the song's end goes on from its start. + + Args: + frame: The order position the span starts in. + row: The row within that frame's pattern the span starts on. + rows: How many rows the span covers, at least zero. + frames: How many frames the order plays before it returns to the first. + + Returns: + int: The ticks those rows last. + """ + pattern_rows = self.meter.rows + passes, remainder = divmod(frame * pattern_rows + row + rows, frames * pattern_rows) + end_frame, end_row = divmod(remainder, pattern_rows) + end = passes * self.frame_tick(frames) + self.tick_at(end_frame, end_row) + return end - self.tick_at(frame, row) + + def _bar_plan(self, frame: int, bar: BarSlot) -> Tuple[int, ...]: + start = frame * self.meter.rows + bar.first_row + total = bar_line(start + bar.rows, self.exact_row_ticks) - bar_line(start, self.exact_row_ticks) + return plan_bar(total, bar.beats, self.exact_row_ticks) diff --git a/src/sampletones_core/trackers/artifact.py b/src/sampletones_core/trackers/artifact.py deleted file mode 100644 index 9d99a04bb..000000000 --- a/src/sampletones_core/trackers/artifact.py +++ /dev/null @@ -1,19 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Optional, Tuple - -from sampletones_core.exporters.truncation import EnvelopeTruncation - - -@dataclass(frozen=True) -class ExportArtifact: - """What one export run left on disk. - - Attributes: - paths: Every file the run wrote, in write order. - truncation: What the target format's item limit left out, and ``None`` when - every instrument carries its whole envelope. - """ - - paths: Tuple[Path, ...] - truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_core/trackers/backend.py b/src/sampletones_core/trackers/backend.py deleted file mode 100644 index 6f3a6d779..000000000 --- a/src/sampletones_core/trackers/backend.py +++ /dev/null @@ -1,96 +0,0 @@ -from pathlib import Path -from typing import FrozenSet, Protocol - -from sampletones_core.trackers.artifact import ExportArtifact -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.request import ( - InstrumentExport, - ProjectExport, - SampleExport, -) -from sampletones_core.trackers.scope import ExportScope - - -class TrackerBackend(Protocol): - """Writes the application's work in the file format one tracker reads. - - A backend owns both the byte layout and the shape each :class:`ExportScope` takes on - disk, so a format that gathers a whole reconstruction into one document writes one - where another writes a file per instrument. Every scope is written to a file path the - caller chooses, and :meth:`extension` names the extension it carries. - """ - - @property - def tracker_format(self) -> TrackerFormat: - """The format this backend writes.""" - - @property - def supported_scopes(self) -> FrozenSet[ExportScope]: - """The scopes this format can express.""" - - def extension(self, scope: ExportScope) -> str: - """The extension the files of ``scope`` carry, leading dot included. - - Args: - scope: The scope about to be exported. - - Returns: - str: The extension of each file the run writes. - """ - - def write_instrument( - self, - destination: Path, - request: InstrumentExport, - ) -> ExportArtifact: - """Writes one generator slice. - - Args: - destination: The file to write. - request: The slice to write. - - Returns: - ExportArtifact: The paths written and what the format's limits left out. - - Raises: - OSError: If the destination cannot be written. - """ - - def write_sample( - self, - destination: Path, - request: SampleExport, - ) -> ExportArtifact: - """Writes every generator slice of one reconstruction. - - Args: - destination: The file this scope is written to. A format that keeps one - instrument per file writes its slices beside it, each named after the - instrument it carries. - request: The reconstruction's slices. - - Returns: - ExportArtifact: The paths written and what the format's limits left out. - - Raises: - OSError: If the destination cannot be written. - """ - - def write_project( - self, - destination: Path, - request: ProjectExport, - ) -> ExportArtifact: - """Writes a whole composition. - - Args: - destination: The file to write. - request: The project to write. - - Returns: - ExportArtifact: The paths written and what the format's limits left out. - - Raises: - OSError: If the destination cannot be written. - ValueError: If the project holds more than the format has room for. - """ diff --git a/src/sampletones_core/trackers/extensions.py b/src/sampletones_core/trackers/extensions.py deleted file mode 100644 index 7333ea06c..000000000 --- a/src/sampletones_core/trackers/extensions.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Mapping, Optional - -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.scope import ExportScope - - -def format_for_extension( - backends: Mapping[TrackerFormat, TrackerBackend], - scope: ExportScope, - extension: str, -) -> Optional[TrackerFormat]: - """The format whose ``scope`` files carry ``extension``. - - The destination the user names decides which tracker the export is written for, so the - extension it ends in resolves to a format here. Case folds, letting a destination typed - in capitals reach the same backend. - - Args: - backends: Every backend the application writes through, keyed by its format. - scope: The scope about to be exported. - extension: The extension the chosen destination carries, leading dot included. - - Returns: - Optional[TrackerFormat]: The format claiming ``extension``, or ``None`` when no - format able to express ``scope`` writes it. - """ - wanted = extension.casefold() - for tracker_format, backend in backends.items(): - if scope in backend.supported_scopes and backend.extension(scope).casefold() == wanted: - return tracker_format - - return None diff --git a/src/sampletones_core/trackers/format.py b/src/sampletones_core/trackers/format.py 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/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py deleted file mode 100644 index 38b3561eb..000000000 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ /dev/null @@ -1,121 +0,0 @@ -from pathlib import Path -from typing import FrozenSet, List - -from sampletones_core.formats.bitphase.btp import write_btp -from sampletones_core.formats.bitphase.builder import ( - instrument_to_bitphase, - project_to_bitphase, - sample_to_bitphase, -) -from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset -from sampletones_core.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.system.paths import get_filename - -DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) -PRESET_SCOPES: FrozenSet[ExportScope] = frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) - -WHOLE_ENVELOPE: None = None - - -class BitphaseBackend: - """Writes Bitphase's ``.btp`` documents. - - A ``.btp`` holds a whole document, so every scope lands in one file: an instrument - and a reconstruction each become a playable document whose pattern triggers the - instruments it carries. Bitphase stores instrument and table rows without a length - limit, so every envelope crosses over whole. - """ - - @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.BITPHASE - - @property - def supported_scopes(self) -> FrozenSet[ExportScope]: - return DOCUMENT_SCOPES - - def extension(self, scope: ExportScope) -> str: - return EXT_FILE_BITPHASE - - def write_instrument( - self, - destination: Path, - request: InstrumentExport, - ) -> ExportArtifact: - write_btp(destination, instrument_to_bitphase(request)) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) - - def write_sample( - self, - destination: Path, - request: SampleExport, - ) -> ExportArtifact: - write_btp(destination, sample_to_bitphase(request)) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) - - def write_project( - self, - destination: Path, - request: ProjectExport, - ) -> ExportArtifact: - write_btp(destination, project_to_bitphase(request.project)) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) - - -class BitphasePresetBackend: - """Writes the single-instrument ``.json`` files Bitphase's instruments panel loads. - - The panel reads one instrument per file into the slot the user has selected, so a - whole reconstruction lands as a set of them beside the chosen destination, one file - per generator slice named after the instrument. A preset carries rows alone, so its - pitch contour rides in each row's tone offset. - """ - - @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.BITPHASE_PRESET - - @property - def supported_scopes(self) -> FrozenSet[ExportScope]: - return PRESET_SCOPES - - def extension(self, scope: ExportScope) -> str: - return EXT_FILE_JSON - - def write_instrument( - self, - destination: Path, - request: InstrumentExport, - ) -> ExportArtifact: - write_preset(destination, instrument_to_preset(request)) - return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE) - - def write_sample( - self, - destination: Path, - request: SampleExport, - ) -> ExportArtifact: - destination.parent.mkdir(parents=True, exist_ok=True) - - paths: List[Path] = [] - for instrument in request.instruments: - filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_JSON)) - paths.extend(self.write_instrument(filepath, instrument).paths) - - return ExportArtifact(paths=tuple(paths), truncation=WHOLE_ENVELOPE) - - def write_project( - self, - destination: Path, - request: ProjectExport, - ) -> ExportArtifact: - """Reports that a preset holds one instrument. - - Raises: - ValueError: Always, since a preset file carries a single instrument. - """ - raise ValueError("A Bitphase instrument preset holds one instrument, not a whole project") diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py deleted file mode 100644 index dfe9bee04..000000000 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ /dev/null @@ -1,99 +0,0 @@ -from pathlib import Path -from typing import FrozenSet, List, Optional - -from sampletones_core.exporters.truncation import EnvelopeTruncation -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.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.system.paths import get_filename - -SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) - - -class FamiTrackerBackend: - """Writes FamiTracker's ``.fti`` instruments and ``.ftm`` modules. - - FamiTracker reads one instrument per ``.fti`` file, so a whole reconstruction lands - as a set of them beside the chosen destination, one file per generator slice named - after the instrument. - """ - - @property - def tracker_format(self) -> TrackerFormat: - return TrackerFormat.FAMITRACKER - - @property - def supported_scopes(self) -> FrozenSet[ExportScope]: - return SUPPORTED_SCOPES - - def extension(self, scope: ExportScope) -> str: - return EXT_FILE_MODULE if scope == ExportScope.PROJECT else EXT_FILE_INSTRUMENT - - def write_instrument( - self, - destination: Path, - request: InstrumentExport, - ) -> ExportArtifact: - instrument = build_instrument( - STANDALONE_INSTRUMENT_INDEX, - request.name, - request.features, - loop=request.loop, - ) - write_fti(destination, instrument) - - return ExportArtifact( - paths=(destination,), - truncation=EnvelopeTruncation.measure( - request.features.frame_count, - MAX_SEQUENCE_ITEMS, - ), - ) - - def write_sample( - self, - destination: Path, - request: SampleExport, - ) -> ExportArtifact: - destination.parent.mkdir(parents=True, exist_ok=True) - - paths: List[Path] = [] - truncations: List[Optional[EnvelopeTruncation]] = [] - for instrument in request.instruments: - filepath = destination.with_name( - get_filename( - instrument.name, - EXT_FILE_INSTRUMENT, - ) - ) - artifact = self.write_instrument(filepath, instrument) - paths.extend(artifact.paths) - truncations.append(artifact.truncation) - - return ExportArtifact( - paths=tuple(paths), - truncation=EnvelopeTruncation.summarize(truncations), - ) - - def write_project( - self, - destination: Path, - request: ProjectExport, - ) -> ExportArtifact: - write_ftm(destination, request.project) - return ExportArtifact(paths=(destination,), truncation=None) diff --git a/src/sampletones_core/trackers/registry.py b/src/sampletones_core/trackers/registry.py 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/trackers/scope.py b/src/sampletones_core/trackers/scope.py deleted file mode 100644 index c7bd3afe0..000000000 --- a/src/sampletones_core/trackers/scope.py +++ /dev/null @@ -1,13 +0,0 @@ -from enum import StrEnum - - -class ExportScope(StrEnum): - """How much of the application's work one export run carries. - - A backend decides how each scope materialises on disk, so a format that reads a - whole reconstruction from a single file is free to write one. - """ - - INSTRUMENT = "instrument" - SAMPLE = "sample" - PROJECT = "project" diff --git a/src/sampletones_core/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..ac911fbc1 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.general import MAX_PERIOD +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +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.frequencies import period_to_name, pitch_to_name, played_pitch from sampletones_shared.constants.symbols import MINUS, PLUS DEFAULT_DISPLAY_LENGTH: Final[int] = 2 @@ -32,52 +35,80 @@ 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_pitch(pitch: Optional[RowPitch]) -> str: + """Render a pitch column as the face it was written in, or ``...`` for an empty one. + + A note prints as its name and a step as a signed offset, so the grid shows what the reader + typed whichever voice the row names. A note's value names a period while it lies among the + sixteen the noise channel has, and a pitch above them; the two ranges stand apart, so the name + needs no channel. A pitch below the lowest note a channel plays prints as that note, the way + the channel sounds it. + + Args: + pitch: The pitch the row states, or ``None`` for an empty cell. + + Returns: + str: The note name or the signed step, three characters wide like every other reading of + the column. + """ + match pitch: + case None: + return NOTE_BLANK + case Step(): + return display_transpose(pitch.value) + case Note(): + if pitch.value <= MAX_PERIOD: + return period_to_name(pitch.value) + + return pitch_to_name(played_pitch(pitch.value)) + + def display_transpose(value: Optional[int]) -> str: - """Render a transpose as a signed two-digit offset, or ``...`` for an empty one. + """Render a step as a signed two-digit decimal offset, or ``...`` for an empty one. - An explicit zero reads ``+00``, since a row storing it resets the channel's - transpose to the sample's own pitch, while an empty cell keeps whatever - transpose is already in force. + An explicit zero reads ``+00``, since a row storing it moves the voice back to its own pitch, + while an empty cell keeps whatever pitch is already in force. Decimal digits keep every step a + reader can type on the digit keys alone, and read as the semitones a musician counts. """ if value is None: return NOTE_BLANK sign = PLUS if value >= 0 else MINUS - return f"{sign}{abs(value):02X}" + return f"{sign}{abs(value):02d}" diff --git a/src/sampletones_core/utils/frequencies.py b/src/sampletones_core/utils/frequencies.py index 4423c0cdb..c3dd02678 100644 --- a/src/sampletones_core/utils/frequencies.py +++ b/src/sampletones_core/utils/frequencies.py @@ -1,69 +1,35 @@ -import numpy as np +from typing import Final from sampletones_core.constants.general import ( - A4_FREQUENCY, - A4_PITCH, - LIMIT_MAX_PITCH, - LIMIT_MIN_PITCH, MAX_PERIOD, MAX_PITCH, MIN_PITCH, + MIN_PLAYED_PITCH, NOISE_PERIODS, NOTE_NAMES, + NUM_PERIODS, ) +from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES from sampletones_shared.utils.arrays import clamp +from sampletones_shared.utils.frequencies import validate_pitch +PERIOD_NAME_SUFFIX: Final[str] = "-#" -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. +def played_pitch(pitch: int) -> int: + """The note a channel plays a pitch at, held within ``MIN_PLAYED_PITCH`` (C-0) and ``MAX_PITCH`` (B-7). - 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 is_pitch_valid(pitch: int) -> bool: - """ - Checks if a pitch value is in the defined range. + Those are the lowest and highest notes FamiTracker and Bitphase write, and both hold a note + moved past them there. A note below A-0 asks for a timer longer than the eleven bits the + register holds, so it sounds the longest one, as it does on the console. Args: - pitch: The pitch value to check. + pitch: The pitch a note, its transpose and its arpeggio step reach. Returns: - True if the pitch is valid, False otherwise. - """ - return MIN_PITCH <= pitch <= MAX_PITCH - - -def validate_frequency(frequency: float) -> None: + int: The note the channel plays. """ - 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") + return clamp_pitch(pitch, MIN_PLAYED_PITCH, MAX_PITCH) def validate_period(period: int) -> None: @@ -84,99 +50,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). - - 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. +def transpose_pitch(pitch: int, transpose: int) -> int: + """The pitch a transpose reaches, held inside the range the channels play (see :func:`played_pitch`).""" + return played_pitch(pitch + transpose) - 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 +104,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}" @@ -256,7 +137,7 @@ def period_to_name(period: int) -> str: ValueError: Period must be in the range 0-15 """ validate_period(period) - return f"{period:X}-#" + return f"{period:X}{PERIOD_NAME_SUFFIX}" def clamp_pitch(pitch: int, min_pitch: int = MIN_PITCH, max_pitch: int = MAX_PITCH) -> int: @@ -349,7 +230,7 @@ def sanitize_pitch(name: str) -> str: Sanitizes a pitch name by keeping only valid pitch-related characters. Removes whitespace, converts to uppercase, and filters to only allow: - digits (0-9), hyphen (-), sharp (#), and hexadecimal letters (A-F). + digits (0-9), hyphen (-), sharp (#), and the note letters (A-G). Args: name: The pitch name string to sanitize. @@ -367,7 +248,7 @@ def sanitize_pitch(name: str) -> str: >>> sanitize_pitch("F-#") # invalid as a pitch name though 'F-#' """ - return "".join([character for character in sanitize(name) if character in "0123456789-#ABCDEF"]) + return "".join([character for character in sanitize(name) if character in "0123456789-#ABCDEFG"]) def sanitize_period(name: str) -> str: @@ -398,4 +279,7 @@ def sanitize_period(name: str) -> str: SANITIZED_NAME_TO_PITCH = {sanitize_pitch(pitch_to_name(pitch)): pitch for pitch in range(MIN_PITCH, MAX_PITCH + 1)} +SANITIZED_NAME_TO_PLAYED_PITCH = { + sanitize_pitch(pitch_to_name(pitch)): pitch for pitch in range(MIN_PLAYED_PITCH, MAX_PITCH + 1) +} SANITIZED_NAME_TO_PERIOD = {sanitize_period(period_to_name(period)): period for period in range(len(NOISE_PERIODS))} diff --git a/src/sampletones_core/utils/pitch_kind.py b/src/sampletones_core/utils/pitch_kind.py index 05e16184b..01e347aa7 100644 --- a/src/sampletones_core/utils/pitch_kind.py +++ b/src/sampletones_core/utils/pitch_kind.py @@ -1,14 +1,18 @@ from dataclasses import dataclass from typing import Callable, Mapping -from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH, MIN_PLAYED_PITCH +from sampletones_core.features import speaks_in_periods from sampletones_core.utils.frequencies import ( SANITIZED_NAME_TO_PERIOD, SANITIZED_NAME_TO_PITCH, + SANITIZED_NAME_TO_PLAYED_PITCH, clamp_period, clamp_pitch, period_to_name, pitch_to_name, + played_pitch, sanitize_period, sanitize_pitch, ) @@ -46,6 +50,15 @@ def from_text(self, text: str, fallback: int) -> int: sanitized_name_to_value=SANITIZED_NAME_TO_PITCH, ) +PLAYED_PITCH_VALUE_KIND = PitchValueKind( + minimum=MIN_PLAYED_PITCH, + maximum=MAX_PITCH, + clamp=played_pitch, + to_name=pitch_to_name, + sanitize=sanitize_pitch, + sanitized_name_to_value=SANITIZED_NAME_TO_PLAYED_PITCH, +) + PERIOD_VALUE_KIND = PitchValueKind( minimum=0, maximum=MAX_PERIOD, @@ -54,3 +67,31 @@ 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 + + +def note_value_kind(channel_name: ChannelName) -> PitchValueKind: + """The terms a row's note is held to on a channel. + + A tonal channel plays every note from C-0 up, a wider range than a voice's reference may rest + at, so a note typed into a row reaches the lowest notes the trackers write. The noise channel + names its sixteen periods. + + Args: + channel_name: The channel the row stands on. + + Returns: + PitchValueKind: The noise channel's periods, or the notes the others play. + """ + return PERIOD_VALUE_KIND if speaks_in_periods(channel_name) else PLAYED_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..9c2bff749 --- /dev/null +++ b/src/sampletones_player/builder.py @@ -0,0 +1,260 @@ +from typing import AbstractSet, Dict, Final, Mapping, Optional, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +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.scheme import CompressionScheme +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.registers.channel import channel_registers +from sampletones_player.registers.streams import ChannelStreams +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], + scheme: CompressionScheme, + 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. + scheme: The layers of the codec the song is written with. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. + + 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, + scheme=scheme, + 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, + *, + channels: AbstractSet[ChannelName], + loop_tick: Optional[int], + scheme: CompressionScheme, + 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. The slices on the channels the song sounds are the ones played, and + every other channel rests throughout. + + Args: + request: The slices to play together. + channels: The channels the song sounds. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. + + 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, or ``loop_tick`` lies outside the song's + ticks. + """ + return Song.from_streams( + streams=streams_from_instructions( + instructions_from_instruments( + [instrument for instrument in request.instruments if instrument.channel in channels] + ), + get_timer_table(request.tuning), + ), + pitches=PitchTable.from_tuning(request.tuning), + schedule=PlaySchedule.from_parameters(request.nes_frequency), + loop_tick=loop_tick, + seeds=NO_SEEDS, + scheme=scheme, + report=report, + ) + + +def song_from_project( + project: Project, + *, + channels: AbstractSet[ChannelName], + loop_tick: Optional[int], + scheme: CompressionScheme, + report: CodecReporter = silent_reporter, + walk: WalkReporter = silent_reporter, +) -> Song: + """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. The channels the song + sounds are the ones that play and seed it, and every other channel rests throughout. + + Args: + project: The project whose song is played. + channels: The channels the song sounds. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. + walk: Hears how far the song has been played out, and answers whether the walk goes on. + + 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) + instructions = song_instructions(project, walk) + return Song.from_streams( + streams=streams_from_instructions( + {channel: stream for channel, stream in instructions.items() if channel in channels}, + get_timer_table(tuning), + ), + pitches=PitchTable.from_tuning(tuning), + schedule=PlaySchedule.from_parameters(project.settings.nes_frequency), + loop_tick=loop_tick, + seeds=phrases_from_project(project, tuning, channels), + scheme=scheme, + 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..a7dc6d064 --- /dev/null +++ b/src/sampletones_player/clock/schedule.py @@ -0,0 +1,156 @@ +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..027593174 --- /dev/null +++ b/src/sampletones_player/compression/admit.py @@ -0,0 +1,171 @@ +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, default=False) + shifted = phrase_size(CROWDED_PHRASE_ID, SHIFTED_TOKEN_TRANSPOSE, default=False) + 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/budget.py b/src/sampletones_player/compression/budget.py new file mode 100644 index 000000000..bc2fbb87f --- /dev/null +++ b/src/sampletones_player/compression/budget.py @@ -0,0 +1,61 @@ +from typing import Final, List, Sequence, Tuple + +from pydantic import BaseModel, ConfigDict, Field + + +class SearchBudget(BaseModel): + """How much work the search spends on the phrases a song's own planes repeat. + + A round gathers candidate windows from what the parse still spells out, ranks them, and + confirms the best few by parsing the song again with each one added. Each figure here + bounds one of those steps, so the budget is what an export trades against the bytes the + search earns. + + Attributes: + candidate_entries: The candidate windows one round gathers over every plane together, + shared among the planes by what each has to offer. + rounds: The rounds the search runs, each adding at most one phrase. + confirmed_candidates: The best-ranked candidates a round parses the song again with. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + candidate_entries: int = Field(ge=1) + rounds: int = Field(ge=0) + confirmed_candidates: int = Field(ge=1) + + +DEFAULT_SEARCH_BUDGET: Final[SearchBudget] = SearchBudget( + candidate_entries=200_000, + rounds=64, + confirmed_candidates=3, +) + + +def shares( + demands: Sequence[int], + total: int, +) -> Tuple[int, ...]: + """Divides ``total`` among claimants, meeting small demands in full and splitting what + remains evenly among the larger ones. + + Claimants are served from the smallest demand upward, each taking the lesser of its demand + and an even share of what is left, so a claimant asking for little leaves its surplus to the + rest and a claimant asking for much is held to the same share as its peers. + + Args: + demands: What each claimant would take, given the room. + total: What there is to share. + + Returns: + Tuple[int, ...]: What each claimant receives, in the order the demands were given. + """ + granted: List[int] = [0] * len(demands) + remaining = total + pending = len(demands) + for claimant in sorted(range(len(demands)), key=lambda index: demands[index]): + granted[claimant] = min(demands[claimant], remaining // pending) + remaining -= granted[claimant] + pending -= 1 + + return tuple(granted) diff --git a/src/sampletones_player/compression/compressed.py b/src/sampletones_player/compression/compressed.py new file mode 100644 index 000000000..eb7eb4788 --- /dev/null +++ b/src/sampletones_player/compression/compressed.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Optional, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_player.compression.dictionary.table import PhraseTable +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. + loop_entries: The byte each stream is re-entered at when the song comes round, counted + from that stream's own start, ``None`` for a plane holding no stream. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + phrases: PhraseTable + streams: PlaneOrder + ticks: int + loop_entries: Tuple[Optional[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 + + @model_validator(mode="after") + def _validate_every_stream_states_where_it_is_re_entered( + self, + ) -> CompressedPlanes: + if len(self.loop_entries) != len(self.streams): + raise ValueError( + f"an entry stands for each of the {len(self.streams)} streams, " f"and {len(self.loop_entries)} stand" + ) + + 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) diff --git a/src/sampletones_player/compression/decode.py b/src/sampletones_player/compression/decode.py new file mode 100644 index 000000000..ef4ff72fe --- /dev/null +++ b/src/sampletones_player/compression/decode.py @@ -0,0 +1,143 @@ +from typing import List, Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.planes.flags import flagged_ticks +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.planes.symbols import unpack_plane +from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, + PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, + TOKEN_OPERAND_MASK, + TOKEN_TAG_MASK, + TokenTag, +) +from sampletones_player.specification.planes import ( + PLANES, + SINGLE_TICK, + Plane, + PlaneRole, + plane_index, +) + + +def _phrase_values( + operand: int, + data: bytes, + position: int, + table: PhraseTable, + *, + transposed: bool, +) -> Tuple[bytes, int]: + phrase_id = operand & PHRASE_ID_MASK + if phrase_id == PHRASE_ID_ESCAPE: + phrase_id = data[position] + position += 1 + + if operand & DEFAULT_COUNT_FLAG: + ticks = table[phrase_id].default + else: + 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, + plane: Plane, + 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. A + symbol covers the ticks its own count states, so the reading stops once the ticks the song + lasts are covered, wherever in a symbol that falls. An absent plane's empty stream plays the + value the driver seeds it to throughout. + + Args: + data: The plane's token stream. + table: The dictionary the tokens name. + plane: The plane the stream belongs to, for the byte it seeds to and how its byte divides. + ticks: The ticks the song lasts. + + Returns: + bytes: The values the plane writes, one per tick. + """ + form = plane.form + if not data: + return bytes((plane.seeded,)) * ticks + + symbols = bytearray() + current = form.symbol(plane.seeded, SINGLE_TICK) + covered = 0 + position = 0 + while covered < 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] + symbols.extend(played) + covered += sum(form.repeated(symbol) for symbol in played) + + return unpack_plane(bytes(symbols), form)[:ticks] + + +def decode_planes(compressed: CompressedPlanes) -> SongPlanes: + """Plays a song's token streams back into the planes they were written from. + + A bend plane holds a value per tick its channel flags, so each is played for as many values + as its channel's value plane flags once that plane is played back. + + Args: + compressed: The dictionary, the streams and the ticks the song lasts. + + Returns: + SongPlanes: Every plane, in the order the song block writes them. + """ + played: List[bytes] = [] + for plane, stream in zip(PLANES, compressed.streams, strict=True): + reach = ( + flagged_ticks(played[plane_index(plane.channel, PlaneRole.VALUE)]) + if plane.spans_flagged_ticks + else compressed.ticks + ) + played.append(decode_plane(stream, compressed.phrases, plane, reach)) + + return SongPlanes(planes=PlaneOrder.across(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..72cbdc112 --- /dev/null +++ b/src/sampletones_player/compression/dictionary/phrase.py @@ -0,0 +1,72 @@ +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, + NO_DEFAULT_COUNT, + PHRASE_DEFAULT_SIZE, + 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 + PHRASE_DEFAULT_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. + default: The count its tokens play it at most often, which a token may leave unstated. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + body: bytes + default: int = NO_DEFAULT_COUNT + + @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..50fce0792 --- /dev/null +++ b/src/sampletones_player/compression/dictionary/prune.py @@ -0,0 +1,50 @@ +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], + counts: 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. Each phrase + kept carries the count its own tokens play it at most often, which is the count a token may + then leave unstated. + + 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. + counts: The count each phrase's tokens play it at most often. + + 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].model_copy(update={"default": counts[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..f8620b982 --- /dev/null +++ b/src/sampletones_player/compression/encode.py @@ -0,0 +1,315 @@ +from collections import Counter +from dataclasses import replace +from typing import Dict, Final, FrozenSet, Iterable, List, Sequence, Tuple + +from sampletones_player.compression.admit import admit_seeds +from sampletones_player.compression.budget import ( + DEFAULT_SEARCH_BUDGET, + SearchBudget, +) +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.entries import stream_entry +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.planes.symbols import ( + pack_plane, + symbol_boundaries, +) +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 ( + DEFAULT_COUNT_FLAG, + NO_DEFAULT_COUNT, + PHRASE_ID_ESCAPE, + TokenTag, +) +from sampletones_player.specification.planes import PLANES +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 | (DEFAULT_COUNT_FLAG if token.default else 0) | named) + if named == PHRASE_ID_ESCAPE: + stream.append(token.phrase_id) + + if not token.default: + 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 _modal(played: Counter[int]) -> int: + """The count a phrase's tokens play it at most often, the shortest breaking a tie.""" + if not played: + return NO_DEFAULT_COUNT + + return min(played, key=lambda ticks: (-played[ticks], ticks)) + + +def _counts(parses: Iterable[Parse], phrases: int) -> Dict[int, int]: + """The count each phrase's tokens play it at most often, the shortest breaking a tie.""" + played: List[Counter[int]] = [Counter() for _ in range(phrases)] + for parse in parses: + for token in parse.tokens: + if isinstance(token, PhraseToken): + played[token.phrase_id][token.ticks] += 1 + + return {phrase_id: _modal(counter) for phrase_id, counter in enumerate(played)} + + +def _settle( + cache: MatchCache, + table: PhraseTable, + options: CodecOptions, + boundaries: Sequence[FrozenSet[int]], + monitor: CodecMonitor, + baseline: Sequence[Parse], +) -> Tuple[PhraseTable, Tuple[Parse, ...]]: + """The table and its parses settled together, each round reading the other. + + A phrase's place, its id and the count its tokens leave unstated are all read off a parse + that was made under the table before it, so the table is rebuilt and the planes read again + until the two stand still. + """ + 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)), + _counts(parses, 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_streams( + planes: Sequence[bytes], + seeds: Sequence[Phrase], + *, + options: CodecOptions, + boundaries: Sequence[FrozenSet[int]], + budget: SearchBudget = DEFAULT_SEARCH_BUDGET, + report: CodecReporter = silent_reporter, +) -> Tuple[PhraseTable, Tuple[bytes, ...]]: + """Compresses a run of planes into one dictionary and a token stream for each. + + Every layer is weighed against one reading of the planes 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. + + Each plane is read against the shared dictionary on its own, so the planes may cover + different numbers of values. A plane reaching the encoder empty is absent: it takes no + stream, and the block states it with a sentinel the driver skips. + + Args: + planes: The planes, each at least one value long. + seeds: The phrases the song's instruments offer. + options: Which of the codec's layers the encoding is built from. + boundaries: The positions a token starts on in each plane beyond its first, one set per + plane. + budget: How much work the search spends beyond the phrases the instruments seed. + report: Hears what the run holds each time it looks up, and answers whether it goes on. + + Returns: + Tuple[PhraseTable, Tuple[bytes, ...]]: The dictionary, then each plane's token stream in + the order the planes were given, an absent plane's empty. + + Raises: + OperationCanceled: If ``report`` withdraws the run. + ValueError: If the boundaries name a set for other than every plane. + """ + if len(boundaries) != len(planes): + raise ValueError(f"a set of boundaries stands for each plane, and {len(boundaries)} stand for {len(planes)}") + + present = [plane for plane, written in enumerate(planes) if written] + cache = MatchCache(PlaneIndex.from_plane(planes[plane]) for plane in present) + monitor = CodecMonitor(report) + entries = tuple(boundaries[plane] | {STREAM_START} for plane in present) + 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, + budget, + ) + + table, parses = _settle( + cache, + table, + options, + entries, + monitor, + baseline, + ) + written = iter(emit(parse.tokens) for parse in parses) + streams = tuple(next(written) if plane else b"" for plane in planes) + monitor.reached(len(table), table.size + sum(len(stream) for stream in streams)) + return table, streams + + +def _returned(boundaries: FrozenSet[int]) -> int: + """The tick a song comes round to, the song's first standing in where it plays once.""" + return min(boundaries, default=STREAM_START) + + +def encode_planes( + planes: SongPlanes, + seeds: Sequence[Phrase], + *, + options: CodecOptions, + boundaries: FrozenSet[int], + budget: SearchBudget = DEFAULT_SEARCH_BUDGET, + report: CodecReporter = silent_reporter, +) -> CompressedPlanes: + """Compresses a song's planes into the dictionary and streams the driver reads. + + 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; a bend plane + starts one at the position it stands at once those ticks have played. + budget: How much work the search spends beyond the phrases the instruments seed. + 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. + """ + positions = [planes.positions(tick) for tick in boundaries] + entries = [frozenset(position[index] for position in positions) for index in range(len(planes.planes))] + packed = tuple( + (b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=entry)) + for plane, played, entry in zip(PLANES, planes.planes, entries, strict=True) + ) + table, streams = encode_streams( + packed, + seeds, + options=options, + boundaries=[ + symbol_boundaries(played, plane.form, boundaries=entry) + for plane, played, entry in zip(PLANES, planes.planes, entries, strict=True) + ], + budget=budget, + report=report, + ) + returned = [ + symbol_boundaries(played, plane.form, boundaries=frozenset({position})) + for plane, played, position in zip( + PLANES, + planes.planes, + planes.positions(_returned(boundaries)), + strict=True, + ) + ] + return CompressedPlanes( + phrases=table, + streams=PlaneOrder.across(streams), + ticks=planes.ticks, + loop_entries=tuple( + (stream_entry(stream, next(iter(entry), STREAM_START), table) if stream else None) + for stream, entry in zip(streams, returned, strict=True) + ), + ) diff --git a/src/sampletones_player/compression/entries.py b/src/sampletones_player/compression/entries.py new file mode 100644 index 000000000..ab3ffe58b --- /dev/null +++ b/src/sampletones_player/compression/entries.py @@ -0,0 +1,38 @@ +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.tokens.span import token_span + + +def stream_entry( + stream: bytes, + position: int, + table: PhraseTable, +) -> int: + """The byte of ``stream`` the token covering the value at ``position`` 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 position each plane stands at once the song returns, so the walk lands on it exactly. + + Args: + stream: The plane's token stream. + position: The value the stream is re-entered at. + table: The dictionary the tokens name. + + Returns: + int: The byte the token covering ``position`` begins at, counted from the stream's own + start. + + Raises: + ValueError: If the stream spans ``position`` rather than starting a token there. + """ + offset = 0 + reached = 0 + while reached < position: + span = token_span(stream, offset, table) + reached += span.ticks + offset += span.size + + if reached != position: + raise ValueError(f"the stream spans position {position} rather than starting a token there") + + return offset 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..1883000a0 --- /dev/null +++ b/src/sampletones_player/compression/matches/cache.py @@ -0,0 +1,121 @@ +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/matcher.py b/src/sampletones_player/compression/matches/matcher.py new file mode 100644 index 000000000..c6ec842bb --- /dev/null +++ b/src/sampletones_player/compression/matches/matcher.py @@ -0,0 +1,111 @@ +from itertools import chain +from typing import Dict, Iterator, List, NamedTuple, 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.specification.binary import BYTE_VALUES + + +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 + + +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._table: PhraseTable = table + 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 table(self) -> PhraseTable: + """The dictionary the matcher names phrases from.""" + return self._table + + @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..8ab7fcf97 --- /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 onward.""" + 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..ed0ab9c99 --- /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 forward and backward 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..2f7a7b5a4 --- /dev/null +++ b/src/sampletones_player/compression/parse/plane.py @@ -0,0 +1,152 @@ +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, + NO_DEFAULT_COUNT, +) + + +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, default=False), + PhraseToken( + phrase_id=phrase_id, + ticks=ticks, + transpose=transpose, + default=False, + ), + ) + carried = matcher.table[phrase_id].default + if NO_DEFAULT_COUNT < carried <= ticks: + shortest.relax( + position, + position + carried, + cost + phrase_size(phrase_id, transpose, default=True), + PhraseToken( + phrase_id=phrase_id, + ticks=carried, + transpose=transpose, + default=True, + ), + ) + + +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..f00f0d71b --- /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: Sequence[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 positions a token starts on, one set per plane. + 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[plane])) + monitor.poll() + + return tuple(parses) + + +def parse_planes_offered( + cache: MatchCache, + table: PhraseTable, + options: CodecOptions, + boundaries: Sequence[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 positions a token starts on, one set per plane. + 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[plane])) + 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..378a75477 --- /dev/null +++ b/src/sampletones_player/compression/pitch.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from functools import cached_property +from typing import Final, Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_core.timers.nearest import NearestPitch, nearest_pitches +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 upward. + """ + + 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, + ) + ) + ) + + def index(self, pitch: int) -> int: + """The index a plane names a pitch by, its distance above the lowest pitch the table covers. + + Args: + pitch: The pitch. + + Returns: + int: The index. + + Raises: + ValueError: If the table covers no such pitch. + """ + index = pitch - LIMIT_MIN_PITCH + if not 0 <= index < len(self.timers): + raise ValueError( + f"the table covers pitches {LIMIT_MIN_PITCH} to {LIMIT_MAX_PITCH}, and {pitch} lies beyond" + ) + + return index + + def pitch(self, index: int) -> int: + """The pitch a plane's index names. + + Args: + index: The index. + + Returns: + int: The pitch, counted the way the project counts pitches. + """ + return LIMIT_MIN_PITCH + index + + @cached_property + def nearest(self) -> Tuple[NearestPitch, ...]: + """The index lying nearest every divider the register holds, beside the steps between them. + + A tone channel's planes carry each tick's divider as this index and a bend of the steps + left over, which the driver adds back. Within the table's span the steps stay within half + the widest gap between neighboring pitches, well inside the signed byte a bend plane holds. + Pitches beyond the divider's range share the timer they are clamped to, and they sound + alike, so the lowest index stands for the whole group. + """ + return nearest_pitches(dict(enumerate(self.timers))) + + @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/flags.py b/src/sampletones_player/compression/planes/flags.py new file mode 100644 index 000000000..92a641d83 --- /dev/null +++ b/src/sampletones_player/compression/planes/flags.py @@ -0,0 +1,101 @@ +from typing import List, Sequence, Tuple + +from sampletones_player.specification.compression import ( + BEND_FLAG, + PITCH_INDEX_MASK, +) + + +def is_flagged(value: int) -> bool: + """Whether a tone channel's value byte carries a bend, read from the channel's bend plane. + + Args: + value: The value byte. + + Returns: + bool: Whether the tick reads its bend from the bend plane. + """ + return bool(value & BEND_FLAG) + + +def pitch_index(value: int) -> int: + """The pitch index a tone channel's value byte names, whatever its flag says. + + Args: + value: The value byte. + + Returns: + int: The index into the pitch table. + """ + return value & PITCH_INDEX_MASK + + +def flagged_value(index: int, flag: bool) -> int: + """The value byte naming a pitch index, flagged where the tick carries a bend. + + Args: + index: The index into the pitch table. + flag: Whether the tick reads its bend from the bend plane. + + Returns: + int: The value byte. + + Raises: + ValueError: If the index reaches the flag's bit. + """ + if index & ~PITCH_INDEX_MASK: + raise ValueError(f"a value byte names indices up to {PITCH_INDEX_MASK}, and this is {index}") + + return index | BEND_FLAG if flag else index + + +def flagged_ticks(value: bytes) -> int: + """The ticks of a tone channel carrying a bend, which is how many values its bend plane holds. + + Args: + value: The channel's value plane. + + Returns: + int: The flagged ticks. + """ + return sum(1 for byte in value if is_flagged(byte)) + + +def note_flags( + indices: Sequence[int], + offsets: Sequence[int], +) -> Tuple[bool, ...]: + """Which ticks of a tone channel carry a bend: each note from its first bent tick to its last. + + A note is a run of ticks naming one pitch. Flagging it whole between its bends keeps the + value plane still while a bend passes through zero, as a vibrato does, and a note that never + bends carries no flag at all, so its bend plane holds nothing for it. + + Args: + indices: The pitch index each tick names. + offsets: The divider steps each tick stands away from its pitch. + + Returns: + Tuple[bool, ...]: One flag per tick. + + Raises: + ValueError: If the indices and the offsets cover different ticks. + """ + if len(indices) != len(offsets): + raise ValueError( + f"a flag reads an index and an offset per tick, and these cover {len(indices)} and {len(offsets)}" + ) + + flags: List[bool] = [False] * len(indices) + start = 0 + for end in range(1, len(indices) + 1): + if end < len(indices) and indices[end] == indices[start]: + continue + + bent = [tick for tick in range(start, end) if offsets[tick] != 0] + if bent: + flags[bent[0] : bent[-1] + 1] = [True] * (bent[-1] - bent[0] + 1) + + start = end + + return tuple(flags) diff --git a/src/sampletones_player/compression/planes/order.py b/src/sampletones_player/compression/planes/order.py new file mode 100644 index 000000000..35133463a --- /dev/null +++ b/src/sampletones_player/compression/planes/order.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Iterable, NamedTuple, Tuple + +from sampletones_player.specification.planes import PLANE_COUNT, PLANE_NAMES + + +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. + + The fields spell out what ``specification.planes.PLANES`` states, so that a reader reaches a + plane by its own name and a type checker knows which names there are. A test holds the two + in step. + + 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_value: The triangle channel's pitch, the index above the table naming a rest. + 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_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 PLANE_NAMES + + @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..9d78c1680 --- /dev/null +++ b/src/sampletones_player/compression/planes/rebuild.py @@ -0,0 +1,157 @@ +from typing import Final, Iterator, List, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.flags import is_flagged, pitch_index +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.planes import SILENT_PITCH_INDEX +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, +) + +FIRST_PITCH: Final[int] = 0 + + +def tone_dividers( + value: bytes, + bend: bytes, + timers: Tuple[int, ...], +) -> Tuple[int, ...]: + """The divider each tick of a tone channel reaches, its note and its bend together. + + This is the reading the driver performs between the pitch table and the timer registers, + stated where it is testable: a flagged tick takes the bend plane's next value, and every + other tick sounds its pitch's own divider. A tick naming the index that stands for silence + holds the divider the channel last sounded, and the lowest pitch's before it sounds at all. + + Args: + value: The pitch each tick names, under the flag saying whether it bends. + bend: The steps each flagged tick stands from its pitch's own divider. + timers: The divider each pitch sounds at, in pitch order. + + Returns: + Tuple[int, ...]: One divider per tick. + """ + bends = iter(bend) + dividers: List[int] = [] + held = timers[FIRST_PITCH] + for named in value: + if named != SILENT_PITCH_INDEX: + held = timers[pitch_index(named)] + (signed_byte(next(bends)) if is_flagged(named) else 0) + + dividers.append(held) + + return tuple(dividers) + + +def held_pitches(value: bytes) -> Tuple[int, ...]: + """The pitch index each tick sounds at, a rest holding the one the channel last sounded. + + Args: + value: The pitch each tick names, the index above the table naming a rest. + + Returns: + Tuple[int, ...]: One index per tick. + """ + indices: List[int] = [] + held = FIRST_PITCH + for named in value: + if named != SILENT_PITCH_INDEX: + held = pitch_index(named) + + indices.append(held) + + return tuple(indices) + + +def _sounded( + planes: Tuple[bytes, ...], + pitches: PitchTable, +) -> Iterator[Tuple[int, int, int]]: + """Each tick's control byte, the divider its registers carry, and the pitch it is counted from.""" + control, value, bend = planes + yield from zip( + control, + tone_dividers(value, bend, pitches.timers), + (pitches.pitch(pitch_index(named)) for named in value), + ) + + +def _pulse_registers( + planes: Tuple[bytes, ...], + pitches: PitchTable, +) -> Tuple[PulseRegisters, ...]: + return tuple( + PulseRegisters( + control=control, + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + anchor=anchor, + ) + for control, timer, anchor in _sounded(planes, pitches) + ) + + +def _triangle_registers( + planes: Tuple[bytes, ...], + pitches: PitchTable, +) -> Tuple[TriangleRegisters, ...]: + """The triangle's ticks, its linear counter read from the pitch its value plane names. + + An index above every pitch the table holds names a rest, which silences the counter and + leaves the divider where the channel last sounded. + """ + value, bend = planes + dividers = tone_dividers(value, bend, pitches.timers) + anchors = held_pitches(value) + return tuple( + TriangleRegisters( + linear_counter=TRIANGLE_COUNTER_CONTROL + | (TRIANGLE_SILENT_RELOAD if named == SILENT_PITCH_INDEX else TRIANGLE_SOUNDING_RELOAD), + timer_low=timer & MAX_REGISTER_VALUE, + timer_high=timer >> TIMER_HIGH_SHIFT, + anchor=pitches.pitch(index), + ) + for named, timer, index in zip(value, dividers, anchors, strict=True) + ) + + +def _noise_registers(planes: Tuple[bytes, ...]) -> Tuple[NoiseRegisters, ...]: + control, value = planes + return tuple( + NoiseRegisters( + control=timbre, + period=period, + ) + for timbre, period in zip(control, 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: Every plane, in the order the song block writes them. + pitches: The timer each pitch sounds at. + + Returns: + ChannelStreams: The per-tick register values every channel plays. + """ + return ChannelStreams( + pulse1=_pulse_registers(planes.of(ChannelName.PULSE1), pitches), + pulse2=_pulse_registers(planes.of(ChannelName.PULSE2), pitches), + triangle=_triangle_registers(planes.of(ChannelName.TRIANGLE), pitches), + noise=_noise_registers(planes.of(ChannelName.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..0563e9c06 --- /dev/null +++ b/src/sampletones_player/compression/planes/separate.py @@ -0,0 +1,142 @@ +from typing import Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.flags import ( + flagged_value, + is_flagged, + note_flags, +) +from sampletones_player.compression.planes.order import PlaneOrder +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.registers.tone import ToneRegisters +from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.planes import SILENT_PITCH_INDEX +from sampletones_player.specification.registers import TRIANGLE_SOUNDING_RELOAD + +CONTROL_VALUE_INDEX: Final[int] = 0 +FIRST_VALUE_INDEX: Final[int] = 1 + + +def _tone_ticks(registers: Sequence[ChannelRegisters]) -> List[ToneRegisters]: + ticks: List[ToneRegisters] = [] + for tick in registers: + match tick: + case ToneRegisters(): + ticks.append(tick) + case _: + raise TypeError(f"a tone channel's planes read tone registers, and a tick holds {type(tick).__name__}") + + return ticks + + +def _tone_planes( + registers: Sequence[ChannelRegisters], + pitches: PitchTable, +) -> Tuple[bytes, ...]: + ticks = _tone_ticks(registers) + indices = [pitches.index(tick.anchor) for tick in ticks] + offsets = [tick.divider - pitches.timers[index] for tick, index in zip(ticks, indices)] + flags = note_flags(indices, offsets) + return ( + bytes(tick.values[CONTROL_VALUE_INDEX] for tick in ticks), + bytes(flagged_value(index, flag) for index, flag in zip(indices, flags)), + bytes(unsigned_byte(offset) for offset, flag in zip(offsets, flags) if flag), + ) + + +def _noise_planes(registers: Sequence[ChannelRegisters]) -> Tuple[bytes, ...]: + return ( + bytes(tick.values[CONTROL_VALUE_INDEX] for tick in registers), + bytes(tick.values[FIRST_VALUE_INDEX] for tick in registers), + ) + + +def _triangle_planes( + registers: Sequence[ChannelRegisters], + pitches: PitchTable, +) -> Tuple[bytes, ...]: + """The triangle's planes, its silence named in the pitch its value plane carries. + + The channel sounds at one level, so a tick states whether it sounds through the index it + names: the index standing above every pitch the table holds silences the linear counter, and + the divider stays where the channel last sounded. A resting tick names no pitch, so it bends + nowhere and its bend plane holds nothing for it. + """ + control, value, bend = _tone_planes(registers, pitches) + offsets = iter(bend) + named = bytearray() + bent = bytearray() + for timbre, pitch in zip(control, value, strict=True): + sounding = bool(timbre & TRIANGLE_SOUNDING_RELOAD) + flagged = is_flagged(pitch) + offset = next(offsets) if flagged else None + named.append(pitch if sounding else SILENT_PITCH_INDEX) + if sounding and offset is not None: + bent.append(offset) + + return (bytes(named), bytes(bent)) + + +def channel_planes( + channel: ChannelName, + registers: Sequence[ChannelRegisters], + pitches: PitchTable, +) -> Tuple[bytes, ...]: + """Separates one channel's ticks into the planes the codec reads. + + A tone channel's value plane names the pitch each divider is counted from, and its bend plane + the steps from that pitch's own divider on the ticks the value flags: each note from its + first bent tick to its last. A note the frame sounds unbent takes nothing from the bend plane. + + Args: + channel: The channel the registers belong to. + registers: The channel's per-tick register values. + pitches: The timer each pitch sounds at. + + Returns: + Tuple[bytes, ...]: The channel's own planes, in the order the song block writes them. + + Raises: + TypeError: If a tone channel's ticks hold registers another channel writes. + ValueError: If a tone channel's divider lies further from the pitch it is counted from than + a signed byte states. + """ + match channel: + case ChannelName.TRIANGLE: + return _triangle_planes(registers, pitches) + case _ if channel in TONE_CHANNELS: + return _tone_planes(registers, pitches) + case _: + 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's divider lies further from the pitch it is counted from than + a signed byte states. + """ + return SongPlanes( + planes=PlaneOrder.across( + plane + for channel, registers in zip(ChannelName.items(), streams.padded, strict=True) + for plane in channel_planes(channel, registers, pitches) + ) + ) diff --git a/src/sampletones_player/compression/planes/song.py b/src/sampletones_player/compression/planes/song.py new file mode 100644 index 000000000..a4754a55c --- /dev/null +++ b/src/sampletones_player/compression/planes/song.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Final, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.planes.flags import flagged_ticks +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.planes import ( + PLANES, + PlaneRole, + channel_indices, + plane_index, +) + +FIRST_PLANE: Final[int] = 0 + + +class SongPlanes(BaseModel): + """Every plane of a song, in the order the song block writes them. + + A channel writes what it sounds and how it sounds it as separate byte series. Read tick by + tick those 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. + + A bend plane holds a value on the ticks its channel's value plane flags and on those alone, + so it covers fewer ticks than the song lasts while every other plane covers them all. + + Attributes: + planes: The byte series each plane plays, under the name the song block writes it by. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + planes: PlaneOrder + + @model_validator(mode="after") + def _validate_every_plane_covers_the_song(self) -> SongPlanes: + if not self.planes[FIRST_PLANE]: + raise ValueError("a song's planes cover at least one tick") + + for plane, played in zip(PLANES, self.planes, strict=True): + reach = self._flagged(plane.channel) if plane.spans_flagged_ticks else self.ticks + if len(played) != reach: + raise ValueError( + f"the {plane.name} plane covers {reach} of the song's values, and this covers {len(played)}" + ) + + return self + + def _flagged(self, channel: ChannelName) -> int: + return flagged_ticks(self.planes[plane_index(channel, PlaneRole.VALUE)]) + + @property + def ticks(self) -> int: + """The ticks the song lasts.""" + return len(self.planes[FIRST_PLANE]) + + def of(self, channel: ChannelName) -> Tuple[bytes, ...]: + """The planes ``channel`` writes, in the order the song block writes them. + + Args: + channel: The channel to read. + + Returns: + Tuple[bytes, ...]: That channel's own planes. + """ + return tuple(self.planes[index] for index in channel_indices(channel)) + + def positions(self, tick: int) -> Tuple[int, ...]: + """Where each plane stands once ``tick`` ticks have played, in block order. + + Every plane reads one value a tick but a bend plane, which reads one on each tick its + channel flags, so a song returning to a tick re-enters each plane at a position of its own. + + Args: + tick: The ticks played. + + Returns: + Tuple[int, ...]: One position per plane. + """ + return tuple( + ( + flagged_ticks(self.planes[plane_index(plane.channel, PlaneRole.VALUE)][:tick]) + if plane.spans_flagged_ticks + else tick + ) + for plane in PLANES + ) diff --git a/src/sampletones_player/compression/planes/symbols.py b/src/sampletones_player/compression/planes/symbols.py new file mode 100644 index 000000000..6c1093121 --- /dev/null +++ b/src/sampletones_player/compression/planes/symbols.py @@ -0,0 +1,104 @@ +from typing import FrozenSet, List, Tuple + +from sampletones_player.specification.planes import SINGLE_TICK, PlaneForm + + +def pack_plane( + played: bytes, + form: PlaneForm, + *, + boundaries: FrozenSet[int], +) -> bytes: + """The symbols a plane plays, each a value and the ticks it repeats for. + + A run of one value becomes one symbol for as many ticks as the form's count reaches, and a + longer run becomes as many symbols as that takes. A tick the song re-enters on begins a + symbol of its own, so the plane stands at a symbol's first byte wherever a token may start. + + Args: + played: The values the plane plays. + form: How the plane's byte divides. + boundaries: The ticks a symbol begins on, beyond the plane's first. + + Returns: + bytes: The symbols, in the order they are played. + + Raises: + ValueError: If a value the plane plays carries fixed bits other than the form's. + """ + symbols = bytearray() + for start, length in _runs(played, boundaries=boundaries): + held = length + while held: + repeats = min(held, form.repeats) + symbols.append(form.symbol(played[start], repeats)) + held -= repeats + + return bytes(symbols) + + +def unpack_plane( + symbols: bytes, + form: PlaneForm, +) -> bytes: + """The values a run of symbols plays, one per tick. + + Args: + symbols: The symbols the plane is written as. + form: How the plane's byte divides. + + Returns: + bytes: The values, one per tick. + """ + values = bytearray() + for symbol in symbols: + values.extend((form.value(symbol),) * form.repeated(symbol)) + + return bytes(values) + + +def symbol_boundaries( + played: bytes, + form: PlaneForm, + *, + boundaries: FrozenSet[int], +) -> FrozenSet[int]: + """Where the ticks a token starts on stand once the plane is packed. + + A packed plane is read a symbol at a time, so the parse counts symbols where it counted + ticks and a boundary reaches it as the symbol the tick begins. + + Args: + played: The values the plane plays. + form: How the plane's byte divides. + boundaries: The ticks a token starts on, beyond the plane's first. + + Returns: + FrozenSet[int]: The symbols those ticks begin. + """ + positions = {} + symbol = 0 + for start, length in _runs(played, boundaries=boundaries): + positions[start] = symbol + symbol += -(-length // form.repeats) + + return frozenset(positions[tick] for tick in boundaries if tick in positions) + + +def _runs( + played: bytes, + *, + boundaries: FrozenSet[int], +) -> Tuple[Tuple[int, int], ...]: + """Where each run of one value begins and how many ticks it lasts, cut at every boundary.""" + runs: List[Tuple[int, int]] = [] + start = 0 + for tick in range(SINGLE_TICK, len(played)): + if played[tick] != played[start] or tick in boundaries: + runs.append((start, tick - start)) + start = tick + + if played: + runs.append((start, len(played) - start)) + + return tuple(runs) 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..16bfcd6de --- /dev/null +++ b/src/sampletones_player/compression/progress/monitor.py @@ -0,0 +1,56 @@ +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/scheme.py b/src/sampletones_player/compression/scheme.py new file mode 100644 index 000000000..7430b321a --- /dev/null +++ b/src/sampletones_player/compression/scheme.py @@ -0,0 +1,56 @@ +from enum import StrEnum +from typing import Tuple + +from sampletones_player.compression.options import EVERY_LAYER, CodecOptions + + +class CompressionScheme(StrEnum): + """How hard a song is compressed, stated as the layers of the codec it is written with. + + Each scheme keeps the layers of the one before it and adds its own, so the choice runs from a + song spelled out value by value to the smallest encoding the codec reaches, and the time an + export spends grows along the way. Every scheme writes a song the driver plays. + """ + + NONE = "none" + RUNS = "runs" + INSTRUMENTS = "instruments" + SEARCH = "search" + + @property + def options(self) -> CodecOptions: + """The layers a song compressed under this scheme is written with. + + ``NONE`` spells every value out as literals, ``RUNS`` writes a held value once, + ``INSTRUMENTS`` names the phrases the instruments seed at whatever pitch they are played + at, and ``SEARCH`` adds the phrases the song's own planes repeat. + """ + match self: + case CompressionScheme.NONE: + return CodecOptions( + holds=False, + phrases=False, + transposition=False, + search=False, + ) + case CompressionScheme.RUNS: + return CodecOptions(holds=True, phrases=False, transposition=False, search=False) + case CompressionScheme.INSTRUMENTS: + return CodecOptions(holds=True, phrases=True, transposition=True, search=False) + case CompressionScheme.SEARCH: + return EVERY_LAYER + + +def offered_schemes(seeded: bool) -> Tuple[CompressionScheme, ...]: + """The schemes worth choosing between for a song, in the order they compress harder. + + ``INSTRUMENTS`` names only the phrases a song's instruments seed, so a song seeding none reads + the same under it as under ``RUNS`` and is offered the schemes that differ. + + Args: + seeded: Whether the song's instruments seed the dictionary. + + Returns: + Tuple[CompressionScheme, ...]: The schemes, from the lightest to the hardest. + """ + return tuple(scheme for scheme in CompressionScheme if seeded or scheme != CompressionScheme.INSTRUMENTS) diff --git a/src/sampletones_player/compression/search.py b/src/sampletones_player/compression/search.py new file mode 100644 index 000000000..019d4b409 --- /dev/null +++ b/src/sampletones_player/compression/search.py @@ -0,0 +1,253 @@ +from typing import ( + Callable, + Dict, + Final, + FrozenSet, + Iterator, + List, + NamedTuple, + Sequence, + Tuple, +) + +from sampletones_player.compression.budget import SearchBudget, shares +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 +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 _windows(parse: Parse) -> Iterator[Tuple[int, int]]: + """Each position the parse still spells out, beside the longest candidate starting there.""" + for span in _residue_spans(parse): + for position in range(span.start, span.end): + yield position, min(MAX_CANDIDATE_LENGTH, span.end - position) + + +def _entries(longest: int) -> int: + return max(0, longest - MIN_CANDIDATE_LENGTH + 1) + + +def _demand(parse: Parse) -> int: + return sum(_entries(longest) for _, longest in _windows(parse)) + + +def _gather_plane( + plane: int, + index: PlaneIndex, + parse: Parse, + allowance: int, + gather: Callable[[bytes, List[_Occurrence]], List[_Occurrence]], +) -> None: + differences = index.differences + entries = 0 + for position, longest in _windows(parse): + if entries >= allowance: + return + + occurrence = _Occurrence(plane=plane, position=position) + for length in range(MIN_CANDIDATE_LENGTH, longest + 1): + gather(differences[position : position + length - 1], []).append(occurrence) + + entries += _entries(longest) + + +def _candidates( + indices: Sequence[PlaneIndex], + parses: Sequence[Parse], + monitor: CodecMonitor, + entries: int, +) -> Dict[bytes, List[_Occurrence]]: + """Every candidate shape the planes still spell out, beside where each occurs. + + The entries a round gathers are shared among the planes by what each has to offer, so a + plane that spells out little is read whole and a dense one is read as far as its share + reaches, and every plane is heard from. + """ + found: Dict[bytes, List[_Occurrence]] = {} + allowances = shares([_demand(parse) for parse in parses], entries) + for plane, (index, parse, allowance) in enumerate(zip(indices, parses, allowances)): + monitor.poll() + _gather_plane(plane, index, parse, allowance, found.setdefault) + + 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, default=False) + shifted = phrase_size(phrase_id, SHIFTED_OCCURRENCE_TRANSPOSE, default=False) + return parsed - stated - shifted * (len(occurrences) - 1) + + +def _ranked( + indices: Sequence[PlaneIndex], + parses: Sequence[Parse], + phrase_id: int, + monitor: CodecMonitor, + budget: SearchBudget, +) -> List[_Candidate]: + ranked: List[_Candidate] = [] + for key, occurrences in _candidates(indices, parses, monitor, budget.candidate_entries).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[: budget.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: Sequence[FrozenSet[int]], + monitor: CodecMonitor, + budget: SearchBudget, +) -> 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 positions a token starts on, one set per plane. + monitor: Carries the run's reckoning of itself onward. + budget: How many candidates a round gathers and confirms, and how many rounds run. + + 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(budget.rounds): + if len(table) == MAX_PHRASE_IDS: + return table + + settled = False + for candidate in _ranked(indices, parses, len(table), monitor, budget): + 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..466271b51 --- /dev/null +++ b/src/sampletones_player/compression/seeds.py @@ -0,0 +1,70 @@ +from typing import AbstractSet, Final, FrozenSet, List, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.exporters.slices import iterate_voice_slices +from sampletones_core.project.project import Project +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.compression.planes.symbols import pack_plane +from sampletones_player.registers.channel import channel_registers +from sampletones_player.specification.compression import MAX_PHRASE_LENGTH +from sampletones_player.specification.planes import PLANES, channel_indices +from sampletones_shared.music import Tuning + +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() + + +def phrases_from_project( + project: Project, + tuning: Tuning, + channels: AbstractSet[ChannelName], +) -> 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. Each reaches the dictionary as the + symbols its own plane is read in, so a seed and a stream name the same shape. The rows of a song play the slices on + the channels it sounds, so those are the slices that seed it. + + Args: + project: The project whose samples the song plays. + tuning: Where concert pitch sits, which decides the timer each pitch sounds at. + channels: The channels the song sounds. + + Returns: + Tuple[Phrase, ...]: The phrases, in instrument-table order. + """ + 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 + if channel not in channels: + continue + + 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=pack_plane(plane, PLANES[index].form, boundaries=NO_BOUNDARIES)[:MAX_PHRASE_LENGTH]) + for index, plane in zip(channel_indices(channel), planes, strict=True) + 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..84edb28f0 --- /dev/null +++ b/src/sampletones_player/compression/song.py @@ -0,0 +1,83 @@ +from typing import FrozenSet, Optional, Sequence + +from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.encode import encode_planes +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.rebuild import streams_from_planes +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.progress.report import CodecReporter +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.registers.streams import ChannelStreams +from sampletones_shared.utils.progress import silent_reporter + + +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], + scheme: CompressionScheme, + 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 names a divider as an index and a + bend. + 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. + scheme: The layers of the codec the song is written with. + report: Hears what the codec holds each time it looks up, and answers whether it goes on. + + Returns: + 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 divider lying further from every pitch of the table than + a signed byte states. + """ + return encode_planes( + planes_from_streams(streams, pitches), + seeds, + options=scheme.options, + boundaries=_entries(loop_tick), + budget=DEFAULT_SEARCH_BUDGET, + 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..237fb7604 --- /dev/null +++ b/src/sampletones_player/compression/tokens/phrase.py @@ -0,0 +1,23 @@ +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 onward, the way a note whose + envelope has finished keeps sounding, and a count short of it cuts the note off. A token + playing the count the phrase itself carries states none, which is a byte it spares. + """ + + phrase_id: int + ticks: int + transpose: int + default: bool + + @property + def size(self) -> int: + """The bytes the token takes.""" + return phrase_size(self.phrase_id, self.transpose, default=self.default) diff --git a/src/sampletones_player/compression/tokens/sizes.py b/src/sampletones_player/compression/tokens/sizes.py new file mode 100644 index 000000000..a628efa23 --- /dev/null +++ b/src/sampletones_player/compression/tokens/sizes.py @@ -0,0 +1,43 @@ +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, + *, + default: bool, +) -> 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 token stating a count of its own carries it in a byte, and one + playing the phrase's own count carries none. 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. + default: Whether the token plays the count the phrase carries. + + Returns: + int: The bytes the token takes. + """ + escape = PHRASE_ESCAPE_SIZE if phrase_id >= PHRASE_ID_ESCAPE else 0 + count = 0 if default else PHRASE_COUNT_SIZE + shift = TRANSPOSE_SIZE if transpose else 0 + return OPCODE_SIZE + count + 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..587b361bf --- /dev/null +++ b/src/sampletones_player/compression/tokens/span.py @@ -0,0 +1,77 @@ +from typing import NamedTuple + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, + OPCODE_SIZE, + PHRASE_COUNT_SIZE, + PHRASE_ESCAPE_SIZE, + PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, + 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, + table: PhraseTable, + *, + transposed: bool, +) -> TokenSpan: + named = data[position] & PHRASE_ID_MASK + stated = not data[position] & DEFAULT_COUNT_FLAG + escape = PHRASE_ESCAPE_SIZE if named == PHRASE_ID_ESCAPE else 0 + after = position + OPCODE_SIZE + escape + phrase_id = data[position + OPCODE_SIZE] if escape else named + shift = TRANSPOSE_SIZE if transposed else 0 + return TokenSpan( + size=OPCODE_SIZE + escape + (PHRASE_COUNT_SIZE if stated else 0) + shift, + ticks=data[after] + 1 if stated else table[phrase_id].default, + ) + + +def token_span( + data: bytes, + position: int, + table: PhraseTable, +) -> 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. + table: The dictionary the tokens name, for the count a phrase carries. + + Returns: + TokenSpan: The bytes the token takes and the values 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, table, transposed=False) + case TokenTag.TRANSPOSED_PHRASE: + return _phrase_span(data, position, table, 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/binary/driver.bin b/src/sampletones_player/driver/binary/driver.bin new file mode 100644 index 000000000..b998206d4 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..6343006a1 --- /dev/null +++ b/src/sampletones_player/driver/image.py @@ -0,0 +1,87 @@ +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 ``sampletones driver`` 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/__init__.py b/src/sampletones_player/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_player/export/backend.py b/src/sampletones_player/export/backend.py new file mode 100644 index 000000000..8e997a595 --- /dev/null +++ b/src/sampletones_player/export/backend.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from pathlib import Path +from typing import FrozenSet + +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ExportReporter +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope +from sampletones_player.export.choice import ProgramChoice +from sampletones_player.export.chosen import ChosenProgram +from sampletones_player.export.program import NSFProgram +from sampletones_player.export.stated import StatedProgram +from sampletones_player.export.writer import NSFWriter +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from sampletones_shared.utils.progress import silent_reporter + +SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset( + { + ExportScope.INSTRUMENT, + ExportScope.SAMPLE, + ExportScope.PROJECT, + } +) + + +class NSFBackend: + """Writes the ``.nsf`` files NES sound players and the console itself play. + + An NSF carries its own driver, so the file plays the reconstruction rather than describing + it to a program that does: every channel slice sounds at once on the channel it was + reconstructed for, at the rate it was built at and in the tuning it was built with. One file + holds one song, so a reconstruction and a single slice each become a program of their own, + the slice sounding on its channel alone. + + What the program states beyond its song — the channels, the repeat, the compression and the + text — is a :class:`ProgramChoice`. The backend the application registers writes each request + as its source states; :meth:`choosing` answers with one writing the program a user settled. + + The console's program area bounds how long a song may run, and one outgrowing it is reported + rather than written short. + """ + + def __init__( + self, + writer: NSFWriter, + choice: ProgramChoice, + ) -> None: + self._writer = writer + self._choice = choice + + @classmethod + def stated(cls) -> NSFBackend: + """The backend writing each request as the program its own source states. + + The driver every file carries is read here, so a build shipping without it reports itself + where the backends are composed. + + Raises: + OSError: If the packaged driver is absent. + ValueError: If the packaged driver lays out something other than the addresses it + is built to answer at. + """ + return cls(NSFWriter.load(), StatedProgram()) + + def choosing(self, program: NSFProgram) -> NSFBackend: + """The backend writing every request as ``program``, over the driver this one holds. + + Args: + program: The choices the file carries. + + Returns: + NSFBackend: The backend to hand the run. + """ + return NSFBackend(self._writer, ChosenProgram(program)) + + @property + def export_format(self) -> ExportFormat: + return ExportFormat.NSF + + @property + def supported_scopes(self) -> FrozenSet[ExportScope]: + return SUPPORTED_SCOPES + + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument + return EXT_FILE_NSF + + def write_instrument( + self, + destination: Path, + request: InstrumentExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Writes a program playing one channel slice. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the slice runs longer than the program area holds. + OSError: If the destination cannot be written. + """ + sample = SampleExport( + name=request.name, + instruments=(request,), + nes_frequency=request.nes_frequency, + tuning=request.tuning, + ) + return self.write_sample(destination, sample, report) + + def write_sample( + self, + destination: Path, + request: SampleExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Writes a program playing every channel slice of one reconstruction together. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the reconstruction runs longer than the program area holds. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song. + """ + return self._writer.write_sample( + destination, + request, + self._choice.for_sample(request), + report, + ) + + def write_project( + self, + destination: Path, + request: ProjectExport, + report: ExportReporter = silent_reporter, + ) -> ExportArtifact: + """Writes a program playing a whole composition. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song, or the project's samples + were reconstructed against tunings that differ. + """ + return self._writer.write_project( + destination, + request.project, + self._choice.for_project(request.project), + report, + ) diff --git a/src/sampletones_player/export/choice.py b/src/sampletones_player/export/choice.py new file mode 100644 index 000000000..d49716c5c --- /dev/null +++ b/src/sampletones_player/export/choice.py @@ -0,0 +1,20 @@ +from typing import Protocol + +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.export.program import NSFProgram + + +class ProgramChoice(Protocol): + """Which program a request is written as. + + A request arriving through the export seam carries the work alone, so the program it is + written under is decided beside it: either the one each source states for itself, or one the + user settled before the run. + """ + + def for_sample(self, request: SampleExport) -> NSFProgram: + """The program a reconstruction's slices are written as.""" + + def for_project(self, project: Project) -> NSFProgram: + """The program a whole composition is written as.""" diff --git a/src/sampletones_player/export/chosen.py b/src/sampletones_player/export/chosen.py new file mode 100644 index 000000000..b4435917f --- /dev/null +++ b/src/sampletones_player/export/chosen.py @@ -0,0 +1,20 @@ +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.export.program import NSFProgram + + +class ChosenProgram: + """Writes a request as the one program the user settled before the run. + + Attributes: + program: The program the run writes. + """ + + def __init__(self, program: NSFProgram) -> None: + self.program = program + + def for_sample(self, request: SampleExport) -> NSFProgram: # pylint: disable=unused-argument + return self.program + + def for_project(self, project: Project) -> NSFProgram: # pylint: disable=unused-argument + return self.program diff --git a/src/sampletones_player/export/program.py b/src/sampletones_player/export/program.py new file mode 100644 index 000000000..a7ccf240d --- /dev/null +++ b/src/sampletones_player/export/program.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Annotated, Final, FrozenSet, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.builder import SONG_START, loop_tick_from_instruments +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.application import SAMPLETONES_COPYRIGHT + +NO_ARTIST: Final[str] = "" +DEFAULT_SCHEME: Final[CompressionScheme] = CompressionScheme.SEARCH + + +class NSFProgram(BaseModel): + """Everything a written program states beyond the song it plays. + + A song reaches the console as the channels it sounds, the tick it returns to once it ends and + the layers it is compressed with, and the file lists it under three lines of text. Those are + the choices an export makes, and :meth:`for_project` and :meth:`for_sample` state the ones it + makes on its own. + + Attributes: + information: The text the file is listed under. + channels: The channels the program sounds, every other one resting throughout. + loop_tick: The tick the song returns to once it ends, or ``None`` where it stops there. + scheme: The layers of the codec the song is written with. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + information: NSFInformation + channels: FrozenSet[ChannelName] = Field(min_length=1) + loop_tick: Optional[Annotated[int, Field(ge=0)]] + scheme: CompressionScheme + + @classmethod + def for_project(cls, project: Project) -> NSFProgram: + """The program a whole composition is written as. + + The file is listed under the project's own title and author, sounds every channel, and + repeats from its first tick, which is how a piece of music is listened to and what an NSF + player expects of a song that has reached its end. + + Args: + project: The composition to write. + + Returns: + NSFProgram: The program, compressed as hard as the codec reaches. + """ + return cls( + information=NSFInformation( + title=project.info.title, + artist=project.info.author, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=DEFAULT_SCHEME, + ) + + @classmethod + def for_sample(cls, request: SampleExport) -> NSFProgram: + """The program a reconstruction's slices are written as. + + The file is listed under the reconstruction's name and credited to nobody, sounds every + slice the request carries, and repeats where every slice repeats. + + Args: + request: The slices to write. + + Returns: + NSFProgram: The program, compressed as hard as the codec reaches. + """ + return cls( + information=NSFInformation( + title=request.name, + artist=NO_ARTIST, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS, + loop_tick=loop_tick_from_instruments(request.instruments), + scheme=DEFAULT_SCHEME, + ) diff --git a/src/sampletones_player/export/reports.py b/src/sampletones_player/export/reports.py new file mode 100644 index 000000000..1ef30f9b8 --- /dev/null +++ b/src/sampletones_player/export/reports.py @@ -0,0 +1,59 @@ +from sampletones_core.exports.progress import ExportProgress, ExportReporter +from sampletones_core.exports.stage import ExportStage +from sampletones_core.performance import WalkProgress, WalkReporter +from sampletones_player.compression.progress.report import ( + CodecProgress, + CodecReporter, +) + +UNMEASURED: None = None + + +def walk_reporter(report: ExportReporter) -> WalkReporter: + """The walk's own reckoning, said in the words an export reports itself in. + + A song's order states the ticks it lasts before a row of it is played, so this stage travels + toward a length it knows and reads as a true fraction of the song. + + Args: + report: Hears each stage of the export, and answers whether it goes on. + + Returns: + WalkReporter: What playing the song out tells the export about itself. + """ + + def reached(progress: WalkProgress) -> bool: + return report( + ExportProgress( + stage=ExportStage.WALKING, + completed=progress.ticks, + total=progress.total, + ) + ) + + return reached + + +def codec_reporter(report: ExportReporter) -> CodecReporter: + """The codec's own reckoning, said in the words an export reports itself in. + + A codec run ends when no further phrase pays for itself, which the song decides, so what it + offers is the bytes it has laid down so far, measured against no length of its own. + + Args: + report: Hears each stage of the export, and answers whether it goes on. + + Returns: + CodecReporter: What the compression tells the export about itself. + """ + + def reached(progress: CodecProgress) -> bool: + return report( + ExportProgress( + stage=ExportStage.COMPRESSING, + completed=progress.size, + total=UNMEASURED, + ) + ) + + return reached diff --git a/src/sampletones_player/export/stated.py b/src/sampletones_player/export/stated.py new file mode 100644 index 000000000..7d50a3969 --- /dev/null +++ b/src/sampletones_player/export/stated.py @@ -0,0 +1,17 @@ +from sampletones_core.exports.request import SampleExport +from sampletones_core.project.project import Project +from sampletones_player.export.program import NSFProgram + + +class StatedProgram: + """Writes each request as the program its own source states. + + This is what an export asks for when nobody set one up: the text, channels, repeat and + compression :meth:`NSFProgram.for_sample` and :meth:`NSFProgram.for_project` state. + """ + + def for_sample(self, request: SampleExport) -> NSFProgram: + return NSFProgram.for_sample(request) + + def for_project(self, project: Project) -> NSFProgram: + return NSFProgram.for_project(project) diff --git a/src/sampletones_player/export/writer.py b/src/sampletones_player/export/writer.py new file mode 100644 index 000000000..3b7d39290 --- /dev/null +++ b/src/sampletones_player/export/writer.py @@ -0,0 +1,135 @@ +from pathlib import Path +from typing import Final, Self + +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.exports.artifact import ExportArtifact +from sampletones_core.exports.progress import ExportReporter, announce +from sampletones_core.exports.request import SampleExport +from sampletones_core.exports.stage import ExportStage +from sampletones_core.project.project import Project +from sampletones_player.builder import song_from_project, song_from_sample +from sampletones_player.driver.image import DriverImage +from sampletones_player.export.program import NSFProgram +from sampletones_player.export.reports import ( + UNMEASURED, + codec_reporter, + walk_reporter, +) +from sampletones_player.nsf.file import write_nsf +from sampletones_player.song import Song + +WHOLE_ENVELOPE: None = None +NOTHING_DONE: Final[int] = 0 +ONE_FILE: Final[int] = 1 + + +class NSFWriter: + """Lays a program down on disk: its song played out, compressed, and written behind the driver. + + Each of those says so as it starts, so a run of seconds reads as the work it is doing, and the + program states every choice the file carries — the channels the song sounds, where it repeats, + how hard it is compressed and the text it is listed under. + + Every file carries the same assembled driver, which the writer holds from the moment it is + built, so a run spends its reads on the song alone. + """ + + def __init__(self, image: DriverImage) -> None: + self._image = image + + @classmethod + def load(cls) -> Self: + """Builds the writer over the driver the package ships. + + Raises: + OSError: If the packaged driver is absent. + ValueError: If the packaged driver lays out something other than the addresses it + is built to answer at. + """ + return cls(DriverImage.load()) + + def write_sample( + self, + destination: Path, + request: SampleExport, + program: NSFProgram, + report: ExportReporter, + ) -> ExportArtifact: + """Writes a program playing a reconstruction's slices together. + + Args: + destination: The file to write. + request: The slices to play. + program: The choices the file carries. + report: Hears each stage of the write, and answers whether it goes on. + + Returns: + ExportArtifact: The file written. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + announce(report, ExportStage.WALKING, NOTHING_DONE, UNMEASURED) + song = song_from_sample( + request, + channels=program.channels, + loop_tick=program.loop_tick, + scheme=program.scheme, + report=codec_reporter(report), + ) + return self._write(destination, song, program, report) + + def write_project( + self, + destination: Path, + project: Project, + program: NSFProgram, + report: ExportReporter, + ) -> ExportArtifact: + """Writes a program playing a whole composition. + + The arrangement is played out row by row into the ticks each channel sounds, and the walk + reads as a fraction of the song it is playing out. + + Args: + destination: The file to write. + project: The composition to play. + program: The choices the file carries. + report: Hears each stage of the write, and answers whether it goes on. + + Returns: + ExportArtifact: The file written. + + Raises: + OperationCanceled: If ``report`` withdraws the write. + SongTooLargeError: If the song holds more than the program area has room for. + OSError: If the destination cannot be written. + ValueError: If the program's loop tick lies outside the song, or the project's samples + were reconstructed against tunings that differ. + """ + destination.parent.mkdir(parents=True, exist_ok=True) + song = song_from_project( + project, + channels=program.channels, + loop_tick=program.loop_tick, + scheme=program.scheme, + report=codec_reporter(report), + walk=walk_reporter(report), + ) + return self._write(destination, song, program, report) + + def _write( + self, + destination: Path, + song: Song, + program: NSFProgram, + report: ExportReporter, + ) -> ExportArtifact: + announce(report, ExportStage.WRITING, NOTHING_DONE, ONE_FILE) + write_nsf(destination, song, program.information, self._image) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=WHOLE_ENVELOPE, skipped_rows=NO_SKIPPED_ROWS) 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..e12ceede1 --- /dev/null +++ b/src/sampletones_player/nsf/information.py @@ -0,0 +1,53 @@ +from typing import Final + +from pydantic import BaseModel, ConfigDict, field_validator + +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from sampletones_shared.application import SAMPLETONES_COPYRIGHT + +TEXT_ENCODING: Final[str] = "utf-8" + + +def fit_field(text: str) -> str: + """The part of ``text`` a header string field holds. + + A field is a fixed run of bytes ending in a terminator, so its text is the longest run of + whole characters whose encoding fits in front of it. What a player lists is what this returns, + which lets whoever offers the text for editing show exactly what the file will carry. + + Args: + text: The text offered for the field. + + Returns: + str: ``text`` itself, or as many of its leading characters as the field holds. + """ + return text.encode(TEXT_ENCODING)[:STRING_TEXT_SIZE].decode(TEXT_ENCODING, errors="ignore") + + +def field_size(text: str) -> int: + """The bytes ``text`` takes in a header string field, ahead of its terminator.""" + return len(text.encode(TEXT_ENCODING)) + + +class NSFInformation(BaseModel): + """The three text fields an NSF header carries, shown by the players that read them. + + Each field reaches the file as a fixed 32-byte string ending in a terminator, so every text + is held as the part of itself the field has room for (:func:`fit_field`). + + Attributes: + title: Name the song is listed under. + 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 + + @field_validator("title", "artist", "copyright") + @classmethod + def _fit_each_field(cls, text: str) -> str: + return fit_field(text) diff --git a/src/sampletones_player/nsf/layout.py b/src/sampletones_player/nsf/layout.py new file mode 100644 index 000000000..39d6f5c5c --- /dev/null +++ b/src/sampletones_player/nsf/layout.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Optional, 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_DEFAULT_SIZE, + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_COUNT_SIZE, + PHRASE_TABLE_ENTRY_SIZE, +) +from sampletones_player.specification.song import ( + ABSENT_STREAM, + SONG_HEADER_SIZE, +) + +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) + + +def _stated(offset: int, stream: bytes) -> int: + """The offset the header states for a stream, an absent plane's standing as the sentinel.""" + return offset if stream else ABSENT_STREAM + + +def _entered(offset: int, entry: Optional[int]) -> int: + """The offset a stream is re-entered at, an absent plane's standing as the sentinel.""" + return ABSENT_STREAM if entry is None else offset + entry + + +@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, or + ``ABSENT_STREAM`` for a plane the block leaves out. + loop_entries: Where each plane's stream is re-entered once the song repeats, or + ``ABSENT_STREAM`` for a plane the block leaves out. + 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_DEFAULT_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.loop_entries + return cls( + timer_table=timer_table, + phrase_table=phrase_table, + bodies=_running(bodies, body_sizes), + streams=tuple(_stated(offset, stream) for offset, stream in zip(stream_offsets, streams)), + loop_entries=tuple(_entered(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 for a part it holds, 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) + if offset != ABSENT_STREAM + ) diff --git a/src/sampletones_player/nsf/song.py b/src/sampletones_player/nsf/song.py new file mode 100644 index 000000000..84826f084 --- /dev/null +++ b/src/sampletones_player/nsf/song.py @@ -0,0 +1,102 @@ +from typing import Final + +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.compression import NO_DEFAULT_COUNT +from sampletones_player.specification.song import MAX_BLOCK_OFFSET, NO_LOOP +from sampletones_shared.exceptions import SongTooLargeError + +STATED_BEYOND_THE_FIRST: Final[int] = 1 + + +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_uint8(max(phrase.default - STATED_BEYOND_THE_FIRST, NO_DEFAULT_COUNT)) + 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/py.typed b/src/sampletones_player/py.typed new file mode 100644 index 000000000..e69de29bb 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..bf62431ff --- /dev/null +++ b/src/sampletones_player/registers/channel.py @@ -0,0 +1,119 @@ +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.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. + + 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" + ) + + typed.append(instruction) + + 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/dividers.py b/src/sampletones_player/registers/dividers.py new file mode 100644 index 000000000..d7af4590f --- /dev/null +++ b/src/sampletones_player/registers/dividers.py @@ -0,0 +1,66 @@ +from typing import Dict, Mapping, Sequence, Tuple + +from sampletones_core.timers.arithmetic import bent_timer +from sampletones_core.timers.nearest import nearest_pitch +from sampletones_player.specification.binary import SIGNED_BYTE_LIMIT + + +def bent_dividers( + pitches: Sequence[int], + offsets: Sequence[int], + timer_table: Dict[int, int], +) -> Tuple[int, ...]: + """The divider each frame sounds: its note's own, moved by the bend the frame carries. + + This is the divider the generators render a frame at, so a tone channel's timer registers + carry exactly what the sequencer sounds. + + Args: + pitches: The note each frame names, held across rests. + offsets: The timer steps each frame is bent by, held across rests the same way. + timer_table: The timer register value each pitch sounds at. + + Returns: + Tuple[int, ...]: One divider per frame. + + Raises: + ValueError: If the notes and the bends cover different frames. + """ + return tuple(bent_timer(timer_table[pitch], offset) for pitch, offset in zip(pitches, offsets, strict=True)) + + +def anchored_pitches( + pitches: Sequence[int], + dividers: Sequence[int], + timer_table: Mapping[int, int], +) -> Tuple[int, ...]: + """The pitch each frame's divider is counted from, which the channel's value plane names. + + Counting a bend from the frame's own note keeps it the same bytes at every pitch a row + transposes the note to, since a transpose moves the note and keeps the bend's steps. That is + what lets one dictionary entry serve a bent sample at every transposition. A bend past the + signed byte a bend plane holds is counted from the pitch lying nearest instead. + + Args: + pitches: The note each frame names. + dividers: The divider each frame sounds. + timer_table: The timer register value each pitch sounds at. + + Returns: + Tuple[int, ...]: One pitch per frame. + + Raises: + ValueError: If the notes and the dividers cover different frames. + """ + return tuple(_anchor(pitch, divider, timer_table) for pitch, divider in zip(pitches, dividers, strict=True)) + + +def _anchor( + pitch: int, + divider: int, + timer_table: Mapping[int, int], +) -> int: + if -SIGNED_BYTE_LIMIT <= divider - timer_table[pitch] < SIGNED_BYTE_LIMIT: + return pitch + + return nearest_pitch(timer_table, divider).pitch 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/pulse.py b/src/sampletones_player/registers/pulse.py new file mode 100644 index 000000000..99eeb5c97 --- /dev/null +++ b/src/sampletones_player/registers/pulse.py @@ -0,0 +1,68 @@ +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.dividers import ( + anchored_pitches, + bent_dividers, +) +from sampletones_player.registers.hold import hold +from sampletones_player.registers.tone import ToneRegisters +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + MAX_REGISTER_VALUE, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, +) + + +class PulseRegisters(ToneRegisters): + control: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + @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. The timer carries the note's divider moved by the frame's bend. + Holding it 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) + dividers = bent_dividers(pitches, PulseExporter.read_timer_offsets(instructions), timer_table) + anchors = anchored_pitches(pitches, dividers, timer_table) + + registers: List[PulseRegisters] = [] + for index, volume in enumerate(volumes): + timer = hold(dividers, 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, + anchor=hold(anchors, index), + ) + ) + + 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/tone.py b/src/sampletones_player/registers/tone.py new file mode 100644 index 000000000..166ba5dfb --- /dev/null +++ b/src/sampletones_player/registers/tone.py @@ -0,0 +1,34 @@ +from abc import ABC + +from pydantic import Field + +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + MAX_TIMER_HIGH, + TIMER_HIGH_SHIFT, +) +from sampletones_shared.constants.music import LIMIT_MAX_PITCH, LIMIT_MIN_PITCH + + +class ToneRegisters(ChannelRegisters, ABC): + """The timer a tone channel writes for one tick, beside the pitch its divider is counted from. + + The anchor never reaches a register. It is what the channel's value plane names, and the bend + plane holds the steps from that pitch's own divider to the one written, which the driver adds + back. + + Attributes: + timer_low: The timer's low byte. + timer_high: The timer's high bits. + anchor: The pitch the divider is counted from. + """ + + timer_low: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + timer_high: int = Field(..., ge=0, le=MAX_TIMER_HIGH) + anchor: int = Field(..., ge=LIMIT_MIN_PITCH, le=LIMIT_MAX_PITCH) + + @property + def divider(self) -> int: + """The divider the two halves of the timer carry together.""" + return (self.timer_high << TIMER_HIGH_SHIFT) | self.timer_low diff --git a/src/sampletones_player/registers/triangle.py b/src/sampletones_player/registers/triangle.py new file mode 100644 index 000000000..ed1affd45 --- /dev/null +++ b/src/sampletones_player/registers/triangle.py @@ -0,0 +1,84 @@ +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.dividers import ( + anchored_pitches, + bent_dividers, +) +from sampletones_player.registers.hold import hold +from sampletones_player.registers.tone import ToneRegisters +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, +) +from sampletones_shared.constants.music import LIMIT_MIN_PITCH + + +class TriangleRegisters(ToneRegisters): + linear_counter: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + @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 carries the note's divider moved by the frame's bend, and the channel sounds an + octave below the pitch it names — the same octave a rendered triangle sounds. A resting + tick holds the divider the channel last sounded, and the lowest pitch's before it sounds + at all, so the pitch a rest leaves unstated is one that can be reached again. + + 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) + dividers = bent_dividers( + pitches, + TriangleExporter.read_timer_offsets(instructions), + timer_table, + ) + anchors = anchored_pitches(pitches, dividers, timer_table) + + registers: List[TriangleRegisters] = [] + timer = timer_table[LIMIT_MIN_PITCH] + anchor = LIMIT_MIN_PITCH + for index, volume in enumerate(volumes): + sounding = volume > 0 + if sounding: + timer = hold(dividers, index) + anchor = hold(anchors, index) + + reload_value = TRIANGLE_SOUNDING_RELOAD if sounding 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, + anchor=anchor, + ) + ) + + return registers diff --git a/src/sampletones_player/song.py b/src/sampletones_player/song.py new file mode 100644 index 000000000..de33ff431 --- /dev/null +++ b/src/sampletones_player/song.py @@ -0,0 +1,128 @@ +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.scheme import CompressionScheme +from sampletones_player.compression.song import compress_song, decompress_song +from sampletones_player.registers.streams import ChannelStreams +from sampletones_shared.utils.progress import silent_reporter + + +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], + scheme: CompressionScheme, + 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. + scheme: The layers of the codec the song is written with. + report: Hears what the codec holds each time it looks up, and answers whether the + compression goes on. + + 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 + divider lying further from every pitch of the table than a signed byte states. + """ + return cls( + planes=compress_song( + streams, + pitches, + seeds=seeds, + loop_tick=loop_tick, + scheme=scheme, + 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..9d86de7da --- /dev/null +++ b/src/sampletones_player/specification/channels.py @@ -0,0 +1,27 @@ +from typing import Dict, Final, 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), +} 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..c2a96c2df --- /dev/null +++ b/src/sampletones_player/specification/compression.py @@ -0,0 +1,62 @@ +from enum import IntEnum +from math import ceil +from typing import Final + +from sampletones_player.specification.binary import ( + BYTE_VALUES, + MAX_BYTE_VALUE, + WORD_SIZE, +) +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. + + A phrase opcode spends the top bit of its operand on whether the token states a count of its + own or plays the count the phrase itself carries, so the ids it names outright are the lower + half of what a hold or a literal counts. + """ + + 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 + +DEFAULT_COUNT_FLAG: Final[int] = (TOKEN_OPERAND_MASK + 1) >> 1 +PHRASE_ID_MASK: Final[int] = DEFAULT_COUNT_FLAG - 1 +PHRASE_ID_ESCAPE: Final[int] = PHRASE_ID_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 +PHRASE_DEFAULT_SIZE: Final[int] = 1 +NO_DEFAULT_COUNT: Final[int] = 0 + +INITIAL_PLANE_VALUE: Final[int] = 0 +BEND_FLAG: Final[int] = 0x80 +PITCH_INDEX_MASK: Final[int] = BEND_FLAG - 1 + +PLANE_STATE_SIZE: Final[int] = 10 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..4b431c74f --- /dev/null +++ b/src/sampletones_player/specification/nsf.py @@ -0,0 +1,50 @@ +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 +STRING_TERMINATOR_SIZE: Final[int] = 1 +STRING_TEXT_SIZE: Final[int] = STRING_FIELD_SIZE - STRING_TERMINATOR_SIZE +BANKSWITCH_SIZE: Final[int] = 8 +NSF2_LENGTH_SIZE: Final[int] = 3 + +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 +PAL_REGION_FLAG: Final[int] = 0x01 +DUAL_REGION_FLAG: Final[int] = 0x02 +NO_EXPANSION_CHIPS: Final[int] = 0x00 +NO_NSF2_FEATURES: Final[int] = 0x00 +NO_BANKSWITCHING: Final[bytes] = bytes(BANKSWITCH_SIZE) +NSF2_LENGTH_UNSTATED: Final[bytes] = bytes(NSF2_LENGTH_SIZE) diff --git a/src/sampletones_player/specification/planes.py b/src/sampletones_player/specification/planes.py new file mode 100644 index 000000000..e7e15087e --- /dev/null +++ b/src/sampletones_player/specification/planes.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Final, Tuple + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import ( + MAX_DUTY_CYCLE, + MAX_PERIOD, + MAX_VOLUME, +) +from sampletones_player.specification.binary import MAX_BYTE_VALUE +from sampletones_player.specification.compression import PITCH_INDEX_MASK +from sampletones_player.specification.registers import ( + DUTY_CYCLE_SHIFT, + NOISE_MODE_SHIFT, + SUSTAINED_LEVEL, +) + +SINGLE_TICK: Final[int] = 1 +NO_BITS: Final[int] = 0 +COUNT_STEP: Final[int] = 0x10 +SILENT_PITCH_INDEX: Final[int] = PITCH_INDEX_MASK + + +class PlaneForm(BaseModel): + """How a plane's byte divides between the value its register reads and a repeat count. + + A channel's register reads some of a byte's bits and ignores the rest: a pulse channel's + volume is four bits under two the hardware wants set, and a noise period is four bits with + three above it that say nothing. A plane writes its value in the bits that reach the register + and counts the ticks that value repeats for in the ones left over, so a run costs one byte + however long it rests. + + The division is a mask, which is what keeps the reading cheap: the register byte is the + symbol masked and ored with the bits the hardware fixes, and one repeat is a subtraction of + ``COUNT_STEP``. A plane whose values need every bit states a full ``value_mask``, carries no + count, and reads byte for byte. + + Attributes: + value_mask: The bits the value occupies. + value_or: The bits the register fixes, which every value of the plane carries. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + value_mask: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + value_or: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + + @model_validator(mode="after") + def _validate_the_fixed_bits_lie_outside_the_value(self) -> PlaneForm: + if self.value_or & self.value_mask: + raise ValueError( + f"the bits a register fixes lie outside the value's own, and {self.value_or:#04x} " + f"meets {self.value_mask:#04x}" + ) + + return self + + @model_validator(mode="after") + def _validate_a_count_steps_from_one_place(self) -> PlaneForm: + if self.counts and self.count_mask & -self.count_mask != COUNT_STEP: + raise ValueError( + f"a repeat count steps from {COUNT_STEP:#04x}, and {self.count_mask:#04x} steps " + f"from {self.count_mask & -self.count_mask:#04x}" + ) + + return self + + @model_validator(mode="after") + def _validate_the_count_occupies_one_run_of_bits(self) -> PlaneForm: + counted = self.count_mask // COUNT_STEP if self.counts else NO_BITS + if counted & (counted + 1): + raise ValueError(f"a repeat count occupies one run of bits, and {self.count_mask:#04x} breaks apart") + + return self + + @property + def count_mask(self) -> int: + """The bits a repeat count occupies.""" + return MAX_BYTE_VALUE ^ self.value_mask + + @property + def counts(self) -> bool: + """Whether the plane's byte has room to count a repeat.""" + return bool(self.count_mask) + + @property + def repeats(self) -> int: + """The ticks one symbol covers at most.""" + if not self.counts: + return SINGLE_TICK + + return self.count_mask // COUNT_STEP + SINGLE_TICK + + def symbol( + self, + value: int, + repeats: int, + ) -> int: + """The byte a plane writes for ``value`` sounding ``repeats`` ticks running. + + Args: + value: The register byte the ticks play. + repeats: The ticks the value sounds for. + + Returns: + int: The symbol. + + Raises: + ValueError: If the value carries fixed bits other than the form's, or the repeats + reach past what one symbol counts. + """ + if value & self.count_mask != self.value_or: + raise ValueError( + f"a value of this plane carries {self.value_or:#04x} in the bits the count takes, " + f"and {value:#04x} carries {value & self.count_mask:#04x}" + ) + + if not SINGLE_TICK <= repeats <= self.repeats: + raise ValueError(f"one symbol counts {SINGLE_TICK} through {self.repeats} ticks, and this counts {repeats}") + + return (value & self.value_mask) | (repeats - SINGLE_TICK) * COUNT_STEP + + def value(self, symbol: int) -> int: + """The register byte ``symbol`` plays.""" + return (symbol & self.value_mask) | self.value_or + + def repeated(self, symbol: int) -> int: + """The ticks ``symbol`` covers.""" + if not self.counts: + return SINGLE_TICK + + return (symbol & self.count_mask) // COUNT_STEP + SINGLE_TICK + + def fits(self, plane: bytes) -> bool: + """Whether every value the plane plays carries the bits this form fixes. + + Args: + plane: The values the plane plays. + + Returns: + bool: Whether the form reads the plane. + """ + return all(value & self.count_mask == self.value_or for value in plane) + + +WHOLE_BYTE: Final[PlaneForm] = PlaneForm(value_mask=MAX_BYTE_VALUE, value_or=NO_BITS) + +DUTY_CYCLE_FIELD: Final[int] = MAX_DUTY_CYCLE << DUTY_CYCLE_SHIFT +VOLUME_FIELD: Final[int] = MAX_VOLUME +PERIOD_FIELD: Final[int] = MAX_PERIOD +NOISE_MODE_FIELD: Final[int] = 1 << NOISE_MODE_SHIFT + +PULSE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( + value_mask=DUTY_CYCLE_FIELD | VOLUME_FIELD, + value_or=SUSTAINED_LEVEL, +) +NOISE_CONTROL_FORM: Final[PlaneForm] = PlaneForm( + value_mask=VOLUME_FIELD, + value_or=SUSTAINED_LEVEL, +) +NOISE_VALUE_FORM: Final[PlaneForm] = PlaneForm( + value_mask=NOISE_MODE_FIELD | PERIOD_FIELD, + value_or=NO_BITS, +) + + +class PlaneRole(StrEnum): + """The part of a channel's tick one plane carries. + + Attributes: + CONTROL: How the channel sounds — its timbre, and its volume where it has one. + VALUE: What the channel sounds — a pitch index, or the noise channel's period byte. + BEND: How far a tick stands from the divider the pitch it names sounds at. + """ + + CONTROL = "control" + VALUE = "value" + BEND = "bend" + + +class Plane(BaseModel): + """One byte series a song block writes, named by the channel and the part it carries. + + Attributes: + channel: The channel the plane belongs to. + role: The part of the channel's tick the plane carries. + form: How the plane's byte divides between its value and a repeat count. + seeded: The register byte the plane stands at before its first token, which the driver + writes when it starts a song and a plane holding it throughout takes no stream. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + channel: ChannelName + role: PlaneRole + form: PlaneForm + seeded: int = Field(..., ge=0, le=MAX_BYTE_VALUE) + + @model_validator(mode="after") + def _validate_the_seeded_value_reads_as_the_plane_does(self) -> Plane: + if not self.form.fits(bytes((self.seeded,))): + raise ValueError( + f"a plane stands at a value its own form reads, and {self.name} stands at {self.seeded:#04x}" + ) + + return self + + @property + def name(self) -> str: + """The name the song block writes the plane under.""" + return f"{self.channel.value}_{self.role.value}" + + @property + def spans_flagged_ticks(self) -> bool: + """Whether the plane holds a value per flagged tick rather than one per tick.""" + return self.role is PlaneRole.BEND + + def idles(self, played: bytes) -> bool: + """Whether the plane holds the value it is seeded to throughout, so it takes no stream. + + Args: + played: The values the plane plays. + + Returns: + bool: Whether the plane stands where the driver seeds it. + """ + return set(played) <= {self.seeded} + + +def _tone( + channel: ChannelName, + role: PlaneRole, +) -> Plane: + return Plane(channel=channel, role=role, form=WHOLE_BYTE, seeded=NO_BITS) + + +PLANES: Final[Tuple[Plane, ...]] = ( + Plane( + channel=ChannelName.PULSE1, + role=PlaneRole.CONTROL, + form=PULSE_CONTROL_FORM, + seeded=SUSTAINED_LEVEL, + ), + _tone(ChannelName.PULSE1, PlaneRole.VALUE), + _tone(ChannelName.PULSE1, PlaneRole.BEND), + Plane( + channel=ChannelName.PULSE2, + role=PlaneRole.CONTROL, + form=PULSE_CONTROL_FORM, + seeded=SUSTAINED_LEVEL, + ), + _tone(ChannelName.PULSE2, PlaneRole.VALUE), + _tone(ChannelName.PULSE2, PlaneRole.BEND), + Plane( + channel=ChannelName.TRIANGLE, + role=PlaneRole.VALUE, + form=WHOLE_BYTE, + seeded=SILENT_PITCH_INDEX, + ), + _tone(ChannelName.TRIANGLE, PlaneRole.BEND), + Plane( + channel=ChannelName.NOISE, + role=PlaneRole.CONTROL, + form=NOISE_CONTROL_FORM, + seeded=SUSTAINED_LEVEL, + ), + Plane( + channel=ChannelName.NOISE, + role=PlaneRole.VALUE, + form=NOISE_VALUE_FORM, + seeded=NO_BITS, + ), +) +PLANE_COUNT: Final[int] = len(PLANES) +PLANE_NAMES: Final[Tuple[str, ...]] = tuple(plane.name for plane in PLANES) + + +def channel_indices(channel: ChannelName) -> Tuple[int, ...]: + """Where ``channel``'s own planes stand in the song block, in the order it writes them. + + Args: + channel: The channel to read. + + Returns: + Tuple[int, ...]: The positions that channel's planes take. + """ + return tuple(index for index, plane in enumerate(PLANES) if plane.channel is channel) + + +def plane_index( + channel: ChannelName, + role: PlaneRole, +) -> int: + """Where the plane ``channel`` writes for ``role`` stands in the song block. + + Args: + channel: The channel the plane belongs to. + role: The part of the channel's tick the plane carries. + + Returns: + int: The position the plane takes. + + Raises: + ValueError: If the channel writes no plane for that role. + """ + for index, plane in enumerate(PLANES): + if plane.channel is channel and plane.role is role: + return index + + raise ValueError(f"the {channel.value} channel writes no {role.value} plane") diff --git a/src/sampletones_player/specification/registers.py b/src/sampletones_player/specification/registers.py new file mode 100644 index 000000000..01b93ec7f --- /dev/null +++ b/src/sampletones_player/specification/registers.py @@ -0,0 +1,53 @@ +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 +DMC_DIRECT_LOAD: Final[int] = 0x4011 +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..f6e03ad09 --- /dev/null +++ b/src/sampletones_player/specification/song.py @@ -0,0 +1,18 @@ +from typing import Final + +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.planes 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 +ABSENT_STREAM: Final[int] = 0xFFFF +MAX_BLOCK_OFFSET: Final[int] = ABSENT_STREAM - 1 diff --git a/src/sampletones_shared/application.py b/src/sampletones_shared/application.py index cf5222e64..503597228 100644 --- a/src/sampletones_shared/application.py +++ b/src/sampletones_shared/application.py @@ -6,10 +6,12 @@ SAMPLETONES_ENV_PREFIX: Final[str] = "SAMPLETONES_" 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_LIBRARY_DATA_VERSION: Final[str] = "2.1" +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/command.py b/src/sampletones_shared/command.py new file mode 100644 index 000000000..a9490fc22 --- /dev/null +++ b/src/sampletones_shared/command.py @@ -0,0 +1,23 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from typing import Callable + + +@dataclass(frozen=True) +class Command: + """One named operation the ``sampletones`` entry runs: its options and what it does. + + A command module declares one of these at import time and keeps its implementation behind + ``run``, so listing the commands loads nothing a command needs to do its work. + + Attributes: + name: The word that selects the command on the command line. + help: One line saying what the command does, shown in the command list. + configure: Adds the command's arguments to the parser it is given. + run: Performs the command over the parsed arguments and answers with the exit status. + """ + + name: str + help: str + configure: Callable[[ArgumentParser], None] + run: Callable[[Namespace], int] diff --git a/src/sampletones_shared/constants/general.py b/src/sampletones_shared/constants/general.py new file mode 100644 index 000000000..9b5fe5e36 --- /dev/null +++ b/src/sampletones_shared/constants/general.py @@ -0,0 +1,6 @@ +from typing import Final + +HEXADECIMAL_BASE: Final[int] = 16 +DECIMAL_BASE: Final[int] = 10 +BITS_PER_BYTE: Final[int] = 8 +BYTES_PER_MEGABYTE: Final[int] = 1 << 20 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/deployment/version.py b/src/sampletones_shared/deployment/version.py index cc420a03a..8cbf15ce9 100644 --- a/src/sampletones_shared/deployment/version.py +++ b/src/sampletones_shared/deployment/version.py @@ -55,26 +55,6 @@ def parse_string(cls, value: RawVersion) -> Dict[str, int]: } -def _split_version(version: RawVersion) -> Version: - """ - Splits a dotted version string into its integer components. - - Args: - version (RawVersion): A dotted version string such as ``1.4.0``, - or a tuple of integers such as ``(1, 4, 0)``. - - Returns: - Version: The version object. - - Raises: - SystemError: If input raw version object is not valid. - """ - try: - return Version.model_validate(version) - except ValueError as exception: - raise SystemError(f"Invalid version format: {exception}") from exception - - def compare_versions( version1: Union[Version, RawVersion], version2: Union[Version, RawVersion], @@ -92,13 +72,13 @@ def compare_versions( int: ``-1`` if version1 precedes version2, ``1`` if it follows, ``0`` if they are equal. Raises: - SystemError: If versions raw objects are not valid. + ValueError: If either version is malformed. """ if not isinstance(version1, Version): - version1 = _split_version(version1) + version1 = Version.model_validate(version1) if not isinstance(version2, Version): - version2 = _split_version(version2) + version2 = Version.model_validate(version2) if version1 == version2: return 0 diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 169cd46b2..08c783f8c 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -1,8 +1,22 @@ -from .audio import AudioWriteError, PlaybackError, UnsupportedAudioFormatError +from .audio import ( + AudioWriteError, + NoOutputDeviceError, + PlaybackError, + UnsupportedAudioFormatError, +) from .base import SampleToNESError 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 +29,13 @@ NoLibraryDataError, UnhandledLibraryError, ) +from .operation import OperationCanceled +from .player import ( + DriverBuildError, + PlayerError, + SongTooLargeError, + ToolchainMissingError, +) from .project import ( IncompatibleProjectVersionError, IncorrectReconstructionDataError, @@ -22,6 +43,7 @@ LoadProjectError, MissingProjectDataFileError, NotAValidArchiveError, + ProjectTuningError, UnhandledProjectError, ) from .reconstruction import ( @@ -38,6 +60,7 @@ DeserializationError, InvalidMetadataError, SerializationError, + TruncatedDataError, ) from .window import WindowError, WindowNotAvailableError @@ -46,13 +69,17 @@ "CallbackQueueStop", "CuPyNotInstalledWarning", "DeserializationError", + "DriverBuildError", "FileDialogUnavailableError", + "IncompatibleInstrumentVersionError", "IncompatibleLibraryDataVersionError", "IncompatibleProjectVersionError", "IncompatibleReconstructionVersionError", "IncompleteHistogramRebinningWarning", "IncorrectReconstructionDataError", "InstructionTypeMismatchError", + "InstrumentError", + "InvalidInstrumentValuesError", "InvalidLibraryDataError", "InvalidLibraryDataValuesError", "InvalidMetadataError", @@ -62,23 +89,34 @@ "LanguageError", "LibraryDisplayError", "LibraryError", + "LoadInstrumentError", "LoadLibraryError", "LoadProjectError", "LoadReconstructionError", + "MalformedInstrumentError", "MalformedTextKeyError", "MissingProjectDataFileError", "MissingTextError", "NoFilesToProcessError", "NoLibraryDataError", + "NoOutputDeviceError", "NotAValidArchiveError", + "NotAnInstrumentFileError", + "OperationCanceled", "PlaybackError", + "PlayerError", + "ProjectTuningError", "ReconstructionError", "SampleToNESError", "SerializationError", + "SongTooLargeError", + "ToolchainMissingError", + "TruncatedDataError", "UnhandledLibraryError", "UnhandledProjectError", "UnhandledReconstructionError", "UnsupportedAudioFormatError", + "UnsupportedInstrumentTypeError", "WindowError", "WindowNotAvailableError", ] diff --git a/src/sampletones_shared/exceptions/audio.py b/src/sampletones_shared/exceptions/audio.py index c7ef79698..a16aa7dca 100644 --- a/src/sampletones_shared/exceptions/audio.py +++ b/src/sampletones_shared/exceptions/audio.py @@ -13,5 +13,9 @@ class PlaybackError(AudioError): """Base class for exceptions raised during playback.""" +class NoOutputDeviceError(PlaybackError): + """Exception raised when playback is asked for while no output device is in force.""" + + class AudioWriteError(AudioError): """Exception raised when audio cannot be written to a file.""" 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/project.py b/src/sampletones_shared/exceptions/project.py index 34dd007b5..189a3c230 100644 --- a/src/sampletones_shared/exceptions/project.py +++ b/src/sampletones_shared/exceptions/project.py @@ -33,3 +33,7 @@ class MissingProjectDataFileError(LoadProjectError): class UnhandledProjectError(LoadProjectError): """Raised when an unhandled error is encountered while loading a project.""" + + +class ProjectTuningError(ProjectError, ValueError): + """Raised when a project's samples were converted at tunings that differ, where one tuning is needed.""" 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/source/index.py b/src/sampletones_shared/meta/source/index.py deleted file mode 100644 index b61822453..000000000 --- a/src/sampletones_shared/meta/source/index.py +++ /dev/null @@ -1,43 +0,0 @@ -import ast -from dataclasses import dataclass -from typing import Dict, Iterable, Mapping, Tuple - -from sampletones_shared.meta.source.bindings.containers import container_item_types -from sampletones_shared.meta.source.constants import module_constants -from sampletones_shared.meta.source.modules import SourceModule - - -@dataclass(frozen=True) -class SourceIndex: - """What a tree of modules declares for any one of them to read. - - A module reads names its own source never states — a container annotated elsewhere and walked - here, a constant declared elsewhere and used here — so reading one module rests on what the - others declare. - """ - - item_types: Mapping[str, Tuple[str, ...]] - constants: Mapping[str, ast.expr] - - -def source_index(modules: Iterable[SourceModule]) -> SourceIndex: - """Indexes every container annotation and module-level constant of a tree. - - Where two modules declare the same spelling, the module read last states it. - - Args: - modules: Modules to index. - - Returns: - SourceIndex: Item types and constant values, each keyed by the spelling naming it. - """ - item_types: Dict[str, Tuple[str, ...]] = {} - constants: Dict[str, ast.expr] = {} - for module in modules: - item_types.update(container_item_types(module.tree)) - constants.update({constant.name: constant.value for constant in module_constants(module.tree)}) - - return SourceIndex( - item_types=item_types, - constants=constants, - ) diff --git a/src/sampletones_shared/meta/source/modules.py b/src/sampletones_shared/meta/source/modules.py deleted file mode 100644 index d2b5efd7e..000000000 --- a/src/sampletones_shared/meta/source/modules.py +++ /dev/null @@ -1,123 +0,0 @@ -import ast -from dataclasses import dataclass -from pathlib import Path -from typing import Final, Iterable, List - -from sampletones_shared.meta.source.nodes import PositionedNode - -SOURCE_PATTERN: Final[str] = "*.py" -SOURCE_ENCODING: Final[str] = "utf-8-sig" -HIDDEN_PREFIX: Final[str] = "." -PACKAGE_INITIALIZER: Final[str] = "__init__" -MODULE_SEPARATOR: Final[str] = "." - - -@dataclass(frozen=True) -class SourceModule: - """One parsed Python file, together with the path a report names it by.""" - - path: Path - tree: ast.Module - - def location(self, node: PositionedNode) -> str: - """Spells where a node sits as `path:line`, the form an editor jumps to.""" - return f"{self.path}:{node.lineno}" - - -def parse_module(path: Path) -> SourceModule: - """Parses one Python file into a tree. - - The file is decoded as `utf-8-sig`, which reads both plain UTF-8 and a byte-order-marked file. - - Args: - path: File to read. - - Returns: - SourceModule: The file paired with its tree. - - Raises: - SyntaxError: If the file holds source Python rejects. - """ - source = path.read_text(encoding=SOURCE_ENCODING) - return SourceModule( - path=path, - tree=ast.parse( - source, - filename=str(path), - ), - ) - - -def module_name(path: Path, root: Path) -> str: - """The dotted name an import statement reaches a source file by. - - A check that finds a module by reading it can then reach the objects it declares, which is what - lets a static sweep and a runtime read describe the same module. - - Args: - path: Source file under the root. - root: Directory imports resolve from, such as the source root. - - Returns: - str: The dotted name, where a package's `__init__.py` names the package itself. - - Raises: - ValueError: If the file sits outside the root. - """ - relative = path.relative_to(root).with_suffix("") - parts = relative.parts[:-1] if relative.name == PACKAGE_INITIALIZER else relative.parts - return MODULE_SEPARATOR.join(parts) - - -def is_visible(path: Path) -> bool: - """States whether every component of a path is a visible name.""" - return all(not part.startswith(HIDDEN_PREFIX) for part in path.parts) - - -def source_paths(roots: Iterable[Path]) -> List[Path]: - """Every Python file under the given roots, in path order. - - The sweep visits visible paths, so a virtual environment or a tooling cache sitting inside a - root stays aside from a whole-repository run. A check built on a sweep that reads nothing - reports nothing, which reads as a clean tree, so each root must name a directory and the roots - together must hold source to read. - - Args: - roots: Directories to search. - - Returns: - List[Path]: The paths found, each listed once however many roots hold it. - - Raises: - NotADirectoryError: If a root names something other than a directory, such as the - `__init__.py` a package resource resolves to. - FileNotFoundError: If the roots together hold no Python file. - """ - directories = list(roots) - for root in directories: - if not root.is_dir(): - raise NotADirectoryError(f"The source root {root} names no directory to sweep") - - found = {path for root in directories for path in root.rglob(SOURCE_PATTERN) if is_visible(path)} - if not found: - listed = ", ".join(str(root) for root in directories) - raise FileNotFoundError(f"The source roots hold no {SOURCE_PATTERN} file to read: {listed}") - - return sorted(found) - - -def discover_modules(roots: Iterable[Path]) -> List[SourceModule]: - """Parses every Python file under the given roots, in path order. - - Args: - roots: Directories to search. - - Returns: - List[SourceModule]: One entry per file found. - - Raises: - NotADirectoryError: If a root names something other than a directory. - FileNotFoundError: If the roots together hold no Python file. - SyntaxError: If a file holds source Python rejects. - """ - return [parse_module(path) for path in source_paths(roots)] diff --git a/src/sampletones_shared/meta/source/packages.py b/src/sampletones_shared/meta/source/packages.py deleted file mode 100644 index f19a3899a..000000000 --- a/src/sampletones_shared/meta/source/packages.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path - -from sampletones_shared.paths.source import SOURCE_ROOT - - -def package_directory(name: str, *parts: str) -> Path: - """The directory a package occupies, named by path rather than by import. - - A source check reads the tree it checks, so taking a package from the source root keeps the - check free of importing the code under it and free of the layout of any one installation. - - Args: - name: Top-level package name. - parts: Subpackage names, innermost last. - - Returns: - Path: The directory the package occupies. - - Raises: - NotADirectoryError: If the source root holds no directory at that path. - """ - directory = SOURCE_ROOT.joinpath(name, *parts) - if not directory.is_dir(): - raise NotADirectoryError(f"The source root {SOURCE_ROOT} holds no package directory at {directory}") - - return directory 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/options.py b/src/sampletones_shared/options.py new file mode 100644 index 000000000..9de454668 --- /dev/null +++ b/src/sampletones_shared/options.py @@ -0,0 +1,10 @@ +from argparse import ArgumentParser +from pathlib import Path +from typing import Final + +CONFIG_HELP: Final[str] = "a configuration .json file; without it, the saved configuration or the built-in defaults" + + +def add_config_option(parser: ArgumentParser) -> None: + """Adds the ``--config`` option the commands reading a configuration share.""" + parser.add_argument("--config", "-c", type=Path, default=None, help=CONFIG_HELP) diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py index 857c1c911..91dc48988 100644 --- a/src/sampletones_shared/paths/extensions.py +++ b/src/sampletones_shared/paths/extensions.py @@ -1,3 +1,5 @@ +import os +from pathlib import Path from typing import Final, Tuple EXT_FILE_JSON: Final[str] = ".json" @@ -8,6 +10,9 @@ 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_LOG: Final[str] = ".log" +EXT_FILE_WINDOWS_PROGRAM: Final[str] = ".exe" EXT_FILE_WAVE: Final[str] = ".wav" EXT_FILE_MP3: Final[str] = ".mp3" EXT_FILE_FLAC: Final[str] = ".flac" @@ -22,3 +27,13 @@ EXT_FILE_AIFF, EXT_FILE_AU, ) + + +def is_audio_file(path: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO) -> bool: + """Whether a path names a recording a run converts. + + A recording is a file the reader may inspect, so an entry of a folder that lists its names and + keeps the rest to itself reads as none. ``os.path.isfile`` answers False for such an entry, + where ``Path.is_file`` raises ``PermissionError``, so a walk goes on past it. + """ + return path.suffix.lower() in extensions and os.path.isfile(path) diff --git a/src/sampletones_shared/paths/package.py b/src/sampletones_shared/paths/package.py new file mode 100644 index 000000000..6b967884f --- /dev/null +++ b/src/sampletones_shared/paths/package.py @@ -0,0 +1,27 @@ +from importlib.util import find_spec +from pathlib import Path + + +def package_directory(package: str) -> Path: + """The directory an import package's files lie in, its data files among them. + + The import system's own record of where the package lives places it, which holds in a + checkout, in an installed copy and in a bundle. PyInstaller unpacks a package's collected data + into a directory of the package's name, which a data package reaches as a namespace package, + so the location is read from the spec whatever kind of package answers. + + Args: + package: The package's dotted name. + + Returns: + Path: The directory. + + Raises: + FileNotFoundError: If no package of that name is importable. + ModuleNotFoundError: If a package the dotted name passes through is absent. + """ + spec = find_spec(package) + if spec is None or spec.submodule_search_locations is None: + raise FileNotFoundError(f"No package {package} is importable to place its directory by") + + return Path(next(iter(spec.submodule_search_locations))) diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py index 9da9292dd..2e415f2ff 100644 --- a/src/sampletones_shared/paths/resources.py +++ b/src/sampletones_shared/paths/resources.py @@ -1,15 +1,9 @@ -import sys -from importlib.resources import files from pathlib import Path -from typing import Final, Optional +from typing import Final -_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None) +from sampletones_shared.paths.package import package_directory -CONFIG_DIRECTORY: Final[Path] = ( - Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config"))) -) - -ASSETS_DIRECTORY: Final[str] = "assets" +CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_config") ICON_DIRECTORY: Final[str] = "icons" ICON_WIN_FILENAME: Final[str] = "sampletones.ico" diff --git a/src/sampletones_shared/types/callback.py b/src/sampletones_shared/types/callback.py index 61f75b918..668d11a6f 100644 --- a/src/sampletones_shared/types/callback.py +++ b/src/sampletones_shared/types/callback.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import Any, Callable, TypeVar +from typing import Any, Callable, Tuple, TypeVar Callback = Callable[..., Any] VoidCallback = Callable[[], None] PathCallback = Callable[[Path], None] +PathsCallback = Callable[[Tuple[Path, ...]], None] StringCallback = Callable[[str], None] MessageCallback = Callable[..., str] CallbackT = TypeVar("CallbackT", bound=Callback) diff --git a/src/sampletones_shared/utils/callbacks.py b/src/sampletones_shared/utils/callbacks.py index 71a8f6b19..445484e19 100644 --- a/src/sampletones_shared/utils/callbacks.py +++ b/src/sampletones_shared/utils/callbacks.py @@ -12,14 +12,15 @@ class CallbackMixin: them: optional hook attributes are the sole channel outward. ``call`` announces an event and ``query`` asks a hook for a value; both are deliberately lenient about missing hooks — partial wiring during - construction is expected and logged rather than raised. + construction is expected, so an unset hook is noted at debug and the + caller carries on. """ def call(self, callback: Optional[Callback], *args: Any, **kwargs: Any) -> Any: """ Safely invokes a callback with provided arguments. - Handles None callbacks gracefully by logging a warning and returning None. + Handles None callbacks gracefully by noting them at debug and returning None. Validates that the callback is callable before invocation. Args: @@ -34,7 +35,7 @@ def call(self, callback: Optional[Callback], *args: Any, **kwargs: Any) -> Any: TypeError: If callback is not None but is not callable. """ if callback is None: - logger.warning(f"No callback for {self.__class__.__name__} to call.") + logger.debug(f"No callback for {self.__class__.__name__} to call.") return None if not callable(callback): @@ -71,7 +72,7 @@ def query( TypeError: If callback is not None but is not callable. """ if callback is None: - logger.warning(f"No callback for {self.__class__.__name__} to query.") + logger.debug(f"No callback for {self.__class__.__name__} to query.") return default if not callable(callback): 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/hashing.py b/src/sampletones_shared/utils/hashing.py new file mode 100644 index 000000000..6a2f37134 --- /dev/null +++ b/src/sampletones_shared/utils/hashing.py @@ -0,0 +1,124 @@ +import hashlib +from collections.abc import Hashable +from typing import Final + +from pydantic import BaseModel + +from sampletones_shared.types.data import ModelHashable +from sampletones_shared.utils.serialization import dump + +HASH_LENGTH: Final[int] = 32 +HASH_PATTERN: Final[str] = rf"^[0-9a-f]{{{HASH_LENGTH}}}$" +IDENTITY_SEPARATOR: Final[str] = "\x00" + + +def get_hash_bytes(data: Hashable) -> bytes: + """ + Converts hashable data types to signed bytes. + + Args: + data (Hashable): The data to convert. Must be hashable. + + Returns: + bytes: Byte representation of the data. + + Raises: + TypeError: If the data is not hashable. + """ + if not isinstance(data, Hashable): + raise TypeError("Data must be hashable to convert to hash bytes") + + signed = hash(data) + unsigned = signed & ((1 << 64) - 1) + return unsigned.to_bytes(8, byteorder="big", signed=False) + + +def calculate_hash(data: ModelHashable, *, length: int = HASH_LENGTH) -> str: + """ + Calculates a SHA-256 hash for hashable data types. + + Supports BaseModel instances, primitive types (bool, int, float, bytes, str), + and other hashable objects. BaseModel instances are serialized to JSON before hashing. + + Args: + data (ModelHashable): The data to hash. Can be BaseModel, bool, int, float, + bytes, str, or any hashable object. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string truncated to the specified length. + + Raises: + ValueError: If the length lies outside 1 to 64. + """ + if length <= 0 or length > 64: + raise ValueError("Hash length must be between 1 and 64") + + raw: bytes + if isinstance(data, BaseModel): + raw = dump(data.model_dump()).encode("utf-8") + elif isinstance(data, (bool, int, float, bytes, str)): + if not data: + data = "" + + if isinstance(data, (bool, int, float)): + data = str(float(data)) + + if isinstance(data, str): + data = data.encode("utf-8") + + raw = data + else: + raw = get_hash_bytes(data) + + return hashlib.sha256(raw).hexdigest()[:length] + + +def hash_models(*models: BaseModel, length: int = HASH_LENGTH) -> str: + """ + Calculates a combined hash for multiple BaseModel instances. + + Models are serialized to JSON as a list and hashed together, ensuring + the hash depends on both the models' content and their order. + + Args: + *models (BaseModel): One or more BaseModel instances to hash. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string representing all models combined. + """ + combined = [model.model_dump() for model in models] + json_string = dump(combined) + return calculate_hash(json_string, length=length) + + +def hash_model(model: BaseModel, *, length: int = HASH_LENGTH) -> str: + """ + Calculates a hash for a single BaseModel instance. + + Args: + model (BaseModel): The BaseModel instance to hash. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string representing the model. + """ + return hash_models(model, length=length) + + +def identity_digest(*parts: str, length: int = HASH_LENGTH) -> str: + """ + Calculates a hash for an identity spelled out in parts. + + The parts are joined on a separator that names carry nowhere, so one identity reaches + one digest and two identities spelled in different parts reach different digests. + + Args: + *parts (str): The pieces the identity is spelled in, in the order they belong. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string representing the identity. + """ + return calculate_hash(IDENTITY_SEPARATOR.join(parts), length=length) 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..a4cf51803 100644 --- a/src/sampletones_shared/utils/serialization.py +++ b/src/sampletones_shared/utils/serialization.py @@ -1,23 +1,21 @@ import base64 -import hashlib import json -from collections.abc import Hashable from contextlib import suppress +from functools import partial from pathlib import Path -from typing import Any, Dict, Final, List, Mapping, Optional, Type, TypeVar, Union +from typing import Any, Callable, Dict, Final, List, Mapping, Optional, Type, TypeVar, Union import numpy as np import yaml from pydantic import BaseModel from sampletones_shared.types.array import Array -from sampletones_shared.types.data import ModelHashable, SerializedData +from sampletones_shared.types.data import SerializedData from sampletones_shared.types.path import Pathlike JSON_INDENT: Final[int] = 2 YAML_ROOT_STEM: Final[str] = "root" -HASH_LENGTH: Final[int] = 32 -HASH_PATTERN: Final[str] = rf"^[0-9a-f]{{{HASH_LENGTH}}}$" +TEMPORARY_SUFFIX: Final[str] = ".tmp" ModelTypeT = TypeVar("ModelTypeT", bound=BaseModel) @@ -82,25 +80,36 @@ def save_yaml(filepath: Pathlike, data: Union[List[Any], SerializedData]) -> Non def save_yaml_atomic(filepath: Pathlike, data: Union[List[Any], SerializedData]) -> None: """ - Saves data to a YAML file atomically via a temporary file. - - The data is written to a sibling ``.tmp`` file and then moved into place with a - single ``replace``, so the target file updates only once the whole write - succeeds. The temporary file is removed if the write fails. + Saves data to a YAML file atomically, through :func:`write_atomically`. Args: filepath (Pathlike): Path to the output YAML file. data (Union[List[Any], SerializedData]): The data to save. Must be YAML-serializable. """ + write_atomically(filepath, partial(save_yaml, data=data)) + + +def write_atomically(filepath: Pathlike, write: Callable[[Path], None]) -> None: + """ + Writes a file through a temporary sibling moved into place once the whole write succeeded. + + ``write`` fills the sibling, named after the target with ``.tmp`` appended, and a single + ``replace`` puts it in the target's place, so a reader of the target meets the previous + file or the complete new one, whatever becomes of the writer in between. The sibling is + removed whichever way the write ends. + + Args: + filepath (Pathlike): Path to the file to write. + write (Callable[[Path], None]): Writes the whole content to the path it is given. + """ path = Path(filepath) - tmp = path.with_suffix(".tmp") + temporary = path.with_name(f"{path.name}{TEMPORARY_SUFFIX}") try: - save_yaml(tmp, data) - tmp.replace(path) - except Exception: + write(temporary) + temporary.replace(path) + finally: with suppress(FileNotFoundError): - tmp.unlink() - raise + temporary.unlink() def load_yaml(filepath: Pathlike) -> Union[List[Any], SerializedData]: @@ -132,7 +141,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 +175,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. @@ -189,12 +198,16 @@ def load_yaml_model_dir( def save_binary(filepath: Pathlike, data: bytes) -> None: """ - Saves binary data to a file. + Saves binary data to a file atomically, through :func:`write_atomically`. Args: filepath (Pathlike): Path to the output binary file. data (bytes): The binary data to save. """ + write_atomically(filepath, partial(_write_bytes, data=data)) + + +def _write_bytes(filepath: Path, data: bytes) -> None: with open(filepath, "wb") as file: file.write(data) @@ -254,98 +267,6 @@ def deserialize_array(data: SerializedData) -> np.ndarray: return array.reshape(data["shape"]) -def get_hash_bytes(data: Hashable) -> bytes: - """ - Converts hashable data types to signed bytes. - - Args: - data (Hashable): The data to convert. Must be hashable. - - Returns: - bytes: Byte representation of the data. - - Raises: - TypeError: If the data is not hashable. - """ - if not isinstance(data, Hashable): - raise TypeError("Data must be hashable to convert to hash bytes") - - signed = hash(data) - unsigned = signed & ((1 << 64) - 1) - return unsigned.to_bytes(8, byteorder="big", signed=False) - - -def calculate_hash(data: ModelHashable, *, length: int = HASH_LENGTH) -> str: - """ - Calculates a SHA-256 hash for hashable data types. - - Supports BaseModel instances, primitive types (bool, int, float, bytes, str), - and other hashable objects. BaseModel instances are serialized to JSON before hashing. - - Args: - data (ModelHashable): The data to hash. Can be BaseModel, bool, int, float, - bytes, str, or any hashable object. - length (int): The length of the hash string to return. Defaults to 32. - - Returns: - str: Hexadecimal hash string truncated to the specified length. - """ - if length <= 0 or length > 64: - raise ValueError("Hash length must be between 1 and 64") - - raw: bytes - if isinstance(data, BaseModel): - raw = dump(data.model_dump()).encode("utf-8") - elif isinstance(data, (bool, int, float, bytes, str)): - if not data: - data = "" - - if isinstance(data, (bool, int, float)): - data = str(float(data)) - - if isinstance(data, str): - data = data.encode("utf-8") - - raw = data - else: - raw = get_hash_bytes(data) - - return hashlib.sha256(raw).hexdigest()[:length] - - -def hash_models(*models: BaseModel, length: int = HASH_LENGTH) -> str: - """ - Calculates a combined hash for multiple BaseModel instances. - - Models are serialized to JSON as a list and hashed together, ensuring - the hash depends on both the models' content and their order. - - Args: - *models (BaseModel): One or more BaseModel instances to hash. - length (int): The length of the hash string to return. Defaults to 32. - - Returns: - str: Hexadecimal hash string representing all models combined. - """ - combined = [model.model_dump() for model in models] - json_string = dump(combined) - return calculate_hash(json_string, length=length) - - -def hash_model(model: BaseModel, *, length: int = HASH_LENGTH) -> str: - """ - Calculates a hash for a single BaseModel instance. - - Args: - model (BaseModel): The BaseModel instance to hash. - length (int): The length of the hash string to return. Defaults to 32. - - Returns: - str: Hexadecimal hash string representing the model. - """ - return hash_models(model, length=length) - - def snake_to_camel(snake_str: str) -> str: """ Converts a snake_case string to CamelCase. 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..dc4c3b3e1 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 @@ -49,6 +49,23 @@ def to_path(path: GeneralPathlike) -> Path: return Path(path) +def is_same_path(first: Path, second: Path) -> bool: + """ + Whether two paths name the same location, however each of them is spelled. + + A file is reached through a relative path, a detour through a parent, or a link, and each + spelling resolves to the one absolute location compared here. The file itself may be absent. + + Args: + first (Path): One spelling of a location. + second (Path): Another spelling to compare it with. + + Returns: + bool: Whether both resolve to the same location. + """ + return first.resolve() == second.resolve() + + def get_filename(name: str, extension: str) -> str: """ Composes a file name from the name a thing is known by and its extension. @@ -198,6 +215,73 @@ def get_directory(path: Pathlike) -> Path: return path if path.is_dir() else path.parent +def nearest_directory(path: Path) -> Optional[Path]: + """ + Returns the directory standing closest to a path: the path itself, or the nearest one above it. + + A location remembered from an earlier session may have gone from the disk since, and the + closest folder still standing on its way is the one that keeps the reader nearest to it. + + Args: + path: The location to begin from. + + Returns: + Optional[Path]: The nearest standing directory, or ``None`` when no directory on the path + stands, as for a drive that is gone. + """ + for directory in (path, *path.parents): + if directory.is_dir(): + return directory + + return None + + +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_shared/utils/tables.py b/src/sampletones_shared/utils/tables.py new file mode 100644 index 000000000..76370119c --- /dev/null +++ b/src/sampletones_shared/utils/tables.py @@ -0,0 +1,59 @@ +import csv +from pathlib import Path +from typing import Final, List, Self, Sequence, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +MARKDOWN_RULE: Final[str] = "---" +MARKDOWN_SEPARATOR: Final[str] = " | " + + +class Table(BaseModel): + """A report's table: a header and rows of cells already written as text. + + A report builds a table once and writes it both ways, as CSV another tool reads and as the + lines of a Markdown document a person reads, so the two copies carry the same cells. + + Attributes: + columns: The header row. + rows: The rows, each with one cell per column. + """ + + model_config = ConfigDict(frozen=True) + + columns: Tuple[str, ...] + rows: Tuple[Tuple[str, ...], ...] + + @model_validator(mode="after") + def _rows_fill_the_columns(self) -> Self: + """Raises: + ValueError: If a row holds a different number of cells than there are columns. + """ + for index, row in enumerate(self.rows): + if len(row) != len(self.columns): + raise ValueError(f"Row {index} holds {len(row)} cells for {len(self.columns)} columns.") + + return self + + def write_csv(self, path: Path) -> None: + """Writes the table as UTF-8 CSV, the header first. + + Args: + path: Where the table is written. + """ + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(self.columns) + writer.writerows(self.rows) + + def markdown_lines(self) -> List[str]: + """The table as the lines of a Markdown document: the header, the rule and one line per row.""" + return [ + self._markdown_line(self.columns), + "|" + "|".join(MARKDOWN_RULE for _ in self.columns) + "|", + *(self._markdown_line(row) for row in self.rows), + ] + + @staticmethod + def _markdown_line(cells: Sequence[str]) -> str: + return f"| {MARKDOWN_SEPARATOR.join(cells)} |" diff --git a/src/sampletones_shared/utils/text.py b/src/sampletones_shared/utils/text.py index 1abc9f9e8..d1954b5bc 100644 --- a/src/sampletones_shared/utils/text.py +++ b/src/sampletones_shared/utils/text.py @@ -1,9 +1,10 @@ import re -from typing import Final, Tuple, TypeAlias +from typing import Final, List, Tuple, TypeAlias NaturalSortKey: TypeAlias = Tuple[Tuple[int, str], ...] _DIGIT_RUN_PATTERN: Final[re.Pattern[str]] = re.compile(r"(\d+)") +LIST_SEPARATOR: Final[str] = "," def natural_sort_key(text: str) -> NaturalSortKey: @@ -30,3 +31,23 @@ def natural_sort_key(text: str) -> NaturalSortKey: (int(part), "") if part.isdecimal() else (0, part.casefold()) for part in _DIGIT_RUN_PATTERN.split(text) ) return tokens + ((0, text),) + + +def listed_items(stated: str) -> List[str]: + """ + Reads a comma-separated list the way a command line states one. + + Each item keeps its own words with the spaces around it stripped, and an empty item, as a + trailing comma leaves, is dropped. + + Args: + stated: The list as written. + + Returns: + The items, in the order written. + + Examples: + >>> listed_items("pulse1, triangle,,noise,") + ['pulse1', 'triangle', 'noise'] + """ + return [item.strip() for item in stated.split(LIST_SEPARATOR) if item.strip()] diff --git a/src/sampletones_shared/utils/time.py b/src/sampletones_shared/utils/time.py new file mode 100644 index 000000000..e5997e7eb --- /dev/null +++ b/src/sampletones_shared/utils/time.py @@ -0,0 +1,84 @@ +from typing import Final + +SECONDS_PER_MINUTE: Final[int] = 60 +MINUTES_PER_HOUR: Final[int] = 60 + + +def seconds_from_samples(samples: int, sample_rate: int) -> float: + """How long a run of samples lasts. + + Args: + samples: The samples counted. + sample_rate: The samples one second holds. + + Returns: + float: The seconds those samples span, zero where the rate names none. + """ + return samples / sample_rate if sample_rate > 0 else 0.0 + + +def seconds_from_ticks(ticks: int, tick_rate: int) -> float: + """How long a run of ticks lasts. + + Args: + ticks: The ticks counted. + tick_rate: The ticks one second holds. + + Returns: + float: The seconds those ticks span, zero where the rate names none. + """ + return ticks / tick_rate if tick_rate > 0 else 0.0 + + +def format_span(seconds: float) -> str: + """How long something lasts, stated in the largest units it fills. + + A span is read as a quantity — what a render costs, what a song runs to, what an estimate + has left — so it names its units and rounds down to whole seconds. + + Args: + seconds: The span measured. + + Returns: + str: The span as ``1h 02m 13s``, ``2m 13s`` or ``13s``. + """ + whole_seconds = max(0, int(seconds)) + minutes, seconds_remaining = divmod(whole_seconds, SECONDS_PER_MINUTE) + hours, minutes = divmod(minutes, MINUTES_PER_HOUR) + + 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 format_clock(seconds: float, decimals: int) -> str: + """How far into something a moment stands, stated as a clock reading. + + A position is read against its neighbors — the marks along an axis, a playhead beside them — + so minutes and seconds carry fixed widths and the hour appears once the run reaches one. + ``decimals`` states how finely the reading divides a second, which lets a close view name a + moment the whole seconds beside it would repeat. + + Args: + seconds: How far in the moment stands. + decimals: The digits the fraction of a second is stated to. + + Returns: + str: The moment as ``2:13``, ``1:02:13`` or ``2:13.25``. + """ + places = max(0, decimals) + position = round(max(0.0, seconds), places) + whole_seconds = int(position) + minutes, seconds_remaining = divmod(whole_seconds, SECONDS_PER_MINUTE) + hours, minutes = divmod(minutes, MINUTES_PER_HOUR) + + width = 2 if places == 0 else places + 3 + reading = f"{seconds_remaining + position - whole_seconds:0{width}.{places}f}" + + if hours: + return f"{hours}:{minutes:02d}:{reading}" + + return f"{minutes}:{reading}" diff --git a/src/sampletones_shared/utils/validation.py b/src/sampletones_shared/utils/validation.py index f4d15912f..e17a82370 100644 --- a/src/sampletones_shared/utils/validation.py +++ b/src/sampletones_shared/utils/validation.py @@ -3,12 +3,14 @@ import copy from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Generic, List, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Final, Generic, List, Optional, Tuple, Type, TypeVar, Union from pydantic import BaseModel, ValidationError +from pydantic_core import ErrorDetails ModelTypeT = TypeVar("ModelTypeT", bound=BaseModel) Location = Tuple[Union[str, int], ...] +VALUE_ERROR: Final[str] = "value_error" @dataclass(frozen=True) @@ -33,7 +35,7 @@ def flatten_location(location: Location) -> str: Renders a Pydantic error location as a dotted path for display. Mapping keys join with a dot and list indices append in brackets, so - ``("generation", "drive")`` becomes ``generation.drive`` and + ``("generation", "reset_phase")`` becomes ``generation.reset_phase`` and ``("generators", 2)`` becomes ``generators[2]``. """ parts: List[str] = [] @@ -48,6 +50,35 @@ def flatten_location(location: Location) -> str: return "".join(parts) +def describe_failure(error: ValueError) -> str: + """ + Renders a refused value the way a command line reports it, one line per problem. + + A reason a validator raised keeps its own words, and a broken constraint is named by the + field it binds, so a person reads what to change and where. + + Args: + error: The failure, a Pydantic validation error or a plain ``ValueError``. + + Returns: + str: The reasons, one per line. + """ + match error: + case ValidationError(): + return "\n".join(_describe_detail(detail) for detail in error.errors()) + case _: + return str(error) + + +def _describe_detail(detail: ErrorDetails) -> str: + context = detail.get("ctx") + if detail["type"] == VALUE_ERROR and context is not None: + return str(context["error"]) + + location = flatten_location(tuple(detail["loc"])) + return f"{location}: {detail['msg']}" if location else detail["msg"] + + def validate_with_recovery( model_class: Type[ModelTypeT], data: Mapping[str, Any], @@ -122,7 +153,7 @@ def _existing_prefix(container: Any, location: Location) -> Location: node: Any = container length = 0 for index, key in enumerate(location): - if isinstance(node, Mapping) and key in node: + if isinstance(node, Mapping) and key in node: # noqa: SIM114 node = node[key] elif isinstance(node, list) and isinstance(key, int) and -len(node) <= key < len(node): node = node[key] diff --git a/src/sampletones_synthesis/envelopes/__init__.py b/src/sampletones_synthesis/envelopes/__init__.py deleted file mode 100644 index 8093a34dd..000000000 --- a/src/sampletones_synthesis/envelopes/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .exponential_decay import ExponentialDecayEnvelope -from .linear_attack import LinearAttackEnvelope -from .linear_ramp import LinearRampEnvelope -from .types import EnvelopeUnion - -__all__ = [ - "EnvelopeUnion", - "ExponentialDecayEnvelope", - "LinearAttackEnvelope", - "LinearRampEnvelope", -] diff --git a/src/sampletones_synthesis/envelopes/types.py b/src/sampletones_synthesis/envelopes/types.py deleted file mode 100644 index 7b7a009a4..000000000 --- a/src/sampletones_synthesis/envelopes/types.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Annotated, Union - -from pydantic import Field - -from .exponential_decay import ExponentialDecayEnvelope -from .linear_attack import LinearAttackEnvelope -from .linear_ramp import LinearRampEnvelope - -EnvelopeUnion = Annotated[ - Union[ - ExponentialDecayEnvelope, - LinearAttackEnvelope, - LinearRampEnvelope, - ], - Field(discriminator="kind"), -] diff --git a/src/sampletones_synthesis/oscillators/pulse.py b/src/sampletones_synthesis/oscillators/pulse.py deleted file mode 100644 index 8bb5e90d4..000000000 --- a/src/sampletones_synthesis/oscillators/pulse.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Annotated, Literal - -import numpy as np -from pydantic import BaseModel, ConfigDict, Field - -from sampletones_synthesis.frequency import FrequencySpec, resolve_frequency - -DutyCycle = Annotated[float, Field(gt=0.0, lt=1.0)] - - -class PulseOscillator(BaseModel): - """Rectangular wave alternating between +1 and -1 at the resolved frequency.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - kind: Literal["pulse"] - frequency: FrequencySpec - duty_cycle: DutyCycle = Field(description="Fraction of each period spent at +1.") - - def render( - self, - time: np.ndarray, - *, - generator: np.random.Generator, # pylint: disable=unused-argument - ) -> np.ndarray: - """ - Render the pulse over the time axis. - - Produces identical output for every generator state. - - Args: - time: Sample times in seconds, float64. - generator: Random source shared across a render pass. - - Returns: - The waveform as float64 taking values in {-1, 1}. - """ - phase = (resolve_frequency(self.frequency) * time) % 1.0 - return np.where(phase < self.duty_cycle, 1.0, -1.0) diff --git a/src/sampletones_synthesis/voice/voice.py b/src/sampletones_synthesis/voice/voice.py deleted file mode 100644 index 968502c5a..000000000 --- a/src/sampletones_synthesis/voice/voice.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import Final, Tuple - -import numpy as np -from pydantic import BaseModel, ConfigDict, Field - -from sampletones_synthesis.filters.types import FilterUnion - -from .layer import Layer - -MINIMUM_RENDER_SAMPLES: Final[int] = 2 - - -class Voice(BaseModel): - """ - A complete synthesized sound: layered oscillators summed and filtered. - - Layers render independently over one shared time axis and sum in float64; - filters then shape the sum in order. The output is raw unit-scale audio — - loudness policy (peak normalization or amplitude scaling) belongs to the - caller, so one voice definition serves every loudness convention. - """ - - model_config = ConfigDict(frozen=True, extra="forbid") - - duration_seconds: float = Field(gt=0.0, description="Length of the rendered sound in seconds.") - layers: Tuple[Layer, ...] = Field(min_length=1, description="Components summed into the voice.") - filters: Tuple[FilterUnion, ...] = Field(description="Spectral shaping applied to the layer sum, in order.") - - def render( - self, - *, - sample_rate: int, - generator: np.random.Generator, - ) -> np.ndarray: - """ - Render the voice at a sample rate. - - Layers draw from the generator in declaration order, so a fixed seed - reproduces the voice exactly. - - Args: - sample_rate: Sampling rate in Hz. - generator: Random source consumed by stochastic oscillators. - - Returns: - The rendered waveform as float64. - - Raises: - ValueError: If the duration yields fewer than two samples. - """ - time = self._timeline(sample_rate) - audio = np.zeros(time.shape[0], dtype=np.float64) - for layer in self.layers: - audio = audio + layer.render(time, generator=generator) - - for audio_filter in self.filters: - audio = audio_filter.apply(audio, sample_rate=sample_rate) - - return audio - - def _timeline(self, sample_rate: int) -> np.ndarray: - """ - Sample times in seconds for the configured duration. - - Raises: - ValueError: If the duration yields fewer than two samples, the - minimum for span-dependent components (ramps, sweeps, walks). - """ - length = round(self.duration_seconds * sample_rate) - if length < MINIMUM_RENDER_SAMPLES: - raise ValueError( - f"Voice of {self.duration_seconds}s at {sample_rate}Hz yields {length} samples; " - f"at least {MINIMUM_RENDER_SAMPLES} are required" - ) - - return np.arange(length, dtype=np.float64) / sample_rate diff --git a/src/sampletones_tools/__init__.py b/src/sampletones_tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/calibration/__init__.py b/src/sampletones_tools/calibration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/calibration/board/__init__.py b/src/sampletones_tools/calibration/board/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/calibration/board/assets.py b/src/sampletones_tools/calibration/board/assets.py new file mode 100644 index 000000000..dca558a73 --- /dev/null +++ b/src/sampletones_tools/calibration/board/assets.py @@ -0,0 +1,38 @@ +from pathlib import Path +from shutil import copyfile +from typing import Dict + +from .layout import PAGE_DIRECTORY, PAGE_FILE, SHIPPED_FILES +from .paths import STATIC_DIRECTORY + + +def write_assets(output: Path, generated: Dict[str, str]) -> Path: + """ + Put the page's files in place: the shipped ones copied, the generated ones written. + + The page itself lands in the output directory, where a person opens it, and everything it reads + sits one directory down, so a run directory gains one file a reader can name. + + Args: + output: The directory the page is written into. + generated: The generated files, keyed by the name the page reads them under. + + Returns: + Path: The page, the file a reader opens. + + Raises: + FileNotFoundError: If the package is missing a shipped file. + """ + directory = output / PAGE_DIRECTORY + directory.mkdir(parents=True, exist_ok=True) + for name in SHIPPED_FILES: + source = STATIC_DIRECTORY / name + if not source.is_file(): + raise FileNotFoundError(f"The package ships no page file '{name}' at '{source}'") + + copyfile(source, output / name if name == PAGE_FILE else directory / name) + + for name, content in generated.items(): + (directory / name).write_text(content, encoding="utf-8") + + return output / PAGE_FILE diff --git a/src/sampletones_tools/calibration/board/browser.py b/src/sampletones_tools/calibration/board/browser.py new file mode 100644 index 000000000..644b60b25 --- /dev/null +++ b/src/sampletones_tools/calibration/board/browser.py @@ -0,0 +1,24 @@ +import webbrowser +from pathlib import Path + +from sampletones_shared.logger import logger + + +def open_page(page: Path) -> bool: + """ + Show the page in the reader's own browser. + + A run ends with what it wrote in front of the person who started it, on whichever system they + are on, and a system with no browser to reach leaves them the link the run printed. + + Args: + page: The page to open. + + Returns: + bool: Whether a browser took the page. + """ + opened = webbrowser.open(page.resolve().as_uri()) + if not opened: + logger.info("No browser answered; open the page from the link above") + + return opened diff --git a/src/sampletones_tools/calibration/board/clips.py b/src/sampletones_tools/calibration/board/clips.py new file mode 100644 index 000000000..337387114 --- /dev/null +++ b/src/sampletones_tools/calibration/board/clips.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from shutil import copyfile +from typing import Protocol, Sequence + +from .layout import AUDIO_DIRECTORY, PAGE_DIRECTORY +from .reading import RunReading + + +class ClipStore(Protocol): + """Where the page finds a clip, told as a reference relative to the page itself.""" + + def href(self, run: RunReading, source: Path) -> str: ... + + +@dataclass(frozen=True) +class InPlaceClips: + """Clips the page reaches where the run already wrote them. + + A page written into its own run stands beside the renders and the recordings it plays, so it + names them as they lie and the run grows by the page alone. + """ + + def href(self, run: RunReading, source: Path) -> str: + """The run's own clip, named relative to the run directory the page sits in.""" + return PurePosixPath(source.relative_to(run.directory)).as_posix() + + +@dataclass(frozen=True) +class GatheredClips: + """Clips the page carries with it, copied under its own directory. + + A page comparing runs lies apart from all of them, so each clip it plays is brought along and + the page moves as one folder. + + Attributes: + output: The directory the page is written into. + """ + + output: Path + + def href(self, run: RunReading, source: Path) -> str: + """The clip's copy under the page's audio directory, named relative to the page.""" + relative = PurePosixPath(PAGE_DIRECTORY, AUDIO_DIRECTORY, run.label, *source.relative_to(run.directory).parts) + destination = self.output / relative + destination.parent.mkdir(parents=True, exist_ok=True) + copyfile(source, destination) + return relative.as_posix() + + +def clip_store(runs: Sequence[RunReading], output: Path) -> ClipStore: + """ + The store a page built over these runs into this directory plays from. + + A page written into the one run it reports plays that run's files where they lie; a page + written anywhere else carries copies, so either page opens from the folder it was written into. + + Args: + runs: The runs the page reports. + output: The directory the page is written into. + + Returns: + The store the page's references come from. + """ + if len(runs) == 1 and runs[0].directory.resolve() == output.resolve(): + return InPlaceClips() + + return GatheredClips(output=output) diff --git a/src/sampletones_tools/calibration/board/composition.py b/src/sampletones_tools/calibration/board/composition.py new file mode 100644 index 000000000..298a3ba9b --- /dev/null +++ b/src/sampletones_tools/calibration/board/composition.py @@ -0,0 +1,174 @@ +from pathlib import Path +from typing import Dict, Final, List, Optional, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName, ordered_channels +from sampletones_shared.paths.extensions import EXT_FILE_FLAC +from sampletones_shared.utils.system.paths import get_filename +from sampletones_tools.calibration.layout import RECORDINGS_DIRECTORY, combination_name +from sampletones_tools.calibration.renders import RenderRecord, item_directory, variant_directory + +from .clips import ClipStore +from .model import BoardCell, BoardChannel, BoardGroup, BoardPage, BoardRow +from .palette import channel_token +from .reading import RunReading + +NO_CLIP: Final[str] = "" + + +def compose_page( + runs: Sequence[RunReading], + store: ClipStore, + title: str, +) -> BoardPage: + """ + Assemble everything a page draws from the runs it reports. + + One run is read across its variants, which become the columns of a single table. Several runs + are read against each other, so each variant becomes a table of its own and the runs stand as + its columns — the comparison a person makes when they run the same settings on two versions. + + Args: + runs: The runs the page reports, in the order the page holds them. + store: Where the page finds each clip it plays. + title: The heading the page carries. + + Returns: + The page, ready to be written. + + Raises: + ValueError: If no run is given. + """ + if not runs: + raise ValueError("A page reports at least one run") + + groups = _variant_groups(runs, store) if len(runs) > 1 else _column_group(runs[0], store) + return BoardPage( + title=title, + referee=runs[0].referee, + colors={channel.value: channel_token(channel.value) for channel in ChannelName}, + groups=groups, + ) + + +def _column_group(run: RunReading, store: ClipStore) -> Tuple[BoardGroup, ...]: + """One table whose columns are the configurations the run measured.""" + variants = run.variants + items = [(item, run) for item in _items(run)] + rows = _rows(store, items, [(run, variant) for variant in variants]) + return (BoardGroup(name=run.label, columns=variants, rows=rows),) + + +def _variant_groups(runs: Sequence[RunReading], store: ClipStore) -> Tuple[BoardGroup, ...]: + """One table per configuration, whose columns are the runs that measured it.""" + groups: List[BoardGroup] = [] + for variant in _shared_variants(runs): + holders = [run for run in runs if variant in run.variants] + rows = _rows(store, _shared_items(holders), [(run, variant) for run in holders]) + groups.append(BoardGroup(name=variant, columns=tuple(run.label for run in holders), rows=rows)) + + return tuple(groups) + + +def _rows( + store: ClipStore, + items: Sequence[Tuple[str, RunReading]], + places: Sequence[Tuple[RunReading, str]], +) -> Tuple[BoardRow, ...]: + """A row per item every column holds a render of, so each row reads across the whole table.""" + rows = [_row(store, item, recording_run, places) for item, recording_run in items] + return tuple(row for row in rows if row is not None) + + +def _row( + store: ClipStore, + item: str, + recording_run: RunReading, + places: Sequence[Tuple[RunReading, str]], +) -> Optional[BoardRow]: + records = [(run, _record(run, item, variant)) for run, variant in places] + held = [(run, record) for run, record in records if record is not None] + if len(held) != len(records): + return None + + return BoardRow( + item=item, + category=held[0][1].category, + recording=store.href(recording_run, _recording_path(recording_run, item)), + cells=tuple(_cell(run, store, record) for run, record in held), + ) + + +def _cell(run: RunReading, store: ClipStore, record: RenderRecord) -> BoardCell: + channels = _sounding_channels(record) + return BoardCell( + render=store.href(run, _render_path(run, record)), + score=run.score(record), + silence=run.silence(record), + channels=tuple(_channel(run, store, record, channel, channels) for channel in channels), + ) + + +def _channel( + run: RunReading, + store: ClipStore, + record: RenderRecord, + channel: ChannelName, + channels: Sequence[ChannelName], +) -> BoardChannel: + others = [other for other in channels if other != channel] + return BoardChannel( + name=channel.value, + timeline=record.timelines[channel.value], + solo=_part(run, store, record, [channel]) if others else NO_CLIP, + mute=_part(run, store, record, others), + ) + + +def _part( + run: RunReading, + store: ClipStore, + record: RenderRecord, + channels: Sequence[ChannelName], +) -> str: + if not channels: + return NO_CLIP + + directory = item_directory(run.directory, record.variant, record.item) + return store.href(run, directory / get_filename(combination_name(channels), EXT_FILE_FLAC)) + + +def _render_path(run: RunReading, record: RenderRecord) -> Path: + return variant_directory(run.directory, record.variant) / get_filename(record.item, EXT_FILE_FLAC) + + +def _recording_path(run: RunReading, item: str) -> Path: + return run.directory / RECORDINGS_DIRECTORY / get_filename(item, EXT_FILE_FLAC) + + +def _sounding_channels(record: RenderRecord) -> List[ChannelName]: + return ordered_channels({ChannelName(name) for name in record.timelines}) + + +def _record(run: RunReading, item: str, variant: str) -> Optional[RenderRecord]: + for record in run.records: + if record.item == item and record.variant == variant: + return record + + return None + + +def _items(run: RunReading) -> Tuple[str, ...]: + return tuple(dict.fromkeys(record.item for record in run.records)) + + +def _shared_variants(runs: Sequence[RunReading]) -> Tuple[str, ...]: + return tuple(dict.fromkeys(variant for run in runs for variant in run.variants)) + + +def _shared_items(runs: Sequence[RunReading]) -> List[Tuple[str, RunReading]]: + seen: Dict[str, RunReading] = {} + for run in runs: + for item in _items(run): + seen.setdefault(item, run) + + return list(seen.items()) diff --git a/src/sampletones_tools/calibration/board/data.py b/src/sampletones_tools/calibration/board/data.py new file mode 100644 index 000000000..6ded127f9 --- /dev/null +++ b/src/sampletones_tools/calibration/board/data.py @@ -0,0 +1,20 @@ +from typing import Final + +from .model import BoardPage + +DATA_GLOBAL: Final[str] = "BOARD" + + +def data_script(page: BoardPage) -> str: + """The page's own contents, as the one global its script reads. + + Everything the page draws travels as data — the sounds, the scores, the clips and the channel + colors — so the script it is read by holds layout alone and stays the same from run to run. + + Args: + page: Everything the page draws. + + Returns: + The script assigning the page's contents to its global. + """ + return f"const {DATA_GLOBAL} = {page.model_dump_json(indent=1)};\n" diff --git a/src/sampletones_tools/calibration/board/fonts.py b/src/sampletones_tools/calibration/board/fonts.py new file mode 100644 index 000000000..c6cb82861 --- /dev/null +++ b/src/sampletones_tools/calibration/board/fonts.py @@ -0,0 +1,73 @@ +from base64 import b64encode +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +from sampletones_shared.paths.resources import ( + FONT_MONO_REGULAR, + FONT_SANS_BOLD, + FONT_SANS_REGULAR, +) + +from .paths import FONTS_DIRECTORY + +SANS_FAMILY: Final[str] = "SampleToNES Sans" +MONO_FAMILY: Final[str] = "SampleToNES Mono" + +REGULAR_WEIGHT: Final[int] = 400 +BOLD_WEIGHT: Final[int] = 600 + +FONT_MEDIA_TYPE: Final[str] = "font/ttf" + + +@dataclass(frozen=True) +class PageFont: + """One face the page draws with, and the file it is read from. + + Attributes: + family: The family name the stylesheet asks for. + weight: The weight the face answers at. + filename: The face's file in the assets package. + """ + + family: str + weight: int + filename: str + + +PAGE_FONTS: Final[Tuple[PageFont, ...]] = ( + PageFont(family=SANS_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_SANS_REGULAR), + PageFont(family=SANS_FAMILY, weight=BOLD_WEIGHT, filename=FONT_SANS_BOLD), + PageFont(family=MONO_FAMILY, weight=REGULAR_WEIGHT, filename=FONT_MONO_REGULAR), +) + + +def fonts_stylesheet(directory: Path = FONTS_DIRECTORY) -> str: + """The faces of the application, embedded so the page draws with them wherever it is opened. + + A page opened from a file has an origin of its own, and a face carried inside the stylesheet + reaches it there as surely as it does over the web. + + Args: + directory: The directory the face files are read from. + + Returns: + The stylesheet, one ``@font-face`` rule per face. + + Raises: + FileNotFoundError: If the directory holds no file of a face's name. + """ + return "\n".join(_face(font, directory) for font in PAGE_FONTS) + "\n" + + +def _face(font: PageFont, directory: Path) -> str: + encoded = b64encode((directory / font.filename).read_bytes()).decode("ascii") + return ( + "@font-face {\n" + f' font-family: "{font.family}";\n' + f" font-weight: {font.weight};\n" + " font-style: normal;\n" + " font-display: block;\n" + f' src: url("data:{FONT_MEDIA_TYPE};base64,{encoded}") format("truetype");\n' + "}" + ) diff --git a/src/sampletones_tools/calibration/board/layout.py b/src/sampletones_tools/calibration/board/layout.py new file mode 100644 index 000000000..e0bb3d52f --- /dev/null +++ b/src/sampletones_tools/calibration/board/layout.py @@ -0,0 +1,14 @@ +from typing import Final, Tuple + +PAGE_FILE: Final[str] = "index.html" +STYLE_FILE: Final[str] = "style.css" +SCRIPT_FILE: Final[str] = "page.js" +PALETTE_FILE: Final[str] = "palette.css" +FONTS_FILE: Final[str] = "fonts.css" +DATA_FILE: Final[str] = "data.js" + +PAGE_DIRECTORY: Final[str] = "page" +AUDIO_DIRECTORY: Final[str] = "audio" + +SHIPPED_FILES: Final[Tuple[str, ...]] = (PAGE_FILE, STYLE_FILE, SCRIPT_FILE) +GENERATED_FILES: Final[Tuple[str, ...]] = (PALETTE_FILE, FONTS_FILE, DATA_FILE) diff --git a/src/sampletones_tools/calibration/board/model.py b/src/sampletones_tools/calibration/board/model.py new file mode 100644 index 000000000..b8387e5b1 --- /dev/null +++ b/src/sampletones_tools/calibration/board/model.py @@ -0,0 +1,91 @@ +from typing import Dict, Tuple + +from pydantic import BaseModel, ConfigDict + + +class BoardChannel(BaseModel): + """One channel of a render, as the page draws and plays it. + + Attributes: + name: The channel's own name, which its palette token is derived from. + timeline: One character per frame, ``1`` where the channel sounds. + solo: The page's reference to the clip holding this channel alone. + mute: The page's reference to the clip holding every other channel. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + timeline: str + solo: str + mute: str + + +class BoardCell(BaseModel): + """One render of one item under one column: what it scores, and what a listener may play. + + Attributes: + render: The page's reference to the whole render. + score: The headline referee's distance between the render and its recording. + silence: What the same referee scores complete silence at, the distance a render closes. + channels: The channels the render sounds, in the order the hardware holds them. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + render: str + score: float + silence: float + channels: Tuple[BoardChannel, ...] + + +class BoardRow(BaseModel): + """One reference sound across every column of a group. + + Attributes: + item: The corpus item's name. + category: The corpus category the item belongs to. + recording: The page's reference to the recording every cell reconstructs. + cells: One cell per column, in the columns' order. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + item: str + category: str + recording: str + cells: Tuple[BoardCell, ...] + + +class BoardGroup(BaseModel): + """One table of the page: the columns it holds side by side, and the sounds they reconstruct. + + Attributes: + name: What the group is called on its tab; empty where the page holds one group. + columns: The column headings, in the order the cells follow. + rows: One row per reference sound. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + columns: Tuple[str, ...] + rows: Tuple[BoardRow, ...] + + +class BoardPage(BaseModel): + """Everything the page draws, ready to be written as the data file it reads. + + Attributes: + title: The heading the page carries. + referee: The name of the referee whose scores the cells report. + colors: The palette token each channel's color stands under, by channel name. + groups: The tables the page holds, one per tab. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + title: str + referee: str + colors: Dict[str, str] + groups: Tuple[BoardGroup, ...] diff --git a/src/sampletones_tools/calibration/board/palette.py b/src/sampletones_tools/calibration/board/palette.py new file mode 100644 index 000000000..238d0284a --- /dev/null +++ b/src/sampletones_tools/calibration/board/palette.py @@ -0,0 +1,52 @@ +from typing import Final, Tuple + +from sampletones_application.paths import PALETTES_DIRECTORY +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME, PaletteCatalog +from sampletones_application.utils.palette.palette import Palette +from sampletones_shared.types.application import ColorRGBA + +DEFAULT_PALETTE: Final[str] = DEFAULT_PALETTE_NAME +OPAQUE_ALPHA: Final[int] = 255 +CHANNEL_TOKEN_PREFIX: Final[str] = "channel_" + + +def board_palette(name: str) -> Palette: + """The palette a page is drawn in, by the name the application knows it under. + + Raises: + KeyError: If the build ships no palette of that name. + """ + return PaletteCatalog.load(PALETTES_DIRECTORY).get(name) + + +def palette_names() -> Tuple[str, ...]: + """Every palette name a page may be drawn in, the application's own default among them.""" + return PaletteCatalog.load(PALETTES_DIRECTORY).names + + +def palette_stylesheet(palette: Palette) -> str: + """The palette's tokens as custom properties, under the names the application states them by. + + A token keeps its name from the palette file to the page, so a color the application draws with + and the color the page draws with are one value read from one place. + + Args: + palette: The palette the page is drawn in. + + Returns: + The stylesheet, one ``:root`` block holding every token. + """ + declarations = "".join(f" --{token}: {css_color(color)};\n" for token, color in sorted(palette.colors.items())) + return f":root {{\n{declarations}}}\n" + + +def channel_token(channel: str) -> str: + """The palette token a channel's color stands under.""" + return f"{CHANNEL_TOKEN_PREFIX}{channel}" + + +def css_color(color: ColorRGBA) -> str: + """A palette color as CSS writes it: six hexadecimal digits, eight where it is translucent.""" + red, green, blue, alpha = color + opaque = f"#{red:02x}{green:02x}{blue:02x}" + return opaque if alpha == OPAQUE_ALPHA else f"{opaque}{alpha:02x}" diff --git a/src/sampletones_tools/calibration/board/paths.py b/src/sampletones_tools/calibration/board/paths.py new file mode 100644 index 000000000..fcd858834 --- /dev/null +++ b/src/sampletones_tools/calibration/board/paths.py @@ -0,0 +1,10 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory + +STATIC_PACKAGE: Final[str] = "sampletones_tools.calibration.board.static" +FONTS_PACKAGE: Final[str] = "sampletones_assets.fonts" + +STATIC_DIRECTORY: Final[Path] = package_directory(STATIC_PACKAGE) +FONTS_DIRECTORY: Final[Path] = package_directory(FONTS_PACKAGE) diff --git a/src/sampletones_tools/calibration/board/reading.py b/src/sampletones_tools/calibration/board/reading.py new file mode 100644 index 000000000..38064ac9d --- /dev/null +++ b/src/sampletones_tools/calibration/board/reading.py @@ -0,0 +1,108 @@ +from pathlib import Path +from typing import Dict, List, Sequence, Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_shared.paths.extensions import EXT_FILE_JSON +from sampletones_tools.calibration.layout import RENDERS_DIRECTORY +from sampletones_tools.calibration.referee.protocol import SCORE_READING +from sampletones_tools.calibration.renders import RenderRecord + + +class RunReading(BaseModel): + """One calibration run as the page reads it: where it lies, and what it recorded. + + Attributes: + label: The name the page calls the run by, which is unique among the runs it holds. + directory: The run directory every reference the page makes resolves against. + records: Every render the run wrote, in the order the corpus generates its items. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + label: str + directory: Path + records: Tuple[RenderRecord, ...] + + @property + def variants(self) -> Tuple[str, ...]: + """The configurations the run measured, in the order they were measured.""" + return tuple(dict.fromkeys(record.variant for record in self.records)) + + @property + def referee(self) -> str: + """The referee whose score the page reports, which is the one the run's report leads with. + + Raises: + ValueError: If the run recorded no judgment to read a referee from. + """ + for record in self.records: + for referee in record.judgments: + return referee + + raise ValueError(f"Run '{self.directory}' recorded no judgment to read a referee from") + + def score(self, record: RenderRecord) -> float: + """The headline referee's distance between a render and its recording.""" + return record.judgments[self.referee][SCORE_READING] + + def silence(self, record: RenderRecord) -> float: + """The headline referee's distance between complete silence and the same recording.""" + return record.silence[self.referee] + + +def read_run(directory: Path, label: str) -> RunReading: + """ + Read what a calibration run recorded, from the render records it wrote. + + The records carry the scores, the categories and the frames each channel sounded, so a page is + assembled from a finished run without repeating any measurement. + + Args: + directory: The run directory, the one holding ``renders``. + label: The name the page calls the run by. + + Returns: + The run's records, ordered by the corpus position they carry and then by variant. + + Raises: + FileNotFoundError: If the directory holds no renders. + """ + renders = directory / RENDERS_DIRECTORY + paths = sorted(renders.glob(f"*/*{EXT_FILE_JSON}")) + if not paths: + raise FileNotFoundError(f"Directory '{directory}' holds no calibration renders to read") + + records = [RenderRecord.model_validate_json(path.read_text(encoding="utf-8")) for path in paths] + records.sort(key=lambda record: (record.position, record.variant)) + return RunReading(label=label, directory=directory, records=tuple(records)) + + +def read_runs(directories: Sequence[Path]) -> Tuple[RunReading, ...]: + """ + Read every run a page compares, each under a label of its own. + + A run is called by its directory's name, and a name two runs share is numbered, so every column + the page draws names exactly one run. + + Args: + directories: The run directories, in the order the page holds them. + + Returns: + One reading per directory, in the order given. + + Raises: + FileNotFoundError: If a directory holds no renders. + """ + return tuple(read_run(directory, label) for directory, label in zip(directories, _labels(directories))) + + +def _labels(directories: Sequence[Path]) -> List[str]: + taken: Dict[str, int] = {} + labels: List[str] = [] + for directory in directories: + name = directory.name + taken[name] = taken.get(name, 0) + 1 + labels.append(name if taken[name] == 1 else f"{name} ({taken[name]})") + + return labels diff --git a/src/sampletones_tools/calibration/board/session.py b/src/sampletones_tools/calibration/board/session.py new file mode 100644 index 000000000..d822c6164 --- /dev/null +++ b/src/sampletones_tools/calibration/board/session.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Final, List + +from pydantic import BaseModel, ConfigDict, Field + +from .assets import write_assets +from .clips import clip_store +from .composition import compose_page +from .data import data_script +from .fonts import fonts_stylesheet +from .layout import DATA_FILE, FONTS_FILE, PALETTE_FILE +from .palette import DEFAULT_PALETTE, board_palette, palette_stylesheet +from .reading import read_runs + +PAGE_TITLE: Final[str] = "Calibration" + + +class BoardRequest(BaseModel): + """What a listening page is asked for: the runs it reports, where it goes, and how it looks. + + Attributes: + runs: The run directories the page reports, in the order it holds them. + output: The directory the page is written into. + palette: The name of the palette the page is drawn in. + title: The heading the page carries. + """ + + model_config = ConfigDict(frozen=True) + + runs: List[Path] = Field(min_length=1) + output: Path + palette: str = DEFAULT_PALETTE + title: str = PAGE_TITLE + + +def build_board(request: BoardRequest) -> Path: + """ + Build the page a person listens to a run on, from what the runs already wrote. + + Everything the page needs travels with it: the palette as custom properties, the application's + own faces inside the stylesheet, and the run's contents as data. A page written into the run it + reports plays that run's clips where they lie; anywhere else it carries copies, so either page + opens straight from the folder it was written into. + + Args: + request: The runs the page reports, where it goes, and the palette it is drawn in. + + Returns: + Path: The page, the file a reader opens. + + Raises: + FileNotFoundError: If a run directory holds no renders, or the package a page file. + KeyError: If the build ships no palette of the requested name. + """ + runs = read_runs(request.runs) + request.output.mkdir(parents=True, exist_ok=True) + page = compose_page(runs, clip_store(runs, request.output), request.title) + return write_assets( + request.output, + { + PALETTE_FILE: palette_stylesheet(board_palette(request.palette)), + FONTS_FILE: fonts_stylesheet(), + DATA_FILE: data_script(page), + }, + ) diff --git a/src/sampletones_tools/calibration/board/static/__init__.py b/src/sampletones_tools/calibration/board/static/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/calibration/board/static/index.html b/src/sampletones_tools/calibration/board/static/index.html new file mode 100644 index 000000000..8d283fe49 --- /dev/null +++ b/src/sampletones_tools/calibration/board/static/index.html @@ -0,0 +1,27 @@ + + + + + +Calibration + + + + + +
+

Calibration

+

+ +
+
+
+

Click a clip to play it from its beginning. Space pauses and resumes, + ← → move across a sound's versions, ↑ ↓ + move to another sound. S plays one channel alone, M plays every other channel. + A bar shows the frames its channel sounds.

+
+ + + + diff --git a/src/sampletones_tools/calibration/board/static/page.js b/src/sampletones_tools/calibration/board/static/page.js new file mode 100644 index 000000000..0e1fb2b80 --- /dev/null +++ b/src/sampletones_tools/calibration/board/static/page.js @@ -0,0 +1,245 @@ +const player = new Audio(); +let playing = null; + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) { node.className = className; } + if (text !== undefined) { node.textContent = text; } + return node; +} + +function clip(label, source, className) { + const button = element("button", className, label); + button.dataset.source = source; + button.setAttribute("aria-pressed", "false"); + return button; +} + +function gradient(timeline, color) { + const frames = timeline.length; + if (!frames) { return "none"; } + const stops = []; + let start = 0; + while (start < frames) { + let end = start; + while (end < frames && timeline[end] === timeline[start]) { end += 1; } + const paint = timeline[start] === "1" ? `var(--${color})` : "transparent"; + stops.push(`${paint} ${(100 * start) / frames}% ${(100 * end) / frames}%`); + start = end; + } + return `linear-gradient(90deg, ${stops.join(", ")})`; +} + +function channelRow(channel, color) { + const row = element("div", "channel"); + const swatch = element("span", "swatch"); + swatch.style.background = `var(--${color})`; + swatch.title = channel.name; + row.append(swatch); + if (channel.solo) { row.append(clip("S", channel.solo, "clip part")); } + if (channel.mute) { row.append(clip("M", channel.mute, "clip part")); } + const bar = element("div", "bar"); + bar.style.backgroundImage = gradient(channel.timeline, color); + bar.title = channel.name; + row.append(bar); + return row; +} + +function closed(cell) { + if (cell.silence <= 0) { return "—"; } + if (cell.score >= cell.silence) { return "past silence"; } + return `${Math.round(100 * (1 - cell.score / cell.silence))}% of the gap`; +} + +function cellNode(cell, colors) { + const node = element("div", "cell"); + node.append(clip("play", cell.render, "clip")); + const reading = element("div", "reading"); + reading.append(element("b", null, cell.score.toFixed(2))); + reading.append(element("span", null, closed(cell))); + node.append(reading); + const channels = element("div", "channels"); + for (const channel of cell.channels) { + channels.append(channelRow(channel, colors[channel.name])); + } + node.append(channels); + return node; +} + +function rowNode(row, colors) { + const node = element("tr"); + const sound = element("td", "sound"); + sound.append(element("b", null, row.item)); + sound.append(element("span", null, row.category)); + node.append(sound); + const recording = element("td"); + recording.append(clip("recording", row.recording, "clip")); + node.append(recording); + for (const cell of row.cells) { + const holder = element("td"); + holder.append(cellNode(cell, colors)); + node.append(holder); + } + node.append(changeNode(row)); + return node; +} + +function changeNode(row) { + const node = element("td", "change"); + if (row.cells.length < 2) { return node; } + const change = row.cells[row.cells.length - 1].score - row.cells[0].score; + const shown = change.toFixed(2); + if (Number(shown) !== 0) { node.classList.add(change < 0 ? "better" : "worse"); } + node.textContent = `${change >= 0 ? "+" : ""}${shown}`; + return node; +} + +function legendNode(group, colors) { + const sounding = new Set(); + for (const row of group.rows) { + for (const cell of row.cells) { + for (const channel of cell.channels) { sounding.add(channel.name); } + } + } + const legend = element("span", "legend"); + for (const name of Object.keys(colors).filter((channel) => sounding.has(channel))) { + const entry = element("span"); + const swatch = element("span", "swatch"); + swatch.style.background = `var(--${colors[name]})`; + entry.append(swatch, element("span", null, name)); + legend.append(entry); + } + return legend; +} + +function groupNode(group, colors) { + const card = element("section", "card"); + card.dataset.group = group.name; + const heading = element("h2"); + heading.append(element("span", null, group.name), legendNode(group, colors)); + card.append(heading); + const scroll = element("div", "scroll"); + const table = element("table"); + const head = element("tr"); + for (const heading of ["sound", "recording", ...group.columns, "change"]) { + head.append(element("th", null, heading)); + } + const thead = element("thead"); + thead.append(head); + table.append(thead); + const body = element("tbody"); + for (const row of group.rows) { + body.append(rowNode(row, colors)); + } + table.append(body); + scroll.append(table); + card.append(scroll); + return card; +} + +function buttons() { + return Array.from(document.querySelectorAll("section.card:not([hidden]) button.clip")); +} + +function mark(button) { + for (const other of document.querySelectorAll('button.clip[aria-pressed="true"]')) { + other.setAttribute("aria-pressed", "false"); + } + for (const row of document.querySelectorAll("tr.playing")) { + row.classList.remove("playing"); + } + playing = button; + if (button) { + button.setAttribute("aria-pressed", "true"); + button.closest("tr").classList.add("playing"); + } +} + +function play(button) { + if (!button) { return; } + player.pause(); + player.src = button.dataset.source; + player.currentTime = 0; + player.play().catch(() => mark(null)); + mark(button); +} + +function rows() { + return Array.from(document.querySelectorAll("section.card:not([hidden]) tbody tr")); +} + +function inRow(row) { + return Array.from(row.querySelectorAll("button.clip")); +} + +function stepAcross(offset) { + const row = playing ? inRow(playing.closest("tr")) : buttons(); + if (!row.length) { return; } + const place = playing ? row.indexOf(playing) : -1; + play(row[(place + offset + row.length) % row.length]); +} + +function stepDown(offset) { + const all = rows(); + if (!all.length) { return; } + if (!playing) { play(inRow(all[0])[0]); return; } + const current = playing.closest("tr"); + const place = inRow(current).indexOf(playing); + const next = inRow(all[(all.indexOf(current) + offset + all.length) % all.length]); + play(next[Math.min(place, next.length - 1)]); +} + +function selectGroup(name) { + for (const tab of document.querySelectorAll("#tabs button")) { + tab.setAttribute("aria-selected", String(tab.dataset.group === name)); + } + for (const card of document.querySelectorAll("section.card")) { + card.hidden = card.dataset.group !== name; + } + player.pause(); + mark(null); +} + +function build(board) { + document.title = board.title; + document.getElementById("title").textContent = board.title; + document.getElementById("lead").textContent = + `Scored by ${board.referee}; lower is closer to the recording.`; + const groups = document.getElementById("groups"); + for (const group of board.groups) { + groups.append(groupNode(group, board.colors)); + } + if (board.groups.length > 1) { + const tabs = document.getElementById("tabs"); + tabs.hidden = false; + for (const group of board.groups) { + const tab = element("button", null, group.name); + tab.dataset.group = group.name; + tab.addEventListener("click", () => selectGroup(group.name)); + tabs.append(tab); + } + selectGroup(board.groups[0].name); + } +} + +player.addEventListener("ended", () => mark(null)); + +document.addEventListener("click", (event) => { + const button = event.target.closest("button.clip"); + if (button) { play(button); } +}); + +document.addEventListener("keydown", (event) => { + if (event.key === " ") { + event.preventDefault(); + if (!playing) { stepDown(0); return; } + if (player.paused) { player.play().catch(() => mark(null)); } else { player.pause(); } + return; + } + if (event.key === "ArrowRight") { event.preventDefault(); stepAcross(1); } + if (event.key === "ArrowLeft") { event.preventDefault(); stepAcross(-1); } + if (event.key === "ArrowDown") { event.preventDefault(); stepDown(1); } + if (event.key === "ArrowUp") { event.preventDefault(); stepDown(-1); } +}); + +build(BOARD); diff --git a/src/sampletones_tools/calibration/board/static/style.css b/src/sampletones_tools/calibration/board/static/style.css new file mode 100644 index 000000000..2b5069e05 --- /dev/null +++ b/src/sampletones_tools/calibration/board/static/style.css @@ -0,0 +1,285 @@ +:root { + --page-gap: 8px; + --card-radius: 8px; + --frame-radius: 6px; + --header-height: 35px; + --bar-height: 6px; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + padding: 16px 16px 32px; + background: var(--ground); + color: var(--text); + font: 400 15px/1.45 "SampleToNES Sans", system-ui, sans-serif; +} + +.head, +main, +.foot { + max-width: 1400px; + margin: 0 auto; +} + +h1 { + margin: 0 0 4px; + font-size: 22px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--accent); +} + +.lead { + margin: 0 0 var(--page-gap); + color: var(--text_muted); +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin-bottom: var(--page-gap); + padding: 4px; + background: var(--tab_strip); + border: 1px solid var(--border); + border-radius: var(--card-radius); +} + +.tabs button { + font: inherit; + color: var(--text_muted); + background: var(--tab); + border: 1px solid transparent; + border-radius: var(--frame-radius); + padding: 6px 16px; + cursor: pointer; +} + +.tabs button:hover { + background: var(--tab_hovered); + color: var(--text); +} + +.tabs button[aria-selected="true"] { + background: var(--tab_active); + border-color: var(--border); + color: var(--text); +} + +.card { + margin-bottom: var(--page-gap); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--card-radius); + overflow: hidden; +} + +.card > h2 { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: var(--header-height); + margin: 0; + padding: 0 12px; + background: var(--surface_accent); + border-bottom: 1px solid var(--border); + color: var(--accent); + font-size: 15px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.legend { + display: flex; + gap: 12px; + color: var(--text_muted); + font-family: "SampleToNES Mono", ui-monospace, monospace; + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + text-transform: none; +} + +.legend span { + display: flex; + align-items: center; + gap: 4px; +} + +.scroll { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 8px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--table_border); +} + +thead th { + background: var(--table_header); + color: var(--text); + font-weight: 600; + white-space: nowrap; +} + +tbody tr:nth-child(odd) { + background: var(--table_row); +} + +tbody tr:nth-child(even) { + background: var(--table_row_alt); +} + +tbody tr.playing { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +.sound { + white-space: nowrap; +} + +.sound b { + font-family: "SampleToNES Mono", ui-monospace, monospace; + font-weight: 400; +} + +.sound span { + display: block; + color: var(--text_muted); + font-size: 13px; +} + +button.clip { + font: 400 14px/1 "SampleToNES Sans", system-ui, sans-serif; + color: var(--text); + background: var(--button); + border: 1px solid var(--border); + border-radius: var(--frame-radius); + padding: 7px 12px; + cursor: pointer; +} + +button.clip:hover { + background: var(--button_hovered); +} + +button.clip[aria-pressed="true"] { + background: var(--button_active); + border-color: var(--accent); +} + +button.part { + padding: 2px 6px; + font-family: "SampleToNES Mono", ui-monospace, monospace; + font-size: 12px; + background: var(--secondary); +} + +button.part:hover { + background: var(--secondary_hover); +} + +.cell { + display: inline-flex; + align-items: flex-start; + gap: 10px; +} + +.reading { + min-width: 104px; + white-space: nowrap; + font-family: "SampleToNES Mono", ui-monospace, monospace; +} + +.reading b { + font-weight: 400; + font-size: 15px; +} + +.reading span { + display: block; + color: var(--text_muted); + font-size: 12px; +} + +.channels { + display: flex; + flex: none; + flex-direction: column; + gap: 3px; + width: 150px; +} + +.channel { + display: flex; + align-items: center; + gap: 4px; +} + +.bar { + flex: 1; + min-width: 0; + height: var(--bar-height); + border-radius: 2px; + background: var(--recess); +} + +.swatch { + width: 8px; + height: 8px; + border-radius: 2px; + flex: none; +} + +.sound, +td.change { + width: 1%; +} + +td.change { + text-align: right; + white-space: nowrap; + font-family: "SampleToNES Mono", ui-monospace, monospace; +} + +.better { + color: var(--accent); +} + +.worse { + color: var(--danger); +} + +.foot { + margin-top: var(--page-gap); + color: var(--text_muted); + font-size: 13px; +} + +kbd { + padding: 0 5px; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text); + font-family: "SampleToNES Mono", ui-monospace, monospace; +} diff --git a/src/sampletones_tools/calibration/command.py b/src/sampletones_tools/calibration/command.py new file mode 100644 index 000000000..74efa9684 --- /dev/null +++ b/src/sampletones_tools/calibration/command.py @@ -0,0 +1,171 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Optional + +from sampletones_shared.command import Command + +NAME: Final[str] = "calibration" +HELP: Final[str] = ( + "measure how the program reconstructs the reference sounds and write the renders, a report and a listening page" +) +OUTPUT_HELP: Final[str] = ( + "the directory the run writes into; without it, a timestamped directory under Documents/SampleToNES/calibration" +) +CONFIG_HELP: Final[str] = "the configuration file to measure; without it, the program's default settings" +METHODS_HELP: Final[str] = "spectrum methods to measure, comma separated; without it, the suite's methods" +EXPONENTS_HELP: Final[str] = ( + "values of metric.perceptual_exponent to measure, comma separated; without it, the suite's values" +) +WEIGHTS_HELP: Final[str] = ( + "values of weights.temporal_loss_weight to measure, comma separated; without it, the suite's values" +) +CHANNELS_HELP: Final[str] = ( + "channels every variant reconstructs with, comma separated; without it, the suite's channels" +) +BOARD_HELP: Final[str] = ( + "run directories to compare on one listening page, measuring nothing; without -o the page lands in a " + "timestamped directory under Documents/SampleToNES/calibration/pages" +) +PALETTE_HELP: Final[str] = "the palette the listening page is drawn in; without it, the application's own" +NO_OPEN_HELP: Final[str] = "leave the listening page closed, printing its link alone" + +MEASURING_OPTIONS: Final[List[str]] = [ + "--config", + "--methods", + "--perceptual-exponents", + "--temporal-weights", + "--channels", +] + + +@dataclass(frozen=True) +class CalibrationArguments: + """What a calibration run is given, as written on the command line.""" + + config: Optional[Path] + output: Optional[Path] + methods: Optional[str] + perceptual_exponents: Optional[str] + temporal_weights: Optional[str] + channels: Optional[str] + board: Optional[List[Path]] + palette: Optional[str] + open_page: bool + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("--config", "-c", type=Path, default=None, help=CONFIG_HELP) + parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP) + parser.add_argument("--methods", type=str, default=None, help=METHODS_HELP) + parser.add_argument("--perceptual-exponents", type=str, default=None, help=EXPONENTS_HELP) + parser.add_argument("--temporal-weights", type=str, default=None, help=WEIGHTS_HELP) + parser.add_argument("--channels", type=str, default=None, help=CHANNELS_HELP) + parser.add_argument("--board", type=Path, nargs="+", default=None, metavar="RUN", help=BOARD_HELP) + parser.add_argument("--palette", type=str, default=None, help=PALETTE_HELP) + parser.add_argument("--no-open", dest="open_page", action="store_false", help=NO_OPEN_HELP) + + +def run(arguments: Namespace) -> int: + """Runs the calibration the options describe, or builds a page over the runs they name. + + Raises: + SystemExit: If a method, a value, a channel or a palette is unknown, a run directory holds + no renders, or a page is asked for beside the options that measure. + """ + given = CalibrationArguments( + config=arguments.config, + output=arguments.output, + methods=arguments.methods, + perceptual_exponents=arguments.perceptual_exponents, + temporal_weights=arguments.temporal_weights, + channels=arguments.channels, + board=arguments.board, + palette=arguments.palette, + open_page=arguments.open_page, + ) + + return _compare(given) if given.board is not None else _measure(given) + + +def _measure(given: CalibrationArguments) -> int: + from sampletones_core.headless.conversion.request import channels_named + from sampletones_shared.utils.validation import describe_failure + from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE + from sampletones_tools.calibration.config.suite import SuiteConfig + from sampletones_tools.calibration.session import ( + CalibrationRequest, + base_configuration, + calibrate, + default_output, + floats_named, + methods_named, + ) + + suite = SuiteConfig.load() + try: + request = CalibrationRequest( + base=base_configuration(given.config), + output=given.output if given.output is not None else default_output(), + methods=methods_named(given.methods, suite.methods), + perceptual_exponents=floats_named(given.perceptual_exponents, suite.perceptual_exponents), + temporal_weights=floats_named(given.temporal_weights, suite.temporal_weights), + channels=channels_named(given.channels) if given.channels is not None else list(suite.channels), + palette=given.palette if given.palette is not None else DEFAULT_PALETTE, + ) + except ValueError as error: + raise SystemExit(describe_failure(error)) from error + + outcome = calibrate(request) + print(f"Report: {outcome.report.resolve().as_uri()}") + return _show(outcome.page, given.open_page) + + +def _compare(given: CalibrationArguments) -> int: + from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE + from sampletones_tools.calibration.board.session import BoardRequest, build_board + from sampletones_tools.calibration.session import default_page_output + + _refuse_measuring(given) + request = BoardRequest( + runs=list(given.board or []), + output=given.output if given.output is not None else default_page_output(), + palette=given.palette if given.palette is not None else DEFAULT_PALETTE, + ) + try: + page = build_board(request) + except (FileNotFoundError, KeyError) as error: + raise SystemExit(str(error)) from error + + return _show(page, given.open_page) + + +def _show(page: Path, wanted: bool) -> int: + from sampletones_tools.calibration.board.browser import open_page + + print(f"Page: {page.resolve().as_uri()}") + if wanted: + open_page(page) + + return 0 + + +def _refuse_measuring(given: CalibrationArguments) -> None: + measuring = ( + given.config, + given.methods, + given.perceptual_exponents, + given.temporal_weights, + given.channels, + ) + if any(value is not None for value in measuring): + named = ", ".join(MEASURING_OPTIONS) + raise SystemExit(f"--board reads finished runs and measures nothing; it takes none of {named}.") + + +CALIBRATION: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/calibration/config/__init__.py b/src/sampletones_tools/calibration/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/calibration/config/corpus.py b/src/sampletones_tools/calibration/config/corpus.py new file mode 100644 index 000000000..0750b7e70 --- /dev/null +++ b/src/sampletones_tools/calibration/config/corpus.py @@ -0,0 +1,78 @@ +from typing import Self + +from pydantic import BaseModel, Field + +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.calibration.paths import CORPUS_CONFIG_PATH + +from .dynamics import DynamicsConfig +from .mix import MixConfig +from .noise import NoiseConfig +from .polyphony import PolyphonyConfig +from .timbre import TimbreConfig +from .tone import ToneConfig +from .transient import TransientConfig + + +class CorpusConfig(BaseModel, frozen=True): + """ + Tuning of the calibration probe corpus. + + Every probe is synthesized at unit scale and multiplied by the corpus + amplitude, so the per-class parameters compose under one loudness + convention. Values are loaded from the `corpus.yaml` beside this model, + so corpus content stays reproducible across calibration runs while + remaining adjustable in one place. + """ + + seed: int = Field( + ge=0, + description="Seed of the random generator behind the stochastic probes.", + ) + item_seconds: float = Field( + gt=0.0, + description="Duration of every corpus item in seconds.", + ) + amplitude: float = Field( + gt=0.0, + le=1.0, + description="Scale applied to every unit-level probe.", + ) + reference_frequency: float = Field( + gt=0.0, + description="Anchor tone in Hz shared by the mix, pluck, and crescendo probes.", + ) + tone: ToneConfig = Field( + description="Steady sine probes.", + ) + timbre: TimbreConfig = Field( + description="Pulse-wave probes.", + ) + noise: NoiseConfig = Field( + description="Broadband noise probes.", + ) + mix: MixConfig = Field( + description="Tone-plus-noise probes.", + ) + transient: TransientConfig = Field( + description="Percussive probes.", + ) + dynamics: DynamicsConfig = Field( + description="The level probe beside the crescendo.", + ) + polyphony: PolyphonyConfig = Field( + description="Probes holding more than one voice.", + ) + + @classmethod + def load(cls) -> Self: + """ + Load the packaged corpus tuning. + + Returns: + The corpus configuration validated from `sampletones_tools/calibration/config/corpus.yaml`. + + Raises: + TypeError: If the configuration file holds anything other than a mapping. + """ + return load_yaml_model(CORPUS_CONFIG_PATH, cls) diff --git a/src/sampletones_tools/calibration/config/corpus.yaml b/src/sampletones_tools/calibration/config/corpus.yaml new file mode 100644 index 000000000..8c306a0af --- /dev/null +++ b/src/sampletones_tools/calibration/config/corpus.yaml @@ -0,0 +1,35 @@ +seed: 420 +item_seconds: 1.5 +amplitude: 0.5 +reference_frequency: 440.0 +tone: + frequencies: [55.0, 110.0, 220.0, 440.0, 880.0, 1760.0] +timbre: + duty_cycles: [0.125, 0.25, 0.5] + frequency: 220.0 +noise: + white_level: 0.5 +mix: + noise_levels: [0.05, 0.15] + bass_frequency: 55.0 + hat_period_seconds: 0.25 + hat_decay_seconds: 0.008 + hat_level: 0.4 +transient: + snare_decay_seconds: 0.15 + kick_decay_seconds: 0.25 + kick_sweep_frequencies: [150.0, 50.0] + attack_seconds: 0.005 + attack_tone_decay_seconds: 0.3 +dynamics: + burst_seconds: 0.2 + hiss_level: 0.09 +polyphony: + chord_frequencies: [220.0, 277.18, 329.63] + melody_frequencies: [440.0, 523.25, 659.25, 880.0, 659.25, 523.25] + note_seconds: 0.25 + note_decay_seconds: 0.4 + snare_period_seconds: 0.5 + snare_delay_seconds: 0.25 + snare_decay_seconds: 0.04 + snare_level: 0.35 diff --git a/src/sampletones_tools/calibration/config/dynamics.py b/src/sampletones_tools/calibration/config/dynamics.py new file mode 100644 index 000000000..bad1e97e4 --- /dev/null +++ b/src/sampletones_tools/calibration/config/dynamics.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field + + +class DynamicsConfig(BaseModel, frozen=True): + """A level probe beside the crescendo: a loud white-noise burst dropping straight to a quiet hiss.""" + + burst_seconds: float = Field( + gt=0.0, + description="Duration of the burst at the white noise level, in seconds.", + ) + hiss_level: float = Field( + gt=0.0, + lt=1.0, + description="Level of the hiss following the burst, against the burst.", + ) diff --git a/src/sampletones_tools/calibration/config/mix.py b/src/sampletones_tools/calibration/config/mix.py new file mode 100644 index 000000000..a79932ca0 --- /dev/null +++ b/src/sampletones_tools/calibration/config/mix.py @@ -0,0 +1,27 @@ +from typing import Tuple + +from pydantic import BaseModel, Field, PositiveFloat + + +class MixConfig(BaseModel, frozen=True): + """Tone-plus-noise probes: a steady tone under steady noise, and a bass under hi-hat ticks.""" + + noise_levels: Tuple[PositiveFloat, ...] = Field( + min_length=1, + description="Noise standard deviations relative to the unit reference tone, one probe each.", + ) + bass_frequency: PositiveFloat = Field( + description="Frequency of the sine bass under the hi-hats in Hz.", + ) + hat_period_seconds: float = Field( + gt=0.0, + description="Time between hi-hat ticks in seconds.", + ) + hat_decay_seconds: float = Field( + gt=0.0, + description="Exponential decay constant of every hi-hat tick.", + ) + hat_level: float = Field( + gt=0.0, + description="Scale of the hi-hat noise against the unit-level bass.", + ) diff --git a/src/sampletones_core/calibration/config/noise.py b/src/sampletones_tools/calibration/config/noise.py similarity index 100% rename from src/sampletones_core/calibration/config/noise.py rename to src/sampletones_tools/calibration/config/noise.py diff --git a/src/sampletones_tools/calibration/config/polyphony.py b/src/sampletones_tools/calibration/config/polyphony.py new file mode 100644 index 000000000..91bc0fffe --- /dev/null +++ b/src/sampletones_tools/calibration/config/polyphony.py @@ -0,0 +1,40 @@ +from typing import Tuple + +from pydantic import BaseModel, Field, PositiveFloat + + +class PolyphonyConfig(BaseModel, frozen=True): + """Probes holding more than one voice: a held chord, and a pulse melody over a snare.""" + + chord_frequencies: Tuple[PositiveFloat, ...] = Field( + min_length=2, + description="Sine frequencies in Hz held together, sharing the unit level equally.", + ) + melody_frequencies: Tuple[PositiveFloat, ...] = Field( + min_length=1, + description="Pulse-wave note frequencies in Hz, played one after another.", + ) + note_seconds: float = Field( + gt=0.0, + description="Duration of every melody note in seconds.", + ) + note_decay_seconds: float = Field( + gt=0.0, + description="Exponential decay constant of every melody note, struck again on each note.", + ) + snare_period_seconds: float = Field( + gt=0.0, + description="Time between snare hits in seconds.", + ) + snare_delay_seconds: float = Field( + ge=0.0, + description="When the first snare hit falls, in seconds.", + ) + snare_decay_seconds: float = Field( + gt=0.0, + description="Exponential decay constant of every snare hit.", + ) + snare_level: float = Field( + gt=0.0, + description="Scale of the snare noise against the unit-level melody.", + ) diff --git a/src/sampletones_tools/calibration/config/referee.py b/src/sampletones_tools/calibration/config/referee.py new file mode 100644 index 000000000..a644cbeb8 --- /dev/null +++ b/src/sampletones_tools/calibration/config/referee.py @@ -0,0 +1,64 @@ +from typing import Self, Tuple + +from pydantic import BaseModel, Field, PositiveInt + +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.calibration.paths import REFEREE_CONFIG_PATH + + +class RefereeConfig(BaseModel, frozen=True): + """ + Tuning of the built-in referees, which share one band analysis. + + Values are loaded from the packaged `calibration/referee.yaml`, so referee + scores stay comparable across calibration runs while remaining adjustable + in one place. + """ + + window_sizes: Tuple[PositiveInt, ...] = Field( + min_length=1, + description="STFT window sizes covering the time-frequency trade-off.", + ) + hop_divisor: PositiveInt = Field( + description="Window size divided by this gives the STFT hop.", + ) + band_count: PositiveInt = Field( + description="Number of ERB-spaced aggregation bands.", + ) + low_frequency: float = Field( + gt=0.0, + description="Lower bound of the band axis in Hz.", + ) + energy_floor: float = Field( + gt=0.0, + description="Absolute floor keeping silent band energies finite.", + ) + audibility_range_decibels: float = Field( + gt=0.0, + description="Audible range below the reference's loudest band; quieter content saturates.", + ) + compression_exponent: float = Field( + gt=0.0, + le=1.0, + description="Exponent turning a band's energy relative to the loudest into its specific loudness.", + ) + dynamic_range_decibels: float = Field( + gt=0.0, + description="Range below the reference's loudest band the loudness-weighted referee reads.", + ) + level_matching: bool = Field( + description="Whether the loudness-weighted referee matches the estimate's level before comparing shapes.", + ) + + @classmethod + def load(cls) -> Self: + """ + Load the packaged referee tuning. + + Returns: + The referee configuration validated from `sampletones_tools/calibration/config/referee.yaml`. + + Raises: + TypeError: If the configuration file holds anything other than a mapping. + """ + return load_yaml_model(REFEREE_CONFIG_PATH, cls) diff --git a/src/sampletones_tools/calibration/config/referee.yaml b/src/sampletones_tools/calibration/config/referee.yaml new file mode 100644 index 000000000..795d808c7 --- /dev/null +++ b/src/sampletones_tools/calibration/config/referee.yaml @@ -0,0 +1,9 @@ +window_sizes: [512, 2048, 8192] +hop_divisor: 4 +band_count: 40 +low_frequency: 30.0 +energy_floor: 1.0e-10 +audibility_range_decibels: 60.0 +compression_exponent: 0.3 +dynamic_range_decibels: 60.0 +level_matching: true diff --git a/src/sampletones_tools/calibration/config/suite.py b/src/sampletones_tools/calibration/config/suite.py new file mode 100644 index 000000000..23527c1f2 --- /dev/null +++ b/src/sampletones_tools/calibration/config/suite.py @@ -0,0 +1,48 @@ +from typing import Annotated, Self, Tuple + +from pydantic import BaseModel, Field, NonNegativeFloat + +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.calibration.paths import SUITE_CONFIG_PATH + +TemporalWeight = Annotated[float, Field(ge=0.0, le=1.0)] + + +class SuiteConfig(BaseModel, frozen=True): + """ + The sweep a calibration run measures when the command line names none of its parts. + + Values are loaded from the packaged `suite.yaml` beside the corpus and the referee tuning, so + a run with no options measures the same configurations on every machine. An option on the + command line replaces the part of the suite it names. + """ + + methods: Tuple[SpectrumMethod, ...] = Field( + min_length=1, + description="Spectrum methods measured, one variant each.", + ) + perceptual_exponents: Tuple[NonNegativeFloat, ...] = Field( + min_length=1, + description="Values of metric.perceptual_exponent measured.", + ) + temporal_weights: Tuple[TemporalWeight, ...] = Field( + description="Values of weights.temporal_loss_weight measured; empty keeps the base blend.", + ) + channels: Tuple[ChannelName, ...] = Field( + min_length=1, + description="Channels every variant reconstructs with.", + ) + + @classmethod + def load(cls) -> Self: + """ + Load the packaged suite. + + Returns: + The suite validated from `sampletones_tools/calibration/config/suite.yaml`. + + Raises: + TypeError: If the configuration file holds anything other than a mapping. + """ + return load_yaml_model(SUITE_CONFIG_PATH, cls) diff --git a/src/sampletones_tools/calibration/config/suite.yaml b/src/sampletones_tools/calibration/config/suite.yaml new file mode 100644 index 000000000..897731d06 --- /dev/null +++ b/src/sampletones_tools/calibration/config/suite.yaml @@ -0,0 +1,4 @@ +methods: [fft, logfft, cqt] +perceptual_exponents: [1.0] +temporal_weights: [] +channels: [pulse1, triangle, noise] diff --git a/src/sampletones_core/calibration/config/timbre.py b/src/sampletones_tools/calibration/config/timbre.py similarity index 86% rename from src/sampletones_core/calibration/config/timbre.py rename to src/sampletones_tools/calibration/config/timbre.py index 0febd84ae..86c31f775 100644 --- a/src/sampletones_core/calibration/config/timbre.py +++ b/src/sampletones_tools/calibration/config/timbre.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field -from sampletones_synthesis.oscillators.pulse import DutyCycle +from sampletones_tools.synthesis.oscillators.pulse import DutyCycle class TimbreConfig(BaseModel, frozen=True): diff --git a/src/sampletones_core/calibration/config/tone.py b/src/sampletones_tools/calibration/config/tone.py similarity index 100% rename from src/sampletones_core/calibration/config/tone.py rename to src/sampletones_tools/calibration/config/tone.py diff --git a/src/sampletones_core/calibration/config/transient.py b/src/sampletones_tools/calibration/config/transient.py similarity index 100% rename from src/sampletones_core/calibration/config/transient.py rename to src/sampletones_tools/calibration/config/transient.py diff --git a/src/sampletones_tools/calibration/corpus/__init__.py b/src/sampletones_tools/calibration/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_core/calibration/corpus/item.py b/src/sampletones_tools/calibration/corpus/item.py similarity index 100% rename from src/sampletones_core/calibration/corpus/item.py rename to src/sampletones_tools/calibration/corpus/item.py diff --git a/src/sampletones_tools/calibration/corpus/synthesis.py b/src/sampletones_tools/calibration/corpus/synthesis.py new file mode 100644 index 000000000..f13f11755 --- /dev/null +++ b/src/sampletones_tools/calibration/corpus/synthesis.py @@ -0,0 +1,338 @@ +from typing import Final, List, Tuple + +import numpy as np + +from sampletones_core.audio.processing import clip_audio +from sampletones_tools.calibration.config.corpus import CorpusConfig +from sampletones_tools.synthesis.envelopes.exponential_decay import ( + ExponentialDecayEnvelope, +) +from sampletones_tools.synthesis.envelopes.gate import GateEnvelope +from sampletones_tools.synthesis.envelopes.linear_attack import LinearAttackEnvelope +from sampletones_tools.synthesis.envelopes.linear_ramp import LinearRampEnvelope +from sampletones_tools.synthesis.envelopes.periodic_decay import PeriodicDecayEnvelope +from sampletones_tools.synthesis.envelopes.types import EnvelopeUnion +from sampletones_tools.synthesis.oscillators.exponential_glide import ( + ExponentialGlideOscillator, +) +from sampletones_tools.synthesis.oscillators.pulse import PulseOscillator +from sampletones_tools.synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.oscillators.types import OscillatorUnion +from sampletones_tools.synthesis.oscillators.walk_noise import WalkNoiseOscillator +from sampletones_tools.synthesis.oscillators.white_noise import WhiteNoiseOscillator +from sampletones_tools.synthesis.voice.layer import Layer +from sampletones_tools.synthesis.voice.voice import Voice + +from .item import CorpusItem + +Probe = Tuple[str, str, Voice] + +UNIT_GAIN: Final[float] = 1.0 +SQUARE_DUTY_CYCLE: Final[float] = 0.5 +FIRST_STRIKE_SECONDS: Final[float] = 0.0 + + +def build_corpus( + sample_rate: int, + *, + config: CorpusConfig, +) -> List[CorpusItem]: + """ + Synthesize the calibration probe corpus. + + The corpus spans the signal classes the criterion must arbitrate between: + steady tones across the pitch range, pulse timbres of several duty cycles, + broadband noise, tone-plus-noise mixes, percussive transients, level changes + (a crescendo and a burst dropping to a hiss), and voices sounding together. + Probes render in a fixed order from + one seeded generator, so every item is deterministic for a fixed sample + rate and configuration and referee scores are reproducible across runs. + + Args: + sample_rate: Sampling rate of the synthesized items in Hz. + config: Corpus tuning holding the probe parameters per signal class. + + Returns: + The corpus items, each carrying its category for per-class reporting. + """ + generator = np.random.default_rng(config.seed) + probes: List[Probe] = [ + *_tone_probes(config), + *_timbre_probes(config), + *_noise_probes(config), + *_mix_probes(config), + *_transient_probes(config), + _crescendo_probe(config), + *_polyphony_probes(config), + _burst_then_hiss_probe(config), + _bass_hi_hats_probe(config), + ] + return [ + _item( + name, + category, + voice.render(sample_rate=sample_rate, generator=generator), + config, + ) + for name, category, voice in probes + ] + + +def _tone_probes(config: CorpusConfig) -> List[Probe]: + return [ + ( + f"tone-{frequency:g}hz", + "tone", + _voice( + config, + _layer( + SineOscillator( + kind="sine", + frequency=frequency, + ) + ), + ), + ) + for frequency in config.tone.frequencies + ] + + +def _timbre_probes(config: CorpusConfig) -> List[Probe]: + return [ + ( + f"pulse-duty{duty_cycle:g}", + "timbre", + _voice( + config, + _layer( + PulseOscillator( + kind="pulse", + frequency=config.timbre.frequency, + duty_cycle=duty_cycle, + ) + ), + ), + ) + for duty_cycle in config.timbre.duty_cycles + ] + + +def _noise_probes(config: CorpusConfig) -> List[Probe]: + return [ + ( + "noise-white", + "noise", + _voice( + config, + _layer( + WhiteNoiseOscillator(kind="white_noise"), + gain=config.noise.white_level, + ), + ), + ), + ( + "noise-dark", + "noise", + _voice( + config, + _layer( + WalkNoiseOscillator( + kind="walk_noise", + ) + ), + ), + ), + ] + + +def _mix_probes(config: CorpusConfig) -> List[Probe]: + return [ + ( + f"mix-noise{noise_level:g}", + "mix", + _voice( + config, + _layer( + SineOscillator( + kind="sine", + frequency=config.reference_frequency, + ) + ), + _layer( + WhiteNoiseOscillator(kind="white_noise"), + gain=noise_level, + ), + ), + ) + for noise_level in config.mix.noise_levels + ] + + +def _transient_probes(config: CorpusConfig) -> List[Probe]: + transient = config.transient + sweep_start, sweep_end = transient.kick_sweep_frequencies + snare_decay = ExponentialDecayEnvelope( + kind="exponential_decay", + time_constant_seconds=transient.snare_decay_seconds, + ) + kick_decay = ExponentialDecayEnvelope( + kind="exponential_decay", + time_constant_seconds=transient.kick_decay_seconds, + ) + pluck_attack = LinearAttackEnvelope( + kind="linear_attack", + attack_seconds=transient.attack_seconds, + ) + pluck_decay = ExponentialDecayEnvelope( + kind="exponential_decay", + time_constant_seconds=transient.attack_tone_decay_seconds, + ) + kick_glide = ExponentialGlideOscillator( + kind="exponential_glide", + frequency_start=sweep_start, + frequency_end=sweep_end, + time_constant_seconds=transient.kick_decay_seconds, + ) + reference_tone = SineOscillator( + kind="sine", + frequency=config.reference_frequency, + ) + return [ + ( + "transient-snare", + "transient", + _voice(config, _layer(WhiteNoiseOscillator(kind="white_noise"), snare_decay)), + ), + ( + "transient-kick", + "transient", + _voice(config, _layer(kick_glide, kick_decay)), + ), + ( + "transient-pluck", + "transient", + _voice(config, _layer(reference_tone, pluck_attack, pluck_decay)), + ), + ] + + +def _crescendo_probe(config: CorpusConfig) -> Probe: + return ( + "dynamics-crescendo", + "dynamics", + _voice( + config, + _layer( + SineOscillator(kind="sine", frequency=config.reference_frequency), + LinearRampEnvelope(kind="linear_ramp"), + ), + ), + ) + + +def _polyphony_probes(config: CorpusConfig) -> List[Probe]: + polyphony = config.polyphony + chord_gain = UNIT_GAIN / len(polyphony.chord_frequencies) + chord = [ + _layer(SineOscillator(kind="sine", frequency=frequency), gain=chord_gain) + for frequency in polyphony.chord_frequencies + ] + note_decay = PeriodicDecayEnvelope( + kind="periodic_decay", + period_seconds=polyphony.note_seconds, + time_constant_seconds=polyphony.note_decay_seconds, + delay_seconds=FIRST_STRIKE_SECONDS, + ) + melody = [ + _layer( + PulseOscillator(kind="pulse", frequency=frequency, duty_cycle=SQUARE_DUTY_CYCLE), + GateEnvelope( + kind="gate", + start_seconds=index * polyphony.note_seconds, + end_seconds=(index + 1) * polyphony.note_seconds, + ), + note_decay, + ) + for index, frequency in enumerate(polyphony.melody_frequencies) + ] + snare = _layer( + WhiteNoiseOscillator(kind="white_noise"), + PeriodicDecayEnvelope( + kind="periodic_decay", + period_seconds=polyphony.snare_period_seconds, + time_constant_seconds=polyphony.snare_decay_seconds, + delay_seconds=polyphony.snare_delay_seconds, + ), + gain=polyphony.snare_level, + ) + return [ + ("polyphony-chord", "polyphony", _voice(config, *chord)), + ("polyphony-melody-snare", "polyphony", _voice(config, *melody, snare)), + ] + + +def _burst_then_hiss_probe(config: CorpusConfig) -> Probe: + dynamics = config.dynamics + burst = _layer( + WhiteNoiseOscillator(kind="white_noise"), + GateEnvelope(kind="gate", start_seconds=FIRST_STRIKE_SECONDS, end_seconds=dynamics.burst_seconds), + gain=config.noise.white_level, + ) + hiss = _layer( + WhiteNoiseOscillator(kind="white_noise"), + GateEnvelope(kind="gate", start_seconds=dynamics.burst_seconds, end_seconds=config.item_seconds), + gain=config.noise.white_level * dynamics.hiss_level, + ) + return ("dynamics-burst-then-hiss", "dynamics", _voice(config, burst, hiss)) + + +def _bass_hi_hats_probe(config: CorpusConfig) -> Probe: + mix = config.mix + bass = _layer(SineOscillator(kind="sine", frequency=mix.bass_frequency)) + hats = _layer( + WhiteNoiseOscillator(kind="white_noise"), + PeriodicDecayEnvelope( + kind="periodic_decay", + period_seconds=mix.hat_period_seconds, + time_constant_seconds=mix.hat_decay_seconds, + delay_seconds=FIRST_STRIKE_SECONDS, + ), + gain=mix.hat_level, + ) + return ("mix-bass-hi-hats", "mix", _voice(config, bass, hats)) + + +def _layer( + oscillator: OscillatorUnion, + *envelopes: EnvelopeUnion, + gain: float = UNIT_GAIN, +) -> Layer: + return Layer(oscillator=oscillator, envelopes=envelopes, gain=gain) + + +def _voice(config: CorpusConfig, *layers: Layer) -> Voice: + return Voice( + duration_seconds=config.item_seconds, + layers=layers, + filters=(), + ) + + +def _item( + name: str, + category: str, + audio: np.ndarray, + config: CorpusConfig, +) -> CorpusItem: + """ + Assemble a corpus item from a unit-scale probe. + + The probe is scaled by the corpus amplitude, clipped to the valid wave + range, and stored as float32, so every item shares one loudness convention. + """ + scaled = clip_audio(config.amplitude * audio) + return CorpusItem( + name=name, + category=category, + audio=scaled.astype(np.float32), + ) diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_tools/calibration/corpus/writer.py similarity index 100% rename from src/sampletones_core/calibration/corpus/writer.py rename to src/sampletones_tools/calibration/corpus/writer.py diff --git a/src/sampletones_tools/calibration/layout.py b/src/sampletones_tools/calibration/layout.py new file mode 100644 index 000000000..05bb58af0 --- /dev/null +++ b/src/sampletones_tools/calibration/layout.py @@ -0,0 +1,21 @@ +from typing import Final, Sequence + +from sampletones_core.constants.enums import ChannelName + +CORPUS_DIRECTORY: Final[str] = "corpus" +RECORDINGS_DIRECTORY: Final[str] = "recordings" +RENDERS_DIRECTORY: Final[str] = "renders" +CSV_REPORT: Final[str] = "report.csv" +MARKDOWN_REPORT: Final[str] = "report.md" + +CHANNEL_SEPARATOR: Final[str] = "+" +MIX_NAME: Final[str] = "mix" + + +def combination_name(channels: Sequence[ChannelName]) -> str: + """The file name a render of exactly these channels is written under. + + The channels are named in the order they are given, joined by ``CHANNEL_SEPARATOR``, so a page + that knows which channels it wants spells the same name the run wrote. + """ + return CHANNEL_SEPARATOR.join(channel.value for channel in channels) diff --git a/src/sampletones_tools/calibration/paths.py b/src/sampletones_tools/calibration/paths.py new file mode 100644 index 000000000..9b6f01f9c --- /dev/null +++ b/src/sampletones_tools/calibration/paths.py @@ -0,0 +1,9 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory + +CALIBRATION_CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_tools.calibration.config") +REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml" +CORPUS_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "corpus.yaml" +SUITE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "suite.yaml" diff --git a/src/sampletones_tools/calibration/referee/__init__.py b/src/sampletones_tools/calibration/referee/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/calibration/referee/auditory.py b/src/sampletones_tools/calibration/referee/auditory.py new file mode 100644 index 000000000..42a28d635 --- /dev/null +++ b/src/sampletones_tools/calibration/referee/auditory.py @@ -0,0 +1,69 @@ +from typing import Final, List + +import numpy as np + +from sampletones_tools.calibration.config.referee import RefereeConfig + +from .bands import BandAnalyzer +from .protocol import Judgment + +AUDITORY_REFEREE_NAME: Final[str] = "mr-auditory-dB" + + +class MultiResolutionAuditoryReferee: + """ + Log-spectral distance over ERB-spaced bands at several STFT resolutions. + + Judges a reconstruction independently of the matching criterion: the configured + window sizes cover the time-frequency trade-off the criterion resolves with a + single frame, band energies aggregate on the ERB-rate axis with uniform band + weights, and distances compare log magnitudes (mean absolute deviation in dB). + Band energies are floored at the configured audibility range below the + reference's loudest band, so the score reflects audible content, holds steady + under a common gain, and saturates for bands one side leaves silent. Lower + scores mean closer reconstructions; identical signals score zero. + + Every band weighs the same whatever it holds, so the score reads timbre and the balance of + tone against noise across the whole spectrum. On a lone tone the empty bands outnumber the + occupied ones, and a render adding content to them reads as farther than silence. + """ + + def __init__(self, sample_rate: int, *, config: RefereeConfig) -> None: + self.sample_rate = sample_rate + self.config = config + self.analyzer = BandAnalyzer(sample_rate, config=config) + + @property + def name(self) -> str: + return AUDITORY_REFEREE_NAME + + def judge(self, reference: np.ndarray, estimate: np.ndarray) -> Judgment: + """ + Mean absolute log-spectral deviation between two equal-length signals. + + Args: + reference: Reference waveform. + estimate: Waveform under evaluation, of the same length. + + Returns: + Judgment: The mean absolute deviation in dB over bands, frames, and resolutions. + + Raises: + ValueError: If the signals differ in length. + """ + if reference.shape != estimate.shape: + raise ValueError(f"signal shapes differ: {reference.shape} vs {estimate.shape}") + + distances: List[float] = [] + for window_size in self.analyzer.window_sizes: + reference_energy = self.analyzer.band_energies(reference, window_size) + estimate_energy = self.analyzer.band_energies(estimate, window_size) + floor = self.analyzer.audibility_floor( + reference_energy, + range_decibels=self.config.audibility_range_decibels, + ) + reference_bands = 10.0 * np.log10(reference_energy + floor) + estimate_bands = 10.0 * np.log10(estimate_energy + floor) + distances.append(float(np.mean(np.abs(reference_bands - estimate_bands)))) + + return Judgment(score=float(np.mean(distances)), components={}) diff --git a/src/sampletones_tools/calibration/referee/bands.py b/src/sampletones_tools/calibration/referee/bands.py new file mode 100644 index 000000000..bf2708deb --- /dev/null +++ b/src/sampletones_tools/calibration/referee/bands.py @@ -0,0 +1,99 @@ +from typing import Dict, Final, Tuple + +import numpy as np +from scipy.signal import stft + +from sampletones_tools.calibration.config.referee import RefereeConfig + +ERB_RATE_SCALE: Final[float] = 21.4 +ERB_RATE_FACTOR: Final[float] = 4.37e-3 + + +class BandAnalyzer: + """ + Band energies on the ERB-rate axis at every STFT resolution a referee reads. + + Each configured window size holds one rectangular aggregation matrix from STFT bins onto + bands spaced uniformly in ERB rate, so referees built on the same configuration read the + same time-frequency picture of a signal. + """ + + def __init__(self, sample_rate: int, *, config: RefereeConfig) -> None: + self.sample_rate = sample_rate + self.config = config + self._band_matrices: Dict[int, np.ndarray] = { + window_size: self._band_matrix(sample_rate, window_size, config) for window_size in config.window_sizes + } + + @property + def window_sizes(self) -> Tuple[int, ...]: + return tuple(self._band_matrices) + + def band_energies(self, audio: np.ndarray, window_size: int) -> np.ndarray: + """ + Energy per band and STFT frame of a signal at one resolution. + + Args: + audio: The waveform to analyze. + window_size: One of the configured window sizes; the hop is that size over the hop divisor. + + Returns: + np.ndarray: Band energies, shaped (bands, frames). + """ + hop = window_size // self.config.hop_divisor + _, _, spectrum = stft( + audio.astype(np.float64), + fs=self.sample_rate, + nperseg=window_size, + noverlap=window_size - hop, + ) + energy = np.abs(spectrum) ** 2 + band_energy: np.ndarray = self._band_matrices[window_size] @ energy + return band_energy + + def audibility_floor(self, reference_energy: np.ndarray, *, range_decibels: float) -> float: + """ + The energy ``range_decibels`` below the reference's loudest band. + + Content under this level counts as inaudible, so a band one side leaves silent saturates at + the floor and a common gain on both signals leaves the ratio of floor to content unchanged. + The configured energy floor keeps a silent reference's floor finite. + """ + loudest = max(float(np.max(reference_energy)), self.config.energy_floor) + return float(loudest * 10.0 ** (-range_decibels / 10.0)) + + @classmethod + def _band_matrix( + cls, + sample_rate: int, + window_size: int, + config: RefereeConfig, + ) -> np.ndarray: + """ + Rectangular aggregation matrix from STFT bins onto ERB-spaced bands. + + Band edges are uniform on the ERB-rate axis between the low-frequency bound and + the Nyquist frequency; each STFT bin contributes its full energy to the band + containing its center, and bins below the low-frequency bound join the first band. + """ + frequencies = np.fft.rfftfreq(window_size, 1.0 / sample_rate) + nyquist = sample_rate / 2.0 + edges_rate = np.linspace( + cls._erb_rate(np.array([config.low_frequency]))[0], + cls._erb_rate(np.array([nyquist]))[0], + config.band_count + 1, + ) + bin_rates = cls._erb_rate(frequencies) + band_indices = np.clip( + np.searchsorted(edges_rate, bin_rates, side="right") - 1, + 0, + config.band_count - 1, + ) + + matrix = np.zeros((config.band_count, frequencies.shape[0])) + matrix[band_indices, np.arange(frequencies.shape[0])] = 1.0 + return matrix + + @staticmethod + def _erb_rate(frequencies: np.ndarray) -> np.ndarray: + return ERB_RATE_SCALE * np.log10(1.0 + ERB_RATE_FACTOR * frequencies) diff --git a/src/sampletones_tools/calibration/referee/factory.py b/src/sampletones_tools/calibration/referee/factory.py new file mode 100644 index 000000000..b60832c8e --- /dev/null +++ b/src/sampletones_tools/calibration/referee/factory.py @@ -0,0 +1,32 @@ +from typing import List + +from sampletones_tools.calibration.config.referee import RefereeConfig + +from .auditory import MultiResolutionAuditoryReferee +from .loudness import LoudnessWeightedReferee +from .protocol import Referee +from .zimtohrli import ZimtohrliReferee, zimtohrli_available + + +def build_referees(sample_rate: int) -> List[Referee]: + """ + Referees available on this system, the headline one first. + + A report ranks by the first referee. The built-in referees share the packaged tuning, and + Zimtohrli joins as a second opinion where the ``calibration`` dependency group is installed. + + Args: + sample_rate: Sampling rate of the signals under evaluation in Hz. + + Returns: + List[Referee]: The built-in referees with the packaged tuning, then Zimtohrli when available. + """ + config = RefereeConfig.load() + referees: List[Referee] = [ + MultiResolutionAuditoryReferee(sample_rate, config=config), + LoudnessWeightedReferee(sample_rate, config=config), + ] + if zimtohrli_available(): + referees.append(ZimtohrliReferee(sample_rate)) + + return referees diff --git a/src/sampletones_tools/calibration/referee/level.py b/src/sampletones_tools/calibration/referee/level.py new file mode 100644 index 000000000..62df05fdf --- /dev/null +++ b/src/sampletones_tools/calibration/referee/level.py @@ -0,0 +1,46 @@ +import numpy as np + +from sampletones_core.fft.fft import k_weighting + + +def k_weighted_energy(audio: np.ndarray, sample_rate: int) -> float: + """The energy of a signal as loudness weighs it: its power spectrum summed under the K-weighting curve.""" + spectrum = np.fft.rfft(audio.astype(np.float64)) + frequencies = np.fft.rfftfreq(audio.shape[0], 1.0 / sample_rate) + return float(np.sum(k_weighting(frequencies) * np.abs(spectrum) ** 2)) + + +def level_difference_decibels( + reference: np.ndarray, + estimate: np.ndarray, + sample_rate: int, + *, + energy_floor: float, + range_decibels: float, +) -> float: + """ + How much louder the estimate plays than the reference, in decibels of K-weighted energy. + + The energy floor keeps a silent signal's level finite, and the reading is held within + ``range_decibels`` either way, so a silent estimate reads as the whole range quieter. + + Args: + reference: Reference waveform. + estimate: Waveform under evaluation. + sample_rate: Sampling rate of both signals in Hz. + energy_floor: Energy added to both sides before the ratio. + range_decibels: The largest difference reported, in either direction. + + Returns: + float: The level difference, positive when the estimate is louder. + """ + reference_energy = k_weighted_energy(reference, sample_rate) + energy_floor + estimate_energy = k_weighted_energy(estimate, sample_rate) + energy_floor + difference = 10.0 * np.log10(estimate_energy / reference_energy) + return float(np.clip(difference, -range_decibels, range_decibels)) + + +def matched_to_reference(estimate: np.ndarray, *, level_decibels: float) -> np.ndarray: + """The estimate brought to the reference's level, given how much louder it plays.""" + matched: np.ndarray = estimate * 10.0 ** (-level_decibels / 20.0) + return matched diff --git a/src/sampletones_tools/calibration/referee/loudness.py b/src/sampletones_tools/calibration/referee/loudness.py new file mode 100644 index 000000000..2abe8b0bc --- /dev/null +++ b/src/sampletones_tools/calibration/referee/loudness.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from typing import Final, List + +import numpy as np + +from sampletones_tools.calibration.config.referee import RefereeConfig + +from .bands import BandAnalyzer +from .level import level_difference_decibels, matched_to_reference +from .protocol import Judgment + +LOUDNESS_REFEREE_NAME: Final[str] = "mr-loudness-dB" +MISSING_COMPONENT: Final[str] = "missing" +ADDED_COMPONENT: Final[str] = "added" +LEVEL_COMPONENT: Final[str] = "level" + + +@dataclass(frozen=True) +class Deviation: + """The loudness-weighted log-spectral deviation of one resolution, split by which side is louder. + + Attributes: + missing: The part where the reference is louder: content the estimate lacks. + added: The part where the estimate is louder: content the estimate brings in. + """ + + missing: float + added: float + + +class LoudnessWeightedReferee: + """ + Log-spectral distance over ERB-spaced bands, weighted by how loud each band plays. + + Each band-frame's absolute deviation in decibels counts in proportion to the specific + loudness of the louder side, the energy relative to the reference's loudest band raised to + the compression exponent. Occupied bands carry the score and empty ones nearly vanish, so a + missing tone costs what it is heard as, and an estimate's energy measured against the + reference's maximum weighs above one where it brings in louder content. The estimate is + brought to the reference's level first, so the score reads shape and balance, and the level + difference is reported beside it. The deviation splits into ``missing`` (a dull estimate) + and ``added`` (a buzzy one), which sum to the score. Lower scores mean closer + reconstructions; identical signals score zero. + """ + + def __init__(self, sample_rate: int, *, config: RefereeConfig) -> None: + self.sample_rate = sample_rate + self.config = config + self.analyzer = BandAnalyzer(sample_rate, config=config) + + @property + def name(self) -> str: + return LOUDNESS_REFEREE_NAME + + def judge(self, reference: np.ndarray, estimate: np.ndarray) -> Judgment: + """ + Loudness-weighted log-spectral deviation between two equal-length signals. + + Args: + reference: Reference waveform. + estimate: Waveform under evaluation, of the same length. + + Returns: + Judgment: The mean weighted deviation in dB over resolutions, with its missing and added + parts and the level difference in dB. + + Raises: + ValueError: If the signals differ in length. + """ + if reference.shape != estimate.shape: + raise ValueError(f"signal shapes differ: {reference.shape} vs {estimate.shape}") + + level = level_difference_decibels( + reference, + estimate, + self.sample_rate, + energy_floor=self.config.energy_floor, + range_decibels=self.config.dynamic_range_decibels, + ) + compared = matched_to_reference(estimate, level_decibels=level) if self.config.level_matching else estimate + deviations: List[Deviation] = [ + self._deviation(reference, compared, window_size) for window_size in self.analyzer.window_sizes + ] + missing = float(np.mean([deviation.missing for deviation in deviations])) + added = float(np.mean([deviation.added for deviation in deviations])) + return Judgment( + score=missing + added, + components={ + MISSING_COMPONENT: missing, + ADDED_COMPONENT: added, + LEVEL_COMPONENT: level, + }, + ) + + def _deviation( + self, + reference: np.ndarray, + estimate: np.ndarray, + window_size: int, + ) -> Deviation: + reference_energy = self.analyzer.band_energies(reference, window_size) + estimate_energy = self.analyzer.band_energies(estimate, window_size) + loudest = max(float(np.max(reference_energy)), self.config.energy_floor) + floor = self.analyzer.audibility_floor( + reference_energy, + range_decibels=self.config.dynamic_range_decibels, + ) + deviation = 10.0 * np.log10((reference_energy + floor) / (estimate_energy + floor)) + weights = np.maximum(reference_energy, estimate_energy) / loudest + weights = weights**self.config.compression_exponent + total = float(np.sum(weights)) + if total == 0.0: + return Deviation(missing=0.0, added=0.0) + + return Deviation( + missing=float(np.sum(weights * np.maximum(deviation, 0.0))) / total, + added=float(np.sum(weights * np.maximum(-deviation, 0.0))) / total, + ) diff --git a/src/sampletones_tools/calibration/referee/protocol.py b/src/sampletones_tools/calibration/referee/protocol.py new file mode 100644 index 000000000..116f29a0d --- /dev/null +++ b/src/sampletones_tools/calibration/referee/protocol.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Dict, Final, Mapping, Protocol + +import numpy as np + +SCORE_READING: Final[str] = "score" + + +@dataclass(frozen=True) +class Judgment: + """A referee's reading of one estimate: the score it ranks by and the parts that score breaks into. + + Attributes: + score: The distance a report ranks by; zero for identical signals. + components: Named readings behind the score, keyed by the constants the referee declares. + """ + + score: float + components: Mapping[str, float] + + def readings(self) -> Dict[str, float]: + """Every reading of the judgment by name, the score first under ``SCORE_READING``.""" + return {SCORE_READING: self.score, **self.components} + + +class Referee(Protocol): + """Full-reference audio distance: zero for identical signals, growing with audible difference.""" + + @property + def name(self) -> str: ... + + def judge(self, reference: np.ndarray, estimate: np.ndarray) -> Judgment: ... diff --git a/src/sampletones_tools/calibration/referee/zimtohrli.py b/src/sampletones_tools/calibration/referee/zimtohrli.py new file mode 100644 index 000000000..68985af61 --- /dev/null +++ b/src/sampletones_tools/calibration/referee/zimtohrli.py @@ -0,0 +1,76 @@ +from typing import Final + +import numpy as np + +from sampletones_core.audio.processing import clip_audio, resample +from sampletones_shared.utils.system.modules import module_available + +from .protocol import Judgment + +ZIMTOHRLI_MODULE: Final[str] = "zimtohrli" +ZIMTOHRLI_REFEREE_NAME: Final[str] = "zimtohrli" +OPINION_COMPONENT: Final[str] = "mos" +SILENT_PEAK: Final[float] = 1e-9 + + +def zimtohrli_available() -> bool: + """Whether this interpreter carries the ``calibration`` dependency group.""" + return module_available(ZIMTOHRLI_MODULE) + + +class ZimtohrliReferee: + """ + Psychoacoustic distance from the Zimtohrli auditory model, read through its Python wheel. + + The model takes 48 kHz signals in [-1, 1] and hears a full-scale sine at a fixed sound + pressure level, so both signals share one gain bringing the reference's peak to full scale, + the estimate is clipped to that range, and both are resampled to the model's rate. The + wheel's ``full_scale_sine_db`` property calls a method its compiled module lacks, so the + referee plays at the model's built-in level. The score is the Zimtohrli distance, zero for + identical signals, and the mean opinion score the model maps it to is reported beside it. + """ + + def __init__(self, sample_rate: int) -> None: + from zimtohrli import Pyohrli, mos_from_zimtohrli # pylint: disable=import-error + + self.sample_rate = sample_rate + self._model = Pyohrli() + self._opinion = mos_from_zimtohrli + self._model_rate = int(self._model.sample_rate) + + @property + def name(self) -> str: + return ZIMTOHRLI_REFEREE_NAME + + def judge(self, reference: np.ndarray, estimate: np.ndarray) -> Judgment: + """ + Zimtohrli distance between two equal-length signals. + + Args: + reference: Reference waveform. + estimate: Waveform under evaluation, of the same length. + + Returns: + Judgment: The Zimtohrli distance, with the mean opinion score it maps to. + + Raises: + ValueError: If the signals differ in length. + """ + if reference.shape != estimate.shape: + raise ValueError(f"signal shapes differ: {reference.shape} vs {estimate.shape}") + + gain = 1.0 / max(float(np.max(np.abs(reference), initial=0.0)), SILENT_PEAK) + distance = float( + self._model.distance( + self._model_signal(clip_audio(reference * gain)), + self._model_signal(clip_audio(estimate * gain)), + ) + ) + return Judgment( + score=distance, + components={OPINION_COMPONENT: float(self._opinion(distance))}, + ) + + def _model_signal(self, audio: np.ndarray) -> np.ndarray: + signal: np.ndarray = resample(audio.astype(np.float32), self.sample_rate, self._model_rate) + return signal.astype(np.float32) diff --git a/src/sampletones_tools/calibration/renders.py b/src/sampletones_tools/calibration/renders.py new file mode 100644 index 000000000..b73279a95 --- /dev/null +++ b/src/sampletones_tools/calibration/renders.py @@ -0,0 +1,146 @@ +from itertools import combinations +from pathlib import Path +from typing import Dict, Final, List, Mapping, Sequence, Tuple + +import numpy as np +from pydantic import BaseModel, ConfigDict + +from sampletones_core.audio.io import write_flac +from sampletones_core.audio.mixing import mix +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_shared.paths.extensions import EXT_FILE_FLAC, EXT_FILE_JSON +from sampletones_shared.utils.system.paths import get_filename + +from .layout import RECORDINGS_DIRECTORY, RENDERS_DIRECTORY, combination_name + +SOUNDING_FRAME: Final[str] = "1" +RESTING_FRAME: Final[str] = "0" + + +class RenderRecord(BaseModel): + """ + What a calibration run heard in one render: the scores it drew and which channel sounded when. + + A record sits beside its render's audio file, so a run can be studied after it ends from what it + wrote. + + Attributes: + variant: The label of the configuration the render comes from. + item: The corpus item rendered. + position: The item's place in the corpus. + category: The item's corpus category. + timelines: One character per frame for each channel, ``SOUNDING_FRAME`` where it sounds. + judgments: Every referee's readings of the render, by referee name. + silence: Every referee's score for complete silence against the same recording. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + variant: str + item: str + position: int + category: str + timelines: Dict[str, str] + judgments: Dict[str, Dict[str, float]] + silence: Dict[str, float] + + +def sounding_timelines(instructions: Mapping[ChannelName, Sequence[InstructionUnion]]) -> Dict[str, str]: + """Each channel's frames as characters, ``SOUNDING_FRAME`` where its instruction is on.""" + return { + channel_name.value: "".join(SOUNDING_FRAME if instruction.on else RESTING_FRAME for instruction in stream) + for channel_name, stream in instructions.items() + } + + +def channel_subsets(channels: Sequence[ChannelName]) -> List[Tuple[ChannelName, ...]]: + """Every part of the channels a render sounds, from one channel up to all but one. + + A page switching a channel on or off plays a file rather than mixing in the browser, so each + part a listener may ask for is written once and heard exactly as the run made it. + """ + return [subset for size in range(1, len(channels)) for subset in combinations(channels, size)] + + +def write_render( + run_directory: Path, + record: RenderRecord, + audio: np.ndarray, + sample_rate: int, +) -> None: + """ + Write a render's audio and its record under the run's renders, in a directory per variant. + + Args: + run_directory: The directory the calibration run writes into. + record: What the run heard in the render. + audio: The render on the scale of the recording it reconstructs. + sample_rate: Sampling rate of the render in Hz. + """ + directory = variant_directory(run_directory, record.variant) + directory.mkdir(parents=True, exist_ok=True) + write_flac(directory / get_filename(record.item, EXT_FILE_FLAC), sample_rate, audio.astype(np.float32)) + (directory / get_filename(record.item, EXT_FILE_JSON)).write_text( + record.model_dump_json(indent=1), encoding="utf-8" + ) + + +def write_channel_renders( + run_directory: Path, + variant: str, + item: str, + approximations: Mapping[ChannelName, np.ndarray], + sample_rate: int, +) -> List[Tuple[ChannelName, ...]]: + """ + Write one clip per part of the channels a render sounds, in a directory named after the item. + + The parts are mixed from the same per-channel audio the whole render is mixed from, so a part + and the whole stand sample for sample and a listener switching between them hears one decision. + + Args: + run_directory: The directory the calibration run writes into. + variant: The label of the configuration the render comes from. + item: The corpus item rendered. + approximations: The audio each sounding channel contributes, on the render's own scale. + sample_rate: Sampling rate of the render in Hz. + + Returns: + The parts written, each a tuple of channels in the order the render sounds them. + """ + channels = tuple(approximations) + subsets = channel_subsets(channels) + if not subsets: + return [] + + directory = item_directory(run_directory, variant, item) + directory.mkdir(parents=True, exist_ok=True) + for subset in subsets: + audio = mix([approximations[channel] for channel in subset]) + path = directory / get_filename(combination_name(subset), EXT_FILE_FLAC) + write_flac(path, sample_rate, audio.astype(np.float32)) + + return subsets + + +def write_recording( + run_directory: Path, + item: str, + audio: np.ndarray, + sample_rate: int, +) -> None: + """Write the recording a run reconstructs an item from, as the reconstructor prepared it.""" + directory = run_directory / RECORDINGS_DIRECTORY + directory.mkdir(parents=True, exist_ok=True) + write_flac(directory / get_filename(item, EXT_FILE_FLAC), sample_rate, audio.astype(np.float32)) + + +def variant_directory(run_directory: Path, variant: str) -> Path: + """The directory a variant's renders lie in.""" + return run_directory / RENDERS_DIRECTORY / variant + + +def item_directory(run_directory: Path, variant: str, item: str) -> Path: + """The directory an item's per-channel clips lie in, beside the render they are parts of.""" + return variant_directory(run_directory, variant) / item diff --git a/src/sampletones_tools/calibration/report.py b/src/sampletones_tools/calibration/report.py new file mode 100644 index 000000000..d39b84afd --- /dev/null +++ b/src/sampletones_tools/calibration/report.py @@ -0,0 +1,84 @@ +from collections import defaultdict +from pathlib import Path +from typing import Dict, Final, Iterable, List, Tuple + +import numpy as np + +from sampletones_shared.utils.tables import Table +from sampletones_tools.calibration.referee.protocol import SCORE_READING +from sampletones_tools.calibration.runner import CalibrationRow + +VARIANT_COLUMN: Final[str] = "variant" +CSV_COLUMNS: Final[Tuple[str, ...]] = (VARIANT_COLUMN, "item", "category", "referee", "component", "score") +OVERALL_COLUMN: Final[str] = "overall" + + +def write_csv(rows: List[CalibrationRow], path: Path) -> None: + """ + Write every calibration row as CSV. + + Args: + rows: Scored rows from the runner. + path: Target CSV path. + """ + Table( + columns=CSV_COLUMNS, + rows=tuple( + (row.variant, row.item, row.category, row.referee, row.component, f"{row.score:.6f}") for row in rows + ), + ).write_csv(path) + + +def write_markdown(rows: List[CalibrationRow], path: Path) -> None: + """ + Write a per-referee pivot of mean scores: one row per variant, one column per + category, with the overall mean last. Lower scores mean closer reconstructions. + + Each referee's score leads its section, and every further reading it reports follows + in a table of its own. + + Args: + rows: Scored rows from the runner. + path: Target markdown path. + """ + lines: List[str] = ["# Calibration report", ""] + for referee in _ordered(row.referee for row in rows): + referee_rows = [row for row in rows if row.referee == referee] + lines.extend((f"## {referee}", "")) + for component in _ordered(row.component for row in referee_rows): + component_rows = [row for row in referee_rows if row.component == component] + if component != SCORE_READING: + lines.extend((f"### {component}", "")) + + lines.extend((*_pivot_table(component_rows).markdown_lines(), "")) + + path.write_text("\n".join(lines), encoding="utf-8") + + +def _pivot_table(rows: List[CalibrationRow]) -> Table: + categories = _ordered(row.category for row in rows) + means = _mean_scores(rows) + cells: List[Tuple[str, ...]] = [] + for variant in _ordered(row.variant for row in rows): + scores = [f"{means.get((variant, category), float('nan')):.3f}" for category in categories] + overall = np.mean([row.score for row in rows if row.variant == variant]) + cells.append((variant, *scores, f"{float(overall):.3f}")) + + return Table(columns=(VARIANT_COLUMN, *categories, OVERALL_COLUMN), rows=tuple(cells)) + + +def _mean_scores(rows: List[CalibrationRow]) -> Dict[Tuple[str, str], float]: + scores: Dict[Tuple[str, str], List[float]] = defaultdict(list) + for row in rows: + scores[(row.variant, row.category)].append(row.score) + + return {key: float(np.mean(values)) for key, values in scores.items()} + + +def _ordered(values: Iterable[str]) -> List[str]: + seen: List[str] = [] + for value in values: + if value not in seen: + seen.append(value) + + return seen diff --git a/src/sampletones_tools/calibration/runner.py b/src/sampletones_tools/calibration/runner.py new file mode 100644 index 000000000..740d7ca9d --- /dev/null +++ b/src/sampletones_tools/calibration/runner.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, FrozenSet, List, Mapping, Optional, Tuple + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.headless.library import ensure_library +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +from sampletones_shared.logger import logger + +from .corpus.item import CorpusItem +from .referee.protocol import Judgment, Referee +from .renders import RenderRecord, sounding_timelines, write_channel_renders, write_recording, write_render + + +@dataclass(frozen=True) +class CalibrationVariant: + label: str + config: Config + + +@dataclass(frozen=True) +class CalibrationRow: + variant: str + item: str + category: str + referee: str + component: str + score: float + + +def build_variants( + base: Config, + methods: List[SpectrumMethod], + perceptual_exponents: List[float], + temporal_weights: List[float], +) -> List[CalibrationVariant]: + """ + Cartesian sweep of spectrum methods and criterion knobs over a base configuration. + + Each variant keeps the base configuration and overrides the spectrum method, the + perceptual exponent, and (when swept) the temporal loss weight; the label encodes + the overridden values so report rows stay self-describing. + + Args: + base: Configuration providing every value the sweep leaves untouched. + methods: Spectrum methods to evaluate. + perceptual_exponents: Values of `metric.perceptual_exponent` to evaluate. + temporal_weights: Values of `weights.temporal_loss_weight` to evaluate; + an empty list keeps the base blend. + + Returns: + One labeled variant per sweep combination. + """ + variants: List[CalibrationVariant] = [] + swept_temporal: List[Optional[float]] = list(temporal_weights) if temporal_weights else [None] + + for method in methods: + for exponent in perceptual_exponents: + for temporal_weight in swept_temporal: + label = f"{method.value}-pe{exponent:g}" + generation = base.generation.model_copy( + update={ + "metric": base.generation.metric.model_copy(update={"perceptual_exponent": exponent}), + } + ) + if temporal_weight is not None: + label = f"{label}-tw{temporal_weight:g}" + generation = generation.model_copy( + update={ + "weights": generation.weights.model_copy( + update={ + "temporal_loss_weight": temporal_weight, + "spectral_loss_weight": 1.0 - temporal_weight, + } + ) + } + ) + + config = base.model_copy( + update={ + "library": base.library.model_copy(update={"spectrum_method": method}), + "generation": generation, + } + ) + variants.append( + CalibrationVariant( + label=label, + config=config, + ) + ) + + return variants + + +def evaluate_variants( + variants: List[CalibrationVariant], + items: List[CorpusItem], + item_paths: Dict[str, Path], + referees: List[Referee], + run_directory: Path, + channels: FrozenSet[ChannelName], +) -> List[CalibrationRow]: + """ + Reconstruct the corpus under every variant, score the results and keep what was heard. + + Each corpus item is reconstructed with the variant's configuration and every + referee judges the approximation against the preprocessed original, both on the + common scale set by the working-level coefficient. The render, the recording it + reconstructs and a record of the scores and the sounding frames are written under + the run directory, so a render can be heard and read again after the run. + + Args: + variants: Labeled configurations to evaluate. + items: Corpus items, carrying the category used in reports. + item_paths: Written WAV path per corpus item name. + referees: Referees scoring each reconstruction. + run_directory: The directory the run writes its renders and recordings into. + channels: The channels every variant reconstructs with. + + Returns: + One row per (variant, item, referee, reading). + """ + rows: List[CalibrationRow] = [] + for variant in variants: + ensure_library(variant.config) + reconstructor = Reconstructor(variant.config, channels) + sample_rate = variant.config.library.sample_rate + for position, item in enumerate(items): + path = item_paths[item.name] + reconstruction = reconstructor(path) + if reconstruction is None: + logger.info(f"[{variant.label}] {item.name}: reconstruction unavailable") + continue + + reference, estimate = _compared_signals(reconstructor.load_audio(path), reconstruction) + judgments = {referee.name: referee.judge(reference, estimate) for referee in referees} + silence = {referee.name: referee.judge(reference, np.zeros_like(reference)).score for referee in referees} + rows.extend(_rows(variant, item, judgments)) + + write_recording(run_directory, item.name, reference * reconstruction.coefficient, sample_rate) + write_render( + run_directory, + RenderRecord( + variant=variant.label, + item=item.name, + position=position, + category=item.category, + timelines=sounding_timelines(_played_instructions(reconstruction)), + judgments={name: judgment.readings() for name, judgment in judgments.items()}, + silence=silence, + ), + estimate * reconstruction.coefficient, + sample_rate, + ) + write_channel_renders( + run_directory, + variant.label, + item.name, + _channel_audio(reconstruction, estimate.shape[0]), + sample_rate, + ) + logger.info(f"[{variant.label}] {item.name}: scored") + + return rows + + +def _compared_signals( + recording: np.ndarray, + reconstruction: Reconstruction, +) -> Tuple[np.ndarray, np.ndarray]: + """The recording and its approximation on the working-level scale, cut to a common length.""" + reference = recording / reconstruction.coefficient + estimate = np.asarray(rendered_mix(reconstruction), dtype=np.float64) + length = min(reference.shape[0], estimate.shape[0]) + return reference[:length], estimate[:length] + + +def _channel_audio(reconstruction: Reconstruction, length: int) -> Dict[ChannelName, np.ndarray]: + """What each sounding channel contributes to the render, on the recording's scale and length.""" + approximations = rendered_channels(reconstruction) + return { + channel_name: approximations[channel_name][:length] * reconstruction.coefficient + for channel_name in reconstruction.playing_channels + if channel_name in approximations + } + + +def _played_instructions(reconstruction: Reconstruction) -> Dict[ChannelName, List[InstructionUnion]]: + instructions = reconstruction.instructions + return {channel_name: instructions[channel_name] for channel_name in reconstruction.playing_channels} + + +def _rows( + variant: CalibrationVariant, + item: CorpusItem, + judgments: Mapping[str, Judgment], +) -> List[CalibrationRow]: + return [ + CalibrationRow( + variant=variant.label, + item=item.name, + category=item.category, + referee=referee, + component=component, + score=value, + ) + for referee, judgment in judgments.items() + for component, value in judgment.readings().items() + ] diff --git a/src/sampletones_tools/calibration/session.py b/src/sampletones_tools/calibration/session.py new file mode 100644 index 000000000..ad47d7d4d --- /dev/null +++ b/src/sampletones_tools/calibration/session.py @@ -0,0 +1,165 @@ +from pathlib import Path +from typing import Final, List, Optional, Sequence + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_shared.logger import logger +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS +from sampletones_shared.utils.text import listed_items +from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE +from sampletones_tools.calibration.board.session import BoardRequest, build_board +from sampletones_tools.calibration.config.corpus import CorpusConfig +from sampletones_tools.calibration.corpus.synthesis import build_corpus +from sampletones_tools.calibration.corpus.writer import write_corpus +from sampletones_tools.calibration.layout import CORPUS_DIRECTORY, CSV_REPORT, MARKDOWN_REPORT +from sampletones_tools.calibration.referee.factory import build_referees +from sampletones_tools.calibration.report import write_csv, write_markdown +from sampletones_tools.calibration.runner import build_variants, evaluate_variants +from sampletones_tools.runs import stamped_board_directory, stamped_run_directory + +OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration" +BOARD_ROOT: Final[Path] = OUTPUT_ROOT / "pages" + + +def methods_named(stated: Optional[str], default: Sequence[SpectrumMethod]) -> List[SpectrumMethod]: + """The spectrum methods a run evaluates: the ones named, comma separated, or ``default``. + + Raises: + ValueError: If a name is none of the spectrum methods. + """ + if stated is None: + return list(default) + + methods: List[SpectrumMethod] = [] + for name in listed_items(stated): + try: + methods.append(SpectrumMethod(name)) + except ValueError as error: + known = ", ".join(method.value for method in SpectrumMethod) + raise ValueError(f"Unknown spectrum method {name!r}; the methods are {known}.") from error + + return methods + + +def floats_named(stated: Optional[str], default: Sequence[float]) -> List[float]: + """The values a sweep takes: the ones named, comma separated, or ``default``. + + Raises: + ValueError: If a value is no number. + """ + if stated is None: + return list(default) + + values: List[float] = [] + for value in listed_items(stated): + try: + values.append(float(value)) + except ValueError as error: + raise ValueError(f"Not a number: {value!r}.") from error + + return values + + +def base_configuration(path: Optional[Path]) -> Config: + """The configuration a run measures: the file named, or the program's packaged defaults. + + A run with no file measures the same settings on every machine, whatever the application has + saved, so its figures compare with any other such run. + """ + return Config.load(path) if path is not None else Config() + + +def default_output() -> Path: + """A timestamped run directory under the user's calibration documents.""" + return stamped_run_directory(OUTPUT_ROOT) + + +def default_page_output() -> Path: + """A timestamped page directory under the user's calibration documents.""" + return stamped_board_directory(BOARD_ROOT) + + +class CalibrationRequest(BaseModel): + """What a calibration run is asked for: the base configuration, the sweep and where it writes. + + Attributes: + base: The configuration every value the sweep leaves untouched comes from. + output: The directory the corpus and the reports are written into. + methods: The spectrum methods evaluated. + perceptual_exponents: The values of ``metric.perceptual_exponent`` evaluated. + temporal_weights: The values of ``weights.temporal_loss_weight`` evaluated; empty keeps + the base blend. + channels: The channels every variant reconstructs with. + palette: The name of the palette the run's listening page is drawn in. + """ + + model_config = ConfigDict(frozen=True) + + base: Config + output: Path + methods: List[SpectrumMethod] = Field(min_length=1) + perceptual_exponents: List[float] = Field(min_length=1) + temporal_weights: List[float] + channels: List[ChannelName] = Field(min_length=1) + palette: str = DEFAULT_PALETTE + + +class CalibrationOutcome(BaseModel): + """What a calibration run leaves behind for a reader. + + Attributes: + report: The markdown report, which sits in the run directory beside the renders. + page: The listening page, the file a reader opens to hear what the run made. + """ + + model_config = ConfigDict(frozen=True) + + report: Path + page: Path + + +def calibrate(request: CalibrationRequest) -> CalibrationOutcome: + """Reconstructs the corpus under every variant, scores it, and writes the renders, reports and page. + + Returns: + CalibrationOutcome: The report and the listening page the run wrote. + """ + request.output.mkdir(parents=True, exist_ok=True) + + sample_rate = request.base.library.sample_rate + items = build_corpus(sample_rate, config=CorpusConfig.load()) + item_paths = write_corpus(items, request.output / CORPUS_DIRECTORY, sample_rate) + referees = build_referees(sample_rate) + variants = build_variants( + request.base, + request.methods, + request.perceptual_exponents, + request.temporal_weights, + ) + + channel_names = ", ".join(channel.value for channel in request.channels) + logger.info( + f"Evaluating {len(variants)} variants x {len(items)} items x {len(referees)} referees on {channel_names}" + ) + rows = evaluate_variants( + variants, + items, + item_paths, + referees, + request.output, + frozenset(request.channels), + ) + + write_csv(rows, request.output / CSV_REPORT) + report = request.output / MARKDOWN_REPORT + write_markdown(rows, report) + page = build_board( + BoardRequest( + runs=[request.output], + output=request.output, + palette=request.palette, + ) + ) + return CalibrationOutcome(report=report, page=page) diff --git a/src/sampletones_tools/checkout.py b/src/sampletones_tools/checkout.py new file mode 100644 index 000000000..ea09b3193 --- /dev/null +++ b/src/sampletones_tools/checkout.py @@ -0,0 +1,32 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.source import REPOSITORY_ROOT + +PROJECT_FILE: Final[str] = "pyproject.toml" +SOURCE_DIRECTORY: Final[str] = "src" +CHECKOUT_ADVICE: Final[str] = ( + "This command needs the repository and its project environment, so it runs from a checkout: " + "clone SampleToNES, run 'make setup', then 'uv run sampletones {command}'." +) + + +def is_checkout(root: Path) -> bool: + """Whether ``root`` is a SampleToNES checkout: the project file beside the source tree.""" + return (root / PROJECT_FILE).is_file() and (root / SOURCE_DIRECTORY).is_dir() + + +def require_checkout(command: str) -> None: + """Holds a developer command to a checkout, where the repository and the project environment are. + + An installed copy, from the wheel or the bundle, carries the package without the repository + around it or the development dependencies, so the refusal names the way to run the command there. + + Args: + command: The command line the advice names, such as ``check import-boundary --all``. + + Raises: + SystemExit: If the package runs outside a checkout. + """ + if not is_checkout(REPOSITORY_ROOT): + raise SystemExit(CHECKOUT_ADVICE.format(command=command)) diff --git a/src/sampletones_tools/checks/__init__.py b/src/sampletones_tools/checks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/checks/boundary/__init__.py b/src/sampletones_tools/checks/boundary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/checks/boundary/check.py b/src/sampletones_tools/checks/boundary/check.py new file mode 100644 index 000000000..8781c7c70 --- /dev/null +++ b/src/sampletones_tools/checks/boundary/check.py @@ -0,0 +1,60 @@ +from pathlib import Path +from typing import List, Optional, Sequence, Set + +from sampletones_tools.checks.boundary.rule import BoundaryRule +from sampletones_tools.checks.boundary.scope import rule_modules +from sampletones_tools.checks.boundary.token import TokenRule +from sampletones_tools.checks.boundary.violation import Violation +from sampletones_tools.checks.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_tools/checks/boundary/configs/__init__.py b/src/sampletones_tools/checks/boundary/configs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/checks/boundary/configs/declaration.py b/src/sampletones_tools/checks/boundary/configs/declaration.py new file mode 100644 index 000000000..addfc9c5b --- /dev/null +++ b/src/sampletones_tools/checks/boundary/configs/declaration.py @@ -0,0 +1,60 @@ +from typing import Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_tools.checks.boundary.configs.general import GeneralBoundaries +from sampletones_tools.checks.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_tools/checks/boundary/configs/general.py b/src/sampletones_tools/checks/boundary/configs/general.py new file mode 100644 index 000000000..62f6f7724 --- /dev/null +++ b/src/sampletones_tools/checks/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_tools/checks/boundary/configs/paths.py b/src/sampletones_tools/checks/boundary/configs/paths.py new file mode 100644 index 000000000..92ac72066 --- /dev/null +++ b/src/sampletones_tools/checks/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_tools/checks/boundary/configs/rules.py b/src/sampletones_tools/checks/boundary/configs/rules.py new file mode 100644 index 000000000..63566289d --- /dev/null +++ b/src/sampletones_tools/checks/boundary/configs/rules.py @@ -0,0 +1,92 @@ +from typing import Dict, Self, Tuple + +from pydantic import BaseModel, ConfigDict, model_validator + +from sampletones_shared.utils.serialization import load_yaml_model_dir +from sampletones_tools.checks.boundary.configs.declaration import BoundaryDeclaration +from sampletones_tools.checks.boundary.configs.general import GeneralBoundaries +from sampletones_tools.checks.boundary.configs.paths import BOUNDARIES_DIRECTORY +from sampletones_tools.checks.boundary.graph import LayerGraph +from sampletones_tools.checks.boundary.rule import BoundaryRule +from sampletones_tools.checks.boundary.standalone import StandaloneRule +from sampletones_tools.checks.boundary.token import TokenRule + + +class ImportBoundaryRules(BaseModel): + """Every boundary the repository's code is held to, as the shipped configuration states it. + + The declaration comes in five 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 checkout declaration + does the same for a unit standing beside the source tree, named from the repository root. A + token rule names a spelling a tree keeps out. A standalone rule names the scripts that run on + the system interpreter and holds them to the standard library. 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 of the source tree written directly. + checkout: The boundaries of the units beside the source tree, each named from the repository root. + tokens: The spellings kept out of the trees they name. + standalone: The scripts held to the standard library and the tree they sit in. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + general: GeneralBoundaries + graphs: Dict[str, LayerGraph] + rules: Tuple[BoundaryDeclaration, ...] + checkout: Tuple[BoundaryDeclaration, ...] + tokens: Tuple[TokenRule, ...] + standalone: Tuple[StandaloneRule, ...] + + @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, *self.checkout) + 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), + ) + + def checkout_rules(self) -> Tuple[BoundaryRule, ...]: + """Every import boundary of the units beside the source tree, in declaration order.""" + return tuple(declaration.rule(self.general) for declaration in self.checkout) + + @property + def checkout_units(self) -> Tuple[str, ...]: + """The directories under the repository root the checkout declarations hold, in declaration order.""" + return tuple(declaration.root for declaration in self.checkout) diff --git a/src/sampletones_tools/checks/boundary/graph.py b/src/sampletones_tools/checks/boundary/graph.py new file mode 100644 index 000000000..9842a9a54 --- /dev/null +++ b/src/sampletones_tools/checks/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_tools.checks.boundary.rule import BoundaryRule +from sampletones_tools.checks.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_tools/checks/boundary/imports.py b/src/sampletones_tools/checks/boundary/imports.py new file mode 100644 index 000000000..83a41277e --- /dev/null +++ b/src/sampletones_tools/checks/boundary/imports.py @@ -0,0 +1,24 @@ +import re +from typing import Final, Optional + +from sampletones_tools.checks.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_tools/checks/boundary/lines.py b/src/sampletones_tools/checks/boundary/lines.py new file mode 100644 index 000000000..3bdd39d96 --- /dev/null +++ b/src/sampletones_tools/checks/boundary/lines.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Iterator, Tuple + +from sampletones_tools.checks.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_tools/checks/boundary/packages.py b/src/sampletones_tools/checks/boundary/packages.py new file mode 100644 index 000000000..88f548fae --- /dev/null +++ b/src/sampletones_tools/checks/boundary/packages.py @@ -0,0 +1,61 @@ +from pathlib import Path +from typing import Final, List, Optional, Set, Tuple + +from sampletones_tools.checks.boundary.units import INITIALIZER_FILENAME +from sampletones_tools.checks.boundary.violation import Violation +from sampletones_tools.checks.source.modules import source_paths + +NAMESPACE_PACKAGE: Final[str] = "namespace package" + + +def enclosing_directories(module: Path, root: Path) -> Tuple[Path, ...]: + """The directories a module sits in below the root, innermost first. + + Args: + module: Module under the root. + root: Directory the walk stops at, left out of the answer. + + Returns: + Tuple[Path, ...]: Every directory between the root and the module. + + Raises: + ValueError: If the module sits outside the root. + """ + return tuple(root / directory for directory in module.relative_to(root).parents[:-1]) + + +def check_packages(source: Path, selection: Optional[Set[Path]]) -> List[Violation]: + """Every directory under the source root that holds modules and no initializer. + + Every directory between the source root and a module is a package with an `__init__.py` of + its own. A directory holding modules alone is a namespace package, and a tool reading the tree + takes it for a root it imports from. A module inside one then answers for the top-level module + of the same name, the standard library's included, so the rule keeps every module reachable by + its full name alone. + + Args: + source: Source root the packages sit under. + selection: Resolved paths to narrow the check to, or `None` to check the whole tree. A + directory is reported where a selected module sits inside it. + + Returns: + List[Violation]: One violation per directory, in path order. + + Raises: + NotADirectoryError: If the source root names no directory. + FileNotFoundError: If the source root holds no module to read. + """ + root = source.resolve() + modules = [path.resolve() for path in source_paths([root])] + if selection is not None: + modules = [module for module in modules if module in selection] + + directories = {directory for module in modules for directory in enclosing_directories(module, root)} + return [ + Violation( + kind=NAMESPACE_PACKAGE, + location=str(directory), + ) + for directory in sorted(directories) + if not (directory / INITIALIZER_FILENAME).is_file() + ] diff --git a/src/sampletones_tools/checks/boundary/rule.py b/src/sampletones_tools/checks/boundary/rule.py new file mode 100644 index 000000000..5a4e1339b --- /dev/null +++ b/src/sampletones_tools/checks/boundary/rule.py @@ -0,0 +1,82 @@ +from pathlib import Path +from typing import List, Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_tools.checks.boundary.imports import ( + imported_module, + matches_prefix, +) +from sampletones_tools.checks.boundary.lines import numbered_lines +from sampletones_tools.checks.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_tools/checks/boundary/scope.py b/src/sampletones_tools/checks/boundary/scope.py new file mode 100644 index 000000000..86aa45c28 --- /dev/null +++ b/src/sampletones_tools/checks/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_tools/checks/boundary/standalone.py b/src/sampletones_tools/checks/boundary/standalone.py new file mode 100644 index 000000000..93ea73332 --- /dev/null +++ b/src/sampletones_tools/checks/boundary/standalone.py @@ -0,0 +1,144 @@ +import sys +from pathlib import Path +from typing import Final, List, Optional, Sequence, Set, Tuple + +from pydantic import BaseModel, ConfigDict + +from sampletones_tools.checks.boundary.imports import imported_module +from sampletones_tools.checks.boundary.lines import numbered_lines +from sampletones_tools.checks.boundary.scope import rule_modules +from sampletones_tools.checks.boundary.units import INITIALIZER_FILENAME +from sampletones_tools.checks.boundary.violation import Violation +from sampletones_tools.checks.source.modules import ( + MODULE_SEPARATOR, + SOURCE_PATTERN, + source_paths, +) + +SHADOWING: Final[str] = "stands in for a module the tree sits beside" + + +def local_names(root: Path) -> Set[str]: + """The names a script under ``root`` reaches in the tree itself: its modules and its packages. + + A script runs with its tree on the import path, so a module beside it and a package holding + an initializer are reached by their bare names. + + Args: + root: The tree the scripts sit in. + + Returns: + Set[str]: The importable names the tree offers. + """ + modules = {path.stem for path in root.glob(SOURCE_PATTERN)} + packages = {path.name for path in root.iterdir() if (path / INITIALIZER_FILENAME).is_file()} + return modules | packages + + +class StandaloneRule(BaseModel): + """One tree of scripts that run on the system interpreter, and what they may import. + + A script the rule reaches imports the standard library and the tree it sits in, so it runs + on a machine that has Python and nothing more. The tree sits on the import path beside the + standard library and the repository's own packages, so a name in it that stands in for one + of theirs is reported as well. + + Attributes: + pattern: Glob naming the scripts the rule reaches, written against the tree's root. + reserved: Names the tree keeps clear of beyond the standard library's. + message: What the rule holds, printed where a script imports past it. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + pattern: str + reserved: Tuple[str, ...] = () + message: str + + def violations(self, path: Path, local: Set[str]) -> List[Violation]: + """Every import one script takes beyond the standard library and the tree. + + A relative import stays inside the tree by construction, and a name the tree offers is + reached the way a script reaches it when run from the tree. + + Args: + path: Script to read. + local: The names the tree offers, as `local_names` reads them. + + Returns: + List[Violation]: The imports the rule reports, in line order. + + Raises: + OSError: If the script cannot be read. + """ + violations: List[Violation] = [] + for line_number, line in numbered_lines(path): + module = imported_module(line) + if module is None: + continue + + top = module.split(MODULE_SEPARATOR, 1)[0] + if top and top not in sys.stdlib_module_names and top not in local: + violations.append(Violation.at(self.message, path, line_number, line)) + + return violations + + def shadowing(self, root: Path, paths: Sequence[Path]) -> List[Violation]: + """Every name in the tree that stands in for a standard-library or reserved module. + + A name is reported once, at the first script that carries it, since a directory is + named by every script under it. + + Args: + root: The tree the scripts sit in, resolved. + paths: The scripts the rule reaches, resolved and in path order. + + Returns: + List[Violation]: One violation per offending name, in the order the tree is read. + """ + taken = set(sys.stdlib_module_names) | set(self.reserved) + seen: Set[str] = set() + violations: List[Violation] = [] + for path in paths: + for part in path.relative_to(root).with_suffix("").parts: + if part in taken and part not in seen: + seen.add(part) + violations.append( + Violation( + kind=f"{part} {SHADOWING}", + location=str(path), + ) + ) + + return violations + + +def check_standalone( + root: Path, + rules: Sequence[StandaloneRule], + selection: Optional[Set[Path]], +) -> List[Violation]: + """Every import and name the rules forbid under a tree of standalone scripts. + + Args: + root: The tree the rules are written against. + rules: What the scripts may import. + selection: Resolved paths to narrow the check to, or `None` to check the whole tree. + + Returns: + List[Violation]: What the rules report, the shadowing names before the imports. + + Raises: + NotADirectoryError: If the root names no directory. + FileNotFoundError: If the root holds no module to read. + """ + tree = root.resolve() + swept = {path.resolve() for path in source_paths([tree])} + local = local_names(tree) + violations: List[Violation] = [] + for rule in rules: + paths = rule_modules(tree, rule.pattern, (), swept, selection) + violations.extend(rule.shadowing(tree, paths)) + violations.extend(violation for path in paths for violation in rule.violations(path, local)) + + return violations diff --git a/src/sampletones_tools/checks/boundary/token.py b/src/sampletones_tools/checks/boundary/token.py new file mode 100644 index 000000000..e99660945 --- /dev/null +++ b/src/sampletones_tools/checks/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_tools.checks.boundary.lines import numbered_lines +from sampletones_tools.checks.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_tools/checks/boundary/units.py b/src/sampletones_tools/checks/boundary/units.py new file mode 100644 index 000000000..0be671006 --- /dev/null +++ b/src/sampletones_tools/checks/boundary/units.py @@ -0,0 +1,35 @@ +from typing import Final, Iterable, Tuple + +from sampletones_tools.checks.source.modules import MODULE_SEPARATOR, PACKAGE_INITIALIZER + +MODULE_SUFFIX: Final[str] = ".py" +INITIALIZER_FILENAME: Final[str] = f"{PACKAGE_INITIALIZER}{MODULE_SUFFIX}" +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_tools/checks/boundary/violation.py b/src/sampletones_tools/checks/boundary/violation.py new file mode 100644 index 000000000..42eb61260 --- /dev/null +++ b/src/sampletones_tools/checks/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_tools/checks/command.py b/src/sampletones_tools/checks/command.py new file mode 100644 index 000000000..156138c2e --- /dev/null +++ b/src/sampletones_tools/checks/command.py @@ -0,0 +1,48 @@ +from argparse import ArgumentParser, Namespace +from typing import Final + +from sampletones_shared.command import Command +from sampletones_tools.checks.registry import GATES + +NAME: Final[str] = "check" +HELP: Final[str] = "hold the repository to one of its source checks" +GATE_FIELD: Final[str] = "gate" +GATE_METAVAR: Final[str] = "" + + +def configure(parser: ArgumentParser) -> None: + gates = parser.add_subparsers( + dest=GATE_FIELD, + metavar=GATE_METAVAR, + required=True, + ) + for gate in GATES: + gate.configure( + gates.add_parser( + gate.name, + help=gate.help, + description=gate.help, + ) + ) + + +def run(arguments: Namespace) -> int: + """Runs the check named, from a checkout, and answers with its status. + + Raises: + SystemExit: If the package runs outside a checkout. + """ + gate = next(gate for gate in GATES if gate.name == arguments.gate) + + from sampletones_tools.checkout import require_checkout + + require_checkout(f"{NAME} {gate.name}") + return gate.run(arguments) + + +CHECK: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/__init__.py b/src/sampletones_tools/checks/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/checks/commands/import_boundary.py b/src/sampletones_tools/checks/commands/import_boundary.py new file mode 100644 index 000000000..e0e434254 --- /dev/null +++ b/src/sampletones_tools/checks/commands/import_boundary.py @@ -0,0 +1,65 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Tuple + +from sampletones_shared.command import Command + +NAME: Final[str] = "import-boundary" +HELP: Final[str] = "hold the source, checkout and scripts trees to the declared import boundaries" +FILES_HELP: Final[str] = "modules to check" +ALL_HELP: Final[str] = "check every module under the source, checkout and scripts trees instead of named files" +SOURCE_HELP: Final[str] = "source root the rule roots are named within; without it, the repository's src" +SCRIPTS_HELP: Final[str] = "scripts tree the standalone rules are written against; without it, the repository's scripts" +CHECKOUT_HELP: Final[str] = "repository root the checkout units are named within; without it, this repository" + + +@dataclass(frozen=True) +class ImportBoundaryArguments: + """What a boundary check is given: the modules or the whole trees, and where the trees are.""" + + files: Tuple[Path, ...] + everything: bool + source: Optional[Path] + scripts: Optional[Path] + checkout: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("files", nargs="*", type=Path, help=FILES_HELP) + parser.add_argument("--all", action="store_true", help=ALL_HELP) + parser.add_argument("--source", type=Path, default=None, help=SOURCE_HELP) + parser.add_argument("--scripts", type=Path, default=None, help=SCRIPTS_HELP) + parser.add_argument("--checkout", type=Path, default=None, help=CHECKOUT_HELP) + + +def run(arguments: Namespace) -> int: + """Reports every import and token the boundaries forbid.""" + given = ImportBoundaryArguments( + files=tuple(arguments.files), + everything=arguments.all, + source=arguments.source, + scripts=arguments.scripts, + checkout=arguments.checkout, + ) + + from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT + from sampletones_tools.checks.import_boundary import check_imports, report + from sampletones_tools.checks.paths import SCRIPTS_ROOT + + selection = None if given.everything else {path.resolve() for path in given.files} + violations = check_imports( + given.source if given.source is not None else SOURCE_ROOT, + given.scripts if given.scripts is not None else SCRIPTS_ROOT, + given.checkout if given.checkout is not None else REPOSITORY_ROOT, + selection, + ) + return report(violations) + + +IMPORT_BOUNDARY: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/language_keys.py b/src/sampletones_tools/checks/commands/language_keys.py new file mode 100644 index 000000000..0c46a3151 --- /dev/null +++ b/src/sampletones_tools/checks/commands/language_keys.py @@ -0,0 +1,60 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.command import Command + +NAME: Final[str] = "language-keys" +HELP: Final[str] = "hold the code and the language file to each other, in both directions" +SOURCE_HELP: Final[str] = "root of the sources to read; without it, the repository's src" +LANGUAGE_FILE_HELP: Final[str] = "language file to check against; without it, the shipped en.yaml" + + +@dataclass(frozen=True) +class LanguageKeysArguments: + """What a language key check is given: the sources and the language file.""" + + source: Optional[Path] + language_file: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + "--source", + type=Path, + default=None, + help=SOURCE_HELP, + ) + parser.add_argument( + "--language-file", + type=Path, + default=None, + help=LANGUAGE_FILE_HELP, + ) + + +def run(arguments: Namespace) -> int: + """Reports every disagreement between the language file and the lookups reading it.""" + given = LanguageKeysArguments( + source=arguments.source, + language_file=arguments.language_file, + ) + + from sampletones_application.paths import LANG_EN + from sampletones_shared.paths.source import SOURCE_ROOT + from sampletones_tools.checks.language_keys import check_language_keys, report + + findings = check_language_keys( + given.source if given.source is not None else SOURCE_ROOT, + given.language_file if given.language_file is not None else LANG_EN, + ) + return report(findings) + + +LANGUAGE_KEYS: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/palette_colors.py b/src/sampletones_tools/checks/commands/palette_colors.py new file mode 100644 index 000000000..a85c6e5e5 --- /dev/null +++ b/src/sampletones_tools/checks/commands/palette_colors.py @@ -0,0 +1,61 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.command import Command + +NAME: Final[str] = "palette-colors" +HELP: Final[str] = "hold every color to a palette token until it is drawn with" +PACKAGE_HELP: Final[str] = "package whose color reads to check; without it, the application package" +CONFIG_DIRECTORY_HELP: Final[str] = ( + "shipped configuration package whose colors must name palette tokens; without it, the shipped one" +) +PALETTES_HELP: Final[str] = "directory holding the palettes, where color values belong; without it, the shipped one" + + +@dataclass(frozen=True) +class PaletteColorsArguments: + """What a palette check is given: the package, the configuration and the palettes.""" + + package: Optional[Path] + config_directory: Optional[Path] + palettes: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("--package", type=Path, default=None, help=PACKAGE_HELP) + parser.add_argument("--config-directory", type=Path, default=None, help=CONFIG_DIRECTORY_HELP) + parser.add_argument("--palettes", type=Path, default=None, help=PALETTES_HELP) + + +def run(arguments: Namespace) -> int: + """Reports every color the application stores resolved or the configuration writes out.""" + given = PaletteColorsArguments( + package=arguments.package, + config_directory=arguments.config_directory, + palettes=arguments.palettes, + ) + + from sampletones_application.paths import PALETTES_DIRECTORY + from sampletones_shared.paths.resources import CONFIG_DIRECTORY + from sampletones_tools.checks.palette_colors import ( + APPLICATION_PACKAGE, + check_colors, + report, + ) + + findings = check_colors( + given.package if given.package is not None else APPLICATION_PACKAGE, + given.config_directory if given.config_directory is not None else CONFIG_DIRECTORY, + given.palettes if given.palettes is not None else PALETTES_DIRECTORY, + ) + return report(findings) + + +PALETTE_COLORS: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/rendered_literals.py b/src/sampletones_tools/checks/commands/rendered_literals.py new file mode 100644 index 000000000..67dd0a045 --- /dev/null +++ b/src/sampletones_tools/checks/commands/rendered_literals.py @@ -0,0 +1,49 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +from sampletones_shared.command import Command + +NAME: Final[str] = "rendered-literals" +HELP: Final[str] = "hold every case to comparing values, never the text it rendered" +TESTS_HELP: Final[str] = "directory of cases to read, repeatable; without it, the repository's tests" + + +@dataclass(frozen=True) +class RenderedLiteralsArguments: + """What a rendered literal check is given: the directories of cases.""" + + roots: Tuple[Path, ...] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + "--tests", + type=Path, + action="append", + dest="roots", + default=[], + help=TESTS_HELP, + ) + + +def run(arguments: Namespace) -> int: + """Reports every case comparing text it rendered with a literal it spelled out.""" + given = RenderedLiteralsArguments(roots=tuple(arguments.roots)) + + from sampletones_tools.checks.rendered_literals import ( + TEST_ROOTS, + check_cases, + report, + ) + + return report(check_cases(given.roots or TEST_ROOTS)) + + +RENDERED_LITERALS: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/shortcut_actions.py b/src/sampletones_tools/checks/commands/shortcut_actions.py new file mode 100644 index 000000000..4d20fc24b --- /dev/null +++ b/src/sampletones_tools/checks/commands/shortcut_actions.py @@ -0,0 +1,28 @@ +from argparse import ArgumentParser, Namespace +from typing import Final + +from sampletones_shared.command import Command + +NAME: Final[str] = "shortcut-actions" +HELP: Final[str] = "hold every action to its keys, its name and the call it makes" + + +def configure(parser: ArgumentParser) -> None: + del parser + + +def run(arguments: Namespace) -> int: + """Reports every action left short of a combination, a name, or the call it makes.""" + del arguments + + from sampletones_tools.checks.shortcut_actions import check, report + + return report(check()) + + +SHORTCUT_ACTIONS: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/tag_names.py b/src/sampletones_tools/checks/commands/tag_names.py new file mode 100644 index 000000000..bc4640f41 --- /dev/null +++ b/src/sampletones_tools/checks/commands/tag_names.py @@ -0,0 +1,44 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +from sampletones_shared.command import Command + +NAME: Final[str] = "tag-names" +HELP: Final[str] = "hold every tag constant's name to the tag it composes" +FILES_HELP: Final[str] = "modules to check" +ALL_HELP: Final[str] = "check every module of the tags package instead of named files" + + +@dataclass(frozen=True) +class TagNamesArguments: + """What a tag name check is given: the modules, or the whole tags package.""" + + files: Tuple[Path, ...] + everything: bool + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("files", nargs="*", type=Path, help=FILES_HELP) + parser.add_argument("--all", action="store_true", help=ALL_HELP) + + +def run(arguments: Namespace) -> int: + """Reports any tag constant whose name departs from the tag it composes.""" + given = TagNamesArguments( + files=tuple(arguments.files), + everything=arguments.all, + ) + + from sampletones_tools.checks.tag_names import check_tags, report + + return report(check_tags(given.files, given.everything)) + + +TAG_NAMES: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/commands/unused_tags.py b/src/sampletones_tools/checks/commands/unused_tags.py new file mode 100644 index 000000000..eaa545740 --- /dev/null +++ b/src/sampletones_tools/checks/commands/unused_tags.py @@ -0,0 +1,60 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Tuple + +from sampletones_shared.command import Command + +NAME: Final[str] = "unused-tags" +HELP: Final[str] = "hold every declared tag fragment to a read somewhere in the repository" +TAGS_HELP: Final[str] = "package declaring the tag fragments; without it, the application's tags package" +REFERENCE_ROOT_HELP: Final[str] = "directory to count reads in, repeatable; without it, src, tests and scripts" + + +@dataclass(frozen=True) +class UnusedTagsArguments: + """What an unused tag check is given: the tags package and the directories read for references.""" + + tags: Optional[Path] + reference_roots: Tuple[Path, ...] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("--tags", type=Path, default=None, help=TAGS_HELP) + parser.add_argument( + "--reference-root", + type=Path, + action="append", + dest="reference_roots", + default=[], + help=REFERENCE_ROOT_HELP, + ) + + +def run(arguments: Namespace) -> int: + """Reports every tag fragment the repository declares and never reads.""" + given = UnusedTagsArguments( + tags=arguments.tags, + reference_roots=tuple(arguments.reference_roots), + ) + + from sampletones_tools.checks.unused_tags import ( + REFERENCE_ROOTS, + TAGS_PACKAGE, + check_reads, + report, + ) + + unread = check_reads( + given.tags if given.tags is not None else TAGS_PACKAGE, + given.reference_roots or REFERENCE_ROOTS, + ) + return report(unread) + + +UNUSED_TAGS: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/checks/import_boundary.py b/src/sampletones_tools/checks/import_boundary.py new file mode 100644 index 000000000..769894ef0 --- /dev/null +++ b/src/sampletones_tools/checks/import_boundary.py @@ -0,0 +1,50 @@ +import sys +from pathlib import Path +from typing import List, Optional, Sequence, Set + +from sampletones_tools.checks.boundary.check import check_boundaries +from sampletones_tools.checks.boundary.configs.rules import ImportBoundaryRules +from sampletones_tools.checks.boundary.packages import check_packages +from sampletones_tools.checks.boundary.standalone import check_standalone +from sampletones_tools.checks.boundary.violation import Violation + + +def check_imports( + source: Path, + scripts: Path, + checkout: Path, + selection: Optional[Set[Path]], +) -> List[Violation]: + """Every import, token and namespace package the shipped boundaries forbid under the three trees. + + Args: + source: Source root the rule roots are named within. + scripts: Scripts tree the standalone rules are written against. + checkout: Repository root the units beside the source tree are named within. + selection: Resolved paths to narrow the check to, or `None` to check the trees whole. + + Returns: + List[Violation]: What the rules report: the source tree's, then the checkout units', then + the scripts tree's. + """ + boundaries = ImportBoundaryRules.load() + return [ + *check_boundaries(source, boundaries.boundary_rules(), boundaries.tokens, selection), + *check_packages(source, selection), + *check_boundaries(checkout, boundaries.checkout_rules(), (), selection), + *(violation for unit in boundaries.checkout_units for violation in check_packages(checkout / unit, selection)), + *check_standalone(scripts, boundaries.standalone, selection), + ] + + +def report(violations: Sequence[Violation]) -> int: + """Prints every violation on the standard error stream and answers with the exit status.""" + if not violations: + return 0 + + print("Layer boundary violation(s) found:", file=sys.stderr) + for kind, location in violations: + print(f" [forbidden: {kind}] {location}", file=sys.stderr) + + print(f"\nFound {len(violations)} violation(s) in total.", file=sys.stderr) + return 1 diff --git a/src/sampletones_tools/checks/language_keys.py b/src/sampletones_tools/checks/language_keys.py new file mode 100644 index 000000000..d8c4f7f3c --- /dev/null +++ b/src/sampletones_tools/checks/language_keys.py @@ -0,0 +1,282 @@ +import importlib +import inspect +import sys +from enum import Enum, EnumMeta +from pathlib import Path +from types import ModuleType +from typing import ( + Callable, + Dict, + Final, + List, + Mapping, + NamedTuple, + Optional, + Sequence, + Type, +) + +import yaml + +from sampletones_application.categories import hierarchy +from sampletones_application.categories.abstract import AbstractElement +from sampletones_application.tags.compose import TAG_SEPARATOR +from sampletones_shared.paths.source import SOURCE_ROOT +from sampletones_tools.checks.source.classes import declared_subclasses +from sampletones_tools.checks.source.index import source_index +from sampletones_tools.checks.source.lookups import LookupSite, tree_lookups +from sampletones_tools.checks.source.modules import discover_modules, module_name +from sampletones_tools.checks.source.packages import source_package_directory +from sampletones_tools.checks.source.values import EnumMembers, EnumTable + +EnumPredicate = Callable[[object], bool] + +APPLICATION_PACKAGE: Final[Path] = source_package_directory("sampletones_application") + +RECEIVER_TYPE: Final[str] = "LanguageManager" +ELEMENT_BASE: Final[str] = AbstractElement.__name__ + +LANGUAGE_ENCODING: Final[str] = "utf-8" +ENTRY_SEPARATOR: Final[str] = ":" +COMMENT_PREFIX: Final[str] = "#" +FIRST_LINE: Final[int] = 1 + +BROKEN_LOOKUP: Final[str] = "broken lookup" +UNRESOLVED_PART: Final[str] = "unresolved part" +UNREACHED_ENTRY: Final[str] = "unreached entry" + +DYNAMIC_KEY_RULE: Final[str] = ( + f"a key part arriving in a variable is annotated with a concrete element enum, never " + f"{ELEMENT_BASE}, and a key an f-string builds states no values to check" +) + + +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 is_enum(member: object) -> bool: + """States whether a module member is an enum.""" + return isinstance(member, EnumMeta) + + +def is_element_enum(member: object) -> bool: + """States whether a module member is an enum of the elements a key part names.""" + return isinstance(member, EnumMeta) and issubclass(member, AbstractElement) + + +def enum_members(member_type: Type[Enum]) -> EnumMembers: + """The value each member of an enum spells, keyed by member name.""" + return {member.name: str(member.value) for member in member_type} + + +def declared_enums(module: ModuleType, matches: EnumPredicate) -> Dict[str, EnumMembers]: + """The enums a module declares itself, keyed by enum name. + + An enum is read from the module declaring it, so a name a module merely imports states its + members once, under the module that owns it. + + Args: + module: Imported module to read. + matches: What makes a member one of the enums to read. + + Returns: + Dict[str, EnumMembers]: Enum name to its member names and the values they spell. + """ + return { + name: enum_members(member_type) + for name, member_type in inspect.getmembers(module, matches) + if member_type.__module__ == module.__name__ + } + + +def element_enum_modules() -> List[ModuleType]: + """Every module of the application declaring an element enum, imported for its members. + + A module is found by the classes it declares, so an enum naming a domain's own elements lives + beside that domain and the check reads it there. + + Returns: + List[ModuleType]: The imported modules, in path order. + """ + return [ + importlib.import_module(module_name(module.path, SOURCE_ROOT)) + for module in discover_modules([APPLICATION_PACKAGE]) + if declared_subclasses(module.tree, ELEMENT_BASE) + ] + + +def enum_table() -> EnumTable: + """The members of every enum a key part can name, keyed by enum name. + + The hierarchy states the page, panel, and text type of a key, and an element enum states its + element, so together they cover every part a lookup writes. + + Returns: + EnumTable: Enum name to its member names and the values they spell. + """ + table: Dict[str, EnumMembers] = declared_enums(hierarchy, is_enum) + for module in element_enum_modules(): + table.update(declared_enums(module, is_element_enum)) + + return table + + +def entry_key(line: str) -> Optional[str]: + """The key a line of the language file states, where it states one.""" + key, separator, _ = line.partition(ENTRY_SEPARATOR) + stripped = key.strip() + if not separator or not stripped or stripped.startswith(COMMENT_PREFIX): + return None + + return stripped + + +def entry_lines(path: Path) -> Dict[str, int]: + """Where each key sits in the language file, read line by line.""" + lines: Dict[str, int] = {} + for number, line in enumerate( + path.read_text(encoding=LANGUAGE_ENCODING).splitlines(), + start=FIRST_LINE, + ): + key = entry_key(line) + if key is not None: + lines.setdefault(key, number) + + return lines + + +def language_entries(path: Path) -> Dict[str, int]: + """Every key the language file holds, paired with the line stating it. + + Args: + path: Language file to read. + + Returns: + Dict[str, int]: Key to its line, in file order. + + Raises: + TypeError: If the file holds anything other than a mapping. + """ + raw = yaml.safe_load(path.read_text(encoding=LANGUAGE_ENCODING)) + if not isinstance(raw, dict): + raise TypeError(f"Language file {path} must hold a mapping, got {type(raw)}") + + lines = entry_lines(path) + return {str(key): lines.get(str(key), FIRST_LINE) for key in raw} + + +def broken_lookups( + sites: Sequence[LookupSite], + entries: Mapping[str, int], +) -> List[Finding]: + """Every literal key a lookup asks for that the language file holds no entry for. + + Args: + sites: Lookups read from the tree. + entries: Keys the language file holds. + + Returns: + List[Finding]: One finding per missing key. + """ + return [ + Finding( + kind=BROKEN_LOOKUP, + location=site.location, + message=f"the language file holds no entry for {key!r}", + ) + for site in sites + if site.exact + for key in site.values + if key not in entries + ] + + +def unresolved_lookups(sites: Sequence[LookupSite]) -> List[Finding]: + """Every lookup holding a part the check reads no values from. + + Args: + sites: Lookups read from the tree. + + Returns: + List[Finding]: One finding per lookup, naming the parts left unresolved. + """ + return [ + Finding( + kind=UNRESOLVED_PART, + location=site.location, + message=f"this lookup states {', '.join(site.unresolved_parts)}, which reaches no key: {DYNAMIC_KEY_RULE}", + ) + for site in sites + if not site.resolved + ] + + +def unreached_entries( + sites: Sequence[LookupSite], + entries: Mapping[str, int], + path: Path, +) -> List[Finding]: + """Every entry of the language file no lookup asks for. + + Args: + sites: Lookups read from the tree. + entries: Keys the language file holds, paired with their lines. + path: Language file the entries live in. + + Returns: + List[Finding]: One finding per unreached entry, in file order. + """ + reached = {key for site in sites for key in site.values} + return [ + Finding( + kind=UNREACHED_ENTRY, + location=f"{path}:{line}", + message=f"no lookup asks for {key!r}", + ) + for key, line in entries.items() + if key not in reached + ] + + +def check_language_keys(source: Path, language_file: Path) -> List[Finding]: + """Reads every lookup under a source root and holds it against a language file. + + Args: + source: Root of the sources to read. + language_file: Language file to check against. + + Returns: + List[Finding]: Everything the check reports, broken lookups first. + """ + modules = discover_modules([source]) + sites = tree_lookups( + modules, + index=source_index(modules), + enums=enum_table(), + receiver_type=RECEIVER_TYPE, + separator=TAG_SEPARATOR, + ) + entries = language_entries(language_file) + return [ + *broken_lookups(sites, entries), + *unresolved_lookups(sites), + *unreached_entries(sites, entries, language_file), + ] + + +def report(findings: Sequence[Finding]) -> int: + """Prints every finding on the standard error stream and answers with the exit status.""" + if not findings: + return 0 + + print("Language key finding(s):", file=sys.stderr) + for kind, location, message in findings: + print(f" [{kind}] {location}: {message}", file=sys.stderr) + + print(f"\nFound {len(findings)} language key finding(s).", file=sys.stderr) + return 1 diff --git a/src/sampletones_tools/checks/palette_colors.py b/src/sampletones_tools/checks/palette_colors.py new file mode 100644 index 000000000..a756dcdf1 --- /dev/null +++ b/src/sampletones_tools/checks/palette_colors.py @@ -0,0 +1,216 @@ +import ast +import logging +import re +import sys +from collections.abc import Iterator, Sequence +from itertools import chain +from pathlib import Path +from typing import Final, List, NamedTuple, Tuple, Union + +from sampletones_shared.logger import logger +from sampletones_tools.checks.source.modules import SourceModule, discover_modules +from sampletones_tools.checks.source.nodes import terminal_name +from sampletones_tools.checks.source.packages import source_package_directory + +APPLICATION_PACKAGE: Final[Path] = source_package_directory("sampletones_application") + +HEX_COLOR: Final[re.Pattern[str]] = re.compile(r"[\"']#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?[\"']") + +COLOR_PROPERTY: Final[str] = "rgba" +SELF_NAMES: Final[Tuple[str, ...]] = ("self", "cls") + +THEME_COLOR_CALL: Final[str] = "add_theme_color" +CONFIG_PATTERN: Final[str] = "*.yaml" + + +Assignment = Union[ast.Assign, ast.AnnAssign] + + +class ColorFinding(NamedTuple): + """One place a color stops following the palette, and what to do about it.""" + + location: str + message: str + + +def _assignments(tree: ast.Module) -> Iterator[Assignment]: + for node in ast.walk(tree): + if isinstance(node, (ast.Assign, ast.AnnAssign)): + yield node + + +def _assigned_targets(statement: Assignment) -> Tuple[ast.expr, ...]: + if isinstance(statement, ast.Assign): + return tuple(statement.targets) + + return (statement.target,) + + +def _is_own_attribute(target: ast.expr) -> bool: + return isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id in SELF_NAMES + + +def _resolves_a_color(value: ast.expr) -> bool: + return isinstance(value, ast.Attribute) and value.attr == COLOR_PROPERTY + + +def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: + """Every attribute a module assigns the resolved value of a palette color. + + Args: + module: Module to read. + + Yields: + ColorFinding: One per assignment, naming the attribute that keeps the stale shade. + """ + for statement in _assignments(module.tree): + if statement.value is None or not _resolves_a_color(statement.value): + continue + + for target in _assigned_targets(statement): + if _is_own_attribute(target): + yield ColorFinding( + location=module.location(statement), + message=( + f"stores .{COLOR_PROPERTY}; hold the BaseColor and read " + f".{COLOR_PROPERTY} where the color reaches DearPyGui" + ), + ) + + +def dpg_module_helper() -> Tuple[Path, str]: + """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. + """ + import sampletones_application.utils.gui.palette.dpg as bindings + + return Path(bindings.__file__).resolve(), bindings.dpg_add_palette_theme_color.__name__ + + +def unregistered_theme_colors( + module: SourceModule, + *, + bindings_module: Path, + theme_color_helper: str, +) -> Iterator[ColorFinding]: + """Every theme color a module fills without recording the token behind it. + + Args: + module: Module to read. + bindings_module: Module the call belongs in, which records the token in the same breath. + theme_color_helper: Name of the helper a report points at. + + Yields: + ColorFinding: One per call, naming the theme color that stays at the shade it was + built with. + """ + if module.path.resolve() == bindings_module: + return + + for node in ast.walk(module.tree): + if isinstance(node, ast.Call) and terminal_name(node.func) == THEME_COLOR_CALL: + yield ColorFinding( + location=module.location(node), + 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 color a shipped configuration file writes out in place of a palette token. + + Args: + path: Configuration file to read. + + Yields: + ColorFinding: One per literal, naming the line that holds it. + """ + for number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), + start=1, + ): + for match in HEX_COLOR.finditer(line): + yield ColorFinding( + location=f"{path}:{number}", + message=f"writes the color {match.group()} directly; name a palette token instead", + ) + + +def find_detached_colors( + package: Path, + *, + bindings_module: Path, + theme_color_helper: str, +) -> List[ColorFinding]: + return [ + finding + for module in discover_modules([package]) + for finding in chain( + stored_colors(module), + unregistered_theme_colors( + module, + bindings_module=bindings_module, + theme_color_helper=theme_color_helper, + ), + ) + ] + + +def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: + """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 color value belongs. + + Returns: + List[ColorFinding]: One finding per literal, in file order. + + Raises: + FileNotFoundError: If the package holds no configuration file to read. + """ + paths = sorted(package.rglob(CONFIG_PATTERN)) + if not paths: + raise FileNotFoundError(f"The configuration package {package} holds no {CONFIG_PATTERN} file to read") + + return [finding for path in paths if palettes not in path.parents for finding in literal_colors(path)] + + +def check_colors(package: Path, config: Path, palettes: Path) -> List[ColorFinding]: + """Every color the application stores resolved or the configuration writes out. + + Args: + package: Package whose color reads are checked. + config: Shipped configuration package whose colors must name palette tokens. + palettes: Directory holding the palettes, where color values belong. + + Returns: + List[ColorFinding]: The detached colors of the package, then the literal colors of the + configuration. + """ + logger.set_level(level=logging.ERROR) + bindings_module, theme_color_helper = dpg_module_helper() + findings = find_detached_colors( + package, + bindings_module=bindings_module, + theme_color_helper=theme_color_helper, + ) + findings.extend(find_literal_colors(config, palettes)) + return findings + + +def report(findings: Sequence[ColorFinding]) -> int: + """Prints every finding on the standard error stream and answers with the exit status.""" + if not findings: + return 0 + + print("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)} color(s) detached from the palette.", + file=sys.stderr, + ) + return 1 diff --git a/src/sampletones_tools/checks/paths.py b/src/sampletones_tools/checks/paths.py new file mode 100644 index 000000000..becbb4168 --- /dev/null +++ b/src/sampletones_tools/checks/paths.py @@ -0,0 +1,6 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.source import REPOSITORY_ROOT + +SCRIPTS_ROOT: Final[Path] = REPOSITORY_ROOT / "scripts" diff --git a/src/sampletones_tools/checks/registry.py b/src/sampletones_tools/checks/registry.py new file mode 100644 index 000000000..b1cc2af33 --- /dev/null +++ b/src/sampletones_tools/checks/registry.py @@ -0,0 +1,20 @@ +from typing import Final, Tuple + +from sampletones_shared.command import Command +from sampletones_tools.checks.commands.import_boundary import IMPORT_BOUNDARY +from sampletones_tools.checks.commands.language_keys import LANGUAGE_KEYS +from sampletones_tools.checks.commands.palette_colors import PALETTE_COLORS +from sampletones_tools.checks.commands.rendered_literals import RENDERED_LITERALS +from sampletones_tools.checks.commands.shortcut_actions import SHORTCUT_ACTIONS +from sampletones_tools.checks.commands.tag_names import TAG_NAMES +from sampletones_tools.checks.commands.unused_tags import UNUSED_TAGS + +GATES: Final[Tuple[Command, ...]] = ( + IMPORT_BOUNDARY, + LANGUAGE_KEYS, + PALETTE_COLORS, + RENDERED_LITERALS, + SHORTCUT_ACTIONS, + TAG_NAMES, + UNUSED_TAGS, +) diff --git a/src/sampletones_tools/checks/rendered_literals.py b/src/sampletones_tools/checks/rendered_literals.py new file mode 100644 index 000000000..c198fa957 --- /dev/null +++ b/src/sampletones_tools/checks/rendered_literals.py @@ -0,0 +1,114 @@ +import ast +import sys +from pathlib import Path +from typing import Final, Iterator, List, NamedTuple, Sequence, Tuple, Type + +from sampletones_shared.paths.source import REPOSITORY_ROOT +from sampletones_tools.checks.source.modules import SourceModule, discover_modules + +TEST_ROOTS: Final[Tuple[Path, ...]] = (REPOSITORY_ROOT / "tests",) + +EQUALITY_OPERATORS: Final[Tuple[Type[ast.cmpop], ...]] = (ast.Eq, ast.NotEq) +RENDERING_CALL: Final[str] = "str" +CALL_RENDERING: Final[str] = "str()" +TEMPLATE_RENDERING: Final[str] = "an f-string" + + +class Finding(NamedTuple): + """One comparison holding rendered text against a spelled-out literal.""" + + location: str + rendering: str + + +def rendering_of(node: ast.expr) -> str: + """How an expression turns a value into text, where it does. + + Args: + node: Expression to read. + + Returns: + str: The rendering's name, or an empty string where the expression renders nothing. + """ + match node: + case ast.Call(func=ast.Name(id=called)) if called == RENDERING_CALL: + return CALL_RENDERING + case ast.JoinedStr(values=values) if any(isinstance(value, ast.FormattedValue) for value in values): + return TEMPLATE_RENDERING + case _: + return "" + + +def spells_text(node: ast.expr) -> bool: + """Whether an expression is a string written out in the case itself.""" + return isinstance(node, ast.Constant) and isinstance(node.value, str) + + +def states_equality(node: ast.Compare) -> bool: + """Whether a comparison asks for the whole of one side to read as the other. + + Equality is what pins a rendering entire. Containment holds a fragment the case picked, which + it can pick clear of anything a platform decides, so those are left to the case. + """ + return all(isinstance(operator, EQUALITY_OPERATORS) for operator in node.ops) + + +def comparison_finding(module: SourceModule, node: ast.Compare) -> List[Finding]: + """The finding a comparison earns, where one side renders text and another spells it out. + + Args: + module: Module the comparison sits in. + node: Comparison to read. + + Returns: + List[Finding]: The finding, or an empty list where the comparison holds values. + """ + if not states_equality(node): + return [] + + sides = [node.left, *node.comparators] + renderings = tuple(filter(None, (rendering_of(side) for side in sides))) + if not renderings or not any(spells_text(side) for side in sides): + return [] + + return [Finding(location=module.location(node), rendering=renderings[0])] + + +def module_findings(module: SourceModule) -> Iterator[Finding]: + """Every comparison in a module that holds rendered text against a spelled-out literal.""" + for node in ast.walk(module.tree): + if isinstance(node, ast.Compare): + yield from comparison_finding(module, node) + + +def findings(modules: Sequence[SourceModule]) -> List[Finding]: + """Every such comparison across the given modules, in the order they were read.""" + return [finding for module in modules for finding in module_findings(module)] + + +def check_cases(roots: Sequence[Path]) -> List[Finding]: + """Every case under the roots comparing text it rendered with a literal it spelled out.""" + return findings(discover_modules(roots)) + + +def report(found: Sequence[Finding]) -> int: + """Prints every finding on the standard error stream and answers with the exit status.""" + if not found: + return 0 + + print( + "Case(s) holding rendered text against a spelled-out literal:", + file=sys.stderr, + ) + for location, rendering in found: + print( + f" {location}: {rendering} compared with a written-out string", + file=sys.stderr, + ) + + print( + f"\nFound {len(found)} such comparison(s). Compare the values rather than their text: " + "a Path against a Path, a configured value against the configuration it comes from.", + file=sys.stderr, + ) + return 1 diff --git a/src/sampletones_tools/checks/shortcut_actions.py b/src/sampletones_tools/checks/shortcut_actions.py new file mode 100644 index 000000000..b44550f0a --- /dev/null +++ b/src/sampletones_tools/checks/shortcut_actions.py @@ -0,0 +1,224 @@ +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_tools.checks.source.modules import SourceModule, parse_module +from sampletones_tools.checks.source.packages import source_package_directory + +SHORTCUTS_MODULE: Final[Path] = Path(ids_module.__file__) +ELEMENTS_MODULE: Final[Path] = Path(settings_module.__file__) +SHELL_MODULE: Final[Path] = source_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 report(findings: Sequence[Finding]) -> int: + """Prints every finding on the standard error stream and answers with the exit status.""" + 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 diff --git a/src/sampletones_tools/checks/source/__init__.py b/src/sampletones_tools/checks/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_shared/meta/source/annotations.py b/src/sampletones_tools/checks/source/annotations.py similarity index 93% rename from src/sampletones_shared/meta/source/annotations.py rename to src/sampletones_tools/checks/source/annotations.py index 864655523..e71278bfe 100644 --- a/src/sampletones_shared/meta/source/annotations.py +++ b/src/sampletones_tools/checks/source/annotations.py @@ -1,7 +1,7 @@ import ast from typing import Final, FrozenSet, Optional, Tuple -from sampletones_shared.meta.source.nodes import slice_elements, terminal_name +from sampletones_tools.checks.source.nodes import slice_elements, terminal_name ANNOTATION_WRAPPERS: Final[FrozenSet[str]] = frozenset({"Annotated", "ClassVar", "Final", "Optional"}) @@ -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_tools/checks/source/bindings/__init__.py b/src/sampletones_tools/checks/source/bindings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_shared/meta/source/bindings/containers.py b/src/sampletones_tools/checks/source/bindings/containers.py similarity index 96% rename from src/sampletones_shared/meta/source/bindings/containers.py rename to src/sampletones_tools/checks/source/bindings/containers.py index 0e9d81e5e..75c3d1bf8 100644 --- a/src/sampletones_shared/meta/source/bindings/containers.py +++ b/src/sampletones_tools/checks/source/bindings/containers.py @@ -1,8 +1,8 @@ import ast from typing import Dict, Final, FrozenSet, Mapping, NamedTuple, Optional, Tuple -from sampletones_shared.meta.source.annotations import annotation_item_types -from sampletones_shared.meta.source.nodes import expression_spelling +from sampletones_tools.checks.source.annotations import annotation_item_types +from sampletones_tools.checks.source.nodes import expression_spelling KEY_ACCESSOR: Final[str] = "keys" VALUE_ACCESSOR: Final[str] = "values" diff --git a/src/sampletones_shared/meta/source/bindings/environment.py b/src/sampletones_tools/checks/source/bindings/environment.py similarity index 100% rename from src/sampletones_shared/meta/source/bindings/environment.py rename to src/sampletones_tools/checks/source/bindings/environment.py diff --git a/src/sampletones_shared/meta/source/bindings/scopes.py b/src/sampletones_tools/checks/source/bindings/scopes.py similarity index 80% rename from src/sampletones_shared/meta/source/bindings/scopes.py rename to src/sampletones_tools/checks/source/bindings/scopes.py index 38362f18c..6a85bbff9 100644 --- a/src/sampletones_shared/meta/source/bindings/scopes.py +++ b/src/sampletones_tools/checks/source/bindings/scopes.py @@ -1,22 +1,31 @@ import ast from dataclasses import dataclass -from typing import Dict, List, NamedTuple, Optional +from typing import Dict, Final, List, NamedTuple, Optional -from sampletones_shared.meta.source.bindings.containers import ( +from sampletones_tools.checks.source.bindings.containers import ( ItemTypes, container_item_types, iterated_container, iterated_types, ) -from sampletones_shared.meta.source.bindings.environment import TypeEnvironment -from sampletones_shared.meta.source.bindings.statements import ( +from sampletones_tools.checks.source.bindings.environment import TypeEnvironment +from sampletones_tools.checks.source.bindings.statements import ( AliasStatement, LoopStatement, Statement, TypeStatement, + annotated_statement, read_statement, ) -from sampletones_shared.meta.source.nodes import expression_spelling, is_attribute_spelling, nested_scopes, own_nodes +from sampletones_tools.checks.source.nodes import ( + class_attribute_annotations, + expression_spelling, + is_attribute_spelling, + nested_scopes, + own_nodes, +) + +MODULE_SCOPE: Final[int] = 0 @dataclass(frozen=True) @@ -71,6 +80,19 @@ def read(self, node: ast.AST, parent: Optional[int]) -> None: for nested in nested_scopes(node): self.read(nested, scope) + def read_class_attributes(self, tree: ast.AST) -> None: + """Records what each class body says its instances carry, under the spelling `self` reaches. + + An attribute chain belongs to the module, so an annotation written in a class body states + the type for every method of that class, which is how a class inheriting an attribute + declares what it holds. + + Args: + tree: Parsed module to read. + """ + for spelling, annotation in class_attribute_annotations(tree): + self._collect(MODULE_SCOPE, annotated_statement(spelling, annotation)) + def scopes(self) -> List[Scope]: """The scopes read so far, each holding the types visible inside it.""" self._bind_loops() @@ -158,8 +180,10 @@ def module_scopes( A parameter annotation, an annotated assignment, and an assignment from a direct construction each state a type. An assignment of one spelling to another carries that type along, so an - object taken as a parameter and kept as `self._manager` is known under both spellings. A `for` - or comprehension target takes the item type of the container it walks. + object taken as a parameter and kept as `self._manager` is known under both spellings. An + attribute a class body annotates is known the same way, which is how a class that inherits one + states what it holds. A `for` or comprehension target takes the item type of the container it + walks. Args: tree: Parsed module to read. @@ -173,4 +197,5 @@ def module_scopes( """ reader = _ScopeReader({**imported_item_types, **container_item_types(tree)}) reader.read(tree, None) + reader.read_class_attributes(tree) return reader.scopes() diff --git a/src/sampletones_shared/meta/source/bindings/statements.py b/src/sampletones_tools/checks/source/bindings/statements.py similarity index 81% rename from src/sampletones_shared/meta/source/bindings/statements.py rename to src/sampletones_tools/checks/source/bindings/statements.py index cadd9a9a2..2d68a73d9 100644 --- a/src/sampletones_shared/meta/source/bindings/statements.py +++ b/src/sampletones_tools/checks/source/bindings/statements.py @@ -1,8 +1,8 @@ import ast from typing import NamedTuple, Optional, Union -from sampletones_shared.meta.source.annotations import annotation_type_name -from sampletones_shared.meta.source.nodes import expression_spelling, terminal_name +from sampletones_tools.checks.source.annotations import annotation_type_name +from sampletones_tools.checks.source.nodes import expression_spelling, terminal_name class TypeStatement(NamedTuple): @@ -45,7 +45,7 @@ def read_statement(node: ast.AST) -> Optional[Statement]: """ match node: case ast.arg(arg=name, annotation=ast.expr() as annotation): - return _annotated(name, annotation) + return annotated_statement(name, annotation) case ast.AnnAssign(target=target, annotation=annotation): return _annotated_target(target, annotation) case ast.Assign(targets=[target], value=value): @@ -59,7 +59,16 @@ def read_statement(node: ast.AST) -> Optional[Statement]: return None -def _annotated(spelling: str, annotation: ast.expr) -> Optional[TypeStatement]: +def annotated_statement(spelling: str, annotation: ast.expr) -> Optional[TypeStatement]: + """What an annotation standing on ``spelling`` states about it. + + Args: + spelling: Spelling the annotation stands on, as `expression_spelling` writes it. + annotation: Annotation to read. + + Returns: + Optional[TypeStatement]: The type, or `None` where the annotation names none. + """ type_name = annotation_type_name(annotation) if type_name is None: return None @@ -75,7 +84,7 @@ def _annotated_target(target: ast.expr, annotation: ast.expr) -> Optional[TypeSt if spelling is None: return None - return _annotated(spelling, annotation) + return annotated_statement(spelling, annotation) def _assigned(target: ast.expr, value: ast.expr) -> Optional[Statement]: diff --git a/src/sampletones_shared/meta/source/classes.py b/src/sampletones_tools/checks/source/classes.py similarity index 93% rename from src/sampletones_shared/meta/source/classes.py rename to src/sampletones_tools/checks/source/classes.py index 02bac0a34..f4f1483b8 100644 --- a/src/sampletones_shared/meta/source/classes.py +++ b/src/sampletones_tools/checks/source/classes.py @@ -1,7 +1,7 @@ import ast from typing import List -from sampletones_shared.meta.source.nodes import terminal_name +from sampletones_tools.checks.source.nodes import terminal_name def declared_subclasses(tree: ast.Module, base: str) -> List[str]: diff --git a/src/sampletones_shared/meta/source/constants.py b/src/sampletones_tools/checks/source/constants.py similarity index 100% rename from src/sampletones_shared/meta/source/constants.py rename to src/sampletones_tools/checks/source/constants.py diff --git a/src/sampletones_tools/checks/source/index.py b/src/sampletones_tools/checks/source/index.py new file mode 100644 index 000000000..8c670ac28 --- /dev/null +++ b/src/sampletones_tools/checks/source/index.py @@ -0,0 +1,43 @@ +import ast +from dataclasses import dataclass +from typing import Dict, Iterable, Mapping, Tuple + +from sampletones_tools.checks.source.bindings.containers import container_item_types +from sampletones_tools.checks.source.constants import module_constants +from sampletones_tools.checks.source.modules import SourceModule + + +@dataclass(frozen=True) +class SourceIndex: + """What a tree of modules declares for any one of them to read. + + A module reads names its own source never states — a container annotated elsewhere and walked + here, a constant declared elsewhere and used here — so reading one module rests on what the + others declare. + """ + + item_types: Mapping[str, Tuple[str, ...]] + constants: Mapping[str, ast.expr] + + +def source_index(modules: Iterable[SourceModule]) -> SourceIndex: + """Indexes every container annotation and module-level constant of a tree. + + Where two modules declare the same spelling, the module read last states it. + + Args: + modules: Modules to index. + + Returns: + SourceIndex: Item types and constant values, each keyed by the spelling naming it. + """ + item_types: Dict[str, Tuple[str, ...]] = {} + constants: Dict[str, ast.expr] = {} + for module in modules: + item_types.update(container_item_types(module.tree)) + constants.update({constant.name: constant.value for constant in module_constants(module.tree)}) + + return SourceIndex( + item_types=item_types, + constants=constants, + ) diff --git a/src/sampletones_shared/meta/source/lookups.py b/src/sampletones_tools/checks/source/lookups.py similarity index 84% rename from src/sampletones_shared/meta/source/lookups.py rename to src/sampletones_tools/checks/source/lookups.py index 6266cd6d9..43d8abc7b 100644 --- a/src/sampletones_shared/meta/source/lookups.py +++ b/src/sampletones_tools/checks/source/lookups.py @@ -3,11 +3,15 @@ from itertools import product from typing import Iterable, List, Sequence, Tuple -from sampletones_shared.meta.source.bindings.scopes import module_scopes -from sampletones_shared.meta.source.index import SourceIndex -from sampletones_shared.meta.source.modules import SourceModule -from sampletones_shared.meta.source.subscripts import SubscriptSite, find_subscripts -from sampletones_shared.meta.source.values import EnumTable, ResolvedValues, ValueResolver +from sampletones_tools.checks.source.bindings.scopes import module_scopes +from sampletones_tools.checks.source.index import SourceIndex +from sampletones_tools.checks.source.modules import SourceModule +from sampletones_tools.checks.source.subscripts import SubscriptSite, find_subscripts +from sampletones_tools.checks.source.values import ( + EnumTable, + ResolvedValues, + ValueResolver, +) @dataclass(frozen=True) @@ -29,7 +33,10 @@ def resolved(self) -> bool: return not self.unresolved_parts -def composed_values(resolutions: Sequence[ResolvedValues], separator: str) -> Tuple[str, ...]: +def composed_values( + resolutions: Sequence[ResolvedValues], + separator: str, +) -> Tuple[str, ...]: """Every string a lookup's resolved parts spell together. A lookup of one part spells that part's values. A lookup of several parts spells one string per @@ -107,7 +114,18 @@ def module_lookups( enums=enums, constants=index.constants, ) - sites.extend(lookup_site(module, site, resolver, separator) for site in find_subscripts(scope.node, receivers)) + sites.extend( + lookup_site( + module, + site, + resolver, + separator, + ) + for site in find_subscripts( + scope.node, + receivers, + ) + ) return sites diff --git a/src/sampletones_tools/checks/source/modules.py b/src/sampletones_tools/checks/source/modules.py new file mode 100644 index 000000000..27828b2cf --- /dev/null +++ b/src/sampletones_tools/checks/source/modules.py @@ -0,0 +1,124 @@ +import ast +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Iterable, List + +from sampletones_tools.checks.source.nodes import PositionedNode + +SOURCE_PATTERN: Final[str] = "*.py" +SOURCE_ENCODING: Final[str] = "utf-8-sig" +HIDDEN_PREFIX: Final[str] = "." +PACKAGE_INITIALIZER: Final[str] = "__init__" +MODULE_SEPARATOR: Final[str] = "." + + +@dataclass(frozen=True) +class SourceModule: + """One parsed Python file, together with the path a report names it by.""" + + path: Path + tree: ast.Module + + def location(self, node: PositionedNode) -> str: + """Spells where a node sits as `path:line`, the form an editor jumps to.""" + return f"{self.path}:{node.lineno}" + + +def parse_module(path: Path) -> SourceModule: + """Parses one Python file into a tree. + + The file is decoded as `utf-8-sig`, which reads both plain UTF-8 and a byte-order-marked file. + + Args: + path: File to read. + + Returns: + SourceModule: The file paired with its tree. + + Raises: + SyntaxError: If the file holds source Python rejects. + """ + source = path.read_text(encoding=SOURCE_ENCODING) + return SourceModule( + path=path, + tree=ast.parse( + source, + filename=str(path), + ), + ) + + +def module_name(path: Path, root: Path) -> str: + """The dotted name an import statement reaches a source file by. + + A check that finds a module by reading it can then reach the objects it declares, which is what + lets a static sweep and a runtime read describe the same module. + + Args: + path: Source file under the root. + root: Directory imports resolve from, such as the source root. + + Returns: + str: The dotted name, where a package's `__init__.py` names the package itself. + + Raises: + ValueError: If the file sits outside the root. + """ + relative = path.relative_to(root).with_suffix("") + parts = relative.parts[:-1] if relative.name == PACKAGE_INITIALIZER else relative.parts + return MODULE_SEPARATOR.join(parts) + + +def is_visible(path: Path) -> bool: + """States whether every component of a path is a visible name.""" + return all(not part.startswith(HIDDEN_PREFIX) for part in path.parts) + + +def source_paths(roots: Iterable[Path]) -> List[Path]: + """Every Python file under the given roots, in path order. + + The sweep visits the paths visible below each root, so a virtual environment or a tooling cache + sitting inside a root stays aside from a whole-repository run, while a root standing inside a + hidden folder, such as a worktree, is swept whole. A check built on a sweep that reads nothing + reports nothing, which reads as a clean tree, so each root must name a directory and the roots + together must hold source to read. + + Args: + roots: Directories to search. + + Returns: + List[Path]: The paths found, each listed once however many roots hold it. + + Raises: + NotADirectoryError: If a root names something other than a directory, such as the + `__init__.py` a package resource resolves to. + FileNotFoundError: If the roots together hold no Python file. + """ + directories = list(roots) + for root in directories: + if not root.is_dir(): + raise NotADirectoryError(f"The source root {root} names no directory to sweep") + + found = {path for root in directories for path in root.rglob(SOURCE_PATTERN) if is_visible(path.relative_to(root))} + if not found: + listed = ", ".join(str(root) for root in directories) + raise FileNotFoundError(f"The source roots hold no {SOURCE_PATTERN} file to read: {listed}") + + return sorted(found) + + +def discover_modules(roots: Iterable[Path]) -> List[SourceModule]: + """Parses every Python file under the given roots, in path order. + + Args: + roots: Directories to search. + + Returns: + List[SourceModule]: One entry per file found. + + Raises: + NotADirectoryError: If a root names something other than a directory. + FileNotFoundError: If the roots together hold no Python file. + SyntaxError: If a file holds source Python rejects. + """ + return [parse_module(path) for path in source_paths(roots)] diff --git a/src/sampletones_shared/meta/source/nodes.py b/src/sampletones_tools/checks/source/nodes.py similarity index 76% rename from src/sampletones_shared/meta/source/nodes.py rename to src/sampletones_tools/checks/source/nodes.py index 1d024ea2b..6c75dc7a0 100644 --- a/src/sampletones_shared/meta/source/nodes.py +++ b/src/sampletones_tools/checks/source/nodes.py @@ -6,6 +6,7 @@ SCOPE_NODES: Final[Tuple[Type[ast.AST], ...]] = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) ATTRIBUTE_SEPARATOR: Final[str] = "." +INSTANCE_SPELLING: Final[str] = "self" def own_nodes(scope: ast.AST) -> Iterator[ast.AST]: @@ -42,6 +43,29 @@ def nested_scopes(scope: ast.AST) -> Iterator[ast.AST]: yield from nested_scopes(child) +def class_attribute_annotations(tree: ast.AST) -> Iterator[Tuple[str, ast.expr]]: + """Every attribute a class body annotates, spelled the way an instance reaches it. + + A class states the attributes it carries in its own body, which is how a class inheriting one + declares what it holds without assigning it again. Reading those annotations lets a method of + that class be understood through `self`. + + Args: + tree: Parsed module to read. + + Yields: + Tuple[str, ast.expr]: The spelling, such as `self._manager`, and the annotation on it. + """ + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + + for statement in node.body: + match statement: + case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation): + yield f"{INSTANCE_SPELLING}{ATTRIBUTE_SEPARATOR}{name}", annotation + + def slice_elements(subscript: ast.Subscript) -> Tuple[ast.expr, ...]: """The expressions a subscript holds between its brackets, one per comma-separated part.""" if isinstance(subscript.slice, ast.Tuple): diff --git a/src/sampletones_tools/checks/source/packages.py b/src/sampletones_tools/checks/source/packages.py new file mode 100644 index 000000000..4762e1816 --- /dev/null +++ b/src/sampletones_tools/checks/source/packages.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from sampletones_shared.paths.source import SOURCE_ROOT + + +def source_package_directory(name: str, *parts: str) -> Path: + """The directory a package occupies, named by path rather than by import. + + A source check reads the tree it checks, so taking a package from the source root keeps the + check free of importing the code under it and free of the layout of any one installation. + + Args: + name: Top-level package name. + parts: Subpackage names, innermost last. + + Returns: + Path: The directory the package occupies. + + Raises: + NotADirectoryError: If the source root holds no directory at that path. + """ + directory = SOURCE_ROOT.joinpath(name, *parts) + if not directory.is_dir(): + raise NotADirectoryError(f"The source root {SOURCE_ROOT} holds no package directory at {directory}") + + return directory diff --git a/src/sampletones_shared/meta/source/references.py b/src/sampletones_tools/checks/source/references.py similarity index 100% rename from src/sampletones_shared/meta/source/references.py rename to src/sampletones_tools/checks/source/references.py diff --git a/src/sampletones_shared/meta/source/subscripts.py b/src/sampletones_tools/checks/source/subscripts.py similarity index 94% rename from src/sampletones_shared/meta/source/subscripts.py rename to src/sampletones_tools/checks/source/subscripts.py index d942ea362..4121b15e5 100644 --- a/src/sampletones_shared/meta/source/subscripts.py +++ b/src/sampletones_tools/checks/source/subscripts.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Collection, List, Tuple -from sampletones_shared.meta.source.nodes import expression_spelling, own_nodes, slice_elements +from sampletones_tools.checks.source.nodes import expression_spelling, own_nodes, slice_elements @dataclass(frozen=True) diff --git a/src/sampletones_shared/meta/source/values.py b/src/sampletones_tools/checks/source/values.py similarity index 96% rename from src/sampletones_shared/meta/source/values.py rename to src/sampletones_tools/checks/source/values.py index 4abaf4cb7..6a1c1afe2 100644 --- a/src/sampletones_shared/meta/source/values.py +++ b/src/sampletones_tools/checks/source/values.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from typing import Final, FrozenSet, Mapping, Tuple -from sampletones_shared.meta.source.bindings.environment import TypeEnvironment -from sampletones_shared.meta.source.nodes import expression_spelling, terminal_name +from sampletones_tools.checks.source.bindings.environment import TypeEnvironment +from sampletones_tools.checks.source.nodes import expression_spelling, terminal_name EnumMembers = Mapping[str, str] EnumTable = Mapping[str, EnumMembers] diff --git a/src/sampletones_tools/checks/tag_names.py b/src/sampletones_tools/checks/tag_names.py new file mode 100644 index 000000000..8d3ddb4c2 --- /dev/null +++ b/src/sampletones_tools/checks/tag_names.py @@ -0,0 +1,225 @@ +import ast +import sys +from enum import StrEnum +from pathlib import Path +from typing import ( + Dict, + Final, + List, + NamedTuple, + Optional, + Sequence, + Tuple, + Type, + TypeVar, +) + +from sampletones_application.categories.hierarchy import Page, Panel, Widget +from sampletones_application.categories.key.tag import TagName +from sampletones_application.tags.compose import TAG_SEPARATOR +from sampletones_tools.checks.source.constants import ModuleConstant, module_constants +from sampletones_tools.checks.source.modules import ( + SourceModule, + discover_modules, + parse_module, +) +from sampletones_tools.checks.source.nodes import terminal_name +from sampletones_tools.checks.source.packages import source_package_directory + +TAGS_PACKAGE: Final[Path] = source_package_directory("sampletones_application", "tags") + +TAG_PREFIX: Final[str] = "TAG" +TAG_NAME_CLASS: Final[str] = "TagName" + +PAGE_ARGUMENT: Final[str] = "page" +PANEL_ARGUMENT: Final[str] = "panel" +WIDGET_ARGUMENT: Final[str] = "widget" +ELEMENT_ARGUMENT: Final[str] = "element" +TAG_ARGUMENTS: Final[Tuple[str, ...]] = ( + PAGE_ARGUMENT, + PANEL_ARGUMENT, + WIDGET_ARGUMENT, + ELEMENT_ARGUMENT, +) + +EnumMember = TypeVar("EnumMember", bound=StrEnum) + + +class TagFinding(NamedTuple): + """One tag constant the check reports, and what it reports about it.""" + + location: str + message: str + + +def expected_name(tag: TagName) -> str: + """The constant name a tag calls for. + + Args: + tag: Tag the constant composes. + + Returns: + str: The name, such as `TAG_GLOBAL_WINDOW_MAIN`. + """ + return f"{TAG_PREFIX}_{str(tag).upper().replace(TAG_SEPARATOR, '_')}" + + +def tag_call(constant: ModuleConstant) -> Optional[ast.Call]: + """The `TagName(...)` call a constant is bound to, where it is bound to one.""" + value = constant.value + if isinstance(value, ast.Call) and terminal_name(value.func) == TAG_NAME_CLASS: + return value + + return None + + +def call_arguments(call: ast.Call) -> Optional[Dict[str, ast.expr]]: + """The arguments a tag call passes, keyed by the parameter each one fills. + + Args: + call: Call to read, written with positional arguments, keywords, or both. + + Returns: + Optional[Dict[str, ast.expr]]: One entry per tag argument, or `None` where the call fills + them in a form the check reads no arguments from. + """ + arguments = dict(zip(TAG_ARGUMENTS, call.args, strict=False)) + for keyword in call.keywords: + if keyword.arg is None: + return None + + arguments[keyword.arg] = keyword.value + + if set(arguments) != set(TAG_ARGUMENTS): + return None + + return arguments + + +def hierarchy_member( + node: ast.expr, + enum: Type[EnumMember], +) -> Optional[EnumMember]: + """The hierarchy member an argument names, such as `Page.GLOBAL`. + + Args: + node: Argument to read. + enum: Hierarchy enum the argument names a member of. + + Returns: + Optional[EnumMember]: The member, or `None` where the argument names none. + """ + match node: + case ast.Attribute(value=ast.Name(id=owner), attr=member) if owner == enum.__name__: + return enum.__members__.get(member) + case _: + return None + + +def element_name(node: ast.expr) -> Optional[str]: + """The element an argument spells, where it spells one as a string literal.""" + match node: + case ast.Constant(value=str() as element): + return element + case _: + return None + + +def composed_tag(arguments: Dict[str, ast.expr]) -> Optional[TagName]: + """The tag a set of arguments composes, where each one states its part. + + Args: + arguments: Arguments of a tag call, as `call_arguments` keys them. + + Returns: + Optional[TagName]: The composed tag, or `None` where an argument states something the check + reads no part from. + """ + page = hierarchy_member(arguments[PAGE_ARGUMENT], Page) + panel = hierarchy_member(arguments[PANEL_ARGUMENT], Panel) + widget = hierarchy_member(arguments[WIDGET_ARGUMENT], Widget) + element = element_name(arguments[ELEMENT_ARGUMENT]) + if page is None or panel is None or widget is None or element is None: + return None + + return TagName(page, panel, widget, element) + + +def check_constant( + module: SourceModule, + constant: ModuleConstant, +) -> Optional[TagFinding]: + """Checks one constant of a tags module, where it is bound to a tag. + + Args: + module: Module the constant lives in. + constant: Constant to check. + + Returns: + Optional[TagFinding]: What the check reports, or `None` where the constant is well named or + holds no tag. + """ + call = tag_call(constant) + if call is None: + return None + + location = f"{module.path}:{constant.line}" + arguments = call_arguments(call) + if arguments is None: + return TagFinding( + location=location, + message=( + f"{constant.name} calls {TAG_NAME_CLASS} in a form the check reads no arguments from; " + f"a tag states {', '.join(TAG_ARGUMENTS)}" + ), + ) + + tag = composed_tag(arguments) + if tag is None: + return TagFinding( + location=location, + message=( + f"{constant.name} states its parts in a form the check reads no tag from; a tag names a " + f"{Page.__name__}, a {Panel.__name__}, and a {Widget.__name__} member, then spells its " + "element as a string literal" + ), + ) + + expected = expected_name(tag) + if constant.name != expected: + return TagFinding( + location=location, + message=f"{constant.name} composes the tag {str(tag)!r}, which calls for the name {expected}", + ) + + return None + + +def check_module(module: SourceModule) -> List[TagFinding]: + """Checks every tag constant one module declares.""" + findings = (check_constant(module, constant) for constant in module_constants(module.tree)) + return [finding for finding in findings if finding is not None] + + +def check_modules(modules: Sequence[SourceModule]) -> List[TagFinding]: + """Checks every tag constant the given modules declare, in the order they were read.""" + return [finding for module in modules for finding in check_module(module)] + + +def check_tags(files: Sequence[Path], everything: bool) -> List[TagFinding]: + """The findings over the named modules, or over the whole tags package when ``everything``.""" + modules = discover_modules([TAGS_PACKAGE]) if everything else [parse_module(path) for path in files] + return check_modules(modules) + + +def report(findings: Sequence[TagFinding]) -> int: + """Prints every finding on the standard error stream and answers with the exit status.""" + if not findings: + return 0 + + print("Tag name(s) departing from the tag they compose:", file=sys.stderr) + for location, message in findings: + print(f" {location}: {message}", file=sys.stderr) + + print(f"\nFound {len(findings)} tag name(s) to fix.", file=sys.stderr) + return 1 diff --git a/src/sampletones_tools/checks/unused_tags.py b/src/sampletones_tools/checks/unused_tags.py new file mode 100644 index 000000000..9cc7e1ee8 --- /dev/null +++ b/src/sampletones_tools/checks/unused_tags.py @@ -0,0 +1,104 @@ +import sys +from collections import Counter +from pathlib import Path +from typing import Dict, Final, List, NamedTuple, Sequence, Tuple + +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT +from sampletones_tools.checks.paths import SCRIPTS_ROOT +from sampletones_tools.checks.source.constants import module_constants +from sampletones_tools.checks.source.modules import SourceModule, discover_modules +from sampletones_tools.checks.source.packages import source_package_directory +from sampletones_tools.checks.source.references import count_identifier_loads + +TAGS_PACKAGE: Final[Path] = source_package_directory("sampletones_application", "tags") +REFERENCE_ROOTS: Final[Tuple[Path, ...]] = ( + SOURCE_ROOT, + REPOSITORY_ROOT / "tests", + SCRIPTS_ROOT, +) + +FRAGMENT_PREFIXES: Final[Tuple[str, ...]] = ("TAG_", "SUF_", "PRE_") + + +class Fragment(NamedTuple): + """One tag fragment a module declares, and where it declares it.""" + + name: str + location: str + + +def is_fragment(name: str) -> bool: + """Whether a constant name belongs to the tag vocabulary.""" + return name.startswith(FRAGMENT_PREFIXES) + + +def declared_fragments(modules: Sequence[SourceModule]) -> List[Fragment]: + """Every tag fragment the given modules declare, in the order they were read. + + Args: + modules: Modules of the tags package. + + Returns: + List[Fragment]: The fragments, each paired with its location. + """ + return [ + Fragment( + name=constant.name, + location=f"{module.path}:{constant.line}", + ) + for module in modules + for constant in module_constants(module.tree) + if is_fragment(constant.name) + ] + + +def reference_counts(modules: Sequence[SourceModule]) -> Dict[str, int]: + """How often the given modules read each identifier, summed across them. + + Args: + modules: Modules to read. + + Returns: + Dict[str, int]: Identifier to the number of reads. + """ + counts: Counter[str] = Counter() + for module in modules: + counts.update(count_identifier_loads(module.tree)) + + return dict(counts) + + +def unread_fragments( + fragments: Sequence[Fragment], + counts: Dict[str, int], +) -> List[Fragment]: + """The fragments no module reads. + + Args: + fragments: Fragments the tags package declares. + counts: Reads per identifier, as `reference_counts` states them. + + Returns: + List[Fragment]: The fragments standing at no reads, in the order they were declared. + """ + return [fragment for fragment in fragments if counts.get(fragment.name, 0) == 0] + + +def check_reads(tags: Path, reference_roots: Sequence[Path]) -> List[Fragment]: + """Every tag fragment the package declares and the reference roots never read.""" + fragments = declared_fragments(discover_modules([tags])) + counts = reference_counts(discover_modules(reference_roots)) + return unread_fragments(fragments, counts) + + +def report(unread: Sequence[Fragment]) -> int: + """Prints every unread fragment on the standard error stream and answers with the exit status.""" + if not unread: + return 0 + + print("Tag fragment(s) declared and never read:", file=sys.stderr) + for name, location in unread: + print(f" {location}: {name}", file=sys.stderr) + + print(f"\nFound {len(unread)} unread tag fragment(s).", file=sys.stderr) + return 1 diff --git a/src/sampletones_tools/codec/__init__.py b/src/sampletones_tools/codec/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/command.py b/src/sampletones_tools/codec/command.py new file mode 100644 index 000000000..29d0634f8 --- /dev/null +++ b/src/sampletones_tools/codec/command.py @@ -0,0 +1,142 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Tuple + +from sampletones_shared.command import Command + +NAME: Final[str] = "codec" +HELP: Final[str] = "measure the song codec on the synthetic corpus or on the songs named" +ACTION_FIELD: Final[str] = "action" +ACTION_METAVAR: Final[str] = "" +REPORT: Final[str] = "report" +REPORT_HELP: Final[str] = "compress the synthetic corpus under every layer of the codec and write the report tables" +REPORT_OUTPUT_HELP: Final[str] = "the directory the report is written into, created when missing" +STUDY: Final[str] = "study" +STUDY_HELP: Final[str] = "encode the projects and stems named under every candidate change, with a verdict each" +MANIFEST_HELP: Final[str] = ( + "a manifest a run wrote, measured again; its lengthening and variants stand in for the options below" +) +PROJECT_HELP: Final[str] = ( + "a project file to measure, repeatable; a run names at least one project, reconstruction or manifest" +) +RECONSTRUCTION_HELP: Final[str] = ( + "a stem file, or a directory of stems, to measure, repeatable; named sources replace a manifest's own" +) +OUTPUT_HELP: Final[str] = ( + "the directory the run writes into; without it, a timestamped directory under Documents/SampleToNES/compression" +) +LENGTHEN_HELP: Final[str] = "seconds each project's lengthened copy lasts" +VARIANTS_HELP: Final[str] = ( + "variants every song is encoded under, comma separated; without it every one, and the baseline always runs" +) +DEFAULT_LENGTHEN_SECONDS: Final[int] = 180 +NO_SOURCE: Final[str] = ( + "The study reads the files it is given: name a project with --project, a stem file or a directory of " + "stems with --reconstruction, or a manifest a run wrote with --manifest." +) + + +@dataclass(frozen=True) +class ReportArguments: + """What a report run is given: the directory the tables are written into.""" + + output: Path + + +@dataclass(frozen=True) +class StudyArguments: + """What a study run is given, as written on the command line.""" + + manifest: Optional[Path] + projects: Tuple[Path, ...] + reconstructions: Tuple[Path, ...] + output: Optional[Path] + lengthen: int + variants: Optional[str] + + +def configure(parser: ArgumentParser) -> None: + actions = parser.add_subparsers(dest=ACTION_FIELD, metavar=ACTION_METAVAR, required=True) + report = actions.add_parser(REPORT, help=REPORT_HELP, description=REPORT_HELP) + report.add_argument("--output", "-o", type=Path, required=True, help=REPORT_OUTPUT_HELP) + study = actions.add_parser(STUDY, help=STUDY_HELP, description=STUDY_HELP) + study.add_argument("--manifest", type=Path, default=None, help=MANIFEST_HELP) + study.add_argument("--project", type=Path, action="append", dest="projects", default=[], help=PROJECT_HELP) + study.add_argument( + "--reconstruction", + type=Path, + action="append", + dest="reconstructions", + default=[], + help=RECONSTRUCTION_HELP, + ) + study.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP) + study.add_argument("--lengthen", type=int, default=DEFAULT_LENGTHEN_SECONDS, help=LENGTHEN_HELP) + study.add_argument("--variants", type=str, default=None, help=VARIANTS_HELP) + + +def run(arguments: Namespace) -> int: + """Measures the codec the way the action describes.""" + if arguments.action == REPORT: + return _report(ReportArguments(output=arguments.output)) + + return _study( + StudyArguments( + manifest=arguments.manifest, + projects=tuple(arguments.projects), + reconstructions=tuple(arguments.reconstructions), + output=arguments.output, + lengthen=arguments.lengthen, + variants=arguments.variants, + ) + ) + + +def _report(given: ReportArguments) -> int: + from sampletones_tools.codec.report.session import run_report + + report = run_report(given.output) + for path in (report.csv_path, report.markdown_path): + print(f"Wrote {path}") + + return 0 + + +def _study(given: StudyArguments) -> int: + """Measures the sources a run names. + + Raises: + SystemExit: If the run names neither a source nor a manifest, a source or the manifest is + missing, the manifest is broken, the lengthening is below one second, or a variant is + unknown. + """ + if given.manifest is None and not given.projects and not given.reconstructions: + raise SystemExit(NO_SOURCE) + + from sampletones_shared.utils.validation import describe_failure + from sampletones_tools.codec.study.plan import plan_study + from sampletones_tools.codec.study.session import resolve_manifest, run_study, variant_names + + try: + manifest = resolve_manifest( + given.manifest, + projects=given.projects, + reconstructions=given.reconstructions, + lengthen_seconds=given.lengthen, + variants=variant_names(given.variants), + ) + plan = plan_study(manifest) + except ValueError as error: + raise SystemExit(describe_failure(error)) from error + + run_study(plan, given.output) + return 0 + + +CODEC: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/codec/report/__init__.py b/src/sampletones_tools/codec/report/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/report/corpus.py b/src/sampletones_tools/codec/report/corpus.py new file mode 100644 index 000000000..b38156229 --- /dev/null +++ b/src/sampletones_tools/codec/report/corpus.py @@ -0,0 +1,260 @@ +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 ALL_CHANNELS, 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 SONG_TICK_BOUNDS, 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.scheme import CompressionScheme +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.song import Song +from sampletones_shared.music import Tuning +from sampletones_tools.codec.report.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( + 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: + catalog: The samples of the synthetic corpus, by name. + 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 catalog.items(): + tuning = sample.reconstruction.config.library.tuning + entries.append( + CorpusEntry( + name=name, + song=song_from_reconstruction( + sample.reconstruction, + loop_tick=None, + scheme=CompressionScheme.NONE, + ), + seeds=phrases_from_project(_sample_project(sample, settings), tuning, ALL_CHANNELS), + 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, + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.NONE, + ), + seeds=phrases_from_project(project, tuning, ALL_CHANNELS), + 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. + """ + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + pattern_ticks = timing.exact_row_ticks * project.song.rows_per_pattern + frames = ceil(seconds * project.settings.nes_frequency / pattern_ticks) + return lengthened(project, frames) + + +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, + scheme=CompressionScheme.NONE, + ), + seeds=NO_SEEDS, + tuning=tuning, + ) + + +def corpus_entries( + catalog: Dict[str, Sample], + project: Project, +) -> Tuple[CorpusEntry, ...]: + """The songs the codec is measured on: each sample alone, the arrangement at two lengths, and + a minute of dense reconstruction. + + Args: + catalog: The samples of the synthetic corpus, by name. + project: The arrangement those samples are played in. + + Returns: + Tuple[CorpusEntry, ...]: The samples first, then the arrangement, the long one, and the + reconstruction. + """ + return ( + *sample_entries(catalog, project.settings), + arrangement_entry(ARRANGEMENT, project), + arrangement_entry(LONG_ARRANGEMENT, lengthened_arrangement(project, TARGET_SECONDS)), + reconstruction_entry(RECONSTRUCTION, RECONSTRUCTION_SECONDS), + ) diff --git a/src/sampletones_tools/codec/report/encoding.py b/src/sampletones_tools/codec/report/encoding.py new file mode 100644 index 000000000..268f23394 --- /dev/null +++ b/src/sampletones_tools/codec/report/encoding.py @@ -0,0 +1,255 @@ +from dataclasses import dataclass +from time import process_time +from typing import Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import PULSE_CHANNELS +from sampletones_player.compression.compressed import CompressedPlanes +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.song import SongPlanes +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.specification.planes import NO_BITS, PLANES, PlaneRole +from sampletones_player.specification.registers import DUTY_CYCLE_SHIFT +from sampletones_tools.codec.report.corpus import CorpusEntry +from sampletones_tools.codec.report.rows import ReportRow + +LITERALS: Final[str] = "literals" +HOLDS: Final[str] = "holds" +INSTRUMENTS: Final[str] = "instruments" +TRANSPOSITION: Final[str] = "transposition" +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 +HOLDS_OPTIONS: Final[CodecOptions] = CompressionScheme.RUNS.options + +PLANE_VARIANTS: Final[Tuple[Tuple[str, CodecOptions], ...]] = ( + (LITERALS, CompressionScheme.NONE.options), + (HOLDS, HOLDS_OPTIONS), + ( + INSTRUMENTS, + CodecOptions( + holds=True, + phrases=True, + transposition=False, + search=False, + ), + ), + (TRANSPOSITION, CompressionScheme.INSTRUMENTS.options), + (SEARCH, CompressionScheme.SEARCH.options), +) + + +@dataclass(frozen=True) +class Encoding: + """One corpus song compressed under one variant of the codec. + + Attributes: + entry: The song compressed. + variant: The layers the codec was switched on with. + planes: The planes the song separates into. + compressed: What the encoder wrote. + seconds: The processor time the encoding took. + """ + + 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 encode_corpus(entries: Sequence[CorpusEntry]) -> Tuple[Encoding, ...]: + """Compresses every corpus song under every variant of the codec, timing each encoding. + + Args: + entries: The songs to compress. + + Returns: + Tuple[Encoding, ...]: The encodings, song by song, each in the order of the variants. + """ + encodings: List[Encoding] = [] + for entry in entries: + planes = entry.planes + for variant, options in PLANE_VARIANTS: + started = process_time() + compressed = encode_planes( + planes, + entry.seeds, + options=options, + boundaries=frozenset(), + ) + encodings.append( + Encoding( + entry=entry, + variant=variant, + planes=planes, + compressed=compressed, + seconds=process_time() - started, + ) + ) + + return tuple(encodings) + + +def report_rows( + entries: Sequence[CorpusEntry], + encodings: Sequence[Encoding], + space: int, +) -> Tuple[ReportRow, ...]: + """The report's measurements: per song, the baselines and then each of its encodings. + + Args: + entries: The songs measured. + encodings: Their encodings, as `encode_corpus` returns them. + space: The program area a song is written into. + + Returns: + Tuple[ReportRow, ...]: The rows, in the order the report prints them. + """ + rows: List[ReportRow] = [] + for entry in entries: + rows.extend(_baseline_rows(entry, space)) + rows.extend(_encoded_row(encoding, space) for encoding in encodings if encoding.entry is entry) + + return tuple(rows) + + +def _encoded_row(encoding: Encoding, space: int) -> ReportRow: + entry = encoding.entry + return 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, + ) + + +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 _measured_row( + entry: CorpusEntry, + variant: str, + planes: Sequence[bytes], + fixed: int, + space: int, +) -> ReportRow: + started = process_time() + coded = _coded_size(planes, HOLDS_OPTIONS) + 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 _register_planes(streams: ChannelStreams) -> Tuple[bytes, ...]: + """Every register as a plane, the ones a channel leaves at the value it resets to left out.""" + written: List[bytes] = [] + for stream in streams.padded: + for register in range(len(stream[0].values)): + plane = bytes(tick.values[register] for tick in stream) + if set(plane) != {NO_BITS}: + written.append(plane) + + return tuple(written) + + +def _split_control_planes(planes: SongPlanes) -> Tuple[bytes, ...]: + """Every plane the block writes, with each pulse channel's control split into duty and volume. + + A plane standing at the value it is seeded to throughout takes no stream, so it takes no + bytes here either, and a control plane splits into the two series a split layout would store. + + Args: + planes: The song's planes. + + Returns: + Tuple[bytes, ...]: The series the layout stores, in the order the block writes them. + """ + split: List[bytes] = [] + for plane, played in zip(PLANES, planes.planes, strict=True): + if plane.idles(played): + continue + + if plane.role is PlaneRole.CONTROL and plane.channel in PULSE_CHANNELS: + split.append(bytes(control >> DUTY_CYCLE_SHIFT for control in played)) + split.append(bytes(control & CONTROL_LEVEL_MASK for control in played)) + else: + split.append(played) + + return tuple(split) + + +def _coded_size(planes: Sequence[bytes], options: CodecOptions) -> int: + """The bytes the planes take under holds and literals.""" + 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)) + ) diff --git a/src/sampletones_tools/codec/report/rows.py b/src/sampletones_tools/codec/report/rows.py new file mode 100644 index 000000000..408272fec --- /dev/null +++ b/src/sampletones_tools/codec/report/rows.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Sequence, Tuple + +from sampletones_shared.utils.tables import Table + +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 report_table(rows: Sequence[ReportRow]) -> Table: + """The measurements as the report's table, in the order they are reported.""" + return Table(columns=COLUMNS, rows=tuple(row.cells for row in rows)) + + +def write_markdown(table: Table, path: Path, state: int) -> None: + """Writes the measurements as a document a reader reads. + + Args: + table: The measurements. + path: Where the document is written. + state: The zero-page bytes the decoder's plane state takes. + """ + lines = [ + "# Compression report", + "", + f"Decoder state: {state} bytes of zero page.", + "", + *table.markdown_lines(), + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/src/sampletones_tools/codec/report/session.py b/src/sampletones_tools/codec/report/session.py new file mode 100644 index 000000000..c846e428c --- /dev/null +++ b/src/sampletones_tools/codec/report/session.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Sequence, Tuple + +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.compression import PLANE_STATE_SIZE +from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_tools.codec.report.corpus import CorpusEntry, corpus_entries +from sampletones_tools.codec.report.encoding import Encoding, encode_corpus, report_rows +from sampletones_tools.codec.report.rows import report_table, write_markdown +from sampletones_tools.codec.report.songs import available_bytes +from sampletones_tools.corpus.build import build_synthetic_corpus + +CSV_FILENAME: Final[str] = "report.csv" +MARKDOWN_FILENAME: Final[str] = "report.md" + + +@dataclass(frozen=True) +class CompressionReport: + """What a report run measured, and the tables it wrote. + + Attributes: + entries: The songs measured. + encodings: Every song compressed under every variant of the codec. + csv_path: The table another tool reads. + markdown_path: The table a reader reads. + """ + + entries: Tuple[CorpusEntry, ...] + encodings: Tuple[Encoding, ...] + csv_path: Path + markdown_path: Path + + +def write_report( + entries: Sequence[CorpusEntry], + encodings: Sequence[Encoding], + space: int, + output: Path, +) -> Tuple[Path, Path]: + """Writes the measurements as a table another tool reads and a table a reader reads. + + Args: + entries: The songs measured. + encodings: Their encodings. + space: The program area a song is written into. + output: The directory the two tables are written into, created when missing. + + Returns: + Tuple[Path, Path]: The CSV table and the Markdown table. + """ + table = report_table(report_rows(entries, encodings, space)) + output.mkdir(parents=True, exist_ok=True) + csv_path = output / CSV_FILENAME + markdown_path = output / MARKDOWN_FILENAME + table.write_csv(csv_path) + write_markdown(table, markdown_path, PLANE_COUNT * PLANE_STATE_SIZE) + return csv_path, markdown_path + + +def run_report(output: Path) -> CompressionReport: + """Builds the synthetic corpus, compresses it under every variant and writes the report. + + Args: + output: The directory the report is written into. + + Returns: + CompressionReport: The songs, their encodings and the tables written. + """ + corpus = build_synthetic_corpus() + entries = corpus_entries(corpus.catalog, corpus.project) + encodings = encode_corpus(entries) + csv_path, markdown_path = write_report(entries, encodings, available_bytes(DriverImage.load()), output) + return CompressionReport( + entries=entries, + encodings=encodings, + csv_path=csv_path, + markdown_path=markdown_path, + ) diff --git a/src/sampletones_tools/codec/report/songs.py b/src/sampletones_tools/codec/report/songs.py new file mode 100644 index 000000000..01499c2cb --- /dev/null +++ b/src/sampletones_tools/codec/report/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 lengthened song is a copy of its own, so ``project`` keeps its arrangement for every other + reader. + """ + 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/src/sampletones_tools/codec/study/__init__.py b/src/sampletones_tools/codec/study/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/accounting/__init__.py b/src/sampletones_tools/codec/study/accounting/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/accounting/coincident.py b/src/sampletones_tools/codec/study/accounting/coincident.py new file mode 100644 index 000000000..408058e11 --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/coincident.py @@ -0,0 +1,37 @@ +from typing import Final, Mapping, Sequence + +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.compression import OPCODE_SIZE +from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding +from sampletones_tools.codec.study.accounting.tokens import ReadToken + +CONTROL_SUFFIX: Final[str] = "_control" +VALUE_SUFFIX: Final[str] = "_value" + + +def coincident_starts(tokens: Mapping[str, Sequence[ReadToken]]) -> Finding: + """What one opcode for a channel's control and value would spare where both start a token + on the same tick (H5). + + A note usually changes its timbre and its pitch on the same tick, so the two planes pay two + opcodes for one moment. One opcode announcing both is the bound stated here: a byte per + coincidence, before the cost of telling the two apart. + + Args: + tokens: Every plane's tokens, under the plane's name in the song block. + + Returns: + Finding: The opcode bytes at coincident starts, and the bytes one opcode would spare. + """ + finding = NOTHING + for name in PlaneOrder.names(): + if not name.endswith(CONTROL_SUFFIX): + continue + + channel = name.removesuffix(CONTROL_SUFFIX) + control = {token.tick for token in tokens[name]} + value = {token.tick for token in tokens[f"{channel}{VALUE_SUFFIX}"]} + shared = len(control & value) + finding += Finding(2 * OPCODE_SIZE * shared, OPCODE_SIZE * shared) + + return finding diff --git a/src/sampletones_tools/codec/study/accounting/dictionary.py b/src/sampletones_tools/codec/study/accounting/dictionary.py new file mode 100644 index 000000000..03d83e377 --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/dictionary.py @@ -0,0 +1,30 @@ +from typing import Final + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding +from sampletones_tools.codec.study.accounting.runs import runs + +REPEATED: Final[int] = 2 +MIN_RLE_RUN: Final[int] = 3 +RLE_RUN_SIZE: Final[int] = 2 + + +def plateaus(table: PhraseTable) -> Finding: + """What run-length coding inside phrase bodies would spare on the plateaus they hold (H2). + + A phrase is stored verbatim, a value resting for several ticks spelled out once per tick. + A run coded as its value and its count takes two bytes however long it rests. + + Args: + table: The dictionary. + + Returns: + Finding: The body bytes sitting in a repeat, and the bytes run-length coding would spare. + """ + finding = NOTHING + for phrase in table.phrases: + for run in runs(phrase.body): + if run >= REPEATED: + finding += Finding(run - 1, max(0, run - RLE_RUN_SIZE) if run >= MIN_RLE_RUN else 0) + + return finding diff --git a/src/sampletones_tools/codec/study/accounting/finding.py b/src/sampletones_tools/codec/study/accounting/finding.py new file mode 100644 index 000000000..4b04d4351 --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/finding.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Final, NamedTuple + + +class Finding(NamedTuple): + """What one hypothesis reaches in one encoding. + + Attributes: + targeted: The bytes the hypothesis acts upon, as the encoding writes them today. + saving: The bytes it would spare under the pricing the hypothesis states. + """ + + targeted: int + saving: int + + def __add__(self, other: object) -> Finding: + if not isinstance(other, Finding): + return NotImplemented + + return Finding(self.targeted + other.targeted, self.saving + other.saving) + + +NOTHING: Final[Finding] = Finding(0, 0) diff --git a/src/sampletones_tools/codec/study/accounting/fixed.py b/src/sampletones_tools/codec/study/accounting/fixed.py new file mode 100644 index 000000000..3a2a8f2a6 --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/fixed.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +from typing import Final + +from sampletones_core.constants.enums import TONE_CHANNELS +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.planes.flags import pitch_index +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import ( + PHRASE_TABLE_ENTRY_SIZE, +) +from sampletones_player.specification.planes import PLANE_COUNT, PLANES, PlaneRole +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_tools.codec.study.accounting.finding import Finding +from sampletones_tools.codec.study.corpus.song import StudySong + +TIMER_SIZE: Final[int] = WORD_SIZE + + +@dataclass(frozen=True) +class FixedOverheads: + """The bytes a song block pays before any tick is described (H7). + + Attributes: + header: The song header. + pitch_table: The pitch table as written, every pitch the format can name. + pitch_table_used: The pitch table cut at the highest pitch the song sounds. + loop_entries: The re-entry offsets written whether or not the song loops. + phrase_offsets: The table of offsets a sequential walk over the phrases could replace. + """ + + header: int + pitch_table: int + pitch_table_used: int + loop_entries: int + phrase_offsets: int + + @property + def finding(self) -> Finding: + """The fixed bytes, and what trimming the table, the entries and the offsets would spare.""" + targeted = self.header + self.pitch_table + self.phrase_offsets + saving = (self.pitch_table - self.pitch_table_used) + self.loop_entries + self.phrase_offsets + return Finding(targeted, saving) + + +def fixed_overheads( + song: StudySong, + compressed: CompressedPlanes, +) -> FixedOverheads: + """Accounts for the bytes a song block pays regardless of its length. + + Args: + song: The song, for the pitches it sounds. + compressed: The encoding, for the phrases it holds. + + Returns: + FixedOverheads: The fixed bytes, part by part. + """ + tones = ( + song.planes.planes[index] + for index, plane in enumerate(PLANES) + if plane.role is PlaneRole.VALUE and plane.channel in TONE_CHANNELS + ) + highest = max(pitch_index(value) for plane in tones for value in plane) + return FixedOverheads( + header=SONG_HEADER_SIZE, + pitch_table=len(song.pitches.data), + pitch_table_used=TIMER_SIZE * (highest + 1), + loop_entries=WORD_SIZE * PLANE_COUNT, + phrase_offsets=PHRASE_TABLE_ENTRY_SIZE * len(compressed.phrases), + ) diff --git a/src/sampletones_tools/codec/study/accounting/pairs.py b/src/sampletones_tools/codec/study/accounting/pairs.py new file mode 100644 index 000000000..1e00cdbaf --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/pairs.py @@ -0,0 +1,58 @@ +import itertools +from typing import Final, Sequence + +from sampletones_player.specification.compression import TokenTag +from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding +from sampletones_tools.codec.study.accounting.runs import ramps +from sampletones_tools.codec.study.accounting.tokens import ReadToken + +SET_HOLD_SIZE: Final[int] = 2 +SINGLE_VALUE: Final[int] = 1 +RAMP_TOKEN_SIZE: Final[int] = 3 +MIN_RAMP_LENGTH: Final[int] = 3 + + +def set_holds(tokens: Sequence[ReadToken]) -> Finding: + """What a set-then-hold token would spare on a single value followed by a hold (H3). + + A plane that steps to a value and rests there pays a literal of one value and a hold, three + bytes. A token carrying the value and the count together takes two bytes on an opcode of its + own, which is the bound stated here; on the free escape it takes three and spares nothing. + + Args: + tokens: The tokens of one plane's stream. + + Returns: + Finding: The bytes such pairs take, and the bytes a two-byte token would spare. + """ + finding = NOTHING + for token, following in itertools.pairwise(tokens): + if token.tag is TokenTag.LITERAL and len(token.payload) == SINGLE_VALUE and following.tag is TokenTag.HOLD: + now = token.size + following.size + finding += Finding(now, now - SET_HOLD_SIZE) + + return finding + + +def ramps_in_literals(tokens: Sequence[ReadToken]) -> Finding: + """What a ramp token would spare on constant-step runs inside literals (H6). + + A fade or a slide spells every step out. A token naming the first value, the step and the + count takes three bytes however long the ramp runs. + + Args: + tokens: The tokens of one plane's stream. + + Returns: + Finding: The payload bytes lying in ramps, and the bytes ramp tokens would spare. + """ + finding = NOTHING + for token in tokens: + if token.tag is not TokenTag.LITERAL: + continue + + for length in ramps(token.payload): + if length >= MIN_RAMP_LENGTH: + finding += Finding(length, max(0, length - RAMP_TOKEN_SIZE)) + + return finding diff --git a/src/sampletones_tools/codec/study/accounting/rows.py b/src/sampletones_tools/codec/study/accounting/rows.py new file mode 100644 index 000000000..b7c31b957 --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/rows.py @@ -0,0 +1,139 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_tools.codec.study.accounting.coincident import coincident_starts +from sampletones_tools.codec.study.accounting.dictionary import plateaus +from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding +from sampletones_tools.codec.study.accounting.fixed import fixed_overheads +from sampletones_tools.codec.study.accounting.pairs import ramps_in_literals, set_holds +from sampletones_tools.codec.study.accounting.shares import PlaneShares, plane_shares +from sampletones_tools.codec.study.accounting.tokens import ReadToken, read_tokens +from sampletones_tools.codec.study.measure import Measurement + +HYPOTHESES: Final[Tuple[Tuple[str, str], ...]] = ( + ("H1", "hold chains"), + ("H2", "plateaus in bodies"), + ("H3", "set-then-hold"), + ("H5", "coincident starts"), + ("H6", "ramps in literals"), + ("H7", "fixed tables"), +) +LEADING_COLUMNS: Final[Tuple[str, ...]] = ( + "group", + "song", + "variant", + "block", + "dictionary", + "streams", + "hold opcodes", + "literal opcodes", + "literal payload", + "phrase tokens", + "idle planes", + "idle bytes", + "bend bytes", +) +COLUMNS: Final[Tuple[str, ...]] = ( + *LEADING_COLUMNS, + *(f"{label} {column}" for label, _ in HYPOTHESES for column in ("targeted", "saving", "share")), +) + + +@dataclass(frozen=True) +class AccountingRow: + """Where one encoding's bytes go, and what each hypothesis would reach in it. + + Attributes: + group: Which kind of song it is. + song: The song's name. + variant: The variant the encoding was built by. + block: The bytes the whole song block takes. + dictionary: The bytes the dictionary takes. + streams: The bytes the streams take together. + planes: Every plane's shares, in the order the song block writes them. + findings: What each hypothesis reaches, in the order of ``HYPOTHESES``. + """ + + group: str + song: str + variant: str + block: int + dictionary: int + streams: int + planes: Tuple[PlaneShares, ...] + findings: Tuple[Finding, ...] + + @property + def idle_planes(self) -> Tuple[PlaneShares, ...]: + """The planes keeping one value throughout the song.""" + return tuple(plane for plane in self.planes if plane.idle) + + @property + def cells(self) -> Tuple[str, ...]: + """The row as the table prints it, column by column.""" + leading = ( + self.group, + self.song, + self.variant, + f"{self.block}", + f"{self.dictionary}", + f"{self.streams}", + f"{sum(plane.hold_opcodes for plane in self.planes)}", + f"{sum(plane.literal_opcodes for plane in self.planes)}", + f"{sum(plane.literal_payload for plane in self.planes)}", + f"{sum(plane.phrase_bytes for plane in self.planes)}", + f"{len(self.idle_planes)}", + f"{sum(plane.stream for plane in self.idle_planes)}", + f"{sum(plane.stream for plane in self.planes if plane.bend)}", + ) + findings: List[str] = [] + for finding in self.findings: + findings.extend((f"{finding.targeted}", f"{finding.saving}", self.share(finding.saving))) + + return (*leading, *findings) + + def share(self, saving: int) -> str: + """``saving`` as a percentage of the whole block.""" + return f"{100.0 * saving / self.block:.1f}%" + + +def account( + measurement: Measurement, + compressed: CompressedPlanes, +) -> AccountingRow: + """Reads one encoding back and states what every hypothesis would reach in it. + + Args: + measurement: The encoding under the song and variant it belongs to. + compressed: Its streams as the driver reads them. + + Returns: + AccountingRow: The shares and the findings. + """ + tokens: Dict[str, Tuple[ReadToken, ...]] = { + name: read_tokens(stream, compressed.phrases) for name, stream in zip(PlaneOrder.names(), compressed.streams) + } + planes = tuple( + plane_shares(name, plane, tokens[name]) + for name, plane in zip(PlaneOrder.names(), measurement.song.planes.planes) + ) + findings = ( + sum((plane.hold_chains for plane in planes), NOTHING), + plateaus(compressed.phrases), + sum((set_holds(tokens[name]) for name in PlaneOrder.names()), NOTHING), + coincident_starts(tokens), + sum((ramps_in_literals(tokens[name]) for name in PlaneOrder.names()), NOTHING), + fixed_overheads(measurement.song, compressed).finding, + ) + return AccountingRow( + group=measurement.song.group.value, + song=measurement.song.name, + variant=measurement.variant, + block=measurement.block, + dictionary=measurement.dictionary, + streams=measurement.streams, + planes=planes, + findings=findings, + ) diff --git a/src/sampletones_tools/codec/study/accounting/runs.py b/src/sampletones_tools/codec/study/accounting/runs.py new file mode 100644 index 000000000..bbbb19fca --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/runs.py @@ -0,0 +1,32 @@ +from itertools import groupby, pairwise +from typing import Tuple + +from sampletones_player.specification.binary import BYTE_VALUES + + +def runs(data: bytes) -> Tuple[int, ...]: + """The lengths of the runs of one value ``data`` is made of, in order. + + Args: + data: The bytes to read. + + Returns: + Tuple[int, ...]: One length per run; they sum to the length of ``data``. + """ + return tuple(len(list(group)) for _, group in groupby(data)) + + +def ramps(data: bytes) -> Tuple[int, ...]: + """The lengths of the constant-step runs ``data`` is made of, steps of zero left aside. + + A ramp is a series stepping by the same amount from one value to the next, a fade or a + slide, and its length counts the values it covers. + + Args: + data: The bytes to read. + + Returns: + Tuple[int, ...]: The length of every ramp of at least two values. + """ + steps = tuple((following - value) % BYTE_VALUES for value, following in pairwise(data)) + return tuple(len(list(group)) + 1 for step, group in groupby(steps) if step != 0) diff --git a/src/sampletones_tools/codec/study/accounting/shares.py b/src/sampletones_tools/codec/study/accounting/shares.py new file mode 100644 index 000000000..9f722363a --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/shares.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from math import ceil +from typing import Final, List, Sequence + +from sampletones_player.specification.compression import ( + MAX_HOLD_TICKS, + OPCODE_SIZE, + TokenTag, +) +from sampletones_tools.codec.study.accounting.finding import NOTHING, Finding +from sampletones_tools.codec.study.accounting.tokens import ReadToken + +WIDE_HOLD_SIZE: Final[int] = 2 +WIDE_HOLD_UNITS: Final[int] = MAX_HOLD_TICKS +BEND_SUFFIX: Final[str] = "_bend" + + +@dataclass(frozen=True) +class PlaneShares: + """Where one plane's stream bytes go, token kind by token kind. + + Attributes: + name: The plane's name in the song block. + stream: The bytes the stream takes. + hold_opcodes: The bytes spent on hold opcodes. + literal_opcodes: The bytes spent on literal opcodes. + literal_payload: The bytes spent on the values literals carry. + phrase_bytes: The bytes spent on phrase tokens. + idle: Whether the plane keeps one value throughout the song. + bend: Whether the plane is a bend plane. + hold_chains: What a wide hold would reach (H1): runs of consecutive holds. + """ + + name: str + stream: int + hold_opcodes: int + literal_opcodes: int + literal_payload: int + phrase_bytes: int + idle: bool + bend: bool + hold_chains: Finding + + +def plane_shares( + name: str, + plane: bytes, + tokens: Sequence[ReadToken], +) -> PlaneShares: + """Accounts for one plane's stream. + + Args: + name: The plane's name in the song block. + plane: The values the plane plays. + tokens: The tokens the plane's stream was read back as. + + Returns: + PlaneShares: The bytes by token kind, and what a wide hold would reach. + """ + return PlaneShares( + name=name, + stream=sum(token.size for token in tokens), + hold_opcodes=sum(token.size for token in tokens if token.tag is TokenTag.HOLD), + literal_opcodes=sum(OPCODE_SIZE for token in tokens if token.tag is TokenTag.LITERAL), + literal_payload=sum(len(token.payload) for token in tokens), + phrase_bytes=sum(token.size for token in tokens if token.names_a_phrase), + idle=len(set(plane)) == 1, + bend=name.endswith(BEND_SUFFIX), + hold_chains=hold_chains(tokens), + ) + + +def hold_chains(tokens: Sequence[ReadToken]) -> Finding: + """What a wide hold would spare on runs of consecutive holds (H1). + + A hold covers at most ``MAX_HOLD_TICKS``, so a plane resting longer pays an opcode per that + many ticks. A wide hold on the free escape takes two bytes and counts full holds instead of + ticks, so a chain is priced as the wide holds its full holds need and one plain hold for + the ticks left over. + + Args: + tokens: The tokens of one plane's stream. + + Returns: + Finding: The hold opcodes in chains, and the bytes wide holds would spare on them. + """ + finding = NOTHING + for chain in _chains(tokens): + full, rest = divmod(sum(token.ticks for token in chain), MAX_HOLD_TICKS) + if full == 0: + continue + + now = OPCODE_SIZE * len(chain) + wide = WIDE_HOLD_SIZE * ceil(full / WIDE_HOLD_UNITS) + (OPCODE_SIZE if rest else 0) + finding += Finding(now, max(0, now - wide)) + + return finding + + +def _chains(tokens: Sequence[ReadToken]) -> List[List[ReadToken]]: + chains: List[List[ReadToken]] = [] + current: List[ReadToken] = [] + for token in tokens: + if token.tag is TokenTag.HOLD: + current.append(token) + continue + + if len(current) > 1: + chains.append(current) + + current = [] + + if len(current) > 1: + chains.append(current) + + return chains diff --git a/src/sampletones_tools/codec/study/accounting/tokens.py b/src/sampletones_tools/codec/study/accounting/tokens.py new file mode 100644 index 000000000..90734a276 --- /dev/null +++ b/src/sampletones_tools/codec/study/accounting/tokens.py @@ -0,0 +1,115 @@ +from typing import List, NamedTuple, Optional, Tuple + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.tokens.span import token_span +from sampletones_player.specification.compression import ( + DEFAULT_COUNT_FLAG, + OPCODE_SIZE, + PHRASE_COUNT_SIZE, + PHRASE_ESCAPE_SIZE, + PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, + TOKEN_OPERAND_MASK, + TOKEN_TAG_MASK, + TokenTag, +) + + +class ReadToken(NamedTuple): + """One token read back from a written stream, with everything the accounting asks of it. + + Attributes: + tag: What kind of token it is. + tick: The tick the token starts on. + size: The bytes the token takes. + ticks: The ticks the token covers. + payload: The values a literal carries, empty for every other kind. + phrase_id: The phrase a phrase token names, ``None`` for every other kind. + transpose: The shift a transposed phrase is played at, zero otherwise. + """ + + tag: TokenTag + tick: int + size: int + ticks: int + payload: bytes + phrase_id: Optional[int] + transpose: int + + @property + def names_a_phrase(self) -> bool: + """Whether the token plays a phrase from the dictionary.""" + return self.phrase_id is not None + + +def _phrase_operands( + stream: bytes, + position: int, + operand: int, + *, + transposed: bool, +) -> Tuple[int, int]: + after = position + OPCODE_SIZE + phrase_id = operand & PHRASE_ID_MASK + if phrase_id == PHRASE_ID_ESCAPE: + phrase_id = stream[after] + after += PHRASE_ESCAPE_SIZE + + if not operand & DEFAULT_COUNT_FLAG: + after += PHRASE_COUNT_SIZE + + transpose = stream[after] if transposed else 0 + return phrase_id, transpose + + +def read_tokens( + stream: bytes, + table: PhraseTable, +) -> Tuple[ReadToken, ...]: + """Reads a plane's stream back as the tokens it was written from. + + Args: + stream: The plane's token stream. + table: The dictionary the tokens name. + + Returns: + Tuple[ReadToken, ...]: The tokens in the order they are read. + """ + tokens: List[ReadToken] = [] + position = 0 + tick = 0 + while position < len(stream): + span = token_span(stream, position, table) + tag = TokenTag(stream[position] & TOKEN_TAG_MASK) + operand = stream[position] & TOKEN_OPERAND_MASK + payload = b"" + phrase_id: Optional[int] = None + transpose = 0 + match tag: + case TokenTag.LITERAL: + payload = stream[position + OPCODE_SIZE : position + span.size] + case TokenTag.PHRASE | TokenTag.TRANSPOSED_PHRASE: + phrase_id, transpose = _phrase_operands( + stream, + position, + operand, + transposed=tag is TokenTag.TRANSPOSED_PHRASE, + ) + case TokenTag.HOLD: + pass + + tokens.append( + ReadToken( + tag=tag, + tick=tick, + size=span.size, + ticks=span.ticks, + payload=payload, + phrase_id=phrase_id, + transpose=transpose, + ) + ) + position += span.size + tick += span.ticks + + return tuple(tokens) diff --git a/src/sampletones_tools/codec/study/corpus/__init__.py b/src/sampletones_tools/codec/study/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/corpus/build.py b/src/sampletones_tools/codec/study/corpus/build.py new file mode 100644 index 000000000..aec03a639 --- /dev/null +++ b/src/sampletones_tools/codec/study/corpus/build.py @@ -0,0 +1,43 @@ +from typing import List, Tuple + +from sampletones_shared.logger import logger +from sampletones_tools.codec.study.corpus.projects import lengthened_song, project_song +from sampletones_tools.codec.study.corpus.reconstructions import reconstruction_paths, reconstruction_song +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.manifest import StudyManifest, StudySource + + +def build_corpus(manifest: StudyManifest) -> Tuple[StudySong, ...]: + """Reads every song the manifest names. + + Args: + manifest: What the run reads. + + Returns: + Tuple[StudySong, ...]: The projects as they stand, then each one lengthened, then every + stem in manifest order. + """ + songs: List[StudySong] = [] + for source in manifest.projects: + logger.info(f"Reading project {source.path}") + songs.append(project_song(source.path)) + + for source in manifest.projects: + logger.info(f"Lengthening project {source.path} to {manifest.lengthen_seconds} s") + songs.append(lengthened_song(source.path, manifest.lengthen_seconds)) + + for source in manifest.reconstructions: + songs.extend(_reconstructions(source)) + + return tuple(songs) + + +def _reconstructions(source: StudySource) -> List[StudySong]: + paths = reconstruction_paths(source.path) + songs: List[StudySong] = [] + for path in paths: + logger.info(f"Reading stem {path}") + name = source.label if len(paths) == 1 else f"{source.label}/{path.stem}" + songs.append(reconstruction_song(name, path)) + + return songs diff --git a/src/sampletones_tools/codec/study/corpus/notes.py b/src/sampletones_tools/codec/study/corpus/notes.py new file mode 100644 index 000000000..cd36c37b9 --- /dev/null +++ b/src/sampletones_tools/codec/study/corpus/notes.py @@ -0,0 +1,66 @@ +from typing import Final, List, Mapping, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import PulseExporter, TriangleExporter +from sampletones_core.instructions import InstructionUnion, PulseInstruction, TriangleInstruction +from sampletones_player.registers.channel import channel_instructions +from sampletones_player.registers.hold import hold + +TONE_ORDER: Final[Tuple[ChannelName, ...]] = ( + ChannelName.PULSE1, + ChannelName.PULSE2, + ChannelName.TRIANGLE, +) + + +def channel_notes( + channel: ChannelName, + instructions: Sequence[InstructionUnion], +) -> bytes: + """The note each tick of a tone channel names, held the way its registers hold the divider. + + The encoders write a tick per frame and close a sounding stream with one silent tick, and a + rest holds the note the channel last sounded, so this reads the same ticks the channel's + registers cover. + + Args: + channel: The tone channel. + instructions: The channel's stream. + + Returns: + bytes: One note per tick the channel's registers cover. + + Raises: + ValueError: If the channel names no note. + """ + pitches: List[int] + volumes: List[int] + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: + _, pitches, volumes, _ = PulseExporter.extract_data(channel_instructions(instructions, PulseInstruction)) + case ChannelName.TRIANGLE: + _, pitches, volumes = TriangleExporter.extract_data(channel_instructions(instructions, TriangleInstruction)) + case _: + raise ValueError(f"the {channel.value} channel names no note") + + return bytes(hold(pitches, tick) for tick in range(len(volumes))) + + +def song_notes( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + ticks: int, +) -> Tuple[bytes, ...]: + """The note each tick of every tone channel names, carried to the song's length. + + A channel running out early holds its final note through the ticks that remain, as its + registers do. + + Args: + instructions: The stream each channel carries. + ticks: The ticks the song lasts. + + Returns: + Tuple[bytes, ...]: One run of notes per tone channel, in channel order. + """ + notes = (channel_notes(channel, instructions.get(channel, ())) for channel in TONE_ORDER) + return tuple(bytes(hold(channel, tick) for tick in range(ticks)) for channel in notes) diff --git a/src/sampletones_tools/codec/study/corpus/projects.py b/src/sampletones_tools/codec/study/corpus/projects.py new file mode 100644 index 000000000..31aa6170b --- /dev/null +++ b/src/sampletones_tools/codec/study/corpus/projects.py @@ -0,0 +1,97 @@ +from math import ceil +from pathlib import Path + +from sampletones_core.constants.enums import ALL_CHANNELS +from sampletones_core.performance import song_instructions +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_core.project.tuning import tuning_from_project +from sampletones_core.timers.utils import get_timer_table +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.builder import streams_from_instructions +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_shared.utils.progress import silent_reporter +from sampletones_tools.codec.study.corpus.notes import song_notes +from sampletones_tools.codec.study.corpus.slices import project_slices +from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong + + +def project_song(path: Path) -> StudySong: + """Reads a project file as the song its arrangement plays. + + Args: + path: The project file. + + Returns: + StudySong: The song, seeded with the phrases the project's instruments offer. + """ + return _song(path.stem, SongGroup.PROJECT, path, ProjectContainer.load(path)) + + +def lengthened_song( + path: Path, + seconds: int, +) -> StudySong: + """Reads a project file as its arrangement repeated until the song lasts ``seconds``. + + A project on disk is a few seconds of patterns, and an export is measured against a song of + minutes, so the whole order is played through as many times as that takes. + + Args: + path: The project file. + seconds: How long the song is to last. + + Returns: + StudySong: The song, seeded with the phrases the project's instruments offer. + """ + project = ProjectContainer.load(path) + return _song( + f"{path.stem} ({seconds} s)", + SongGroup.LONG_PROJECT, + path, + _repeated(project, seconds), + ) + + +def _song( + name: str, + group: SongGroup, + source: Path, + project: Project, +) -> StudySong: + tuning = tuning_from_project(project) + pitches = PitchTable.from_tuning(tuning) + instructions = song_instructions(project, silent_reporter) + streams = streams_from_instructions(instructions, get_timer_table(tuning)) + return StudySong( + name=name, + group=group, + source=source, + planes=planes_from_streams(streams, pitches), + seeds=phrases_from_project(project, tuning, ALL_CHANNELS), + pitches=pitches, + notes=song_notes(instructions, streams.ticks), + slices=project_slices(project, tuning), + ) + + +def _repeated( + project: Project, + seconds: int, +) -> Project: + order_ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) + repetitions = max(1, ceil(seconds * project.settings.nes_frequency / order_ticks)) + longer = Project.create( + rows_per_pattern=project.song.rows_per_pattern, + settings=project.settings, + ) + for voice in project.voices: + longer.voices.append(voice) + + longer.song = project.song.model_copy(deep=True) + for _ in range(repetitions - 1): + longer.song.order.extend(dict(frame) for frame in project.song.order) + + return longer diff --git a/src/sampletones_tools/codec/study/corpus/reconstructions.py b/src/sampletones_tools/codec/study/corpus/reconstructions.py new file mode 100644 index 000000000..8450976a8 --- /dev/null +++ b/src/sampletones_tools/codec/study/corpus/reconstructions.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Final, Tuple + +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.builder import streams_from_instructions +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_tools.codec.study.corpus.notes import song_notes +from sampletones_tools.codec.study.corpus.song import SongGroup, StudySlice, StudySong + +STEM_SUFFIX: Final[str] = ".stn" +NO_SEEDS: Final[Tuple[Phrase, ...]] = () +NO_SLICES: Final[Tuple[StudySlice, ...]] = () + + +def reconstruction_paths(path: Path) -> Tuple[Path, ...]: + """The stem files ``path`` names: the file itself, or every stem under a directory. + + Args: + path: A stem file or a directory holding stems. + + Returns: + Tuple[Path, ...]: The stem files, a directory's in sorted order. + """ + if path.is_dir(): + return tuple(sorted(path.rglob(f"*{STEM_SUFFIX}"))) + + return (path,) + + +def reconstruction_song( + name: str, + path: Path, +) -> StudySong: + """Reads a stem file as the song the console plays it as. + + A reconstruction sounds each of its slices once, so the song offers the dictionary nothing + and the search fills it from what the streams themselves repeat. + + Args: + name: What the song is called in a report. + path: The stem file. + + Returns: + StudySong: The song, at the tuning and rate the stem was reconstructed at. + """ + reconstruction = Reconstruction.load(path) + tuning = reconstruction.config.tuning + pitches = PitchTable.from_tuning(tuning) + streams = streams_from_instructions( + reconstruction.instructions, + get_timer_table(tuning), + ) + return StudySong( + name=name, + group=SongGroup.RECONSTRUCTION, + source=path, + planes=planes_from_streams(streams, pitches), + seeds=NO_SEEDS, + pitches=pitches, + notes=song_notes(reconstruction.instructions, streams.ticks), + slices=NO_SLICES, + ) diff --git a/src/sampletones_tools/codec/study/corpus/slices.py b/src/sampletones_tools/codec/study/corpus/slices.py new file mode 100644 index 000000000..98f30d7a6 --- /dev/null +++ b/src/sampletones_tools/codec/study/corpus/slices.py @@ -0,0 +1,47 @@ +from typing import List, Tuple + +from sampletones_core.constants.enums import TONE_CHANNELS +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.pitch import PitchTable +from sampletones_player.compression.planes.separate import channel_planes +from sampletones_player.registers.channel import channel_registers +from sampletones_shared.music import Tuning +from sampletones_tools.codec.study.corpus.notes import channel_notes +from sampletones_tools.codec.study.corpus.song import StudySlice + + +def project_slices( + project: Project, + tuning: Tuning, +) -> Tuple[StudySlice, ...]: + """Every channel of every voice a project holds, in the order its instruments seed a song. + + This is the reading ``phrases_from_project`` seeds the production dictionary from, kept whole + so a plane layout can separate each slice its own way. + + Args: + project: The project whose voices are read. + tuning: Where concert pitch sits, which decides the timer each pitch sounds at. + + Returns: + Tuple[StudySlice, ...]: The slices, in instrument-table order. + """ + timer_table = get_timer_table(tuning) + pitches = PitchTable.from_tuning(tuning) + slices: List[StudySlice] = [] + for voice_slice in iterate_voice_slices(project): + channel = voice_slice.channel + instructions = CHANNEL_TO_EXPORTER_MAP[channel].from_features(voice_slice.features) + registers = channel_registers(channel, {channel: instructions}, timer_table) + slices.append( + StudySlice( + channel=channel, + planes=channel_planes(channel, registers, pitches), + notes=channel_notes(channel, instructions) if channel in TONE_CHANNELS else b"", + ) + ) + + return tuple(slices) diff --git a/src/sampletones_tools/codec/study/corpus/song.py b/src/sampletones_tools/codec/study/corpus/song.py new file mode 100644 index 000000000..3f12b9c31 --- /dev/null +++ b/src/sampletones_tools/codec/study/corpus/song.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.song import SongPlanes + + +class SongGroup(StrEnum): + """Which kind of song a corpus entry is, which is what the report aggregates over. + + Attributes: + PROJECT: A project as it stands on disk, a few seconds of patterns. + LONG_PROJECT: A project with its order repeated to the length an export is measured at. + RECONSTRUCTION: A stem reconstructed from audio, its planes turning over at nearly + every tick. + """ + + PROJECT = "project" + LONG_PROJECT = "project-long" + RECONSTRUCTION = "reconstruction" + + +@dataclass(frozen=True) +class StudySlice: + """One channel of one of a song's voices, the material a plane layout builds a seed from. + + Attributes: + channel: The channel the slice plays on. + planes: The slice's planes as the production codec separates them, in block order. + notes: The note each of its ticks names, empty on the noise channel. + """ + + channel: ChannelName + planes: Tuple[bytes, ...] + notes: bytes + + +@dataclass(frozen=True) +class StudySong: + """One song the study measures the codec on. + + Attributes: + name: What the song is called in a report. + group: Which kind of song it is. + source: The file the song was read from. + planes: The planes the codec compresses. + seeds: The phrases the song's instruments offer the dictionary. + pitches: The timer each pitch of the song sounds at. + notes: The note each tick of every tone channel names, in channel order, which a plane + layout may anchor a bend to in place of the pitch nearest the divider. + slices: The song's voices, one channel at a time, which seed a plane layout's dictionary. + """ + + name: str + group: SongGroup + source: Path + planes: SongPlanes + seeds: Tuple[Phrase, ...] + pitches: PitchTable + notes: Tuple[bytes, ...] + slices: Tuple[StudySlice, ...] + + @property + def ticks(self) -> int: + """The ticks the song lasts.""" + return self.planes.ticks diff --git a/src/sampletones_tools/codec/study/layouts/__init__.py b/src/sampletones_tools/codec/study/layouts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/layouts/encode.py b/src/sampletones_tools/codec/study/layouts/encode.py new file mode 100644 index 000000000..816a13283 --- /dev/null +++ b/src/sampletones_tools/codec/study/layouts/encode.py @@ -0,0 +1,56 @@ +from time import process_time + +from sampletones_player.compression.decode import decode_plane +from sampletones_player.compression.encode import encode_streams +from sampletones_player.compression.options import EVERY_LAYER +from sampletones_player.compression.planes.symbols import pack_plane +from sampletones_player.specification.planes import PLANES +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.layouts.layout import PlaneLayout +from sampletones_tools.codec.study.layouts.planes import layout_planes, layout_seeds +from sampletones_tools.codec.study.measure import Encoding +from sampletones_tools.codec.study.variants.production import NO_LOOP_BOUNDARIES + + +def encode_layout( + song: StudySong, + layout: PlaneLayout, +) -> Encoding: + """Encodes a song's planes written under a layout, every layer of the production codec on. + + Args: + song: The song to encode. + layout: How each tone channel's divider is written. + + Returns: + Encoding: The encoding, an absent plane's stream counted as nothing. + """ + planes = layout_planes(song, layout) + seeds = layout_seeds(song.slices, song.pitches, layout) + packed = tuple( + b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=NO_LOOP_BOUNDARIES) + for plane, played in zip(PLANES, planes, strict=True) + ) + + started = process_time() + table, streams = encode_streams( + packed, + seeds, + options=EVERY_LAYER, + boundaries=(NO_LOOP_BOUNDARIES,) * len(packed), + ) + seconds = process_time() - started + + return Encoding( + header=SONG_HEADER_SIZE, + phrases=len(table), + dictionary=table.size, + streams=tuple(len(stream) for stream in streams), + seconds=seconds, + lossless=all( + decode_plane(stream, table, plane, len(played)) == played + for plane, played, stream in zip(PLANES, planes, streams, strict=True) + ), + written=None, + ) diff --git a/src/sampletones_tools/codec/study/layouts/layout.py b/src/sampletones_tools/codec/study/layouts/layout.py new file mode 100644 index 000000000..ec744db84 --- /dev/null +++ b/src/sampletones_tools/codec/study/layouts/layout.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from enum import StrEnum + + +class Anchor(StrEnum): + """What a tone channel's value plane names for a tick whose divider is bent. + + Attributes: + NEAREST: The pitch lying nearest the divider, which the divider alone decides. + NAMED: The note the frame names, which keeps a transposed bend byte for byte; the pitch + lying nearest stands in where the steps would leave a signed byte. + """ + + NEAREST = "nearest" + NAMED = "named" + + +class BendForm(StrEnum): + """How a tone channel's bend plane covers its ticks. + + Attributes: + DENSE: A bend every tick, zero where the tick sounds its pitch's own divider. + FLAGGED_TICKS: The value's top bit marks each tick carrying a bend, and the bend plane + holds those ticks alone. + FLAGGED_NOTES: The flag spans a note from its first bent tick to its last, so a bend + passing through zero keeps the value plane still. + """ + + DENSE = "dense" + FLAGGED_TICKS = "flagged-ticks" + FLAGGED_NOTES = "flagged-notes" + + +@dataclass(frozen=True) +class PlaneLayout: + """One way of writing a tone channel's divider across its value and bend planes. + + Attributes: + anchor: What the value plane names. + form: How the bend plane covers the ticks. + """ + + anchor: Anchor + form: BendForm diff --git a/src/sampletones_tools/codec/study/layouts/planes.py b/src/sampletones_tools/codec/study/layouts/planes.py new file mode 100644 index 000000000..8f8f130d9 --- /dev/null +++ b/src/sampletones_tools/codec/study/layouts/planes.py @@ -0,0 +1,70 @@ +from typing import Final, FrozenSet, List, Sequence, Tuple + +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.symbols import pack_plane +from sampletones_player.specification.compression import MAX_PHRASE_LENGTH +from sampletones_player.specification.planes import PLANES, channel_indices +from sampletones_tools.codec.study.corpus.song import StudySlice, StudySong +from sampletones_tools.codec.study.layouts.layout import PlaneLayout +from sampletones_tools.codec.study.layouts.tone import tone_planes + +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() + + +def layout_planes( + song: StudySong, + layout: PlaneLayout, +) -> Tuple[bytes, ...]: + """Every plane of a song written under a layout, in the order the song block writes them. + + Args: + song: The song. + layout: How each tone channel's divider is written. + + Returns: + Tuple[bytes, ...]: The planes; a flagged bend plane covers fewer values than the song's ticks. + """ + tones = tuple(song.planes.of(channel) for channel in ChannelName.items() if channel in TONE_CHANNELS) + planes: List[bytes] = [] + for channel, notes in zip(tones, song.notes, strict=True): + planes.extend(tone_planes(channel, notes, song.pitches, layout)) + + planes.extend(song.planes.of(ChannelName.NOISE)) + return tuple(planes) + + +def layout_seeds( + slices: Sequence[StudySlice], + pitches: PitchTable, + layout: PlaneLayout, +) -> Tuple[Phrase, ...]: + """The phrases a song's voices offer the dictionary, each slice written under a layout. + + A slice offers every plane it turns over, as ``phrases_from_project`` does, so a layout's + dictionary is seeded by the same shapes its song's planes repeat. + + Args: + slices: The song's voices, one channel at a time. + pitches: The timer each pitch sounds at. + layout: How each tone channel's divider is written. + + Returns: + Tuple[Phrase, ...]: The phrases, in slice order. + """ + phrases: List[Phrase] = [] + for study_slice in slices: + planes = ( + tone_planes(study_slice.planes, study_slice.notes, pitches, layout) + if study_slice.channel in TONE_CHANNELS + else study_slice.planes + ) + + phrases.extend( + Phrase(body=pack_plane(plane, PLANES[index].form, boundaries=NO_BOUNDARIES)[:MAX_PHRASE_LENGTH]) + for index, plane in zip(channel_indices(study_slice.channel), planes, strict=True) + if len(set(plane)) > 1 + ) + + return tuple(phrases) diff --git a/src/sampletones_tools/codec/study/layouts/tone.py b/src/sampletones_tools/codec/study/layouts/tone.py new file mode 100644 index 000000000..cf5849ed1 --- /dev/null +++ b/src/sampletones_tools/codec/study/layouts/tone.py @@ -0,0 +1,115 @@ +from typing import Dict, Sequence, Tuple + +from sampletones_core.timers.nearest import NearestPitch +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.flags import flagged_value, note_flags +from sampletones_player.compression.planes.rebuild import tone_dividers +from sampletones_player.registers.dividers import anchored_pitches +from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.planes import SILENT_PITCH_INDEX +from sampletones_tools.codec.study.layouts.layout import Anchor, BendForm, PlaneLayout + + +def layout_anchors( + dividers: Sequence[int], + notes: bytes, + pitches: PitchTable, + anchor: Anchor, +) -> Tuple[NearestPitch, ...]: + """The index each tick's value plane names, beside the bend that reaches its divider from there. + + Args: + dividers: The divider each tick sounds. + notes: The note each tick's frame names. + pitches: The timer each pitch sounds at. + anchor: What the value plane names. + + Returns: + Tuple[NearestPitch, ...]: One index and bend per tick. + + Raises: + ValueError: If the dividers and the notes cover different ticks. + """ + match anchor: + case Anchor.NEAREST: + return tuple(pitches.nearest[divider] for divider in dividers) + case Anchor.NAMED: + anchors = anchored_pitches(notes, dividers, _timer_table(pitches)) + return tuple(_counted(divider, pitches.index(pitch), pitches) for divider, pitch in zip(dividers, anchors)) + + +def _timer_table(pitches: PitchTable) -> Dict[int, int]: + """The timer each pitch of the table sounds at, keyed by the pitch.""" + return {pitches.pitch(index): timer for index, timer in enumerate(pitches.timers)} + + +def _counted( + divider: int, + index: int, + pitches: PitchTable, +) -> NearestPitch: + return NearestPitch(pitch=index, offset=divider - pitches.timers[index]) + + +def bend_flags( + anchored: Sequence[NearestPitch], + form: BendForm, +) -> Tuple[bool, ...]: + """Which ticks carry a bend byte where the bend plane holds only the ticks flagged. + + Args: + anchored: The index and bend each tick names. + form: How the bend plane covers the ticks. + + Returns: + Tuple[bool, ...]: One flag per tick; a dense plane flags every tick. + """ + match form: + case BendForm.DENSE: + return (True,) * len(anchored) + case BendForm.FLAGGED_TICKS: + return tuple(pitch.offset != 0 for pitch in anchored) + case BendForm.FLAGGED_NOTES: + return note_flags([pitch.pitch for pitch in anchored], [pitch.offset for pitch in anchored]) + + +def tone_planes( + planes: Tuple[bytes, ...], + notes: bytes, + pitches: PitchTable, + layout: PlaneLayout, +) -> Tuple[bytes, ...]: + """A tone channel's planes written under a layout, the divider read across value and bend. + + A tick naming the index that stands for silence keeps it, since a layout moves where a + divider is written rather than whether the channel sounds. + + Args: + planes: The channel's planes as the production codec separates them. + notes: The note each tick's frame names. + pitches: The timer each pitch sounds at. + layout: How the divider is written across the value and the bend. + + Returns: + Tuple[bytes, ...]: The channel's planes as the song block writes them; a flagged bend + plane holds only the ticks its value plane flags. + + Raises: + ValueError: If a flagged layout meets an index reaching the flag's bit. + """ + *timbre, named, offsets = planes + resting = tuple(pitch == SILENT_PITCH_INDEX for pitch in named) + anchored = layout_anchors(tone_dividers(named, offsets, pitches.timers), notes, pitches, layout.anchor) + flags = tuple(flag and not rest for flag, rest in zip(bend_flags(anchored, layout.form), resting, strict=True)) + bend = bytes(unsigned_byte(pitch.offset) for pitch, flag in zip(anchored, flags) if flag) + if layout.form is BendForm.DENSE: + spelled = bytes( + SILENT_PITCH_INDEX if rest else pitch.pitch for rest, pitch in zip(resting, anchored, strict=True) + ) + return (*timbre, spelled, bend) + + value = bytes( + SILENT_PITCH_INDEX if rest else flagged_value(pitch.pitch, flag) + for rest, pitch, flag in zip(resting, anchored, flags, strict=True) + ) + return (*timbre, value, bend) diff --git a/src/sampletones_tools/codec/study/manifest.py b/src/sampletones_tools/codec/study/manifest.py new file mode 100644 index 000000000..026a242c5 --- /dev/null +++ b/src/sampletones_tools/codec/study/manifest.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Final, Self, Tuple + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +NAMES_A_SOURCE: Final[str] = "A study measures at least one project or reconstruction." + + +class StudySource(BaseModel): + """One file or directory the corpus is read from. + + Attributes: + label: What songs read from the source are called in a report. + path: The file, or the directory whose stems are read. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + label: str + path: Path + + @model_validator(mode="after") + def _names_a_file_or_directory(self) -> Self: + """Raises: + ValueError: If nothing stands at the path. + """ + if not self.path.exists(): + raise ValueError(f"No file at {self.path}.") + + return self + + @classmethod + def at(cls, path: Path) -> Self: + """A source labeled by its own name: a file's stem, or a directory's name. + + Args: + path: The file or directory. + + Returns: + Self: The source under that label. + """ + return cls(label=path.name if path.is_dir() else path.stem, path=path) + + +class StudyManifest(BaseModel): + """What one run of the study reads and measures. + + Attributes: + projects: The project files, each measured as it stands and at the lengthened duration. + reconstructions: The stem files and directories of stems. + lengthen_seconds: How long each project's lengthened copy lasts. + variants: The names of the variants every song is encoded under. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + projects: Tuple[StudySource, ...] + reconstructions: Tuple[StudySource, ...] + lengthen_seconds: int = Field(ge=1) + variants: Tuple[str, ...] = Field(min_length=1) + + @model_validator(mode="after") + def _names_a_source(self) -> Self: + """Raises: + ValueError: If the manifest names neither a project nor a reconstruction. + """ + if not self.projects and not self.reconstructions: + raise ValueError(NAMES_A_SOURCE) + + return self + + @classmethod + def load(cls, path: Path) -> Self: + """Reads a manifest a run wrote, or one written by hand. + + Args: + path: The manifest file, as JSON. + + Returns: + Self: The manifest. + """ + return cls.model_validate_json(path.read_text(encoding="utf-8")) + + def save(self, path: Path) -> None: + """Writes the manifest beside a run's report, so the run can be repeated. + + Args: + path: Where the manifest is written, as JSON. + """ + path.write_text(self.model_dump_json(indent=2), encoding="utf-8") diff --git a/src/sampletones_tools/codec/study/measure.py b/src/sampletones_tools/codec/study/measure.py new file mode 100644 index 000000000..22c674733 --- /dev/null +++ b/src/sampletones_tools/codec/study/measure.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass +from typing import Callable, Optional, Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.decode import decode_planes +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_tools.codec.study.corpus.song import StudySong + + +@dataclass(frozen=True) +class Encoding: + """What a variant wrote a song as, priced in the bytes the song block counts. + + A variant of the encoder writes the grammar the driver reads today, and its streams are + kept as the bytes they are. A variant of the grammar is priced at token level, its streams + stated as the bytes each would take, so the two kinds sit in one report on the same terms. + + Attributes: + header: The bytes the song block's header takes, which a format stating more of itself grows. + phrases: The phrases the dictionary holds. + dictionary: The bytes the dictionary takes. + streams: The bytes each plane's stream takes, in the order the song block writes them. + seconds: The processor time the encoding took. + lossless: Whether the streams play back to the planes they were written from. + written: The streams as the driver reads them, where the variant writes its grammar. + """ + + header: int + phrases: int + dictionary: int + streams: Tuple[int, ...] + seconds: float + lossless: bool + written: Optional[CompressedPlanes] + + +Encoder = Callable[[StudySong], Encoding] + + +@dataclass(frozen=True) +class Measurement: + """One song encoded under one variant. + + Attributes: + song: The song encoded. + variant: The name of the variant the encoding was built by. + encoding: What the variant wrote, and what writing it cost. + """ + + song: StudySong + variant: str + encoding: Encoding + + @property + def block(self) -> int: + """The bytes the whole song block takes: header, pitch table, dictionary and streams.""" + return self.encoding.header + len(self.song.pitches.data) + self.dictionary + self.streams + + @property + def dictionary(self) -> int: + """The bytes the dictionary takes.""" + return self.encoding.dictionary + + @property + def streams(self) -> int: + """The bytes the token streams take together.""" + return sum(self.encoding.streams) + + @property + def phrases(self) -> int: + """The phrases the dictionary holds.""" + return self.encoding.phrases + + @property + def seconds(self) -> float: + """The processor time the encoding took.""" + return self.encoding.seconds + + @property + def lossless(self) -> bool: + """Whether the streams play back to the planes they were written from.""" + return self.encoding.lossless + + @property + def bytes_per_tick(self) -> float: + """The bytes each tick of the song costs, the whole block counted.""" + return self.block / self.song.ticks + + +def production_encoding( + song: StudySong, + compressed: CompressedPlanes, + seconds: float, +) -> Encoding: + """What the production codec wrote a song as, played back through the production decoder. + + Args: + song: The song encoded. + compressed: The dictionary and the streams the codec wrote. + seconds: The processor time the encoding took. + + Returns: + Encoding: The encoding, its streams kept as written. + """ + return Encoding( + header=SONG_HEADER_SIZE, + phrases=len(compressed.phrases), + dictionary=compressed.phrases.size, + streams=tuple(len(stream) for stream in compressed.streams), + seconds=seconds, + lossless=decode_planes(compressed) == song.planes, + written=compressed, + ) + + +def measure( + song: StudySong, + variant: str, + encode: Encoder, +) -> Measurement: + """Encodes a song under a variant. + + Args: + song: The song to encode. + variant: The name of the variant. + encode: What the variant writes the song as. + + Returns: + Measurement: The encoding under the song and variant it belongs to. + """ + return Measurement( + song=song, + variant=variant, + encoding=encode(song), + ) diff --git a/src/sampletones_tools/codec/study/plan.py b/src/sampletones_tools/codec/study/plan.py new file mode 100644 index 000000000..f46c7e3c3 --- /dev/null +++ b/src/sampletones_tools/codec/study/plan.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import Tuple + +from sampletones_tools.codec.study.manifest import StudyManifest +from sampletones_tools.codec.study.variants.baselines import Baselines +from sampletones_tools.codec.study.variants.registry import selected_variants +from sampletones_tools.codec.study.variants.variant import Variant + + +@dataclass(frozen=True) +class StudyPlan: + """What one run measures, and the variants its manifest names, so the two stay in step. + + Attributes: + manifest: What the run reads, which the run saves to repeat it. + variants: The variants the manifest's names select, the baseline first. + """ + + manifest: StudyManifest + variants: Tuple[Variant, ...] + + +def plan_study(manifest: StudyManifest) -> StudyPlan: + """The run a manifest describes, its variant names selected over production encodings of their own. + + Args: + manifest: What the run reads. + + Returns: + StudyPlan: The manifest beside the variants it names. + + Raises: + ValueError: If a name is registered to no variant. + """ + return StudyPlan(manifest=manifest, variants=selected_variants(manifest.variants, Baselines())) diff --git a/src/sampletones_tools/codec/study/report/__init__.py b/src/sampletones_tools/codec/study/report/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/report/aggregate.py b/src/sampletones_tools/codec/study/report/aggregate.py new file mode 100644 index 000000000..512042d87 --- /dev/null +++ b/src/sampletones_tools/codec/study/report/aggregate.py @@ -0,0 +1,112 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_tools.codec.study.corpus.song import SongGroup +from sampletones_tools.codec.study.measure import Measurement + +COLUMNS: Final[Tuple[str, ...]] = ( + "group", + "variant", + "songs", + "ticks", + "block", + "bytes per tick", + "change", + "worst song", + "seconds", +) + + +@dataclass(frozen=True) +class GroupRow: + """One kind of song under one variant, summed over the songs of that kind. + + Attributes: + group: Which kind of song it is. + variant: The variant the encodings were built by. + songs: How many songs the group holds. + ticks: The ticks the songs last together. + block: The bytes the song blocks take together. + baseline: The bytes the same songs take under the baseline. + worst: The largest growth any one song shows against its baseline, as a ratio. + seconds: The processor time the encodings took together. + """ + + group: str + variant: str + songs: int + ticks: int + block: int + baseline: int + worst: float + seconds: float + + @property + def change(self) -> float: + """How the group's bytes stand against the baseline, as a ratio; negative is smaller.""" + return self.block / self.baseline - 1.0 + + @property + def cells(self) -> Tuple[str, ...]: + """The row as the table prints it, column by column.""" + return ( + self.group, + self.variant, + f"{self.songs}", + f"{self.ticks}", + f"{self.block}", + f"{self.block / self.ticks:.3f}", + f"{100.0 * self.change:+.1f}%", + f"{100.0 * self.worst:+.1f}%", + f"{self.seconds:.2f}", + ) + + +def group_rows( + measurements: Sequence[Measurement], + baseline: str, +) -> Tuple[GroupRow, ...]: + """Sums the measurements over each kind of song, variant by variant. + + Args: + measurements: Every song under every variant. + baseline: The name of the variant the others are held against. + + Returns: + Tuple[GroupRow, ...]: One row per kind of song and variant, kinds in their declared + order and variants in the order they were measured. + """ + reference: Dict[str, int] = { + measurement.song.name: measurement.block for measurement in measurements if measurement.variant == baseline + } + variants = list(dict.fromkeys(measurement.variant for measurement in measurements)) + rows: List[GroupRow] = [] + for group in SongGroup: + for variant in variants: + chosen = [ + measurement + for measurement in measurements + if measurement.song.group is group and measurement.variant == variant + ] + if chosen: + rows.append(_group_row(group, variant, chosen, reference)) + + return tuple(rows) + + +def _group_row( + group: SongGroup, + variant: str, + chosen: Sequence[Measurement], + reference: Dict[str, int], +) -> GroupRow: + return GroupRow( + group=group.value, + variant=variant, + songs=len(chosen), + ticks=sum(measurement.song.ticks for measurement in chosen), + block=sum(measurement.block for measurement in chosen), + baseline=sum(reference[measurement.song.name] for measurement in chosen), + worst=max(measurement.block / reference[measurement.song.name] - 1.0 for measurement in chosen), + seconds=sum(measurement.seconds for measurement in chosen), + ) diff --git a/src/sampletones_tools/codec/study/report/rows.py b/src/sampletones_tools/codec/study/report/rows.py new file mode 100644 index 000000000..38188afaa --- /dev/null +++ b/src/sampletones_tools/codec/study/report/rows.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +from sampletones_tools.codec.study.measure import Measurement + +COLUMNS: Final[Tuple[str, ...]] = ( + "group", + "song", + "variant", + "ticks", + "block", + "bytes per tick", + "dictionary", + "streams", + "phrases", + "seconds", + "lossless", +) + + +@dataclass(frozen=True) +class StudyRow: + """One song measured under one variant, as the report prints it. + + Attributes: + group: Which kind of song it is. + song: The song's name. + variant: The variant the encoding was built by. + ticks: The ticks the song lasts. + block: The bytes the whole song block takes. + dictionary: The bytes the dictionary takes. + streams: The bytes the streams take together. + phrases: The phrases the dictionary holds. + seconds: The processor time the encoding took. + lossless: Whether the encoding plays back to its planes. + """ + + group: str + song: str + variant: str + ticks: int + block: int + dictionary: int + streams: int + phrases: int + seconds: float + lossless: bool + + @property + def cells(self) -> Tuple[str, ...]: + """The row as the table prints it, column by column.""" + return ( + self.group, + self.song, + self.variant, + f"{self.ticks}", + f"{self.block}", + f"{self.block / self.ticks:.3f}", + f"{self.dictionary}", + f"{self.streams}", + f"{self.phrases}", + f"{self.seconds:.2f}", + "yes" if self.lossless else "no", + ) + + +def study_row(measurement: Measurement) -> StudyRow: + """The report row of one measurement. + + Args: + measurement: The encoding and its cost. + + Returns: + StudyRow: The row. + """ + return StudyRow( + group=measurement.song.group.value, + song=measurement.song.name, + variant=measurement.variant, + ticks=measurement.song.ticks, + block=measurement.block, + dictionary=measurement.dictionary, + streams=measurement.streams, + phrases=measurement.phrases, + seconds=measurement.seconds, + lossless=measurement.lossless, + ) diff --git a/src/sampletones_tools/codec/study/report/run.py b/src/sampletones_tools/codec/study/report/run.py new file mode 100644 index 000000000..6d54cab46 --- /dev/null +++ b/src/sampletones_tools/codec/study/report/run.py @@ -0,0 +1,159 @@ +import subprocess +from datetime import UTC, datetime +from pathlib import Path +from typing import Final, Iterator, List, Optional, Sequence, Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_shared.paths.source import REPOSITORY_ROOT +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS +from sampletones_shared.utils.tables import Table +from sampletones_tools.checkout import is_checkout +from sampletones_tools.codec.study.accounting import rows as accounting +from sampletones_tools.codec.study.manifest import StudyManifest +from sampletones_tools.codec.study.measure import Measurement +from sampletones_tools.codec.study.plan import StudyPlan +from sampletones_tools.codec.study.report import aggregate +from sampletones_tools.codec.study.report import rows as songs +from sampletones_tools.codec.study.report import verdicts +from sampletones_tools.codec.study.variants.production import BASELINE_NAME +from sampletones_tools.codec.study.variants.variant import Variant +from sampletones_tools.runs import stamped_run_directory + +OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "compression" +REPORT_CSV: Final[str] = "report.csv" +REPORT_MARKDOWN: Final[str] = "report.md" +ACCOUNTING_CSV: Final[str] = "accounting.csv" +VERDICTS_CSV: Final[str] = "verdicts.csv" +MANIFEST_JSON: Final[str] = "manifest.json" +UNKNOWN_COMMIT: Final[str] = "unknown" + + +def run_directory(output: Optional[Path]) -> Path: + """The directory a run writes into, created where it is missing. + + Args: + output: The directory asked for, or ``None`` for a stamped one under the documents. + + Returns: + Path: The directory. + """ + directory = output or stamped_run_directory(OUTPUT_ROOT) + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def commit_hash() -> str: + """The short hash of the commit the checkout stands at, which dates the run's report. + + An installed copy has no checkout of its own around it, and a machine may run without git, so + either records ``unknown``. + """ + if not is_checkout(REPOSITORY_ROOT): + return UNKNOWN_COMMIT + + try: + completed = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=False, + cwd=REPOSITORY_ROOT, + ) + except FileNotFoundError: + return UNKNOWN_COMMIT + + return completed.stdout.strip() or UNKNOWN_COMMIT + + +def write_run( + directory: Path, + plan: StudyPlan, + measurements: Sequence[Measurement], + derived: Sequence[Measurement], +) -> None: + """Writes a run's report, its verdicts, its accounting and the manifest that reproduces it. + + Args: + directory: The run's directory. + plan: What the run read and the variants every song was encoded under. + measurements: Every song under every variant, in the order measured. + derived: The strategy-depth measurements drawn from those, reported beside them and + left out of the accounting, which reads each written encoding once. + """ + reported = (*measurements, *derived) + song_rows = [songs.study_row(measurement) for measurement in reported] + group_rows = aggregate.group_rows(reported, BASELINE_NAME) + verdict_rows = verdicts.verdict_rows(group_rows, measurements, plan.variants) + accounting_rows = [ + accounting.account(measurement, compressed) for measurement, compressed in _written(measurements) + ] + song_table = Table(columns=songs.COLUMNS, rows=tuple(row.cells for row in song_rows)) + verdict_table = Table(columns=verdicts.COLUMNS, rows=tuple(row.cells for row in verdict_rows)) + plan.manifest.save(directory / MANIFEST_JSON) + song_table.write_csv(directory / REPORT_CSV) + verdict_table.write_csv(directory / VERDICTS_CSV) + Table(columns=accounting.COLUMNS, rows=tuple(row.cells for row in accounting_rows)).write_csv( + directory / ACCOUNTING_CSV + ) + lines = _header(plan.manifest, plan.variants) + lines.extend(("## Variants", "", *_variants_table(plan.variants).markdown_lines(), "")) + lines.extend(("## Verdicts", "", verdicts.RULE, "", *verdict_table.markdown_lines(), "")) + group_table = Table(columns=aggregate.COLUMNS, rows=tuple(row.cells for row in group_rows)) + lines.extend(("## Groups", "", *group_table.markdown_lines(), "")) + lines.extend(("## Songs", "", *song_table.markdown_lines(), "")) + lines.extend(("## Accounting", "", *_accounting_table(accounting_rows).markdown_lines(), "")) + (directory / REPORT_MARKDOWN).write_text("\n".join(lines), encoding="utf-8") + + +def _written(measurements: Sequence[Measurement]) -> Iterator[Tuple[Measurement, CompressedPlanes]]: + """The measurements whose streams the driver reads as they stand, beside those streams.""" + for measurement in measurements: + written = measurement.encoding.written + if written is not None: + yield measurement, written + + +def _header( + manifest: StudyManifest, + variants: Sequence[Variant], +) -> List[str]: + named = ", ".join(f"{variant.name} ({variant.kind.value})" for variant in variants) + return [ + "# Compression study", + "", + f"Commit: {commit_hash()}", + f"Date: {datetime.now(UTC).isoformat(timespec='seconds')}", + ( + f"Manifest: {MANIFEST_JSON}, {len(manifest.projects)} projects lengthened to " + f"{manifest.lengthen_seconds} s, {len(manifest.reconstructions)} reconstruction sources" + ), + f"Variants: {named}", + "", + "Each accounting share is the saving a hypothesis would reach, as a share of the whole song block.", + "", + ] + + +def _variants_table(variants: Sequence[Variant]) -> Table: + columns = ("variant", "hypothesis", "kind", "driver") + cells = tuple((variant.name, variant.hypothesis, variant.kind.value, variant.note) for variant in variants) + return Table(columns=columns, rows=cells) + + +def _accounting_table(rows: Sequence[accounting.AccountingRow]) -> Table: + columns = ("group", "song", "variant", "block", "dictionary", "idle bytes", "bend bytes") + labels = tuple(f"{label} {name}" for label, name in accounting.HYPOTHESES) + cells = tuple( + ( + row.group, + row.song, + row.variant, + f"{row.block}", + row.share(row.dictionary), + row.share(sum(plane.stream for plane in row.idle_planes)), + row.share(sum(plane.stream for plane in row.planes if plane.bend)), + *(row.share(finding.saving) for finding in row.findings), + ) + for row in rows + ) + return Table(columns=(*columns, *labels), rows=cells) diff --git a/src/sampletones_tools/codec/study/report/verdicts.py b/src/sampletones_tools/codec/study/report/verdicts.py new file mode 100644 index 000000000..8dbcbe677 --- /dev/null +++ b/src/sampletones_tools/codec/study/report/verdicts.py @@ -0,0 +1,180 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Dict, Final, List, Optional, Sequence, Tuple + +from sampletones_tools.codec.study.corpus.song import SongGroup +from sampletones_tools.codec.study.measure import Measurement +from sampletones_tools.codec.study.report.aggregate import GroupRow +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind + +PROJECT_BAR: Final[float] = 0.03 +RECONSTRUCTION_BAR: Final[float] = 0.05 +REGRESSION_BAR: Final[float] = 0.01 +PROJECT_GROUPS: Final[Tuple[SongGroup, ...]] = (SongGroup.PROJECT, SongGroup.LONG_PROJECT) +UNMEASURED: Final[str] = "" +COLUMNS: Final[Tuple[str, ...]] = ( + "variant", + "hypothesis", + "kind", + "projects", + "reconstructions", + "worst song", + "verdict", +) +RULE: Final[str] = ( + f"A variant graduates when it saves {100 * PROJECT_BAR:.0f}% over the projects, short and " + f"lengthened together, or {100 * RECONSTRUCTION_BAR:.0f}% over the reconstructions, with no song " + f"growing by more than {100 * REGRESSION_BAR:.0f}%. A variant priced at token level is provisional " + "until its production layer confirms the figure." +) + + +class Verdict(StrEnum): + """What the decision rule says of a variant. + + Attributes: + GRADUATES: The variant saves enough, harms no song, and its bytes are the driver's. + PROVISIONAL: The variant saves enough and harms no song, priced at token level. + REJECTED: The variant saves too little, or grows a song past the bar. + """ + + GRADUATES = "graduates" + PROVISIONAL = "provisional" + REJECTED = "rejected" + + +@dataclass(frozen=True) +class VerdictRow: + """One variant judged by the decision rule. + + Attributes: + variant: The variant's name. + hypothesis: The hypothesis the variant measures. + kind: What the variant changes. + projects: How the projects' bytes stand against the baseline, short and lengthened + together, as a ratio; ``None`` where the variant measured none. + reconstructions: The same over the reconstructions. + worst: The largest growth any one song shows against its baseline, as a ratio. + verdict: What the rule says. + """ + + variant: str + hypothesis: str + kind: VariantKind + projects: Optional[float] + reconstructions: Optional[float] + worst: float + verdict: Verdict + + @property + def cells(self) -> Tuple[str, ...]: + """The row as the table prints it, column by column.""" + return ( + self.variant, + self.hypothesis, + self.kind.value, + _percent(self.projects), + _percent(self.reconstructions), + f"{100.0 * self.worst:+.1f}%", + self.verdict.value, + ) + + +def _percent(change: Optional[float]) -> str: + return UNMEASURED if change is None else f"{100.0 * change:+.1f}%" + + +def _change(rows: Sequence[GroupRow]) -> Optional[float]: + if not rows: + return None + + return sum(row.block for row in rows) / sum(row.baseline for row in rows) - 1.0 + + +def _earned( + projects: Optional[float], + reconstructions: Optional[float], +) -> bool: + over_projects = projects is not None and projects <= -PROJECT_BAR + over_reconstructions = reconstructions is not None and reconstructions <= -RECONSTRUCTION_BAR + return over_projects or over_reconstructions + + +def judge( + projects: Optional[float], + reconstructions: Optional[float], + worst: float, + *, + priced: bool, +) -> Verdict: + """Applies the decision rule to one variant's aggregates. + + Args: + projects: The change over the projects, short and lengthened together. + reconstructions: The change over the reconstructions. + worst: The largest growth any one song shows. + priced: Whether the variant's bytes were priced at token level. + + Returns: + Verdict: What the rule says. + """ + if worst > REGRESSION_BAR or not _earned(projects, reconstructions): + return Verdict.REJECTED + + return Verdict.PROVISIONAL if priced else Verdict.GRADUATES + + +def verdict_rows( + group_rows: Sequence[GroupRow], + measurements: Sequence[Measurement], + variants: Sequence[Variant], +) -> Tuple[VerdictRow, ...]: + """Judges every variant the run measured, the baseline excepted. + + Args: + group_rows: The measurements summed over each kind of song, variant by variant. + measurements: Every song under every variant. + variants: The variants the run encoded under. + + Returns: + Tuple[VerdictRow, ...]: One row per measured variant, in the order given. + """ + by_variant: Dict[str, List[GroupRow]] = {} + for row in group_rows: + by_variant.setdefault(row.variant, []).append(row) + + priced = {measurement.variant for measurement in measurements if measurement.encoding.written is None} + rows: List[VerdictRow] = [] + for variant in variants: + if variant.kind is VariantKind.BASELINE or variant.name not in by_variant: + continue + + rows.append( + _verdict_row( + variant, + by_variant[variant.name], + priced=variant.name in priced, + ) + ) + + return tuple(rows) + + +def _verdict_row( + variant: Variant, + rows: Sequence[GroupRow], + *, + priced: bool, +) -> VerdictRow: + projects = _change([row for row in rows if SongGroup(row.group) in PROJECT_GROUPS]) + reconstructions = _change([row for row in rows if SongGroup(row.group) is SongGroup.RECONSTRUCTION]) + worst = max(row.worst for row in rows) + return VerdictRow( + variant=variant.name, + hypothesis=variant.hypothesis, + kind=variant.kind, + projects=projects, + reconstructions=reconstructions, + worst=worst, + verdict=judge(projects, reconstructions, worst, priced=priced), + ) diff --git a/src/sampletones_tools/codec/study/sandbox/__init__.py b/src/sampletones_tools/codec/study/sandbox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/sandbox/context.py b/src/sampletones_tools/codec/study/sandbox/context.py new file mode 100644 index 000000000..78e942084 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/context.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import Tuple + +from sampletones_player.compression.matches.index import PlaneIndex +from sampletones_player.compression.matches.matcher import PhraseMatcher +from sampletones_player.compression.parse.boundaries import Boundaries +from sampletones_tools.codec.study.sandbox.costs import Costs + + +@dataclass(frozen=True) +class PlaneContext: + """One plane as a grammar reads it: its values, the phrases it may play, and the terms. + + Attributes: + index: The plane, its steps and its runs. + matcher: Which phrases the plane plays at a symbol, and for how long. + boundaries: The symbols a token starts on, read either way from every symbol. + transposition: Whether a phrase may play at a shift. + defaults: The default count of each phrase, by id, zero where the phrase has none. + seeded: The symbol the plane stands at before its first token. + costs: The bytes each token takes. + """ + + index: PlaneIndex + matcher: PhraseMatcher + boundaries: Boundaries + transposition: bool + defaults: Tuple[int, ...] + seeded: int + costs: Costs + + def value_before(self, position: int) -> int: + """The symbol the plane holds as ``position`` is reached, which a hold there keeps. + + The driver seeds every plane before the first token, so the stream's first symbol is + reached holding the seeded symbol like any other. + + Args: + position: The symbol. + + Returns: + int: The symbol held. + """ + return self.index.plane[position - 1] if position > 0 else self.seeded diff --git a/src/sampletones_tools/codec/study/sandbox/costs.py b/src/sampletones_tools/codec/study/sandbox/costs.py new file mode 100644 index 000000000..d05045678 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/costs.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass +from typing import Final + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.tokens.sizes import hold_size +from sampletones_player.specification.compression import ( + CHEAP_PHRASE_IDS, + OPCODE_SIZE, + PHRASE_COUNT_SIZE, + PHRASE_DEFAULT_SIZE, + PHRASE_ESCAPE_SIZE, + TRANSPOSE_SIZE, +) + +VALUE_SIZE: Final[int] = 1 + + +@dataclass(frozen=True) +class Costs: + """The bytes each token takes under one grammar. + + A token on the free escape is a phrase opcode followed by a count of zero, which the codec + as it stands never writes, so the opcode's operand is what such a token carries: the sizes + it counts are as many as the cheap ids a phrase opcode names. + + Attributes: + opcode: The bytes an opcode takes. + hold: The bytes a hold takes. + wide_hold: The bytes a wide hold takes. + set_hold: The bytes a set-hold takes. + phrase_count: The bytes a phrase token's count takes. + phrase_escape: The bytes naming a phrase beyond the cheap ids takes. + transpose: The bytes a shift takes. + operands: The operand values below the escape: the cheap ids a phrase opcode names, and + the sizes a token on the escape counts. + default_entry: The bytes a table entry grows by to carry the phrase's default count. + """ + + opcode: int + hold: int + wide_hold: int + set_hold: int + phrase_count: int + phrase_escape: int + transpose: int + operands: int + default_entry: int + + def literal(self, length: int) -> int: + """The bytes a literal of ``length`` values takes.""" + return self.opcode + length + + def phrase( + self, + phrase_id: int, + transpose: int, + *, + default: bool, + ) -> int: + """The bytes a token playing a phrase takes. + + Args: + phrase_id: Position the phrase takes in the table. + transpose: The shift every byte of the phrase is played at. + default: Whether the token plays the phrase's default count and carries none. + + Returns: + int: The bytes the token takes. + """ + escape = self.phrase_escape if phrase_id >= self.operands else 0 + count = 0 if default else self.phrase_count + shift = self.transpose if transpose else 0 + return self.opcode + escape + count + shift + + def dictionary(self, table: PhraseTable) -> int: + """The bytes ``table`` takes in the song block under this grammar.""" + return table.size + self.default_entry * len(table) + + +PRODUCTION_COSTS: Final[Costs] = Costs( + opcode=OPCODE_SIZE, + hold=hold_size(), + wide_hold=OPCODE_SIZE + PHRASE_COUNT_SIZE, + set_hold=OPCODE_SIZE + PHRASE_COUNT_SIZE + VALUE_SIZE, + phrase_count=PHRASE_COUNT_SIZE, + phrase_escape=PHRASE_ESCAPE_SIZE, + transpose=TRANSPOSE_SIZE, + operands=CHEAP_PHRASE_IDS, + default_entry=PHRASE_DEFAULT_SIZE, +) +SET_HOLD_BOUND: Final[int] = OPCODE_SIZE + VALUE_SIZE diff --git a/src/sampletones_tools/codec/study/sandbox/decode.py b/src/sampletones_tools/codec/study/sandbox/decode.py new file mode 100644 index 000000000..e0c5db03a --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/decode.py @@ -0,0 +1,57 @@ +from typing import Sequence + +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_player.specification.compression import INITIAL_PLANE_VALUE +from sampletones_tools.codec.study.sandbox.tokens import Hold, Literal, Play, SetHold, StudyToken, WideHold + + +def _played( + token: Play, + table: PhraseTable, +) -> bytes: + body = table[token.phrase_id].body + last = len(body) - 1 + return bytes((body[min(offset, last)] + token.transpose) % BYTE_VALUES for offset in range(token.ticks)) + + +def play_tokens( + tokens: Sequence[StudyToken], + table: PhraseTable, + ticks: int, +) -> bytes: + """Plays a plane's tokens back into the values they write, tick by tick. + + This is the reading a driver of the grammar would perform, stated over tokens: a hold + keeps the value reached, a phrase played past its end holds its final value onward, and + every plane opens on the value the driver seeds it to. An absent plane has no tokens and + stands at that value throughout. + + Args: + tokens: The tokens the plane is written as, in the order they are read. + table: The dictionary the tokens name. + ticks: The ticks the song lasts. + + Returns: + bytes: The values the plane writes, one per tick. + """ + if not tokens: + return bytes((INITIAL_PLANE_VALUE,)) * ticks + + values = bytearray() + current = INITIAL_PLANE_VALUE + for token in tokens: + match token: + case Hold() | WideHold(): + played = bytes([current]) * token.ticks + case SetHold(): + played = bytes([token.value]) * token.ticks + case Literal(): + played = token.values + case Play(): + played = _played(token, table) + + current = played[-1] + values.extend(played) + + return bytes(values[:ticks]) diff --git a/src/sampletones_tools/codec/study/sandbox/defaults.py b/src/sampletones_tools/codec/study/sandbox/defaults.py new file mode 100644 index 000000000..34c697823 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/defaults.py @@ -0,0 +1,44 @@ +from collections import Counter +from typing import Final, List, Sequence, Tuple + +from sampletones_tools.codec.study.sandbox.parse import StudyParse +from sampletones_tools.codec.study.sandbox.tokens import Play + +NO_DEFAULT: Final[int] = 0 + + +def no_defaults(phrases: int) -> Tuple[int, ...]: + """A default count for none of ``phrases`` phrases.""" + return (NO_DEFAULT,) * phrases + + +def modal_counts( + parses: Sequence[StudyParse], + phrases: int, +) -> Tuple[int, ...]: + """The count each phrase is played at most often across the parses (H4). + + A phrase's default count is the one its tokens would leave unstated most often, so it is + read off the parse as it stands. A phrase the parses never play keeps no default. + + Args: + parses: Every plane's parse. + phrases: The phrases the table holds. + + Returns: + Tuple[int, ...]: The default count of each phrase, by id, zero where it has none. + """ + counters: List[Counter[int]] = [Counter() for _ in range(phrases)] + for parse in parses: + for token in parse.tokens: + if isinstance(token, Play): + counters[token.phrase_id][token.ticks] += 1 + + return tuple(_modal(counter) for counter in counters) + + +def _modal(counter: Counter[int]) -> int: + if not counter: + return NO_DEFAULT + + return min(counter, key=lambda ticks: (-counter[ticks], ticks)) diff --git a/src/sampletones_tools/codec/study/sandbox/edges/__init__.py b/src/sampletones_tools/codec/study/sandbox/edges/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/sandbox/edges/generator.py b/src/sampletones_tools/codec/study/sandbox/edges/generator.py new file mode 100644 index 000000000..33a080306 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/edges/generator.py @@ -0,0 +1,53 @@ +from typing import Iterable, Protocol + +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.shortest import Shortest +from sampletones_tools.codec.study.sandbox.tokens import StudyToken + + +class EdgeGenerator(Protocol): + """Offers every token of one kind that may start at a tick of a plane.""" + + def __call__( + self, + context: PlaneContext, + shortest: Shortest[StudyToken], + position: int, + reach: int, + *, + holdable: bool, + ) -> None: + """Relaxes the edges of the tokens starting at ``position``. + + Args: + context: The plane and the terms the grammar reads it on. + shortest: The search the edges join. + position: The tick the tokens start on. + reach: The most ticks a token may cover from there. + holdable: Whether a token may keep the value the plane holds as the tick is reached. + """ + + +def offered_lengths( + longest: int, + *, + least: int, + every: bool, +) -> Iterable[int]: + """The lengths a token kind is offered at from one tick. + + The codec as it stands offers the longest token alone, and a grammar asking for every + length offers each one from ``least`` up to it. + + Args: + longest: The most ticks the token may cover. + least: The fewest ticks a token of the kind covers. + every: Whether every length is offered. + + Returns: + Iterable[int]: The lengths, the longest one last. + """ + if every: + return range(least, longest + 1) + + return (longest,) diff --git a/src/sampletones_tools/codec/study/sandbox/edges/holds.py b/src/sampletones_tools/codec/study/sandbox/edges/holds.py new file mode 100644 index 000000000..d8f703298 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/edges/holds.py @@ -0,0 +1,79 @@ +from typing import Final + +from sampletones_player.specification.compression import MAX_HOLD_TICKS +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.edges.generator import EdgeGenerator, offered_lengths +from sampletones_tools.codec.study.sandbox.shortest import Shortest +from sampletones_tools.codec.study.sandbox.tokens import Hold, StudyToken, WideHold + +LEAST_HOLD_TICKS: Final[int] = 1 +LEAST_WIDE_BLOCKS: Final[int] = 1 + + +def hold_edges(*, every_length: bool) -> EdgeGenerator: + """Holds, as the codec offers them: over the value the plane holds as the tick is reached. + + Args: + every_length: Whether a hold of every length is offered beside the longest one. + + Returns: + EdgeGenerator: The generator. + """ + + def relax( + context: PlaneContext, + shortest: Shortest[StudyToken], + position: int, + reach: int, + *, + holdable: bool, + ) -> None: + index = context.index + if not holdable or index.plane[position] != context.value_before(position): + return + + longest = min(MAX_HOLD_TICKS, index.runs[position], reach) + cost = shortest.costs[position] + context.costs.hold + for ticks in offered_lengths(longest, least=LEAST_HOLD_TICKS, every=every_length): + if shortest.improves(position + ticks, cost): + shortest.relax(position, position + ticks, cost, Hold(ticks=ticks)) + + return relax + + +def wide_hold_edges(*, every_length: bool) -> EdgeGenerator: + """Wide holds (H1): whole blocks of a plain hold's longest reach, counted by one token. + + A wide hold rides the free escape, so it counts as many blocks as the escape has operand + values, and a run shorter than a block is left to a plain hold. + + Args: + every_length: Whether a wide hold of every block count is offered beside the longest. + + Returns: + EdgeGenerator: The generator. + """ + + def relax( + context: PlaneContext, + shortest: Shortest[StudyToken], + position: int, + reach: int, + *, + holdable: bool, + ) -> None: + index = context.index + if not holdable or index.plane[position] != context.value_before(position): + return + + longest = min(context.costs.operands, index.runs[position] // MAX_HOLD_TICKS, reach // MAX_HOLD_TICKS) + if longest < LEAST_WIDE_BLOCKS: + return + + cost = shortest.costs[position] + context.costs.wide_hold + for blocks in offered_lengths(longest, least=LEAST_WIDE_BLOCKS, every=every_length): + end = position + blocks * MAX_HOLD_TICKS + if shortest.improves(end, cost): + shortest.relax(position, end, cost, WideHold(blocks=blocks)) + + return relax diff --git a/src/sampletones_tools/codec/study/sandbox/edges/literals.py b/src/sampletones_tools/codec/study/sandbox/edges/literals.py new file mode 100644 index 000000000..8c19c1950 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/edges/literals.py @@ -0,0 +1,29 @@ +from sampletones_player.compression.parse.literals import LiteralWindow +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.shortest import Shortest +from sampletones_tools.codec.study.sandbox.tokens import Literal, StudyToken + + +def relax_literal( + context: PlaneContext, + shortest: Shortest[StudyToken], + window: LiteralWindow, + position: int, + earliest: int, +) -> None: + """Spelling the values out reaches ``position`` from wherever that costs least. + + Literals are the one token every grammar keeps, priced as the codec prices them: an opcode + and the values, from the start the window finds cheapest. + + Args: + context: The plane and the terms the grammar reads it on. + shortest: The search the edge joins. + window: The starts a literal ending here may take, the cheapest kept at the front. + position: The tick the literal ends at. + earliest: The earliest tick the literal may start on. + """ + start = window.cheapest(position, earliest) + cost = shortest.costs[start] + context.costs.literal(position - start) + if shortest.improves(position, cost): + shortest.relax(start, position, cost, Literal(values=context.index.plane[start:position])) diff --git a/src/sampletones_tools/codec/study/sandbox/edges/phrases.py b/src/sampletones_tools/codec/study/sandbox/edges/phrases.py new file mode 100644 index 000000000..8b5791611 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/edges/phrases.py @@ -0,0 +1,57 @@ +from sampletones_player.specification.compression import MAX_PHRASE_TICKS +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.edges.generator import EdgeGenerator +from sampletones_tools.codec.study.sandbox.shortest import Shortest +from sampletones_tools.codec.study.sandbox.tokens import Play, StudyToken + + +def phrase_edges(*, defaults: bool) -> EdgeGenerator: + """Phrases, as the codec offers them: each one the plane plays from the tick, played whole. + + With defaults on, a phrase played at least its default count from the tick is offered at + that count too, as the token that carries no count of its own (H4). + + Args: + defaults: Whether the default-count token is offered. + + Returns: + EdgeGenerator: The generator. + """ + + def relax( + context: PlaneContext, + shortest: Shortest[StudyToken], + position: int, + reach: int, + *, + holdable: bool, + ) -> None: + del holdable + cost = shortest.costs[position] + costs = context.costs + for phrase_id, ticks, transpose in context.matcher.matches( + position, + min(MAX_PHRASE_TICKS, reach), + transposition=context.transposition, + ): + stated = cost + costs.phrase(phrase_id, transpose, default=False) + if shortest.improves(position + ticks, stated): + shortest.relax( + position, + position + ticks, + stated, + Play(phrase_id=phrase_id, ticks=ticks, transpose=transpose, default=False), + ) + + count = context.defaults[phrase_id] if defaults else 0 + if 0 < count <= ticks: + unstated = cost + costs.phrase(phrase_id, transpose, default=True) + if shortest.improves(position + count, unstated): + shortest.relax( + position, + position + count, + unstated, + Play(phrase_id=phrase_id, ticks=count, transpose=transpose, default=True), + ) + + return relax diff --git a/src/sampletones_tools/codec/study/sandbox/edges/set_hold.py b/src/sampletones_tools/codec/study/sandbox/edges/set_hold.py new file mode 100644 index 000000000..17a62f2e7 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/edges/set_hold.py @@ -0,0 +1,45 @@ +from typing import Final + +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.edges.generator import EdgeGenerator, offered_lengths +from sampletones_tools.codec.study.sandbox.shortest import Shortest +from sampletones_tools.codec.study.sandbox.tokens import SetHold, StudyToken + +LEAST_SET_HOLD_TICKS: Final[int] = 2 + + +def set_hold_edges(*, every_length: bool) -> EdgeGenerator: + """Set-holds (H3): one token taking a value and keeping it for a run of ticks. + + The token names its value, so it starts anywhere a run does, the stream's first tick and a + loop entry included. A run of one tick is a literal's, which costs the same and carries + more. + + Args: + every_length: Whether a set-hold of every length is offered beside the longest one. + + Returns: + EdgeGenerator: The generator. + """ + + def relax( + context: PlaneContext, + shortest: Shortest[StudyToken], + position: int, + reach: int, + *, + holdable: bool, + ) -> None: + del holdable + index = context.index + longest = min(context.costs.operands, index.runs[position], reach) + if longest < LEAST_SET_HOLD_TICKS: + return + + value = index.plane[position] + cost = shortest.costs[position] + context.costs.set_hold + for ticks in offered_lengths(longest, least=LEAST_SET_HOLD_TICKS, every=every_length): + if shortest.improves(position + ticks, cost): + shortest.relax(position, position + ticks, cost, SetHold(value=value, ticks=ticks)) + + return relax diff --git a/src/sampletones_tools/codec/study/sandbox/encode.py b/src/sampletones_tools/codec/study/sandbox/encode.py new file mode 100644 index 000000000..3f84729d4 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/encode.py @@ -0,0 +1,82 @@ +from time import process_time +from typing import Final, Sequence, Tuple + +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_tools.codec.study.measure import Encoding +from sampletones_tools.codec.study.sandbox.defaults import modal_counts, no_defaults +from sampletones_tools.codec.study.sandbox.grammar import Grammar +from sampletones_tools.codec.study.sandbox.parse import StudyParse +from sampletones_tools.codec.study.sandbox.reference import Reference +from sampletones_tools.codec.study.sandbox.verify import plays_back + +DEFAULT_ROUNDS: Final[int] = 3 + + +def encode_grammar( + reference: Reference, + grammar: Grammar, +) -> Encoding: + """Prices a song under a grammar, over the dictionary the production codec settled on. + + Args: + reference: The song, its dictionary and its matches. + grammar: The grammar. + + Returns: + Encoding: The bytes each stream would take, timed, and whether the tokens play back. + """ + started = process_time() + parses = _parses(reference, grammar) + seconds = process_time() - started + return Encoding( + header=SONG_HEADER_SIZE, + phrases=len(reference.table), + dictionary=grammar.costs.dictionary(reference.table), + streams=tuple(parse.size for parse in parses), + seconds=seconds, + lossless=plays_back(parses, reference.table, reference.planes), + written=None, + ) + + +def _parse_all( + reference: Reference, + grammar: Grammar, + defaults: Sequence[int], +) -> Tuple[StudyParse, ...]: + return reference.parses(grammar, defaults) + + +def _streams(parses: Sequence[StudyParse]) -> int: + return sum(parse.size for parse in parses) + + +def _parses( + reference: Reference, + grammar: Grammar, +) -> Tuple[StudyParse, ...]: + """Every plane under the grammar, its default counts settled where it carries them. + + A default count is read off the parse it serves, so the first reading takes the counts the + baseline parse plays each phrase at, and each further round reads them off the parse that + used them, stopping where they stand still or stop paying. + """ + phrases = len(reference.table) + if not grammar.default_counts: + return _parse_all(reference, grammar, no_defaults(phrases)) + + defaults = modal_counts(reference.baseline, phrases) + parses = _parse_all(reference, grammar, defaults) + for _ in range(DEFAULT_ROUNDS - 1): + refined = modal_counts(parses, phrases) + if refined == defaults: + break + + trial = _parse_all(reference, grammar, refined) + if _streams(trial) >= _streams(parses): + break + + defaults = refined + parses = trial + + return parses diff --git a/src/sampletones_tools/codec/study/sandbox/grammar.py b/src/sampletones_tools/codec/study/sandbox/grammar.py new file mode 100644 index 000000000..81cb010e8 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/grammar.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import Final + +from sampletones_tools.codec.study.sandbox.costs import PRODUCTION_COSTS, Costs + + +@dataclass(frozen=True) +class Grammar: + """One token grammar the sandbox prices a plane under. + + The baseline grammar is the codec as it stands, and every other one switches a change on + over it, so what a change earns is read as the difference between the two on the same plane + and the same dictionary. + + Attributes: + every_length: Whether a hold of every length is offered beside the longest one (H9a). + start_hold: Whether a hold may open a stream, over the value the driver seeds every + plane to (H9b). + wide_hold: Whether a wide hold covers blocks of a plain hold's longest reach at once (H1). + set_hold: Whether one token sets a value and holds it (H3). + default_counts: Whether a phrase carries a default count a token may leave unstated (H4). + costs: The bytes each token takes. + """ + + every_length: bool + start_hold: bool + wide_hold: bool + set_hold: bool + default_counts: bool + costs: Costs + + +BASELINE_GRAMMAR: Final[Grammar] = Grammar( + every_length=False, + start_hold=False, + wide_hold=False, + set_hold=False, + default_counts=True, + costs=PRODUCTION_COSTS, +) diff --git a/src/sampletones_tools/codec/study/sandbox/parse.py b/src/sampletones_tools/codec/study/sandbox/parse.py new file mode 100644 index 000000000..b6b5c416d --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/parse.py @@ -0,0 +1,130 @@ +from dataclasses import dataclass +from typing import List, Sequence, Tuple + +from sampletones_player.compression.parse.literals import LiteralWindow +from sampletones_player.specification.compression import MAX_LITERAL_BYTES +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.edges.generator import EdgeGenerator +from sampletones_tools.codec.study.sandbox.edges.holds import ( + hold_edges, + wide_hold_edges, +) +from sampletones_tools.codec.study.sandbox.edges.literals import relax_literal +from sampletones_tools.codec.study.sandbox.edges.phrases import phrase_edges +from sampletones_tools.codec.study.sandbox.edges.set_hold import set_hold_edges +from sampletones_tools.codec.study.sandbox.grammar import Grammar +from sampletones_tools.codec.study.sandbox.shortest import Shortest +from sampletones_tools.codec.study.sandbox.tokens import StudyToken + + +@dataclass(frozen=True) +class StudyParse: + """A plane read as the tokens of one grammar, 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[StudyToken, ...] + costs: Tuple[int, ...] + + @property + def size(self) -> int: + """The bytes the plane's token stream takes.""" + return self.costs[-1] + + +def generators(grammar: Grammar) -> Tuple[EdgeGenerator, ...]: + """The token kinds a grammar offers from a tick, each as the edges it adds to the search. + + Args: + grammar: The grammar. + + Returns: + Tuple[EdgeGenerator, ...]: The generators, holds first and phrases last. + """ + chosen: List[EdgeGenerator] = [hold_edges(every_length=grammar.every_length)] + if grammar.wide_hold: + chosen.append(wide_hold_edges(every_length=grammar.every_length)) + + if grammar.set_hold: + chosen.append(set_hold_edges(every_length=grammar.every_length)) + + chosen.append(phrase_edges(defaults=grammar.default_counts)) + return tuple(chosen) + + +def _relax_forward( + offered: Sequence[EdgeGenerator], + context: PlaneContext, + shortest: Shortest[StudyToken], + position: int, + reach: int, + *, + holdable: bool, +) -> None: + for generator in offered: + generator( + context, + shortest, + position, + reach, + holdable=holdable, + ) + + +def parse_plane( + context: PlaneContext, + grammar: Grammar, +) -> StudyParse: + """Reads a plane as the cheapest token stream ``grammar`` allows. + + This is the production parse with the token kinds plugged in: literals reach every tick + from their cheapest start, and the grammar's other kinds are offered forward from each tick + a token may start on. + + Args: + context: The plane and the terms the grammar reads it on. + grammar: The grammar. + + Returns: + StudyParse: The tokens the plane is written as, and what each of its prefixes costs. + """ + ticks = context.index.ticks + entries = context.boundaries + previous = entries.previous + following = entries.following + offered = generators(grammar) + shortest: Shortest[StudyToken] = Shortest.across(ticks) + window = LiteralWindow(shortest.costs) + _relax_forward( + offered, + context, + shortest, + 0, + following[0], + holdable=grammar.start_hold, + ) + for position in range(1, ticks + 1): + relax_literal( + context, + shortest, + window, + position, + max(position - MAX_LITERAL_BYTES, previous[position]), + ) + if position < ticks: + _relax_forward( + offered, + context, + shortest, + position, + following[position] - position, + holdable=position not in entries.entries, + ) + + return StudyParse( + tokens=shortest.walk(ticks), + costs=tuple(shortest.costs), + ) diff --git a/src/sampletones_tools/codec/study/sandbox/reference.py b/src/sampletones_tools/codec/study/sandbox/reference.py new file mode 100644 index 000000000..850d5f1f7 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/reference.py @@ -0,0 +1,142 @@ +from dataclasses import dataclass +from typing import Final, FrozenSet, Sequence, Tuple + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.table import PhraseTable +from sampletones_player.compression.encode import STREAM_START +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 EVERY_LAYER +from sampletones_player.compression.parse.boundaries import Boundaries +from sampletones_player.compression.planes.symbols import pack_plane +from sampletones_player.specification.planes import PLANES, SINGLE_TICK +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.costs import Costs +from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar +from sampletones_tools.codec.study.sandbox.parse import StudyParse, parse_plane +from sampletones_tools.codec.study.sandbox.verify import verify_baseline + +STREAM_ENTRIES: Final[FrozenSet[int]] = frozenset({STREAM_START}) +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() +ABSENT_PARSE: Final[StudyParse] = StudyParse(tokens=(), costs=(0,)) + + +@dataclass(frozen=True) +class Reference: + """One song as every grammar is priced against it. + + The dictionary is the one the production codec settled on, held fixed across the grammars: + what a grammar earns is read on the streams alone, and how the search and the settling + would answer a new grammar is left to its production layer to measure. + + An absent plane takes no stream in production, so it takes no tokens under any grammar and + the cache holds the planes the block writes alone. + + Attributes: + song: The song. + planes: The byte series each stream was written from, in song-block order. + table: The dictionary the production codec settled on. + cache: What each phrase plays against each written plane, shared by every grammar's parse. + seeds: The symbol each written plane stands at before its first token. + baseline: Every plane under the baseline grammar, held to the production streams. + """ + + song: StudySong + planes: Tuple[bytes, ...] + table: PhraseTable + cache: MatchCache + seeds: Tuple[int, ...] + baseline: Tuple[StudyParse, ...] + + def parses( + self, + grammar: Grammar, + defaults: Sequence[int], + ) -> Tuple[StudyParse, ...]: + """Every plane of the song read under one grammar. + + Args: + grammar: The grammar. + defaults: The default count of each phrase, by id. + + Returns: + Tuple[StudyParse, ...]: One parse per plane, in song-block order, an absent plane's empty. + """ + return plane_parses(self.planes, self.cache, self.table, grammar, defaults, self.seeds) + + +def _contexts( + cache: MatchCache, + table: PhraseTable, + costs: Costs, + defaults: Sequence[int], + seeds: Sequence[int], +) -> Tuple[PlaneContext, ...]: + contexts = [] + for plane, index in enumerate(cache.indices): + contexts.append( + PlaneContext( + index=index, + matcher=PhraseMatcher(table, plane, cache), + boundaries=Boundaries.across(index.ticks, STREAM_ENTRIES), + transposition=EVERY_LAYER.transposition, + defaults=tuple(defaults), + seeded=seeds[plane], + costs=costs, + ) + ) + + return tuple(contexts) + + +def plane_parses( + planes: Sequence[bytes], + cache: MatchCache, + table: PhraseTable, + grammar: Grammar, + defaults: Sequence[int], + seeds: Sequence[int], +) -> Tuple[StudyParse, ...]: + contexts = _contexts(cache, table, grammar.costs, defaults, seeds) + written = iter(parse_plane(context, grammar) for context in contexts) + return tuple(next(written) if plane else ABSENT_PARSE for plane in planes) + + +def reference( + song: StudySong, + compressed: CompressedPlanes, +) -> Reference: + """Reads a song into what every grammar is priced against, proving the parser on the way. + + Args: + song: The song. + compressed: The production encoding of the song. + + Returns: + Reference: The song, its dictionary, its matches and its baseline parse. + + Raises: + ValueError: If the baseline grammar prices a plane differently from the codec's stream. + """ + planes = tuple( + b"" if plane.idles(played) else pack_plane(played, plane.form, boundaries=NO_BOUNDARIES) + for plane, played in zip(PLANES, song.planes.planes, strict=True) + ) + cache = MatchCache(PlaneIndex.from_plane(plane) for plane in planes if plane) + seeds = tuple( + plane.form.symbol(plane.seeded, SINGLE_TICK) for plane, written in zip(PLANES, planes, strict=True) if written + ) + table = compressed.phrases + carried = tuple(phrase.default for phrase in table.phrases) + baseline = plane_parses(planes, cache, table, BASELINE_GRAMMAR, carried, seeds) + verify_baseline(baseline, compressed) + return Reference( + song=song, + planes=planes, + table=table, + cache=cache, + seeds=seeds, + baseline=baseline, + ) diff --git a/src/sampletones_tools/codec/study/sandbox/shortest.py b/src/sampletones_tools/codec/study/sandbox/shortest.py new file mode 100644 index 000000000..8bfa57f44 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/shortest.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Generic, List, Optional, Tuple, TypeVar + +UNREACHED: Final[int] = -1 + +Token = TypeVar("Token") + + +@dataclass +class Shortest(Generic[Token]): + """The cheapest way found so far of reaching each tick of a plane, over any token kind. + + 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. This + is the production search stated over whichever tokens a grammar offers. + + 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[Token]] + + @classmethod + def across(cls, ticks: int) -> Shortest[Token]: + """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: Token, + ) -> 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[Token, ...]: + """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[Token, ...]: The tokens, in the order they are read. + + Raises: + ValueError: If a tick along the path was never reached. + """ + read: List[Token] = [] + 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_tools/codec/study/sandbox/tokens.py b/src/sampletones_tools/codec/study/sandbox/tokens.py new file mode 100644 index 000000000..4accffcb2 --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/tokens.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Union + +from sampletones_player.specification.compression import MAX_HOLD_TICKS + + +@dataclass(frozen=True) +class Hold: + """The plane keeps the value it reached, for ``ticks`` ticks.""" + + ticks: int + + +@dataclass(frozen=True) +class WideHold: + """The plane keeps the value it reached for ``blocks`` blocks of a plain hold's longest reach (H1).""" + + blocks: int + + @property + def ticks(self) -> int: + """The ticks the token covers.""" + return self.blocks * MAX_HOLD_TICKS + + +@dataclass(frozen=True) +class SetHold: + """The plane takes ``value`` and keeps it for ``ticks`` ticks (H3).""" + + value: int + ticks: int + + +@dataclass(frozen=True) +class Literal: + """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) + + +@dataclass(frozen=True) +class Play: + """The plane plays a phrase from the table, shifted by ``transpose``, for ``ticks`` ticks. + + A default play covers the phrase's own default count and carries no count of its own (H4). + """ + + phrase_id: int + ticks: int + transpose: int + default: bool + + +StudyToken = Union[Hold, WideHold, SetHold, Literal, Play] diff --git a/src/sampletones_tools/codec/study/sandbox/verify.py b/src/sampletones_tools/codec/study/sandbox/verify.py new file mode 100644 index 000000000..43c3b68af --- /dev/null +++ b/src/sampletones_tools/codec/study/sandbox/verify.py @@ -0,0 +1,50 @@ +from typing import Sequence + +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_tools.codec.study.sandbox.decode import play_tokens +from sampletones_tools.codec.study.sandbox.parse import StudyParse + + +def verify_baseline( + parses: Sequence[StudyParse], + compressed: CompressedPlanes, +) -> None: + """Holds the sandbox's baseline grammar to the streams the production codec wrote. + + The sandbox prices every grammar on its own parser, so the parser is proven on the one + grammar whose answer is known: each plane under the baseline grammar costs exactly the + bytes the production stream takes, or the study is reading the planes wrong. + + Args: + parses: Every plane under the baseline grammar. + compressed: The production encoding of the same planes over the same dictionary. + + Raises: + ValueError: If a plane is priced differently from the stream the codec wrote. + """ + for name, parse, stream in zip(PlaneOrder.names(), parses, compressed.streams): + if parse.size != len(stream): + raise ValueError( + f"the sandbox reads {name} as {parse.size} bytes where the codec wrote {len(stream)}; " + "the baseline grammar has parted from the production parser" + ) + + +def plays_back( + parses: Sequence[StudyParse], + table: PhraseTable, + planes: Sequence[bytes], +) -> bool: + """Whether every plane's tokens play back to the plane they were written from. + + Args: + parses: Every plane's parse, in the order the song block writes them. + table: The dictionary the tokens name. + planes: The byte series the parses were read from, in song-block order. + + Returns: + bool: Whether the encoding is lossless. + """ + return all(play_tokens(parse.tokens, table, len(plane)) == plane for parse, plane in zip(parses, planes)) diff --git a/src/sampletones_tools/codec/study/session.py b/src/sampletones_tools/codec/study/session.py new file mode 100644 index 000000000..a98c1fd87 --- /dev/null +++ b/src/sampletones_tools/codec/study/session.py @@ -0,0 +1,105 @@ +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_shared.logger import logger +from sampletones_shared.utils.text import listed_items +from sampletones_tools.codec.study.corpus.build import build_corpus +from sampletones_tools.codec.study.manifest import StudyManifest, StudySource +from sampletones_tools.codec.study.measure import Measurement, measure +from sampletones_tools.codec.study.plan import StudyPlan +from sampletones_tools.codec.study.report.run import run_directory, write_run +from sampletones_tools.codec.study.variants.registry import EVERY_VARIANT +from sampletones_tools.codec.study.variants.strategy import STRATEGY_ORDER, depth_measurements + + +def variant_names(stated: Optional[str]) -> Tuple[str, ...]: + """The variants a run encodes under: the ones named, comma separated, or every one.""" + if stated is None: + return (EVERY_VARIANT,) + + return tuple(listed_items(stated)) + + +def resolve_manifest( + path: Optional[Path], + *, + projects: Tuple[Path, ...], + reconstructions: Tuple[Path, ...], + lengthen_seconds: int, + variants: Tuple[str, ...], +) -> StudyManifest: + """The manifest a run measures: the sources named outright, or the ones a manifest file states. + + Sources named outright stand in for a manifest's own, while its lengthening and variants stay. + + Args: + path: A manifest a run wrote, or ``None`` to measure the sources named outright. + projects: Project files to measure. + reconstructions: Stem files, or directories of stems, to measure. + lengthen_seconds: How long each project's lengthened copy lasts, unless a manifest states it. + variants: The names of the variants every song is encoded under, unless a manifest states them. + + Raises: + ValueError: If no manifest stands at the path, a source names nothing, or the manifest + names no source or breaks one of its bounds. + """ + if path is not None and not path.is_file(): + raise ValueError(f"No manifest at {path}.") + + manifest = StudyManifest.load(path) if path is not None else None + if manifest is not None and not projects and not reconstructions: + return manifest + + return StudyManifest( + projects=tuple(StudySource.at(project) for project in projects), + reconstructions=tuple(StudySource.at(reconstruction) for reconstruction in reconstructions), + lengthen_seconds=manifest.lengthen_seconds if manifest is not None else lengthen_seconds, + variants=manifest.variants if manifest is not None else variants, + ) + + +def run_study(plan: StudyPlan, output: Optional[Path]) -> Path: + """Encodes every song of the plan's manifest under every variant it names and writes the run. + + The corpus is read before the run's directory is made, so a source that fails to read leaves + the output untouched. + + Args: + plan: What is measured and the variants it is encoded under. + output: The directory the run writes into, or ``None`` for a stamped one under the + documents. + + Returns: + Path: The directory holding the report, the accounting and the verdicts. + + Raises: + ValueError: If a variant writes a song as streams that play back differently. + """ + corpus = build_corpus(plan.manifest) + directory = run_directory(output) + + measurements: List[Measurement] = [] + for song in corpus: + for variant in plan.variants: + if not variant.applies(song): + continue + + logger.info(f"Encoding {song.name} ({song.ticks} ticks) under {variant.name}") + measurement = measure(song, variant.name, variant.encode) + if not measurement.lossless: + raise ValueError(f"{variant.name} wrote {song.name} as streams that play back differently") + + logger.info( + f" {measurement.block} bytes, {measurement.bytes_per_tick:.3f} bytes per tick, " + f"{measurement.phrases} phrases, {measurement.seconds:.1f} s" + ) + measurements.append(measurement) + + write_run( + directory, + plan, + measurements, + depth_measurements(measurements, STRATEGY_ORDER), + ) + logger.info(f"Report written to {directory}") + return directory diff --git a/src/sampletones_tools/codec/study/variants/__init__.py b/src/sampletones_tools/codec/study/variants/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/codec/study/variants/baselines.py b/src/sampletones_tools/codec/study/variants/baselines.py new file mode 100644 index 000000000..c47e54475 --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/baselines.py @@ -0,0 +1,91 @@ +from time import process_time +from typing import Dict + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.measure import Encoding, production_encoding +from sampletones_tools.codec.study.sandbox.reference import Reference, reference +from sampletones_tools.codec.study.variants.production import BASELINE_NAME, compress_baseline +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind + + +class Baselines: + """The production encoding of each song, taken once and read by every variant built on it. + + A run measures a song under every variant before moving to the next, so one song's + encoding is kept at a time: the baseline variant writes it, and the sandbox reads the + dictionary and the planes' matches from there in place of encoding the song again. A + song asked for before its baseline ran is encoded on the spot. + """ + + def __init__(self) -> None: + self._compressed: Dict[str, CompressedPlanes] = {} + self._references: Dict[str, Reference] = {} + + def encode(self, song: StudySong) -> Encoding: + """Encodes a song as the export does today, keeping the result for the sandbox. + + Args: + song: The song to encode. + + Returns: + Encoding: The encoding, its streams kept as written. + """ + started = process_time() + compressed = compress_baseline(song) + seconds = process_time() - started + self._compressed = {song.name: compressed} + self._references = {} + return production_encoding(song, compressed, seconds) + + def compressed(self, song: StudySong) -> CompressedPlanes: + """The production encoding of ``song``. + + Args: + song: The song. + + Returns: + CompressedPlanes: The dictionary and the streams the codec wrote. + """ + remembered = self._compressed.get(song.name) + if remembered is None: + remembered = compress_baseline(song) + self._compressed = {song.name: remembered} + self._references = {} + + return remembered + + def reference(self, song: StudySong) -> Reference: + """What every grammar prices ``song`` against. + + Args: + song: The song. + + Returns: + Reference: The song, its dictionary, its matches and its baseline parse. + """ + remembered = self._references.get(song.name) + if remembered is None: + remembered = reference(song, self.compressed(song)) + self._references = {song.name: remembered} + + return remembered + + +def baseline_variant(baselines: Baselines) -> Variant: + """The codec as it stands, which every other variant is held against. + + Args: + baselines: Where the encodings are kept for the variants built on them. + + Returns: + Variant: The baseline. + """ + return Variant( + name=BASELINE_NAME, + hypothesis="", + kind=VariantKind.BASELINE, + note="", + encode=baselines.encode, + needs_seeds=False, + ) diff --git a/src/sampletones_tools/codec/study/variants/layouts.py b/src/sampletones_tools/codec/study/variants/layouts.py new file mode 100644 index 000000000..8e2c790d5 --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/layouts.py @@ -0,0 +1,43 @@ +from functools import partial +from typing import Final, Tuple + +from sampletones_tools.codec.study.layouts.encode import encode_layout +from sampletones_tools.codec.study.layouts.layout import Anchor, BendForm, PlaneLayout +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind + +ABSENT_PLANES: Final[str] = "B1" +NAMED_NOTE: Final[str] = "B2" +FLAG_BIT: Final[str] = "B3" +ABSENT_NOTE: Final[str] = ( + "a plane holding zero throughout names a sentinel stream offset, and the driver leaves its value at zero" +) +NAMED_NOTE_NOTE: Final[str] = f"{ABSENT_NOTE}; the value names the frame's note, which the driver reads as it stands" +FLAG_NOTE: Final[str] = ( + f"{ABSENT_NOTE}; bit 7 of the value marks a bent tick, and the bend plane advances only on those ticks" +) + + +def _layout_variant( + name: str, + hypothesis: str, + note: str, + layout: PlaneLayout, +) -> Variant: + return Variant( + name=name, + hypothesis=hypothesis, + kind=VariantKind.FORMAT, + note=note, + encode=partial(encode_layout, layout=layout), + needs_seeds=False, + ) + + +LAYOUT_VARIANTS: Final[Tuple[Variant, ...]] = ( + _layout_variant("absent-planes", ABSENT_PLANES, ABSENT_NOTE, PlaneLayout(Anchor.NEAREST, BendForm.DENSE)), + _layout_variant("named-note", NAMED_NOTE, NAMED_NOTE_NOTE, PlaneLayout(Anchor.NAMED, BendForm.DENSE)), + _layout_variant("flag-ticks", FLAG_BIT, FLAG_NOTE, PlaneLayout(Anchor.NEAREST, BendForm.FLAGGED_TICKS)), + _layout_variant("flag-notes", FLAG_BIT, FLAG_NOTE, PlaneLayout(Anchor.NEAREST, BendForm.FLAGGED_NOTES)), + _layout_variant("named-flag-ticks", FLAG_BIT, FLAG_NOTE, PlaneLayout(Anchor.NAMED, BendForm.FLAGGED_TICKS)), + _layout_variant("named-flag-notes", FLAG_BIT, FLAG_NOTE, PlaneLayout(Anchor.NAMED, BendForm.FLAGGED_NOTES)), +) diff --git a/src/sampletones_tools/codec/study/variants/production.py b/src/sampletones_tools/codec/study/variants/production.py new file mode 100644 index 000000000..e483c40e9 --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/production.py @@ -0,0 +1,198 @@ +from time import process_time +from typing import Callable, Final, Sequence, Tuple + +from sampletones_player.compression.budget import DEFAULT_SEARCH_BUDGET, SearchBudget +from sampletones_player.compression.compressed import CompressedPlanes +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_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.measure import Encoder, Encoding, production_encoding +from sampletones_tools.codec.study.variants.seeds import split, trimmed, whole_and_split +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind + +SeedTransform = Callable[[Sequence[Phrase]], Tuple[Phrase, ...]] + +BASELINE_NAME: Final[str] = "baseline" +NO_LOOP_BOUNDARIES: Final[frozenset[int]] = frozenset() +SEED_SPLITTING: Final[str] = "H2'" +SEARCH_BUDGET: Final[str] = "H8" +DRIVER_UNCHANGED: Final[str] = "driver unchanged" +SPLIT_THRESHOLDS: Final[Tuple[int, ...]] = (2, 3, 4, 6, 8) +LIGHT_ENTRIES: Final[int] = 50_000 +WIDE_ENTRIES: Final[int] = 1_000_000 +LONG_ROUNDS: Final[int] = 255 +LIGHT_CONFIRMED: Final[int] = 1 +DEEP_CONFIRMED: Final[int] = 8 + + +def compress( + song: StudySong, + *, + seeds: Sequence[Phrase], + budget: SearchBudget, +) -> CompressedPlanes: + """Compresses a song as the export does, every layer on, over the seeds and budget given. + + Args: + song: The song to compress. + seeds: The phrases offered to the dictionary. + budget: How much work the search spends. + + Returns: + CompressedPlanes: The dictionary and the token streams. + """ + return encode_planes( + song.planes, + seeds, + options=EVERY_LAYER, + boundaries=NO_LOOP_BOUNDARIES, + budget=budget, + ) + + +def compress_baseline(song: StudySong) -> CompressedPlanes: + """Compresses a song as the export does today. + + Args: + song: The song to compress. + + Returns: + CompressedPlanes: The dictionary and the token streams. + """ + return compress(song, seeds=song.seeds, budget=DEFAULT_SEARCH_BUDGET) + + +def encode_production( + song: StudySong, + *, + seeds: Sequence[Phrase], + budget: SearchBudget, +) -> Encoding: + """Encodes a song as the export does, timing the run and playing the result back. + + Args: + song: The song to encode. + seeds: The phrases offered to the dictionary. + budget: How much work the search spends. + + Returns: + Encoding: The encoding, its streams kept as written. + """ + started = process_time() + compressed = compress(song, seeds=seeds, budget=budget) + return production_encoding(song, compressed, process_time() - started) + + +def seed_encoder(transform: SeedTransform) -> Encoder: + """An encoder offering the dictionary the song's seeds after ``transform``. + + Args: + transform: What the seeds become before they are offered. + + Returns: + Encoder: The encoder, searching at the default budget. + """ + + def encode(song: StudySong) -> Encoding: + return encode_production( + song, + seeds=transform(song.seeds), + budget=DEFAULT_SEARCH_BUDGET, + ) + + return encode + + +def budget_encoder(budget: SearchBudget) -> Encoder: + """An encoder searching under ``budget`` over the song's own seeds. + + Args: + budget: How much work the search spends. + + Returns: + Encoder: The encoder. + """ + + def encode(song: StudySong) -> Encoding: + return encode_production(song, seeds=song.seeds, budget=budget) + + return encode + + +def _seed_variant( + name: str, + transform: SeedTransform, +) -> Variant: + return Variant( + name=name, + hypothesis=SEED_SPLITTING, + kind=VariantKind.ENCODER, + note=DRIVER_UNCHANGED, + encode=seed_encoder(transform), + needs_seeds=True, + ) + + +def _budget_variant( + name: str, + budget: SearchBudget, +) -> Variant: + return Variant( + name=name, + hypothesis=SEARCH_BUDGET, + kind=VariantKind.ENCODER, + note=DRIVER_UNCHANGED, + encode=budget_encoder(budget), + needs_seeds=False, + ) + + +def _splitter(threshold: int) -> SeedTransform: + return lambda seeds: split(seeds, threshold) + + +def _both(threshold: int) -> SeedTransform: + return lambda seeds: whole_and_split(seeds, threshold) + + +SEED_VARIANTS: Final[Tuple[Variant, ...]] = ( + _seed_variant("seeds-trimmed", trimmed), + *(_seed_variant(f"seeds-split-{threshold}", _splitter(threshold)) for threshold in SPLIT_THRESHOLDS), + *(_seed_variant(f"seeds-both-{threshold}", _both(threshold)) for threshold in SPLIT_THRESHOLDS), +) + +BUDGET_VARIANTS: Final[Tuple[Variant, ...]] = ( + _budget_variant( + "search-light", + SearchBudget( + candidate_entries=LIGHT_ENTRIES, + rounds=DEFAULT_SEARCH_BUDGET.rounds, + confirmed_candidates=LIGHT_CONFIRMED, + ), + ), + _budget_variant( + "search-long", + SearchBudget( + candidate_entries=DEFAULT_SEARCH_BUDGET.candidate_entries, + rounds=LONG_ROUNDS, + confirmed_candidates=DEFAULT_SEARCH_BUDGET.confirmed_candidates, + ), + ), + _budget_variant( + "search-wide", + SearchBudget( + candidate_entries=WIDE_ENTRIES, + rounds=DEFAULT_SEARCH_BUDGET.rounds, + confirmed_candidates=DEFAULT_SEARCH_BUDGET.confirmed_candidates, + ), + ), + _budget_variant( + "search-deep", + SearchBudget( + candidate_entries=WIDE_ENTRIES, + rounds=LONG_ROUNDS, + confirmed_candidates=DEEP_CONFIRMED, + ), + ), +) diff --git a/src/sampletones_tools/codec/study/variants/registry.py b/src/sampletones_tools/codec/study/variants/registry.py new file mode 100644 index 000000000..41c2dafcf --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/registry.py @@ -0,0 +1,58 @@ +from typing import Dict, Final, Sequence, Tuple + +from sampletones_tools.codec.study.variants.baselines import Baselines, baseline_variant +from sampletones_tools.codec.study.variants.layouts import LAYOUT_VARIANTS +from sampletones_tools.codec.study.variants.production import BASELINE_NAME, BUDGET_VARIANTS, SEED_VARIANTS +from sampletones_tools.codec.study.variants.sandbox import grammar_variants +from sampletones_tools.codec.study.variants.variant import Variant + +EVERY_VARIANT: Final[str] = "all" + + +def variants(baselines: Baselines) -> Dict[str, Variant]: + """Every variant a run may encode under, by name, the baseline first. + + Args: + baselines: Where the production encodings are kept for the variants built on them. + + Returns: + Dict[str, Variant]: The variants, in the order a run encodes them. + """ + every = ( + baseline_variant(baselines), + *SEED_VARIANTS, + *BUDGET_VARIANTS, + *grammar_variants(baselines), + *LAYOUT_VARIANTS, + ) + return {variant.name: variant for variant in every} + + +def selected_variants( + names: Sequence[str], + baselines: Baselines, +) -> Tuple[Variant, ...]: + """The variants a run encodes every song under, the baseline always first. + + Args: + names: The names the run asks for, or ``all`` for every registered variant. + baselines: Where the production encodings are kept for the variants built on them. + + Returns: + Tuple[Variant, ...]: The baseline, then the named variants in the order given. + + Raises: + ValueError: If a name is registered to no variant. + """ + registered = variants(baselines) + if EVERY_VARIANT in names: + return tuple(registered.values()) + + unknown = [name for name in names if name not in registered] + if unknown: + raise ValueError( + f"No variant is called {', '.join(unknown)}; the variants are {', '.join(registered)} or {EVERY_VARIANT}." + ) + + chosen = [registered[name] for name in names if name != BASELINE_NAME] + return (registered[BASELINE_NAME], *chosen) diff --git a/src/sampletones_tools/codec/study/variants/sandbox.py b/src/sampletones_tools/codec/study/variants/sandbox.py new file mode 100644 index 000000000..6cbaadca6 --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/sandbox.py @@ -0,0 +1,153 @@ +from dataclasses import replace +from typing import Final, NamedTuple, Tuple + +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.measure import Encoder, Encoding +from sampletones_tools.codec.study.sandbox.costs import ( + PRODUCTION_COSTS, + SET_HOLD_BOUND, + Costs, +) +from sampletones_tools.codec.study.sandbox.encode import encode_grammar +from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar +from sampletones_tools.codec.study.variants.baselines import Baselines +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind + +WIDE_HOLD: Final[str] = "H1" +SET_HOLD: Final[str] = "H3" +HOLD_EDGES: Final[str] = "H9" +DRIVER_UNCHANGED: Final[str] = "driver unchanged" +WIDE_HOLD_NOTE: Final[str] = ( + "a phrase opcode with a zero count; the block count can sit in the idle shift field and " + "reload in the token fetch, the tick path unchanged" +) +SET_HOLD_NOTE: Final[str] = ( + "a transposed-phrase opcode with a zero count and the value behind it, three bytes; one compare in the token fetch" +) +SET_HOLD_BOUND_NOTE: Final[str] = ( + "a two-byte set-hold needs an opcode of its own, so this bounds what any reallocation of the opcode space reaches" +) +COMBINED_NOTE: Final[str] = "every change the free escape carries at once, the escape counting 31 blocks and 31 ticks" + +SET_HOLD_BOUND_COSTS: Final[Costs] = replace( + PRODUCTION_COSTS, + set_hold=SET_HOLD_BOUND, +) + + +class GrammarVariant(NamedTuple): + """One grammar the sandbox prices, and how the report names it. + + Attributes: + name: What the variant is called in a report. + hypothesis: The hypothesis the variant measures, by its label in the plan. + kind: What the variant changes. + note: What the driver would have to do. + grammar: The grammar. + """ + + name: str + hypothesis: str + kind: VariantKind + note: str + grammar: Grammar + + +GRAMMAR_VARIANTS: Final[Tuple[GrammarVariant, ...]] = ( + GrammarVariant( + name="start-hold", + hypothesis=HOLD_EDGES, + kind=VariantKind.ENCODER, + note=f"{DRIVER_UNCHANGED}: every plane is seeded before its first token, so a hold may open a stream", + grammar=replace(BASELINE_GRAMMAR, start_hold=True), + ), + GrammarVariant( + name="every-hold", + hypothesis=HOLD_EDGES, + kind=VariantKind.ENCODER, + note=DRIVER_UNCHANGED, + grammar=replace(BASELINE_GRAMMAR, every_length=True), + ), + GrammarVariant( + name="wide-hold", + hypothesis=WIDE_HOLD, + kind=VariantKind.FORMAT, + note=WIDE_HOLD_NOTE, + grammar=replace(BASELINE_GRAMMAR, wide_hold=True), + ), + GrammarVariant( + name="wide-hold+start-hold", + hypothesis=f"{WIDE_HOLD}+{HOLD_EDGES}", + kind=VariantKind.FORMAT, + note=WIDE_HOLD_NOTE, + grammar=replace(BASELINE_GRAMMAR, wide_hold=True, start_hold=True), + ), + GrammarVariant( + name="set-hold-3", + hypothesis=SET_HOLD, + kind=VariantKind.FORMAT, + note=SET_HOLD_NOTE, + grammar=replace(BASELINE_GRAMMAR, set_hold=True), + ), + GrammarVariant( + name="set-hold-2", + hypothesis=SET_HOLD, + kind=VariantKind.FORMAT, + note=SET_HOLD_BOUND_NOTE, + grammar=replace(BASELINE_GRAMMAR, set_hold=True, costs=SET_HOLD_BOUND_COSTS), + ), + GrammarVariant( + name="escape-grammar", + hypothesis=f"{WIDE_HOLD}+{SET_HOLD}+{HOLD_EDGES}", + kind=VariantKind.FORMAT, + note=COMBINED_NOTE, + grammar=replace( + BASELINE_GRAMMAR, + start_hold=True, + wide_hold=True, + set_hold=True, + ), + ), +) + + +def grammar_encoder( + baselines: Baselines, + grammar: Grammar, +) -> Encoder: + """An encoder pricing a song under ``grammar`` over its production dictionary. + + Args: + baselines: Where the production encodings are kept. + grammar: The grammar. + + Returns: + Encoder: The encoder. + """ + + def encode(song: StudySong) -> Encoding: + return encode_grammar(baselines.reference(song), grammar) + + return encode + + +def grammar_variants(baselines: Baselines) -> Tuple[Variant, ...]: + """Every grammar the sandbox prices, as the variants a run encodes under. + + Args: + baselines: Where the production encodings are kept. + + Returns: + Tuple[Variant, ...]: The variants, in the order of ``GRAMMAR_VARIANTS``. + """ + return tuple( + Variant( + name=entry.name, + hypothesis=entry.hypothesis, + kind=entry.kind, + note=entry.note, + encode=grammar_encoder(baselines, entry.grammar), + needs_seeds=False, + ) + for entry in GRAMMAR_VARIANTS + ) diff --git a/src/sampletones_tools/codec/study/variants/seeds.py b/src/sampletones_tools/codec/study/variants/seeds.py new file mode 100644 index 000000000..d0eed4053 --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/seeds.py @@ -0,0 +1,92 @@ +from typing import Final, List, Sequence, Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase + +MIN_PIECE_LENGTH: Final[int] = 2 + + +def trimmed(seeds: Sequence[Phrase]) -> Tuple[Phrase, ...]: + """Every seed with its trailing plateau cut to one value. + + A token playing a phrase past its end holds the final value onward, so the ticks a body + spends resting on its last value are ticks the token covers for free. + + Args: + seeds: The phrases the song's instruments offer. + + Returns: + Tuple[Phrase, ...]: The same phrases, each ending where its last value first appears. + """ + return tuple(Phrase(body=_trimmed_body(seed.body)) for seed in seeds) + + +def split( + seeds: Sequence[Phrase], + threshold: int, +) -> Tuple[Phrase, ...]: + """Every seed cut at the plateaus of at least ``threshold`` ticks it holds. + + A plateau inside a body is stored one byte per tick, where a token's count would cover the + rest for free. Cutting the body where a plateau begins keeps the plateau's first value at + the end of one piece, so the token playing that piece holds it, and the next piece starts + where the body moves again. + + Args: + seeds: The phrases the song's instruments offer. + threshold: The ticks a plateau rests for before the body is cut there. + + Returns: + Tuple[Phrase, ...]: The pieces, each of at least two values, in the order they are cut. + """ + return tuple(Phrase(body=piece) for seed in seeds for piece in _pieces(seed.body, threshold)) + + +def whole_and_split( + seeds: Sequence[Phrase], + threshold: int, +) -> Tuple[Phrase, ...]: + """Every seed as it stands, followed by its pieces, for the encoder to weigh against each other. + + Args: + seeds: The phrases the song's instruments offer. + threshold: The ticks a plateau rests for before the body is cut there. + + Returns: + Tuple[Phrase, ...]: The whole phrases, then the pieces. + """ + return (*seeds, *split(seeds, threshold)) + + +def _trimmed_body(body: bytes) -> bytes: + end = len(body) + while end > 1 and body[end - 1] == body[end - 2]: + end -= 1 + + return body[:end] + + +def _pieces( + body: bytes, + threshold: int, +) -> List[bytes]: + pieces: List[bytes] = [] + current = bytearray() + start = 0 + while start < len(body): + end = start + while end < len(body) and body[end] == body[start]: + end += 1 + + if end - start >= threshold: + current.append(body[start]) + pieces.append(bytes(current)) + current = bytearray() + else: + current.extend(body[start:end]) + + start = end + + if current: + pieces.append(bytes(current)) + + return [piece for piece in pieces if len(piece) >= MIN_PIECE_LENGTH] diff --git a/src/sampletones_tools/codec/study/variants/strategy.py b/src/sampletones_tools/codec/study/variants/strategy.py new file mode 100644 index 000000000..211783f66 --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/strategy.py @@ -0,0 +1,71 @@ +from dataclasses import replace +from typing import Dict, Final, List, Optional, Sequence, Tuple + +from sampletones_tools.codec.study.measure import Measurement + +STRATEGY_ORDER: Final[Tuple[str, ...]] = ( + "baseline", + "seeds-trimmed", + "seeds-both-4", + "seeds-split-4", + "search-long", + "search-wide", + "search-deep", +) +DEPTH_PREFIX: Final[str] = "depth-" + + +def depth_measurements( + measurements: Sequence[Measurement], + order: Sequence[str], +) -> Tuple[Measurement, ...]: + """What an export trying the first ``d`` strategies and keeping the smallest would write. + + An export option choosing how deep to search is modeled as a list of encoder-side + strategies tried in order: at each depth a song is written by whichever of the strategies so + far made it smallest, and the time is what all of them took together. A strategy a song + offers nothing to keeps the song where the previous depth left it. + + Args: + measurements: Every song under every variant the run measured. + order: The strategies, in the order an export would try them. + + Returns: + Tuple[Measurement, ...]: One measurement per song and depth, named ``depth-d``, in song + order then depth order. + """ + present = [name for name in order if any(measurement.variant == name for measurement in measurements)] + derived: List[Measurement] = [] + for name in dict.fromkeys(measurement.song.name for measurement in measurements): + by_variant: Dict[str, Measurement] = { + measurement.variant: measurement for measurement in measurements if measurement.song.name == name + } + derived.extend(_song_depths(by_variant, present)) + + return tuple(derived) + + +def _song_depths( + by_variant: Dict[str, Measurement], + present: Sequence[str], +) -> List[Measurement]: + depths: List[Measurement] = [] + best: Optional[Measurement] = None + seconds = 0.0 + for depth, name in enumerate(present, start=1): + measured = by_variant.get(name) + if measured is not None: + seconds += measured.seconds + if best is None or measured.block < best.block: + best = measured + + if best is not None: + depths.append( + Measurement( + song=best.song, + variant=f"{DEPTH_PREFIX}{depth}", + encoding=replace(best.encoding, seconds=seconds), + ) + ) + + return depths diff --git a/src/sampletones_tools/codec/study/variants/variant.py b/src/sampletones_tools/codec/study/variants/variant.py new file mode 100644 index 000000000..47ea3754e --- /dev/null +++ b/src/sampletones_tools/codec/study/variants/variant.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from enum import StrEnum + +from sampletones_tools.codec.study.corpus.song import StudySong +from sampletones_tools.codec.study.measure import Encoder + + +class VariantKind(StrEnum): + """What a variant changes to earn its bytes. + + Attributes: + BASELINE: The production codec as it stands. + ENCODER: A change to how the encoder chooses, readable by the driver as it stands. + FORMAT: A change to the token grammar, which the driver has to learn. + """ + + BASELINE = "baseline" + ENCODER = "encoder" + FORMAT = "format" + + +@dataclass(frozen=True) +class Variant: + """One way of encoding a song the study prices against the baseline. + + Attributes: + name: What the variant is called in a report. + hypothesis: The hypothesis the variant measures, by its label in the plan. + kind: What the variant changes. + note: What the driver would have to do, where the variant changes the format. + encode: What the variant writes a song as. + needs_seeds: Whether the variant changes anything only where a song offers seeds. + """ + + name: str + hypothesis: str + kind: VariantKind + note: str + encode: Encoder + needs_seeds: bool + + def applies(self, song: StudySong) -> bool: + """Whether encoding ``song`` under this variant can differ from the baseline. + + Args: + song: The song. + + Returns: + bool: Whether the variant is worth measuring on it. + """ + return bool(song.seeds) or not self.needs_seeds diff --git a/src/sampletones_tools/compatibility/__init__.py b/src/sampletones_tools/compatibility/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/compatibility/command.py b/src/sampletones_tools/compatibility/command.py new file mode 100644 index 000000000..aa1747897 --- /dev/null +++ b/src/sampletones_tools/compatibility/command.py @@ -0,0 +1,59 @@ +from argparse import ArgumentParser, Namespace +from typing import Final + +from sampletones_shared.command import Command + +NAME: Final[str] = "compatibility" +HELP: Final[str] = "archive one document per stored format at the version this build writes" +OUTPUT_FLAGS: Final[tuple[str, str]] = ("-o", "--output") +FORCE_FLAG: Final[str] = "--force" +OUTPUT_FIELD: Final[str] = "output" +FORCE_FIELD: Final[str] = "force" + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + *OUTPUT_FLAGS, + dest=OUTPUT_FIELD, + default=None, + help="the corpus the files are written into; the repository's own by default", + ) + parser.add_argument( + FORCE_FLAG, + dest=FORCE_FIELD, + action="store_true", + help="write a version that is already archived again", + ) + + +def run(arguments: Namespace) -> int: + """Archives the corpus and names what it wrote. + + Raises: + SystemExit: If the package runs outside a checkout. + """ + from pathlib import Path + + from sampletones_tools.checkout import require_checkout + from sampletones_tools.compatibility.paths import CORPUS_DIRECTORY + from sampletones_tools.compatibility.writer import archive + + require_checkout(NAME) + output = arguments.output + root = CORPUS_DIRECTORY if output is None else Path(output) + written = archive(root, arguments.force) + for path in written: + print(f"wrote {path} ({path.stat().st_size} bytes)") + + if not written: + print(f"every version this build writes is already archived in {root}") + + return 0 + + +COMPATIBILITY: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/compatibility/documents.py b/src/sampletones_tools/compatibility/documents.py new file mode 100644 index 000000000..878589d30 --- /dev/null +++ b/src/sampletones_tools/compatibility/documents.py @@ -0,0 +1,180 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Sequence, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import PulseGenerator +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.library import InstructionLibraryFragment +from sampletones_core.library.data import InstructionLibraryData +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.pitch import Step +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.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_tools.corpus.written import written_reconstruction + +SOURCE_PATH: Final[Path] = Path("samples") / "kick.wav" +COEFFICIENT: Final[float] = 0.75 +RECORDING_SCALE: Final[float] = 0.5 +ROWS_PER_PATTERN: Final[int] = 4 +PATTERN_NAME: Final[str] = "verse" +FIRST_SAMPLE_NAME: Final[str] = "kick" +SECOND_SAMPLE_NAME: Final[str] = "kick again" +PROJECT_TITLE: Final[str] = "Corpus" +PROJECT_AUTHOR: Final[str] = "Archivist" +PROJECT_COMMENT: Final[str] = "A document kept to be read back" +PROJECT_TEMPO: Final[int] = 132 +ROW_TRANSPOSE: Final[int] = 3 +ROW_VOLUME: Final[int] = 9 +LIBRARY_SAMPLE_RATE: Final[int] = 11025 +LIBRARY_GAMMA: Final[int] = 50 +EMBEDDED_CHANNEL: Final[ChannelName] = ChannelName.PULSE2 + + +def corpus_instructions() -> Dict[ChannelName, List[InstructionUnion]]: + """Three frames on every channel: one sounding, one silent, one sounding. + + The silent frame is what holds a record to naming rest and silence over the same frames, and + every channel carries one so each instruction class is stored at least once. + """ + return { + ChannelName.PULSE1: [ + PulseInstruction(on=True, pitch=45, volume=11, duty_cycle=2), + PulseInstruction(on=False, pitch=45, volume=0, duty_cycle=0), + PulseInstruction(on=True, pitch=48, volume=7, duty_cycle=1, detune=2), + ], + ChannelName.PULSE2: [ + PulseInstruction(on=True, pitch=52, volume=9, duty_cycle=0), + PulseInstruction(on=False, pitch=52, volume=0, duty_cycle=0), + PulseInstruction(on=True, pitch=52, volume=9, duty_cycle=3), + ], + ChannelName.TRIANGLE: [ + TriangleInstruction(on=True, pitch=33), + TriangleInstruction(on=False, pitch=33), + TriangleInstruction(on=True, pitch=35, coarse_detune=1), + ], + ChannelName.NOISE: [ + NoiseInstruction(on=True, period=5, volume=6, short=False), + NoiseInstruction(on=False, period=5, volume=0, short=False), + NoiseInstruction(on=True, period=9, volume=12, short=True), + ], + } + + +def corpus_reconstruction(instructions: Dict[ChannelName, List[InstructionUnion]]) -> Reconstruction: + """A reconstruction naming every channel, its source recording, and one owner per frame.""" + return written_reconstruction( + instructions, + coefficient=COEFFICIENT, + scale=RECORDING_SCALE, + audio_filepath=(SOURCE_PATH,), + ) + + +def embedded_reconstruction() -> Reconstruction: + """The reconstruction a project carries, sounding one channel while the rest stand by. + + A project detaches a recording from its origin before storing it, so this one names no file, + which is the shape a stored project holds and the one an upgrade leaves alone. + """ + instructions: Dict[ChannelName, List[InstructionUnion]] = { + EMBEDDED_CHANNEL: [ + PulseInstruction(on=True, pitch=40, volume=15, duty_cycle=1), + PulseInstruction(on=False, pitch=40, volume=0, duty_cycle=0), + ] + } + return written_reconstruction( + instructions, + coefficient=COEFFICIENT, + scale=RECORDING_SCALE, + audio_filepath=(), + ) + + +def library_config() -> Config: + """The configuration the archived library is analyzed under. + + The sample rate stands as low as a measured sample allows, which is what keeps the archived file + small. + """ + base = Config() + library = base.library.model_copy( + update={ + "spectrum_method": SpectrumMethod.FFT, + "transformation_gamma": LIBRARY_GAMMA, + "sample_rate": LIBRARY_SAMPLE_RATE, + } + ) + return base.model_copy(update={"library": library}) + + +def library_tones() -> Tuple[PulseInstruction, ...]: + """The tones the archived library holds, each measured and stored.""" + return ( + PulseInstruction(on=True, pitch=57, volume=12, duty_cycle=2), + PulseInstruction(on=True, pitch=45, volume=8, duty_cycle=1), + ) + + +def corpus_library() -> InstructionLibraryData: + """A library holding a couple of measured tones, small enough to keep.""" + config = library_config() + extractor = get_feature_extractor(config, Window.from_config(config)) + generator = PulseGenerator(config, ChannelName.PULSE1) + fragments: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = { + instruction: InstructionLibraryFragment.create(generator, instruction, extractor) + for instruction in library_tones() + } + return InstructionLibraryData.create(config, fragments) + + +def corpus_project(reconstruction: Reconstruction) -> Project: + """A project arranging one reconstruction under two voices. + + Two voices sharing one reconstruction is what holds a stored project to keeping a single copy + of it, and the rows name a voice, let it go, and stand empty, so every note column a row can + hold is stored. + """ + project = Project.create( + title=PROJECT_TITLE, + author=PROJECT_AUTHOR, + comment=PROJECT_COMMENT, + rows_per_pattern=ROWS_PER_PATTERN, + settings=ProjectSettings(tempo=PROJECT_TEMPO), + ) + first = Sample(name=FIRST_SAMPLE_NAME, reconstruction=reconstruction) + second = Sample(name=SECOND_SAMPLE_NAME, reconstruction=reconstruction) + project.voices.append(first) + project.voices.append(second) + project.song.channels[EMBEDDED_CHANNEL].patterns[0] = Pattern( + name=PATTERN_NAME, + rows=tuple(_rows(first.id, second.id)), + ) + return project + + +def _rows(first_voice_id: str, second_voice_id: str) -> List[Row]: + """The lines one pattern holds: a note, a second note carrying its columns, a note-off, a blank.""" + return [ + Row(command=NoteOn(voice_id=first_voice_id)), + Row(command=NoteOn(voice_id=second_voice_id), pitch=Step(value=ROW_TRANSPOSE), volume=ROW_VOLUME), + Row(command=NoteOff()), + Row(), + ] + + +def corpus_channels() -> Sequence[ChannelName]: + """The channels the archived reconstruction plays, which is every one of them.""" + return tuple(corpus_instructions()) diff --git a/src/sampletones_tools/compatibility/paths.py b/src/sampletones_tools/compatibility/paths.py new file mode 100644 index 000000000..94fe5e059 --- /dev/null +++ b/src/sampletones_tools/compatibility/paths.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import Dict, Final + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.paths.extensions import ( + EXT_FILE_LIBRARY, + EXT_FILE_PROJECT, + EXT_FILE_RECONSTRUCTION, +) +from sampletones_shared.paths.source import REPOSITORY_ROOT + +CORPUS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "data" / "compatibility" +CORPUS_EXTENSIONS: Final[Dict[ObjectKind, str]] = { + ObjectKind.RECONSTRUCTION: EXT_FILE_RECONSTRUCTION, + ObjectKind.LIBRARY: EXT_FILE_LIBRARY, + ObjectKind.PROJECT: EXT_FILE_PROJECT, +} + + +def version_part(version: str) -> str: + """The spelling a data version takes in a file name. + + A step module is named after the version it writes, so an archived file named after the + version it was written at reads as the step's counterpart: ``v2_1.stn`` is what ``v2_2.py`` + carries forward. + """ + return f"v{version.replace('.', '_')}" + + +def archived_path(kind: ObjectKind, version: str, root: Path = CORPUS_DIRECTORY) -> Path: + """Where the file one format wrote at ``version`` is kept. + + Args: + kind: The format the file belongs to. + version: The data version the file was written at. + root: The corpus the file is kept in. + + Returns: + Path: The archived file's location, whether or not it stands there yet. + """ + return root / kind.value / f"{version_part(version)}{CORPUS_EXTENSIONS[kind]}" diff --git a/src/sampletones_tools/compatibility/writer.py b/src/sampletones_tools/compatibility/writer.py new file mode 100644 index 000000000..193d8e463 --- /dev/null +++ b/src/sampletones_tools/compatibility/writer.py @@ -0,0 +1,55 @@ +from pathlib import Path +from typing import List + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import CURRENT_VERSIONS +from sampletones_core.project.container import ProjectContainer +from sampletones_tools.compatibility.documents import ( + corpus_instructions, + corpus_library, + corpus_project, + corpus_reconstruction, + embedded_reconstruction, +) +from sampletones_tools.compatibility.paths import CORPUS_DIRECTORY, archived_path + + +def archive(root: Path = CORPUS_DIRECTORY, force: bool = False) -> List[Path]: + """Writes one document per format at the version this build states, and answers where they went. + + Each file is written by the build that runs this, so the corpus holds what a release actually + produced rather than what a later build believes it produced. A version already archived stands + as it was written, since replacing it would restate history under the same name; ``force`` is + what a deliberate replacement passes. + + Args: + root: The corpus the files are written into. + force: Whether a version already archived is written again. + + Returns: + List[Path]: The files written, in the order the formats are listed. + """ + written: List[Path] = [] + for kind, version in CURRENT_VERSIONS.items(): + path = archived_path(kind, version, root) + if path.exists() and not force: + continue + + path.parent.mkdir(parents=True, exist_ok=True) + _write(kind, path) + written.append(path) + + return written + + +def _write(kind: ObjectKind, path: Path) -> None: + """Writes one format's document to ``path``.""" + if kind is ObjectKind.RECONSTRUCTION: + corpus_reconstruction(corpus_instructions()).save(path) + return + + if kind is ObjectKind.LIBRARY: + corpus_library().save(path) + return + + ProjectContainer.save(corpus_project(embedded_reconstruction()), path) diff --git a/src/sampletones_tools/console/__init__.py b/src/sampletones_tools/console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/console/cartridge.py b/src/sampletones_tools/console/cartridge.py new file mode 100644 index 000000000..53faa8c0f --- /dev/null +++ b/src/sampletones_tools/console/cartridge.py @@ -0,0 +1,73 @@ +from typing import Final, List + +from py65.memory import ObservableMemory + +from sampletones_player.specification.nsf import PROGRAM_START +from sampletones_tools.console.header import NSFHeader + +BANK_SIZE: Final[int] = 0x1000 +FIRST_BANK_REGISTER: Final[int] = 0x5FF8 +EMPTY_BYTE: Final[int] = 0x00 + + +class Cartridge: + """The program an NSF file maps into a console's memory, loaded whole or in switched banks. + + A file whose header names no bank loads its image whole at the load address. A banked file is + cut into 4 KB banks, the image padded at the front by the load address's place within its own + bank. Writing a bank's number to ``$5FF8`` + slot maps that bank into the slot's 4 KB of + ``$8000``-``$FFFF``, and the header names the bank every slot starts with. + """ + + def __init__(self, header: NSFHeader, image: bytes) -> None: + """Holds a file's image as its header says to map it. + + Args: + header: The file's header. + image: Everything the file holds behind its header. + """ + self._header = header + self._image = image + self._padded = bytes(header.load % BANK_SIZE) + image + + def mount(self, memory: ObservableMemory) -> None: + """Maps the program into a console's memory, and switches banks whenever the program asks. + + Args: + memory: The console's memory. + """ + if not self._header.banked: + memory.write(self._header.load, list(self._image)) + return + + for slot, bank in enumerate(self._header.banks): + self._map(memory, slot, bank) + + def switch(address: int, value: int) -> None: + self._map(memory, address - FIRST_BANK_REGISTER, value) + + memory.subscribe_to_write( + range(FIRST_BANK_REGISTER, FIRST_BANK_REGISTER + len(self._header.banks)), + switch, + ) + + def bank(self, number: int) -> List[int]: + """The 4 KB a bank holds, the part past the image's end reading as empty bytes. + + Args: + number: The bank, counted from the front of the padded image. + + Returns: + List[int]: The bank's bytes. + """ + start = number * BANK_SIZE + held = self._padded[start : start + BANK_SIZE] + return list(held) + [EMPTY_BYTE] * (BANK_SIZE - len(held)) + + def _map( + self, + memory: ObservableMemory, + slot: int, + bank: int, + ) -> None: + memory.write(PROGRAM_START + slot * BANK_SIZE, self.bank(bank)) diff --git a/src/sampletones_tools/console/errors.py b/src/sampletones_tools/console/errors.py new file mode 100644 index 000000000..3714cd9c5 --- /dev/null +++ b/src/sampletones_tools/console/errors.py @@ -0,0 +1,14 @@ +from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.exceptions.validation import InvalidDataError + + +class ConsoleError(SampleToNESError): + """The console could not run a file to the end of a routine.""" + + +class NotAnNSFError(InvalidDataError): + """The data a console was handed is no NSF file: its header lacks the NSF signature.""" + + +class RoutineOverrunError(ConsoleError): + """A routine ran for its whole step budget without returning to the caller.""" diff --git a/src/sampletones_tools/console/header.py b/src/sampletones_tools/console/header.py new file mode 100644 index 000000000..9e702b002 --- /dev/null +++ b/src/sampletones_tools/console/header.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass +from typing import Final, Self, Tuple + +from sampletones_core.formats.binary import BinaryReader +from sampletones_player.specification.nsf import ( + BANKSWITCH_SIZE, + DUAL_REGION_FLAG, + NSF_MAGIC, + PAL_REGION_FLAG, + STRING_FIELD_SIZE, +) +from sampletones_tools.console.errors import NotAnNSFError + +VERSION_SIZE: Final[int] = 1 +STRING_FIELDS: Final[int] = 3 +NTSC_MACHINE: Final[int] = 0x00 +PAL_MACHINE: Final[int] = 0x01 +FIRST_SONG_NUMBER: Final[int] = 1 + + +@dataclass(frozen=True) +class NSFHeader: + """What an NSF player reads from a file's header before it runs the file. + + Attributes: + songs: How many songs the file holds. + first_song: The song a player starts with, counted from 1. + load: The address the image loads at. + init: The routine that starts a song. + play: The routine a player calls once per tick. + ntsc_period: The microseconds between play calls on an NTSC machine. + banks: The bank each 4 KB slot of ``$8000``-``$FFFF`` starts with, all zero for a file loaded whole. + pal_period: The microseconds between play calls on a PAL machine. + region: The machines the file plays on: the PAL flag, and the flag of a file made for both. + """ + + songs: int + first_song: int + load: int + init: int + play: int + ntsc_period: int + banks: Tuple[int, ...] + pal_period: int + region: int + + @classmethod + def read(cls, data: bytes) -> Self: + """Reads the header at the front of a whole NSF file, field by field as the format lays them out. + + Args: + data: The file, header first. + + Returns: + Self: The header. + + Raises: + NotAnNSFError: If the file opens with something other than the NSF signature. + TruncatedDataError: If the file ends inside the header. + """ + reader = BinaryReader(data) + signature = reader.read_bytes(len(NSF_MAGIC)) + if signature != NSF_MAGIC: + raise NotAnNSFError(f"The file opens with {signature!r}, not the NSF signature {NSF_MAGIC!r}") + + reader.skip(VERSION_SIZE) + songs = reader.read_uint8() + first_song = reader.read_uint8() + load = reader.read_uint16() + init = reader.read_uint16() + play = reader.read_uint16() + reader.skip(STRING_FIELD_SIZE * STRING_FIELDS) + ntsc_period = reader.read_uint16() + banks = tuple(reader.read_bytes(BANKSWITCH_SIZE)) + pal_period = reader.read_uint16() + region = reader.read_uint8() + return cls( + songs=songs, + first_song=first_song, + load=load, + init=init, + play=play, + ntsc_period=ntsc_period, + banks=banks, + pal_period=pal_period, + region=region, + ) + + @property + def banked(self) -> bool: + """Whether the file is read in switched banks, which a header naming any bank asks for.""" + return any(self.banks) + + @property + def first_song_index(self) -> int: + """The first song as the init routine is told it, counted from 0.""" + return self.first_song - FIRST_SONG_NUMBER + + @property + def machine(self) -> int: + """The machine the init routine is told it runs on: PAL for a file made for PAL alone, NTSC otherwise.""" + pal_only = bool(self.region & PAL_REGION_FLAG) and not self.region & DUAL_REGION_FLAG + return PAL_MACHINE if pal_only else NTSC_MACHINE diff --git a/src/sampletones_tools/console/machine.py b/src/sampletones_tools/console/machine.py new file mode 100644 index 000000000..f049e9b46 --- /dev/null +++ b/src/sampletones_tools/console/machine.py @@ -0,0 +1,141 @@ +from typing import Final, List, Tuple + +from py65.devices.mpu6502 import MPU +from py65.memory import ObservableMemory + +from sampletones_player.specification.nsf import HEADER_SIZE +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + FIRST_CHANNEL_REGISTER, +) +from sampletones_tools.console.cartridge import Cartridge +from sampletones_tools.console.errors import RoutineOverrunError +from sampletones_tools.console.header import NSFHeader +from sampletones_tools.player.trace.trace import RegisterTrace +from sampletones_tools.player.trace.write import RegisterWrite + +RETURN_SENTINEL: Final[int] = 0xFFF0 +STACK_PAGE: Final[int] = 0x0100 +STACK_TOP: Final[int] = 0xFF +ADDRESS_BITS: Final[int] = 8 +LOW_BYTE: Final[int] = 0xFF +PLAY_ACCUMULATOR: Final[int] = 0x00 +STEP_BUDGET: Final[int] = 1_000_000 + + +class Console: + """A 6502 running an NSF file the way an NSF player does, watching every APU register it writes. + + An NSF player maps the program behind the header, calls the init routine once with the song + in the accumulator and the machine in X, and calls the play routine once per tick. This runs + that sequence over py65's CPU with the APU's address range watched, so each routine answers + with the writes it made. The memory and the APU start at zero, as at power-up. + """ + + def __init__( + self, + data: bytes, + *, + step_budget: int = STEP_BUDGET, + ) -> None: + """Loads an NSF file the way a player loads it. + + Args: + data: The whole file, header included. + step_budget: The most instructions a routine may run before it must return. + + Raises: + NotAnNSFError: If the data opens with something other than the NSF signature. + TruncatedDataError: If the data ends inside the header. + """ + self.header = NSFHeader.read(data) + self._step_budget = step_budget + self._writes: List[RegisterWrite] = [] + self._memory = ObservableMemory() + Cartridge(self.header, data[HEADER_SIZE:]).mount(self._memory) + self._memory.subscribe_to_write( + range(FIRST_CHANNEL_REGISTER, APU_FRAME_COUNTER + 1), + self._observe, + ) + self._processor = MPU(memory=self._memory) + + def initialize(self) -> Tuple[RegisterWrite, ...]: + """Runs the init routine for the header's first song, on the machine the header names. + + Returns: + Tuple[RegisterWrite, ...]: Every APU register the routine wrote, in order. + + Raises: + RoutineOverrunError: If the routine runs past its step budget. + """ + return self._call( + self.header.init, + accumulator=self.header.first_song_index, + index=self.header.machine, + ) + + def play(self) -> Tuple[RegisterWrite, ...]: + """Runs one play call, the way the player calls it each tick. + + Returns: + Tuple[RegisterWrite, ...]: Every APU register the call wrote, in order. + + Raises: + RoutineOverrunError: If the routine runs past its step budget. + """ + return self._call( + self.header.play, + accumulator=PLAY_ACCUMULATOR, + index=self.header.machine, + ) + + def trace(self, play_calls: int) -> RegisterTrace: + """Runs a whole session: initialization followed by ``play_calls`` play calls. + + Args: + play_calls: How many play calls the run covers. + + Returns: + RegisterTrace: The writes of the initialization and of every play call. + + Raises: + RoutineOverrunError: If a routine runs past its step budget. + """ + initialization = self.initialize() + return RegisterTrace( + initialization=initialization, + play_calls=tuple(self.play() for _ in range(play_calls)), + ) + + def _observe(self, address: int, value: int) -> None: + self._writes.append(RegisterWrite(address, value)) + + def _seed_stack(self) -> None: + """Leaves the sentinel on the stack as a return address, so a routine's final RTS lands on it.""" + returned = RETURN_SENTINEL - 1 + self._memory[STACK_PAGE + STACK_TOP] = returned >> ADDRESS_BITS + self._memory[STACK_PAGE + STACK_TOP - 1] = returned & LOW_BYTE + self._processor.sp = STACK_TOP - 2 + + def _call( + self, + address: int, + *, + accumulator: int, + index: int, + ) -> Tuple[RegisterWrite, ...]: + self._writes = [] + self._processor.a = accumulator + self._processor.x = index + self._seed_stack() + self._processor.pc = address + + for _ in range(self._step_budget): + if self._processor.pc == RETURN_SENTINEL: + return tuple(self._writes) + + self._processor.step() + + raise RoutineOverrunError( + f"The routine at {address:#06x} ran for {self._step_budget} instructions without returning" + ) diff --git a/src/sampletones_tools/corpus/__init__.py b/src/sampletones_tools/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/corpus/build.py b/src/sampletones_tools/corpus/build.py new file mode 100644 index 000000000..54e31705e --- /dev/null +++ b/src/sampletones_tools/corpus/build.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Dict, Mapping + +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_tools.corpus.catalog import CatalogSpec, build_catalog +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import SongSpec, build_song +from sampletones_tools.corpus.synth import SynthConfig + + +@dataclass(frozen=True) +class Corpus: + """The synthetic corpus: the reconstructed samples and the arrangement that plays them. + + Attributes: + catalog: The samples, by name. + project: The arrangement, carrying the samples as its voices. + """ + + catalog: Dict[str, Sample] + project: Project + + +def build_project( + catalog: Mapping[str, VoiceUnion], + module_config: ModuleConfig, + song_spec: SongSpec, +) -> Project: + """The arrangement playing the catalog's voices under the module's identity and playback settings.""" + settings = ProjectSettings( + tempo=module_config.tempo, + speed=module_config.speed, + nes_frequency=module_config.nes_frequency, + ) + project = Project.create( + title=module_config.title, + author=module_config.author, + settings=settings, + ) + for voice in catalog.values(): + project.voices.append(voice) + + project.song = build_song(song_spec, catalog) + return project + + +def build_synthetic_corpus() -> Corpus: + """Renders, reconstructs and arranges the corpus the package describes. + + The recordings are rendered into a temporary directory of the build's own, which is gone once + their reconstructions are made. + + Returns: + Corpus: The samples and the arrangement. + """ + with TemporaryDirectory() as recordings: + catalog = build_catalog(CatalogSpec.load(), SynthConfig.load(), tmp_dir=Path(recordings)) + + return Corpus( + catalog=catalog, + project=build_project(catalog, ModuleConfig.load(), SongSpec.load()), + ) diff --git a/src/sampletones_tools/corpus/catalog.py b/src/sampletones_tools/corpus/catalog.py new file mode 100644 index 000000000..a8bf761a9 --- /dev/null +++ b/src/sampletones_tools/corpus/catalog.py @@ -0,0 +1,218 @@ +from pathlib import Path +from typing import Dict, Final, FrozenSet, List, Self, Sequence, Tuple + +import numpy as np +from pydantic import BaseModel, ConfigDict + +from sampletones_core.audio import write_wave +from sampletones_core.audio.processing import normalize +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.constants.enums import ChannelName, GeneratorClassName, SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import get_generators_by_channels +from sampletones_core.instructions import InstructionUnion +from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from sampletones_core.library.creator.creation import generate_instruction +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_shared.types.path import Pathlike +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.paths import CATALOG_CONFIG_PATH +from sampletones_tools.corpus.synth import SynthConfig + +INSTRUCTIONS_PER_GENERATOR: Final[int] = 48 +CHANNELS: Final[List[ChannelName]] = [ + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, +] + + +class ReconstructionSettings(BaseModel): + """The settings the one in-memory library every instrument is matched against is built with.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + spectrum_method: SpectrumMethod + transformation_gamma: int + instructions_per_generator: int + + +class InstrumentSpec(BaseModel): + """One instrument of the catalog: the voice it is rendered from and the channels it covers.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + synth: str + channels: List[ChannelName] + + +class CatalogSpec(BaseModel): + """The reconstructed sample catalog: the library settings and the instruments built under them.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + reconstruction: ReconstructionSettings + instruments: List[InstrumentSpec] + + @classmethod + def load(cls) -> Self: + """The catalog the package ships.""" + return load_yaml_model(CATALOG_CONFIG_PATH, cls) + + +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 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 = { + generator.class_name(): generator for generator in get_generators_by_channels(config, CHANNELS).values() + } + sampled: List[Tuple[GeneratorClassName, InstructionUnion]] = [ + (class_name, instruction) + for class_name, generator in generators.items() + for instruction in _evenly_sampled(generator.get_possible_instructions(), per_generator) + ] + data = dict( + generate_instruction(generators, class_name, instruction, extractor) for class_name, instruction in sampled + ) + + library = InstructionLibrary() + library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) + return library + + +def _evenly_sampled(candidates: Sequence[InstructionUnion], count: int) -> List[InstructionUnion]: + stride = max(1, len(candidates) // count) + return list(candidates[::stride][:count]) + + +def reconstruct_sample( + audio: np.ndarray, + config: Config, + library: InstructionLibrary, + channels: FrozenSet[ChannelName], + *, + tmp_dir: Pathlike, + name: str, +) -> Reconstruction: + """Runs the real reconstruction pipeline on ``audio`` via a temp WAV. + + Raises: + ValueError: If the reconstructor returns no reconstruction for the recording. + """ + path = Path(tmp_dir) / f"{name}.wav" + write_wave(path, config.library.sample_rate, audio) + reconstruction = Reconstructor(config, channels, library=library)(path) + if reconstruction is None: + raise ValueError(f"Reconstruction of '{name}' produced no result") + + return reconstruction + + +def make_sample( + name: str, + audio: np.ndarray, + config: Config, + library: InstructionLibrary, + *, + tmp_dir: Pathlike, + expected_slices: FrozenSet[ChannelName], +) -> Sample: + """Reconstructs ``audio`` into a `Sample` playing exactly the expected channels. + + Raises: + ValueError: If the reconstruction plays other channels than ``expected_slices``. + """ + reconstruction = reconstruct_sample( + audio, + config, + library, + expected_slices, + tmp_dir=tmp_dir, + name=name, + ) + played = frozenset(reconstruction.playing_channels) + if played != expected_slices: + raise ValueError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") + + return Sample(name=name, reconstruction=reconstruction) + + +def build_catalog( + spec: CatalogSpec, + synth_config: SynthConfig, + *, + tmp_dir: Pathlike, +) -> Dict[str, Sample]: + """Builds the reconstructed sample catalog the spec describes. + + The spec fixes the reconstruction settings (spectrum method, gamma, library size) shared by + one in-memory library, and per instrument the voice to render and the channel slices to + cover. Voice parameters come from ``synth_config``. + + Args: + spec: The catalog: the library settings and the instruments. + synth_config: The voices the instruments are rendered from. + tmp_dir: Where the rendered recordings are written before they are reconstructed. + + Returns: + Dict[str, Sample]: The samples, by name, in the order the spec lists them. + """ + settings = spec.reconstruction + library_config = InstructionsLibraryConfig( + spectrum_method=settings.spectrum_method, + transformation_gamma=settings.transformation_gamma, + ) + sample_rate = library_config.sample_rate + library = build_mini_library( + Config(library=library_config), + per_generator=settings.instructions_per_generator, + ) + + catalog: Dict[str, Sample] = {} + for instrument in spec.instruments: + config = Config(library=library_config) + audio = _render_instrument(synth_config, instrument.synth, sample_rate=sample_rate) + catalog[instrument.name] = make_sample( + instrument.name, + audio, + config, + library, + tmp_dir=tmp_dir, + expected_slices=frozenset(instrument.channels), + ) + + return catalog + + +def _render_instrument( + synth_config: SynthConfig, + name: str, + *, + sample_rate: int, +) -> np.ndarray: + """Renders a named voice at peak level 1.0. + + A fresh channel seeded from the synth configuration keeps every instrument reproducible + independently of catalog order. + """ + voice = synth_config.voices[name] + generator = np.random.default_rng(synth_config.seed) + instrument: np.ndarray = normalize( + voice.render( + sample_rate=sample_rate, + generator=generator, + ) + ) + return instrument diff --git a/src/sampletones_tools/corpus/config/__init__.py b/src/sampletones_tools/corpus/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/config/module.yaml b/src/sampletones_tools/corpus/config/module.yaml similarity index 100% rename from tests/integration/config/module.yaml rename to src/sampletones_tools/corpus/config/module.yaml diff --git a/src/sampletones_tools/corpus/config/reconstruction.yaml b/src/sampletones_tools/corpus/config/reconstruction.yaml new file mode 100644 index 000000000..70b10c633 --- /dev/null +++ b/src/sampletones_tools/corpus/config/reconstruction.yaml @@ -0,0 +1,8 @@ +reconstruction: + spectrum_method: cqt + transformation_gamma: 0 + instructions_per_generator: 32 +instruments: + - { name: kick, synth: kick, channels: [pulse1, triangle] } + - { name: lead, synth: lead, channels: [pulse1, pulse2] } + - { name: hihat, synth: hihat, channels: [noise] } diff --git a/src/sampletones_tools/corpus/config/song.yaml b/src/sampletones_tools/corpus/config/song.yaml new file mode 100644 index 000000000..e3e8b757b --- /dev/null +++ b/src/sampletones_tools/corpus/config/song.yaml @@ -0,0 +1,33 @@ +rows_per_pattern: 8 +order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + - { pulse1: 0, pulse2: 0, triangle: 1, noise: 1 } +channels: + pulse1: + patterns: + 0: + - { row: 0, voice: kick, transpose: 0, volume: 15 } + - { row: 2, "off": true } + - { row: 4, voice: kick, transpose: 12, volume: 12 } + - { row: 6, volume: 8 } + pulse2: + patterns: + 0: + - { row: 0, voice: lead, transpose: 0, volume: 12 } + - { row: 4, voice: lead, transpose: 7, volume: 10 } + triangle: + patterns: + 0: + - { row: 0, voice: kick, transpose: -12, volume: 15 } + 1: + - { row: 0, voice: kick, transpose: 7, volume: 10 } + - { row: 4, voice: kick, transpose: 0, volume: 10 } + noise: + patterns: + 0: + - { row: 0, voice: hihat, volume: 15 } + - { row: 2, voice: hihat, volume: 10 } + - { row: 4, voice: hihat, volume: 15 } + - { row: 6, voice: hihat, volume: 8 } + 1: + - { row: 0, voice: hihat, transpose: 3, volume: 12 } diff --git a/tests/integration/config/synth.yaml b/src/sampletones_tools/corpus/config/synth.yaml similarity index 100% rename from tests/integration/config/synth.yaml rename to src/sampletones_tools/corpus/config/synth.yaml diff --git a/src/sampletones_tools/corpus/module.py b/src/sampletones_tools/corpus/module.py new file mode 100644 index 000000000..c8796d2fc --- /dev/null +++ b/src/sampletones_tools/corpus/module.py @@ -0,0 +1,23 @@ +from typing import Self + +from pydantic import BaseModel, ConfigDict + +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.paths import MODULE_CONFIG_PATH + + +class ModuleConfig(BaseModel): + """Module identity and playback settings that shape the exported files.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + title: str + author: str + tempo: int + speed: int + nes_frequency: int + + @classmethod + def load(cls) -> Self: + """The module settings the package ships.""" + return load_yaml_model(MODULE_CONFIG_PATH, cls) diff --git a/src/sampletones_tools/corpus/paths.py b/src/sampletones_tools/corpus/paths.py new file mode 100644 index 000000000..92c1cf78f --- /dev/null +++ b/src/sampletones_tools/corpus/paths.py @@ -0,0 +1,10 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory + +CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_tools.corpus.config") +SYNTH_CONFIG_PATH: Final[Path] = CONFIG_DIRECTORY / "synth.yaml" +CATALOG_CONFIG_PATH: Final[Path] = CONFIG_DIRECTORY / "reconstruction.yaml" +MODULE_CONFIG_PATH: Final[Path] = CONFIG_DIRECTORY / "module.yaml" +SONG_PATH: Final[Path] = CONFIG_DIRECTORY / "song.yaml" diff --git a/src/sampletones_tools/corpus/song.py b/src/sampletones_tools/corpus/song.py new file mode 100644 index 000000000..4baada121 --- /dev/null +++ b/src/sampletones_tools/corpus/song.py @@ -0,0 +1,134 @@ +from typing import Dict, List, Mapping, Optional, Self, Sequence + +from pydantic import BaseModel, ConfigDict + +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.pitch import Step +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.voice import VoiceUnion, voice_channels +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.paths import SONG_PATH + + +class RowSpec(BaseModel): + """One written row of a pattern: what it plays, or that it releases the note. + + Attributes: + row: The row's index in its pattern. + voice: The voice the row plays, or ``None`` for a row that plays none. + transpose: The semitones the row transposes the voice by, or ``None`` to leave it. + volume: The volume the row sets, or ``None`` to leave it. + off: Whether the row releases the note. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + row: int + voice: Optional[str] = None + transpose: Optional[int] = None + volume: Optional[int] = None + off: bool = False + + +class ChannelSpec(BaseModel): + """The patterns one channel holds, each as the rows written into it.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + patterns: Dict[int, List[RowSpec]] + + +class SongSpec(BaseModel): + """The arrangement: the pattern length, the order and the channels' patterns.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + rows_per_pattern: int + order: List[Dict[ChannelName, int]] + channels: Dict[ChannelName, ChannelSpec] + + @classmethod + def load(cls) -> Self: + """The arrangement the package ships.""" + return load_yaml_model(SONG_PATH, cls) + + +def _order( + frames: Sequence[Mapping[ChannelName, int]], +) -> List[Dict[ChannelName, Optional[int]]]: + return [{channel: frame.get(channel) for channel in ChannelName.items()} for frame in frames] + + +def _row(spec: RowSpec, channel: ChannelName, voices_by_name: Mapping[str, VoiceUnion]) -> Row: + if spec.off: + return Row(command=NoteOff(), volume=spec.volume) + + pitch = Step(value=spec.transpose) if spec.transpose is not None else None + if spec.voice is None: + return Row(pitch=pitch, volume=spec.volume) + + voice = voices_by_name[spec.voice] + if channel not in voice_channels(voice): + raise ValueError(f"Voice '{spec.voice}' has no '{channel.value}' slice for the {channel.value} channel") + + return Row( + command=NoteOn(voice_id=voice.id), + pitch=pitch, + volume=spec.volume, + ) + + +def _pattern( + row_specs: Sequence[RowSpec], + rows_per_pattern: int, + channel: ChannelName, + voices_by_name: Mapping[str, VoiceUnion], +) -> Pattern: + rows = [Row() for _ in range(rows_per_pattern)] + for spec in row_specs: + rows[spec.row] = _row(spec, channel, voices_by_name) + + return Pattern(rows=tuple(rows)) + + +def _channels( + channel_specs: Mapping[ChannelName, ChannelSpec], + rows_per_pattern: int, + voices_by_name: Mapping[str, VoiceUnion], +) -> Dict[ChannelName, Channel]: + channels: Dict[ChannelName, Channel] = {} + for channel, spec in channel_specs.items(): + patterns = { + index: _pattern( + row_specs, + rows_per_pattern, + channel, + voices_by_name, + ) + for index, row_specs in spec.patterns.items() + } + channels[channel] = Channel(name=channel, patterns=patterns) + + for channel in ChannelName.items(): + channels.setdefault(channel, Channel(name=channel, patterns={})) + + return channels + + +def build_song(spec: SongSpec, voices_by_name: Mapping[str, VoiceUnion]) -> Song: + """The song the spec describes, playing the named voices. + + Raises: + KeyError: If a row names a voice the catalog lacks. + ValueError: If a row plays a voice on a channel the voice has no slice for. + """ + return Song( + rows_per_pattern=spec.rows_per_pattern, + order=_order(spec.order), + channels=_channels(spec.channels, spec.rows_per_pattern, voices_by_name), + ) diff --git a/src/sampletones_tools/corpus/synth.py b/src/sampletones_tools/corpus/synth.py new file mode 100644 index 000000000..f7fab5a43 --- /dev/null +++ b/src/sampletones_tools/corpus/synth.py @@ -0,0 +1,21 @@ +from typing import Dict, Self + +from pydantic import BaseModel, ConfigDict + +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.paths import SYNTH_CONFIG_PATH +from sampletones_tools.synthesis.voice.voice import Voice + + +class SynthConfig(BaseModel): + """The named synthesizer voices the corpus is rendered from and their shared noise seed.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + seed: int + voices: Dict[str, Voice] + + @classmethod + def load(cls) -> Self: + """The voices the package ships.""" + return load_yaml_model(SYNTH_CONFIG_PATH, cls) diff --git a/src/sampletones_tools/corpus/written.py b/src/sampletones_tools/corpus/written.py new file mode 100644 index 000000000..a6b74aaa8 --- /dev/null +++ b/src/sampletones_tools/corpus/written.py @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import Final, Mapping, Sequence, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +SINGLE_STEM_ID: Final[int] = 0 + + +def single_recording_record( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + scale: float, +) -> StemsData: + """The single-entry record a classic conversion writes over the frames it chose. + + A frame that sounds answers to the one recording, and a silent frame answers to rest, which is + the rule a reconstruction holds its record to. + + Args: + instructions: What each channel plays, one instruction per frame. + scale: The level the recording was read at. + + Returns: + StemsData: The record naming one owner per frame. + """ + assignments = [ + ChannelAssignment( + channel_name=channel_name, + stem_ids=tuple(SINGLE_STEM_ID if instruction.on else RESTING_STEM_ID for instruction in stream), + ) + for channel_name, stream in instructions.items() + ] + return StemsData.single_entry( + StemSettings.covering(list(instructions)), + assignments, + scale, + ) + + +def written_reconstruction( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + *, + coefficient: float, + scale: float, + audio_filepath: Tuple[Path, ...], +) -> Reconstruction: + """A reconstruction playing the frames it is given, as though one conversion had chosen them. + + Writing the frames out by hand is what lets a corpus state exactly what each channel sounds on + every tick. + + Args: + instructions: What each channel plays, one instruction per frame. + coefficient: The working-level coefficient the recording is said to be scaled by. + scale: The level the recording is said to be read at. + audio_filepath: The recordings the frames are said to come from, empty for none. + + Returns: + Reconstruction: The document holding those frames and the record behind them. + """ + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=coefficient, + audio_filepath=audio_filepath, + stems_data=single_recording_record( + instructions, + scale, + ), + ) diff --git a/src/sampletones_tools/player/__init__.py b/src/sampletones_tools/player/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/player/assembler/__init__.py b/src/sampletones_tools/player/assembler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/player/assembler/builder.py b/src/sampletones_tools/player/assembler/builder.py new file mode 100644 index 000000000..b171534b0 --- /dev/null +++ b/src/sampletones_tools/player/assembler/builder.py @@ -0,0 +1,110 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Final, List + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import DRIVER_CODE_NAME +from sampletones_shared.exceptions import DriverBuildError +from sampletones_tools.player.assembler.labels import read_addresses +from sampletones_tools.player.assembler.layout import ( + ASSEMBLY_DIRECTORY, + INCLUDE_DIRECTORY, + LINKER_CONFIGURATION, + SOURCE_DIRECTORY, + SOURCE_NAMES, +) +from sampletones_tools.player.assembler.toolchain import Toolchain + +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, ASSEMBLY_DIRECTORY, work_directory) + assembled = work_directory / DRIVER_CODE_NAME + labels = work_directory / LABELS_NAME + toolchain.link(ASSEMBLY_DIRECTORY / 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, sources: Path, work_directory: Path) -> List[Path]: + """Assembles every source the driver is built from. + + Args: + toolchain: The cc65 programs the build runs. + sources: The assembly package as a directory: the sources, their includes and the + linker configuration. + 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(sources / SOURCE_DIRECTORY / name, sources / 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_tools/player/assembler/labels.py b/src/sampletones_tools/player/assembler/labels.py new file mode 100644 index 000000000..4001a2c21 --- /dev/null +++ b/src/sampletones_tools/player/assembler/labels.py @@ -0,0 +1,84 @@ +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_ENCODING: Final[str] = "utf-8" +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(encoding=LABEL_ENCODING).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_tools/player/assembler/layout.py b/src/sampletones_tools/player/assembler/layout.py new file mode 100644 index 000000000..73f560912 --- /dev/null +++ b/src/sampletones_tools/player/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.package import package_directory +from sampletones_shared.paths.source import SOURCE_ROOT + +ASSEMBLY_PACKAGE: Final[str] = "sampletones_tools.player.assembly" +INCLUDE_DIRECTORY: Final[str] = "include" +SOURCE_DIRECTORY: Final[str] = "source" +LINKER_CONFIGURATION: Final[str] = "nsf.cfg" +SOURCE_NAMES: Final[Tuple[str, ...]] = ("driver.s", "clock.s", "channels.s") +BINARY_DIRECTORY: Final[Path] = SOURCE_ROOT / "sampletones_player" / "driver" / DRIVER_BINARY_DIRECTORY +ASSEMBLY_DIRECTORY: Final[Path] = package_directory(ASSEMBLY_PACKAGE) diff --git a/src/sampletones_tools/player/assembler/report.py b/src/sampletones_tools/player/assembler/report.py new file mode 100644 index 000000000..f7afd1155 --- /dev/null +++ b/src/sampletones_tools/player/assembler/report.py @@ -0,0 +1,15 @@ +from typing import List + +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import DRIVER_CODE_NAME + + +def layout_lines(image: DriverImage) -> List[str]: + """The layout a build produced, one line per figure a reader checks against the header.""" + addresses = image.addresses + return [ + f"{DRIVER_CODE_NAME} {len(image.code)} bytes, ${addresses.load:04X}-${addresses.song - 1:04X}", + f"init ${addresses.init:04X}", + f"play ${addresses.play:04X}", + f"song ${addresses.song:04X}", + ] diff --git a/src/sampletones_tools/player/assembler/toolchain.py b/src/sampletones_tools/player/assembler/toolchain.py new file mode 100644 index 000000000..f27b47c8c --- /dev/null +++ b/src/sampletones_tools/player/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_tools/player/assembly/__init__.py b/src/sampletones_tools/player/assembly/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/player/assembly/include/nes.inc b/src/sampletones_tools/player/assembly/include/nes.inc new file mode 100644 index 000000000..02073cb05 --- /dev/null +++ b/src/sampletones_tools/player/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_tools/player/assembly/include/song.inc b/src/sampletones_tools/player/assembly/include/song.inc new file mode 100644 index 000000000..ee020b183 --- /dev/null +++ b/src/sampletones_tools/player/assembly/include/song.inc @@ -0,0 +1,74 @@ +WORD_SIZE = 2 +PLANE_COUNT = 10 + +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 +ABSENT_STREAM = $FFFF + +TICK_FINISHED = 0 +TICK_PLAYS = 1 +TICK_REPEATED = 2 + +PITCH_COUNT = 104 +SILENT_PITCH_INDEX = $7F +TRIANGLE_COUNTER = $80 +TRIANGLE_SOUNDING = $7F +TRIANGLE_SILENT = $00 +BEND_FLAG = $80 +PITCH_INDEX_MASK = BEND_FLAG - 1 + +TOKEN_TAG_MASK = $C0 +TOKEN_OPERAND_MASK = $3F +TAG_HOLD = $00 +TAG_LITERAL = $40 +TAG_PHRASE = $80 +TAG_TRANSPOSED_PHRASE = $C0 + +OPCODE_SIZE = 1 +DEFAULT_COUNT_FLAG = $20 +PHRASE_ID_MASK = $1F +PHRASE_ID_ESCAPE = $1F +PHRASE_TABLE_COUNT_SIZE = 1 +PHRASE_TABLE_ENTRY_SIZE = 2 +PHRASE_LENGTH_SIZE = 1 +PHRASE_DEFAULT_SIZE = 1 + +COUNT_STEP = $10 +COUNT_SHIFT = 4 +PULSE_CONTROL_MASK = $CF +PULSE_CONTROL_FIXED = $30 +NOISE_CONTROL_MASK = $0F +NOISE_CONTROL_FIXED = $30 +NOISE_VALUE_MASK = $8F +NOISE_VALUE_FIXED = $00 + +PLANE_STATE_SIZE = 10 +PLANE_SOURCE = 0 +PLANE_PHRASE = 2 +PLANE_PHRASE_TICKS = 4 +PLANE_TOKEN_TICKS = 5 +PLANE_VALUE = 6 +PLANE_SHIFT = 7 +PLANE_REPEATS = 8 +PLANE_COUNT_MASK = 9 + +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_VALUE_PLANE = 6 * PLANE_STATE_SIZE +TRIANGLE_BEND_PLANE = 7 * PLANE_STATE_SIZE +NOISE_CONTROL_PLANE = 8 * PLANE_STATE_SIZE +NOISE_VALUE_PLANE = 9 * PLANE_STATE_SIZE diff --git a/src/sampletones_tools/player/assembly/nsf.cfg b/src/sampletones_tools/player/assembly/nsf.cfg new file mode 100644 index 000000000..f27d950a8 --- /dev/null +++ b/src/sampletones_tools/player/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_tools/player/assembly/source/channels.s b/src/sampletones_tools/player/assembly/source/channels.s new file mode 100644 index 000000000..eb8c15856 --- /dev/null +++ b/src/sampletones_tools/player/assembly/source/channels.s @@ -0,0 +1,457 @@ +.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 +ABSENT_PAGE = $00 +LOWEST_PITCH_INDEX = $00 + +.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 +triangle_pitch: .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" +.assert DEFAULT_COUNT_FLAG = PHRASE_ID_MASK + 1, error, "the count flag stands above every id an opcode names" +.assert >song_data <> ABSENT_PAGE, lderror, "a song loaded in page zero reads as an absent plane" +.assert ABSENT_STREAM = $FFFF, error, "an absent stream is told apart by both its bytes reading $FF" +.assert BEND_FLAG = $80, error, "a value's flag is read as the sign bit" +.assert PITCH_COUNT <= BEND_FLAG, error, "every pitch index fits below the flag" +.assert SILENT_PITCH_INDEX >= PITCH_COUNT, error, "the index standing for silence sounds no pitch" +.assert SILENT_PITCH_INDEX < BEND_FLAG, error, "the index standing for silence bends nowhere" +.assert COUNT_STEP = 1 << COUNT_SHIFT, error, "a repeat count steps from the bit it is shifted down by" +.assert PLANE_STATE_BYTES + PLANE_PHRASE < $100, error, "a plane's phrase pointer is reached in page zero" + +; Readies the tables the planes read through and points every plane at its own first token. +channels_reset: + lda #LOWEST_PITCH_INDEX + sta triangle_pitch + + 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. +; A plane the block leaves out states ABSENT_STREAM, and its source lands in page zero, which no +; song occupies: that page is what marks it absent, and its value stays at zero throughout. +seed_planes: + ldx #$00 + ldy #$00 +@next: + lda (pointer),y + iny + and (pointer),y + cmp #song_data + sta plane_state + PLANE_SOURCE + 1,x + iny + +@empty: + 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 + sta plane_state + PLANE_REPEATS,x + + txa + clc + adc #PLANE_STATE_SIZE + tax + cpx #PLANE_STATE_BYTES + bne @next + +; States what each plane's byte holds: the bits its register leaves for a repeat count, and for +; the triangle the index its value plane stands at while the channel rests. Every other plane +; keeps the zero it was seeded with, which is what makes one symbol cover one tick. +plane_forms: + lda #(PULSE_CONTROL_MASK ^ $FF) + sta plane_state + PULSE1_CONTROL_PLANE + PLANE_COUNT_MASK + sta plane_state + PULSE2_CONTROL_PLANE + PLANE_COUNT_MASK + lda #(NOISE_CONTROL_MASK ^ $FF) + sta plane_state + NOISE_CONTROL_PLANE + PLANE_COUNT_MASK + lda #(NOISE_VALUE_MASK ^ $FF) + sta plane_state + NOISE_VALUE_PLANE + PLANE_COUNT_MASK + lda #SILENT_PITCH_INDEX + sta plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + rts + +; Advances every plane the block holds by one tick of the song. A bend plane advances only on a +; tick its channel's new value flags, since it holds a value for those ticks alone. +channels_advance: + ldx #PULSE1_CONTROL_PLANE + jsr plane_step + ldx #PULSE1_VALUE_PLANE + jsr plane_step + bit plane_state + PULSE1_VALUE_PLANE + PLANE_VALUE + bpl @pulse2 + ldx #PULSE1_BEND_PLANE + jsr plane_step +@pulse2: + ldx #PULSE2_CONTROL_PLANE + jsr plane_step + ldx #PULSE2_VALUE_PLANE + jsr plane_step + bit plane_state + PULSE2_VALUE_PLANE + PLANE_VALUE + bpl @triangle + ldx #PULSE2_BEND_PLANE + jsr plane_step +@triangle: + ldx #TRIANGLE_VALUE_PLANE + jsr plane_step + bit plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + bpl @noise + ldx #TRIANGLE_BEND_PLANE + jsr plane_step +@noise: + ldx #NOISE_CONTROL_PLANE + jsr plane_step + ldx #NOISE_VALUE_PLANE + jmp plane_step + +; Advances the plane at X by one tick where the block holds it, an absent plane standing still. +plane_step: + lda plane_state + PLANE_SOURCE + 1,x + beq @absent + jmp plane_advance +@absent: + 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 onward, which is what carries a note whose envelope has finished. +plane_advance: + lda plane_state + PLANE_REPEATS,x + beq @symbol + dec plane_state + PLANE_REPEATS,x + rts +@symbol: + 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: + lda plane_state + PLANE_VALUE,x + and plane_state + PLANE_COUNT_MASK,x + lsr + lsr + lsr + lsr + sta plane_state + PLANE_REPEATS,x + 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 #PHRASE_ID_MASK + cmp #PHRASE_ID_ESCAPE + bne @named + lda (pointer),y + iny +@named: + jsr set_phrase + lda opcode + and #DEFAULT_COUNT_FLAG + bne @shifts + lda (pointer),y + iny + sta plane_state + PLANE_TOKEN_TICKS,x +@shifts: + 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 + iny + lda (entry),y + sta plane_state + PLANE_TOKEN_TICKS,x + + clc + lda plane_state + PLANE_PHRASE,x + adc #(PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE) + sta plane_state + PLANE_PHRASE,x + bcc @body + inc plane_state + PLANE_PHRASE + 1,x +@body: + pla + tay + rts + +; Writes what every plane last played to the registers its channel owns. The triangle names its +; silence in the pitch its value plane carries: the index standing above every pitch the table +; holds silences the linear counter and leaves the divider where the channel last sounded. +channels_write: + lda plane_state + PULSE1_CONTROL_PLANE + PLANE_VALUE + and #PULSE_CONTROL_MASK + ora #PULSE_CONTROL_FIXED + sta CHANNEL_CONTROL + PULSE1_REGISTERS + lda plane_state + PULSE1_BEND_PLANE + PLANE_VALUE + sta bend + ldx #PULSE1_REGISTERS + lda plane_state + PULSE1_VALUE_PLANE + PLANE_VALUE + jsr write_timer + + lda plane_state + PULSE2_CONTROL_PLANE + PLANE_VALUE + and #PULSE_CONTROL_MASK + ora #PULSE_CONTROL_FIXED + sta CHANNEL_CONTROL + PULSE2_REGISTERS + lda plane_state + PULSE2_BEND_PLANE + PLANE_VALUE + sta bend + ldx #PULSE2_REGISTERS + lda plane_state + PULSE2_VALUE_PLANE + PLANE_VALUE + jsr write_timer + + lda plane_state + TRIANGLE_VALUE_PLANE + PLANE_VALUE + cmp #SILENT_PITCH_INDEX + beq @resting + sta triangle_pitch + lda #TRIANGLE_COUNTER | TRIANGLE_SOUNDING + bne @counter +@resting: + lda #TRIANGLE_COUNTER | TRIANGLE_SILENT +@counter: + sta CHANNEL_CONTROL + TRIANGLE_REGISTERS + lda plane_state + TRIANGLE_BEND_PLANE + PLANE_VALUE + sta bend + ldx #TRIANGLE_REGISTERS + lda triangle_pitch + jsr write_timer + + lda plane_state + NOISE_CONTROL_PLANE + PLANE_VALUE + and #NOISE_CONTROL_MASK + ora #NOISE_CONTROL_FIXED + sta CHANNEL_CONTROL + NOISE_REGISTERS + lda plane_state + NOISE_VALUE_PLANE + PLANE_VALUE + and #NOISE_VALUE_MASK + ora #NOISE_VALUE_FIXED + sta CHANNEL_TIMER_LOW + NOISE_REGISTERS + rts + +; Writes the timer the value byte in A names to the channel whose register base lies in X, the +; byte loaded last so its sign stands in N. The low seven bits index the pitch table, and the top +; bit says the tick carries the bend standing in `bend`; an unflagged tick sounds its pitch's own +; divider. 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: + bmi @bent + ldy #$00 + sty bend + tay + jmp @indexed +@bent: + and #PITCH_INDEX_MASK + tay +@indexed: + 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_tools/player/assembly/source/clock.s b/src/sampletones_tools/player/assembly/source/clock.s new file mode 100644 index 000000000..8e8f3abe6 --- /dev/null +++ b/src/sampletones_tools/player/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_tools/player/assembly/source/driver.s b/src/sampletones_tools/player/assembly/source/driver.s new file mode 100644 index 000000000..775715dc0 --- /dev/null +++ b/src/sampletones_tools/player/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_tools/player/command.py b/src/sampletones_tools/player/command.py new file mode 100644 index 000000000..620d4ccb4 --- /dev/null +++ b/src/sampletones_tools/player/command.py @@ -0,0 +1,60 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional + +from sampletones_shared.command import Command + +NAME: Final[str] = "driver" +HELP: Final[str] = "assemble the NES player driver with cc65" +OUTPUT_HELP: Final[str] = "the directory receiving the assembled driver; without it, the driver the package ships" + + +@dataclass(frozen=True) +class DriverArguments: + """What a driver build is given: where the image goes, if anywhere but the package.""" + + output: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument("--output", "-o", type=Path, default=None, help=OUTPUT_HELP) + + +def run(arguments: Namespace) -> int: + """Assembles the driver and prints the layout the build produced. + + Writing the driver the package ships needs a checkout, since that is where the package is. + + Raises: + SystemExit: If the build runs outside a checkout without an output of its own, or fails. + """ + given = DriverArguments(output=arguments.output) + + from sampletones_tools.checkout import require_checkout + + if given.output is None: + require_checkout(NAME) + + from sampletones_shared.exceptions.player import DriverBuildError + from sampletones_tools.player.assembler.builder import build_driver + from sampletones_tools.player.assembler.layout import BINARY_DIRECTORY + from sampletones_tools.player.assembler.report import layout_lines + + try: + image = build_driver(given.output if given.output is not None else BINARY_DIRECTORY) + except DriverBuildError as error: + raise SystemExit(str(error)) from error + + for line in layout_lines(image): + print(line) + + return 0 + + +DRIVER: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/player/trace/__init__.py b/src/sampletones_tools/player/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/player/trace/trace.py b/src/sampletones_tools/player/trace/trace.py new file mode 100644 index 000000000..954aa1e82 --- /dev/null +++ b/src/sampletones_tools/player/trace/trace.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.registers.base import ChannelRegisters +from sampletones_player.song import Song +from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES +from sampletones_player.specification.registers import ( + 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_tools.player.trace.write import RegisterWrite + +FIRST_TICK: Final[int] = 0 + + +def setup_writes() -> Tuple[RegisterWrite, ...]: + """The writes the init routine makes before it sounds the song's first tick. + + It clears every channel register, enables the four channels, runs the frame counter without an + interrupt, turns both sweep units off and loads the noise channel's length counter. + + Returns: + Tuple[RegisterWrite, ...]: The writes, in the order the routine makes them. + """ + writes = [ + RegisterWrite(address, SILENCED_REGISTER) + for address in range(FIRST_CHANNEL_REGISTER, LAST_CHANNEL_REGISTER + 1) + ] + writes.extend( + ( + RegisterWrite(APU_STATUS, CHANNELS_ENABLED), + RegisterWrite(APU_FRAME_COUNTER, FRAME_COUNTER_SEQUENCE), + RegisterWrite(PULSE1_SWEEP, SWEEP_DISABLED), + RegisterWrite(PULSE2_SWEEP, SWEEP_DISABLED), + RegisterWrite(NOISE_LENGTH_COUNTER, NOISE_LENGTH_COUNTER_LOAD), + ) + ) + return tuple(writes) + + +def channel_writes(registers: Sequence[ChannelRegisters]) -> Tuple[RegisterWrite, ...]: + """Every register the four channels' values of one tick reach, each channel in the order the driver writes it. + + Args: + registers: One register set per channel, in channel order. + + Returns: + Tuple[RegisterWrite, ...]: One write per register the channels own. + """ + return tuple( + RegisterWrite(address, value) + for channel, channel_registers in zip(ChannelName.items(), registers, strict=True) + for address, value in zip(CHANNEL_REGISTER_ADDRESSES[channel], channel_registers.values, strict=True) + ) + + +@dataclass(frozen=True) +class RegisterTrace: + """Every APU register write a run of the driver makes, grouped by the call that makes it. + + 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 afterward 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 write in channel_writes(song.streams.at(tick)): + if write.address in REGISTERS_WRITTEN_ON_CHANGE: + if shadows.get(write.address) == write.value: + continue + + shadows[write.address] = write.value + + writes.append(write) + + return tuple(writes) + + @classmethod + def _initialization_writes(cls, song: Song, shadows: Dict[int, int]) -> Tuple[RegisterWrite, ...]: + return setup_writes() + cls._tick_writes(song, FIRST_TICK, shadows) + + @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_tools/player/trace/write.py b/src/sampletones_tools/player/trace/write.py new file mode 100644 index 000000000..ef07da300 --- /dev/null +++ b/src/sampletones_tools/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_tools/py.typed b/src/sampletones_tools/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/registry.py b/src/sampletones_tools/registry.py new file mode 100644 index 000000000..79d5dc780 --- /dev/null +++ b/src/sampletones_tools/registry.py @@ -0,0 +1,24 @@ +from typing import Final, Tuple + +from sampletones_shared.command import Command +from sampletones_tools.calibration.command import CALIBRATION +from sampletones_tools.checks.command import CHECK +from sampletones_tools.codec.command import CODEC +from sampletones_tools.compatibility.command import COMPATIBILITY +from sampletones_tools.player.command import DRIVER +from sampletones_tools.samples.commands.btp import BTP +from sampletones_tools.samples.commands.ftm import FTM +from sampletones_tools.samples.commands.nsf import NSF +from sampletones_tools.tracker_playback.command import TRACKER_PLAYBACK + +DEVELOPER_COMMANDS: Final[Tuple[Command, ...]] = ( + BTP, + CALIBRATION, + CHECK, + CODEC, + COMPATIBILITY, + DRIVER, + FTM, + NSF, + TRACKER_PLAYBACK, +) diff --git a/src/sampletones_tools/runs.py b/src/sampletones_tools/runs.py new file mode 100644 index 000000000..a80b7553d --- /dev/null +++ b/src/sampletones_tools/runs.py @@ -0,0 +1,46 @@ +from datetime import UTC, datetime +from pathlib import Path +from typing import Final + +RUN_STAMP: Final[str] = "run-%Y%m%d-%H%M%S" +BOARD_STAMP: Final[str] = "page-%Y%m%d-%H%M%S" + + +def stamped_run_directory(root: Path) -> Path: + """The directory a measurement run lands in under ``root``, named by the moment it starts. + + Args: + root: The directory the runs of one measurement share. + + Returns: + Path: The run's directory, which the run creates. + """ + return stamped_directory(root, RUN_STAMP) + + +def stamped_board_directory(root: Path) -> Path: + """The directory a page comparing runs lands in under ``root``, named by the moment it is built. + + Args: + root: The directory the pages of one measurement share. + + Returns: + Path: The page's directory, which the build creates. + """ + return stamped_directory(root, BOARD_STAMP) + + +def stamped_directory(root: Path, stamp: str) -> Path: + """The directory of one moment under ``root``, named by the stamp it is given. + + The stamp is UTC and sorts by name, so the directories under one root read in the order they + were made. + + Args: + root: The directory the results of one measurement share. + stamp: The ``strftime`` pattern the directory is named by. + + Returns: + Path: The directory, which its writer creates. + """ + return root / datetime.now(UTC).strftime(stamp) diff --git a/src/sampletones_tools/samples/__init__.py b/src/sampletones_tools/samples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/samples/bitphase.py b/src/sampletones_tools/samples/bitphase.py new file mode 100644 index 000000000..d8debf72c --- /dev/null +++ b/src/sampletones_tools/samples/bitphase.py @@ -0,0 +1,39 @@ +from pathlib import Path +from typing import Final, List + +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.project.project import Project +from sampletones_tools.corpus.build import Corpus + +DOCUMENT_FILENAME: Final[str] = "drums.btp" +GROOVE_DOCUMENT_FILENAME: Final[str] = "drums-groove.btp" +GROOVE_TEMPO: Final[int] = 210 + + +def at_tempo(project: Project, tempo: int) -> Project: + """The same project played at another tempo, leaving the given project as it is.""" + return Project( + metadata=project.metadata, + info=project.info, + settings=project.settings.model_copy(update={"tempo": tempo}), + voices=project.voices, + song=project.song, + ) + + +def write_samples(corpus: Corpus, output: Path) -> List[Path]: + """Writes the corpus arrangement as two Bitphase documents: at its own tempo and as a groove. + + Args: + corpus: The samples and the arrangement. + output: The directory the documents are written into. + + Returns: + List[Path]: The document at the song's tempo, then the one carrying a groove. + """ + document = output / DOCUMENT_FILENAME + write_btp(document, project_to_bitphase(corpus.project)) + groove_document = output / GROOVE_DOCUMENT_FILENAME + write_btp(groove_document, project_to_bitphase(at_tempo(corpus.project, GROOVE_TEMPO))) + return [document, groove_document] diff --git a/src/sampletones_tools/samples/commands/__init__.py b/src/sampletones_tools/samples/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/samples/commands/btp.py b/src/sampletones_tools/samples/commands/btp.py new file mode 100644 index 000000000..fb7dfb124 --- /dev/null +++ b/src/sampletones_tools/samples/commands/btp.py @@ -0,0 +1,45 @@ +from argparse import ArgumentParser, Namespace +from typing import Final + +from sampletones_shared.command import Command +from sampletones_tools.samples.commands.options import ( + ACTION_FIELD, + ACTION_METAVAR, + SAMPLES, + SamplesArguments, + add_output_option, + print_written, +) + +NAME: Final[str] = "btp" +HELP: Final[str] = "write example Bitphase documents (.btp) from the synthetic corpus" +SAMPLES_HELP: Final[str] = "write the corpus arrangement as Bitphase documents (.btp), at its own tempo and as a groove" + + +def configure(parser: ArgumentParser) -> None: + actions = parser.add_subparsers( + dest=ACTION_FIELD, + metavar=ACTION_METAVAR, + required=True, + ) + add_output_option( + actions.add_parser(SAMPLES, help=SAMPLES_HELP, description=SAMPLES_HELP), + ) + + +def run(arguments: Namespace) -> int: + """Builds the corpus, writes its arrangement and prints each file written.""" + given = SamplesArguments(output=arguments.output) + + from sampletones_tools.samples.bitphase import write_samples + from sampletones_tools.samples.emit import emit_samples + + return print_written(emit_samples(given.output, write_samples)) + + +BTP: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/samples/commands/ftm.py b/src/sampletones_tools/samples/commands/ftm.py new file mode 100644 index 000000000..aacbc783e --- /dev/null +++ b/src/sampletones_tools/samples/commands/ftm.py @@ -0,0 +1,45 @@ +from argparse import ArgumentParser, Namespace +from typing import Final + +from sampletones_shared.command import Command +from sampletones_tools.samples.commands.options import ( + ACTION_FIELD, + ACTION_METAVAR, + SAMPLES, + SamplesArguments, + add_output_option, + print_written, +) + +NAME: Final[str] = "ftm" +HELP: Final[str] = "write an example FamiTracker module (.ftm) from the synthetic corpus" +SAMPLES_HELP: Final[str] = "write the corpus arrangement as a FamiTracker module (.ftm)" + + +def configure(parser: ArgumentParser) -> None: + actions = parser.add_subparsers( + dest=ACTION_FIELD, + metavar=ACTION_METAVAR, + required=True, + ) + add_output_option( + actions.add_parser(SAMPLES, help=SAMPLES_HELP, description=SAMPLES_HELP), + ) + + +def run(arguments: Namespace) -> int: + """Builds the corpus, writes its arrangement and prints each file written.""" + given = SamplesArguments(output=arguments.output) + + from sampletones_tools.samples.emit import emit_samples + from sampletones_tools.samples.famitracker import write_samples + + return print_written(emit_samples(given.output, write_samples)) + + +FTM: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/samples/commands/nsf.py b/src/sampletones_tools/samples/commands/nsf.py new file mode 100644 index 000000000..7671686d9 --- /dev/null +++ b/src/sampletones_tools/samples/commands/nsf.py @@ -0,0 +1,106 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +from sampletones_shared.command import Command +from sampletones_tools.samples.commands.options import ( + ACTION_FIELD, + ACTION_METAVAR, + SAMPLES, + SamplesArguments, + add_output_option, + print_written, +) + +NAME: Final[str] = "nsf" +HELP: Final[str] = "write example .nsf files from the synthetic corpus and render .nsf files to waves" +SAMPLES_HELP: Final[str] = "write each corpus sample and the corpus arrangement as .nsf files" +RENDER: Final[str] = "render" +RENDER_HELP: Final[str] = "render the .nsf files in a directory to waves through ffmpeg's libgme demuxer" +DIRECTORY_HELP: Final[str] = "the directory holding the exported .nsf files" +TAIL_HELP: Final[str] = "seconds kept past the end of each song" +DEFAULT_TAIL_SECONDS: Final[float] = 0.5 + + +@dataclass(frozen=True) +class RenderArguments: + """What a render is given: the directory of exported files and the tail each wave keeps.""" + + directory: Path + tail: float + + +def configure(parser: ArgumentParser) -> None: + actions = parser.add_subparsers( + dest=ACTION_FIELD, + metavar=ACTION_METAVAR, + required=True, + ) + samples = actions.add_parser( + SAMPLES, + help=SAMPLES_HELP, + description=SAMPLES_HELP, + ) + add_output_option(samples) + render = actions.add_parser( + RENDER, + help=RENDER_HELP, + description=RENDER_HELP, + ) + render.add_argument( + "--directory", + type=Path, + required=True, + help=DIRECTORY_HELP, + ) + render.add_argument( + "--tail", + type=float, + default=DEFAULT_TAIL_SECONDS, + help=TAIL_HELP, + ) + + +def run(arguments: Namespace) -> int: + """Writes the example files or renders exported ones, as the action names.""" + if arguments.action == SAMPLES: + return _write_samples(SamplesArguments(output=arguments.output)) + + return _render( + RenderArguments(directory=arguments.directory, tail=arguments.tail), + ) + + +def _write_samples(given: SamplesArguments) -> int: + from sampletones_tools.samples.emit import emit_samples + from sampletones_tools.samples.nsf import write_samples + + return print_written(emit_samples(given.output, write_samples)) + + +def _render(given: RenderArguments) -> int: + """Renders the exported files and prints each wave written. + + Raises: + SystemExit: If ffmpeg is unusable or rejects a file. + """ + from sampletones_tools.samples.render import RenderingError, render_directory + + try: + rendered = render_directory(given.directory, given.tail) + except RenderingError as error: + raise SystemExit(str(error)) from error + + for wave in rendered: + print(f"{wave.destination} {wave.seconds:.3f} s") + + return 0 + + +NSF: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/samples/commands/options.py b/src/sampletones_tools/samples/commands/options.py new file mode 100644 index 000000000..1b4343472 --- /dev/null +++ b/src/sampletones_tools/samples/commands/options.py @@ -0,0 +1,29 @@ +from argparse import ArgumentParser +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Sequence + +ACTION_FIELD: Final[str] = "action" +ACTION_METAVAR: Final[str] = "" +SAMPLES: Final[str] = "samples" +OUTPUT_HELP: Final[str] = "the directory the files are written into, created when missing" + + +@dataclass(frozen=True) +class SamplesArguments: + """What an emitter run is given: the directory its files are written into.""" + + output: Path + + +def add_output_option(parser: ArgumentParser) -> None: + """Adds the required output directory an emitter writes into.""" + parser.add_argument("--output", "-o", type=Path, required=True, help=OUTPUT_HELP) + + +def print_written(paths: Sequence[Path]) -> int: + """Prints each file an emitter wrote and returns the command's success status.""" + for path in paths: + print(f"Wrote {path}") + + return 0 diff --git a/src/sampletones_tools/samples/emit.py b/src/sampletones_tools/samples/emit.py new file mode 100644 index 000000000..d334d1255 --- /dev/null +++ b/src/sampletones_tools/samples/emit.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Callable, List + +from sampletones_tools.corpus.build import Corpus, build_synthetic_corpus + +Emitter = Callable[[Corpus, Path], List[Path]] + + +def emit_samples(output: Path, emitter: Emitter) -> List[Path]: + """Builds the synthetic corpus and hands it to an emitter writing into ``output``. + + Args: + output: The directory the files are written into, created when missing. + emitter: The writer of one format. + + Returns: + List[Path]: The files the emitter wrote. + """ + corpus = build_synthetic_corpus() + output.mkdir(parents=True, exist_ok=True) + return emitter(corpus, output) diff --git a/src/sampletones_tools/samples/famitracker.py b/src/sampletones_tools/samples/famitracker.py new file mode 100644 index 000000000..a3dba6459 --- /dev/null +++ b/src/sampletones_tools/samples/famitracker.py @@ -0,0 +1,22 @@ +from pathlib import Path +from typing import Final, List + +from sampletones_core.formats.famitracker.export import write_ftm +from sampletones_tools.corpus.build import Corpus + +MODULE_FILENAME: Final[str] = "drums.ftm" + + +def write_samples(corpus: Corpus, output: Path) -> List[Path]: + """Writes the corpus arrangement as one FamiTracker module. + + Args: + corpus: The samples and the arrangement. + output: The directory the module is written into. + + Returns: + List[Path]: The module written. + """ + destination = output / MODULE_FILENAME + write_ftm(destination, corpus.project) + return [destination] diff --git a/src/sampletones_tools/samples/nsf.py b/src/sampletones_tools/samples/nsf.py new file mode 100644 index 000000000..834879c13 --- /dev/null +++ b/src/sampletones_tools/samples/nsf.py @@ -0,0 +1,54 @@ +from pathlib import Path +from typing import Final, List + +from sampletones_core.exports.request import ProjectExport +from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.driver.image import DriverImage +from sampletones_player.export.backend import NSFBackend +from sampletones_player.nsf.file import write_nsf +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from sampletones_tools.corpus.build import Corpus + +SONG_NAME: Final[str] = "song" + + +def exported_information(name: str, author: str) -> NSFInformation: + """The header text an exported sample carries: its own name, by the author of the arrangement.""" + return NSFInformation(title=name, artist=author) + + +def write_samples(corpus: Corpus, output: Path) -> List[Path]: + """Writes each corpus sample as a program of its own, then the arrangement through the backend. + + Args: + corpus: The samples and the arrangement. + output: The directory the files are written into. + + Returns: + List[Path]: The files written, the samples first and the arrangement last. + """ + image = DriverImage.load() + written: List[Path] = [] + for name, sample in corpus.catalog.items(): + destination = output / f"{name}{EXT_FILE_NSF}" + write_nsf( + destination, + song_from_reconstruction( + sample.reconstruction, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ), + exported_information(name, corpus.project.info.author), + image, + ) + written.append(destination) + + arrangement = output / f"{SONG_NAME}{EXT_FILE_NSF}" + NSFBackend.stated().write_project( + arrangement, + ProjectExport(project=corpus.project), + ) + written.append(arrangement) + return written diff --git a/src/sampletones_tools/samples/render.py b/src/sampletones_tools/samples/render.py new file mode 100644 index 000000000..8da4f30da --- /dev/null +++ b/src/sampletones_tools/samples/render.py @@ -0,0 +1,183 @@ +import struct +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List + +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.exceptions import SampleToNESError +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 + +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 require_renderer() -> None: + """Holds a render to an ffmpeg that decodes exported files. + + Raises: + RenderingError: If ffmpeg is absent, naming how this system installs it, or carries no + libgme demuxer. + """ + if locate_program(FFMPEG) is None: + raise RenderingError( + missing_program_message(FFMPEG, RENDER_PURPOSE, INSTALL_HINTS), + ) + + if not decodes_exports(): + raise RenderingError( + f"{FFMPEG} reports no {GME_FORMAT} demuxer; rendering needs a build made with --enable-libgme" + ) + + +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 render_directory(directory: Path, tail_seconds: float) -> List[RenderedWave]: + """Renders every exported file in a directory to a wave beside it. + + Args: + directory: The directory holding the `.nsf` files. + tail_seconds: How much to keep past the end of each song. + + Returns: + List[RenderedWave]: The waves written, in path order. + + Raises: + RenderingError: If ffmpeg is unusable, the directory holds no exported file, or ffmpeg + rejects one. + """ + require_renderer() + sources = sorted(directory.glob(f"*{EXT_FILE_NSF}")) + if not sources: + raise RenderingError(f"no {EXT_FILE_NSF} files in {directory}") + + code_length = len(DriverImage.load().code) + rendered: List[RenderedWave] = [] + for source in sources: + destination = source.with_suffix(EXT_FILE_WAVE) + seconds = song_seconds(source.read_bytes(), code_length) + tail_seconds + try: + render(source, destination, seconds) + except subprocess.CalledProcessError as error: + raise RenderingError(f"{FFMPEG} rejected {source}: exit status {error.returncode}") from error + + rendered.append( + RenderedWave( + source=source, + destination=destination, + seconds=seconds, + ) + ) + + return rendered diff --git a/src/sampletones_synthesis/__init__.py b/src/sampletones_tools/synthesis/__init__.py similarity index 100% rename from src/sampletones_synthesis/__init__.py rename to src/sampletones_tools/synthesis/__init__.py diff --git a/src/sampletones_tools/synthesis/envelopes/__init__.py b/src/sampletones_tools/synthesis/envelopes/__init__.py new file mode 100644 index 000000000..db80316b3 --- /dev/null +++ b/src/sampletones_tools/synthesis/envelopes/__init__.py @@ -0,0 +1,15 @@ +from .exponential_decay import ExponentialDecayEnvelope +from .gate import GateEnvelope +from .linear_attack import LinearAttackEnvelope +from .linear_ramp import LinearRampEnvelope +from .periodic_decay import PeriodicDecayEnvelope +from .types import EnvelopeUnion + +__all__ = [ + "EnvelopeUnion", + "ExponentialDecayEnvelope", + "GateEnvelope", + "LinearAttackEnvelope", + "LinearRampEnvelope", + "PeriodicDecayEnvelope", +] diff --git a/src/sampletones_synthesis/envelopes/exponential_decay.py b/src/sampletones_tools/synthesis/envelopes/exponential_decay.py similarity index 100% rename from src/sampletones_synthesis/envelopes/exponential_decay.py rename to src/sampletones_tools/synthesis/envelopes/exponential_decay.py diff --git a/src/sampletones_tools/synthesis/envelopes/gate.py b/src/sampletones_tools/synthesis/envelopes/gate.py new file mode 100644 index 000000000..b33f5d01e --- /dev/null +++ b/src/sampletones_tools/synthesis/envelopes/gate.py @@ -0,0 +1,33 @@ +from typing import Literal, Self + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class GateEnvelope(BaseModel): + """Full level from the start of the gate until its end, silence outside it.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["gate"] + start_seconds: float = Field(ge=0.0, description="When the gate opens, in seconds.") + end_seconds: float = Field(gt=0.0, description="When the gate closes, in seconds.") + + @model_validator(mode="after") + def _validate_span(self) -> Self: + if self.end_seconds <= self.start_seconds: + raise ValueError("A gate closes after it opens") + + return self + + def render(self, time: np.ndarray) -> np.ndarray: + """ + Render the gate over the time axis. + + Args: + time: Sample times in seconds, float64. + + Returns: + The multiplicative envelope as float64, 1 inside the gate and 0 outside it. + """ + return ((time >= self.start_seconds) & (time < self.end_seconds)).astype(np.float64) diff --git a/src/sampletones_synthesis/envelopes/linear_attack.py b/src/sampletones_tools/synthesis/envelopes/linear_attack.py similarity index 97% rename from src/sampletones_synthesis/envelopes/linear_attack.py rename to src/sampletones_tools/synthesis/envelopes/linear_attack.py index 9a620aea4..68474e2c4 100644 --- a/src/sampletones_synthesis/envelopes/linear_attack.py +++ b/src/sampletones_tools/synthesis/envelopes/linear_attack.py @@ -5,7 +5,7 @@ class LinearAttackEnvelope(BaseModel): - """Linear rise from 0 to full level over the attack, holding 1 afterwards.""" + """Linear rise from 0 to full level over the attack, holding 1 afterward.""" model_config = ConfigDict(frozen=True, extra="forbid") diff --git a/src/sampletones_synthesis/envelopes/linear_ramp.py b/src/sampletones_tools/synthesis/envelopes/linear_ramp.py similarity index 100% rename from src/sampletones_synthesis/envelopes/linear_ramp.py rename to src/sampletones_tools/synthesis/envelopes/linear_ramp.py diff --git a/src/sampletones_tools/synthesis/envelopes/periodic_decay.py b/src/sampletones_tools/synthesis/envelopes/periodic_decay.py new file mode 100644 index 000000000..16f0cb329 --- /dev/null +++ b/src/sampletones_tools/synthesis/envelopes/periodic_decay.py @@ -0,0 +1,29 @@ +from typing import Literal + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field + + +class PeriodicDecayEnvelope(BaseModel): + """An exponential decay struck again every period, silent until the first strike.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["periodic_decay"] + period_seconds: float = Field(gt=0.0, description="Time between strikes in seconds.") + time_constant_seconds: float = Field(gt=0.0, description="Time constant of each decay in seconds.") + delay_seconds: float = Field(ge=0.0, description="When the first strike falls, in seconds.") + + def render(self, time: np.ndarray) -> np.ndarray: + """ + Render the strikes over the time axis. + + Args: + time: Sample times in seconds, float64. + + Returns: + The multiplicative envelope as float64 in [0, 1], at full level on every strike. + """ + elapsed = time - self.delay_seconds + since_strike = np.mod(elapsed, self.period_seconds) + return np.where(elapsed >= 0.0, np.exp(-since_strike / self.time_constant_seconds), 0.0) diff --git a/src/sampletones_tools/synthesis/envelopes/types.py b/src/sampletones_tools/synthesis/envelopes/types.py new file mode 100644 index 000000000..6148cca1c --- /dev/null +++ b/src/sampletones_tools/synthesis/envelopes/types.py @@ -0,0 +1,20 @@ +from typing import Annotated, Union + +from pydantic import Field + +from .exponential_decay import ExponentialDecayEnvelope +from .gate import GateEnvelope +from .linear_attack import LinearAttackEnvelope +from .linear_ramp import LinearRampEnvelope +from .periodic_decay import PeriodicDecayEnvelope + +EnvelopeUnion = Annotated[ + Union[ + ExponentialDecayEnvelope, + GateEnvelope, + LinearAttackEnvelope, + LinearRampEnvelope, + PeriodicDecayEnvelope, + ], + Field(discriminator="kind"), +] diff --git a/src/sampletones_synthesis/filters/__init__.py b/src/sampletones_tools/synthesis/filters/__init__.py similarity index 100% rename from src/sampletones_synthesis/filters/__init__.py rename to src/sampletones_tools/synthesis/filters/__init__.py diff --git a/src/sampletones_synthesis/filters/butterworth_highpass.py b/src/sampletones_tools/synthesis/filters/butterworth_highpass.py similarity index 100% rename from src/sampletones_synthesis/filters/butterworth_highpass.py rename to src/sampletones_tools/synthesis/filters/butterworth_highpass.py diff --git a/src/sampletones_synthesis/filters/types.py b/src/sampletones_tools/synthesis/filters/types.py similarity index 100% rename from src/sampletones_synthesis/filters/types.py rename to src/sampletones_tools/synthesis/filters/types.py diff --git a/src/sampletones_synthesis/frequency.py b/src/sampletones_tools/synthesis/frequency.py similarity index 83% rename from src/sampletones_synthesis/frequency.py rename to src/sampletones_tools/synthesis/frequency.py index 36f46d085..f6a8fb700 100644 --- a/src/sampletones_synthesis/frequency.py +++ b/src/sampletones_tools/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: @@ -18,7 +18,9 @@ def _require_hertz(value: Any) -> Any: ValueError: If the value is an integer. """ if isinstance(value, int) and not isinstance(value, bool): - raise ValueError("An integer frequency specification is a MIDI pitch and must lie in the pitch range") + raise ValueError( # noqa: TRY004 + "An integer frequency specification is a MIDI pitch and must lie in the pitch range" + ) return value diff --git a/src/sampletones_synthesis/oscillators/__init__.py b/src/sampletones_tools/synthesis/oscillators/__init__.py similarity index 100% rename from src/sampletones_synthesis/oscillators/__init__.py rename to src/sampletones_tools/synthesis/oscillators/__init__.py diff --git a/src/sampletones_synthesis/oscillators/exponential_glide.py b/src/sampletones_tools/synthesis/oscillators/exponential_glide.py similarity index 95% rename from src/sampletones_synthesis/oscillators/exponential_glide.py rename to src/sampletones_tools/synthesis/oscillators/exponential_glide.py index 31254045a..fef55d880 100644 --- a/src/sampletones_synthesis/oscillators/exponential_glide.py +++ b/src/sampletones_tools/synthesis/oscillators/exponential_glide.py @@ -3,7 +3,7 @@ import numpy as np from pydantic import BaseModel, ConfigDict, Field -from sampletones_synthesis.frequency import FrequencySpec, resolve_frequency +from sampletones_tools.synthesis.frequency import FrequencySpec, resolve_frequency class ExponentialGlideOscillator(BaseModel): diff --git a/src/sampletones_synthesis/oscillators/geometric_sweep.py b/src/sampletones_tools/synthesis/oscillators/geometric_sweep.py similarity index 95% rename from src/sampletones_synthesis/oscillators/geometric_sweep.py rename to src/sampletones_tools/synthesis/oscillators/geometric_sweep.py index db207f3c8..8fdd899c7 100644 --- a/src/sampletones_synthesis/oscillators/geometric_sweep.py +++ b/src/sampletones_tools/synthesis/oscillators/geometric_sweep.py @@ -3,7 +3,7 @@ import numpy as np from pydantic import BaseModel, ConfigDict -from sampletones_synthesis.frequency import FrequencySpec, resolve_frequency +from sampletones_tools.synthesis.frequency import FrequencySpec, resolve_frequency class GeometricSweepOscillator(BaseModel): diff --git a/src/sampletones_tools/synthesis/oscillators/pulse.py b/src/sampletones_tools/synthesis/oscillators/pulse.py new file mode 100644 index 000000000..bfaca5790 --- /dev/null +++ b/src/sampletones_tools/synthesis/oscillators/pulse.py @@ -0,0 +1,39 @@ +from typing import Annotated, Literal + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_tools.synthesis.frequency import FrequencySpec, resolve_frequency + +DutyCycle = Annotated[float, Field(gt=0.0, lt=1.0)] + + +class PulseOscillator(BaseModel): + """Rectangular wave alternating between +1 and -1 at the resolved frequency.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["pulse"] + frequency: FrequencySpec + duty_cycle: DutyCycle = Field(description="Fraction of each period spent at +1.") + + def render( + self, + time: np.ndarray, + *, + generator: np.random.Generator, # pylint: disable=unused-argument + ) -> np.ndarray: + """ + Render the pulse over the time axis. + + Produces identical output for every generator state. + + Args: + time: Sample times in seconds, float64. + generator: Random source shared across a render pass. + + Returns: + The waveform as float64 taking values in {-1, 1}. + """ + phase = (resolve_frequency(self.frequency) * time) % 1.0 + return np.where(phase < self.duty_cycle, 1.0, -1.0) diff --git a/src/sampletones_synthesis/oscillators/sine.py b/src/sampletones_tools/synthesis/oscillators/sine.py similarity index 91% rename from src/sampletones_synthesis/oscillators/sine.py rename to src/sampletones_tools/synthesis/oscillators/sine.py index 3e2e96ed3..9abeb685e 100644 --- a/src/sampletones_synthesis/oscillators/sine.py +++ b/src/sampletones_tools/synthesis/oscillators/sine.py @@ -3,7 +3,7 @@ import numpy as np from pydantic import BaseModel, ConfigDict -from sampletones_synthesis.frequency import FrequencySpec, resolve_frequency +from sampletones_tools.synthesis.frequency import FrequencySpec, resolve_frequency class SineOscillator(BaseModel): diff --git a/src/sampletones_synthesis/oscillators/types.py b/src/sampletones_tools/synthesis/oscillators/types.py similarity index 100% rename from src/sampletones_synthesis/oscillators/types.py rename to src/sampletones_tools/synthesis/oscillators/types.py diff --git a/src/sampletones_synthesis/oscillators/walk_noise.py b/src/sampletones_tools/synthesis/oscillators/walk_noise.py similarity index 100% rename from src/sampletones_synthesis/oscillators/walk_noise.py rename to src/sampletones_tools/synthesis/oscillators/walk_noise.py diff --git a/src/sampletones_synthesis/oscillators/white_noise.py b/src/sampletones_tools/synthesis/oscillators/white_noise.py similarity index 100% rename from src/sampletones_synthesis/oscillators/white_noise.py rename to src/sampletones_tools/synthesis/oscillators/white_noise.py diff --git a/src/sampletones_synthesis/protocols.py b/src/sampletones_tools/synthesis/protocols.py similarity index 100% rename from src/sampletones_synthesis/protocols.py rename to src/sampletones_tools/synthesis/protocols.py diff --git a/src/sampletones_synthesis/voice/__init__.py b/src/sampletones_tools/synthesis/voice/__init__.py similarity index 100% rename from src/sampletones_synthesis/voice/__init__.py rename to src/sampletones_tools/synthesis/voice/__init__.py diff --git a/src/sampletones_synthesis/voice/layer.py b/src/sampletones_tools/synthesis/voice/layer.py similarity index 90% rename from src/sampletones_synthesis/voice/layer.py rename to src/sampletones_tools/synthesis/voice/layer.py index 31b8429d7..5ea85b401 100644 --- a/src/sampletones_synthesis/voice/layer.py +++ b/src/sampletones_tools/synthesis/voice/layer.py @@ -3,8 +3,8 @@ import numpy as np from pydantic import BaseModel, ConfigDict, Field -from sampletones_synthesis.envelopes.types import EnvelopeUnion -from sampletones_synthesis.oscillators.types import OscillatorUnion +from sampletones_tools.synthesis.envelopes.types import EnvelopeUnion +from sampletones_tools.synthesis.oscillators.types import OscillatorUnion class Layer(BaseModel): diff --git a/src/sampletones_tools/synthesis/voice/voice.py b/src/sampletones_tools/synthesis/voice/voice.py new file mode 100644 index 000000000..89187ebcd --- /dev/null +++ b/src/sampletones_tools/synthesis/voice/voice.py @@ -0,0 +1,76 @@ +from typing import Final, Tuple + +import numpy as np +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_tools.synthesis.filters.types import FilterUnion + +from .layer import Layer + +MINIMUM_RENDER_SAMPLES: Final[int] = 2 + + +class Voice(BaseModel): + """ + A complete synthesized sound: layered oscillators summed and filtered. + + Layers render independently over one shared time axis and sum in float64; + filters then shape the sum in order. The output is raw unit-scale audio — + loudness policy (peak normalization or amplitude scaling) belongs to the + caller, so one voice definition serves every loudness convention. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + duration_seconds: float = Field(gt=0.0, description="Length of the rendered sound in seconds.") + layers: Tuple[Layer, ...] = Field(min_length=1, description="Components summed into the voice.") + filters: Tuple[FilterUnion, ...] = Field(description="Spectral shaping applied to the layer sum, in order.") + + def render( + self, + *, + sample_rate: int, + generator: np.random.Generator, + ) -> np.ndarray: + """ + Render the voice at a sample rate. + + Layers draw from the generator in declaration order, so a fixed seed + reproduces the voice exactly. + + Args: + sample_rate: Sampling rate in Hz. + generator: Random source consumed by stochastic oscillators. + + Returns: + The rendered waveform as float64. + + Raises: + ValueError: If the duration yields fewer than two samples. + """ + time = self._timeline(sample_rate) + audio = np.zeros(time.shape[0], dtype=np.float64) + for layer in self.layers: + audio = audio + layer.render(time, generator=generator) + + for audio_filter in self.filters: + audio = audio_filter.apply(audio, sample_rate=sample_rate) + + return audio + + def _timeline(self, sample_rate: int) -> np.ndarray: + """ + Sample times in seconds for the configured duration. + + Raises: + ValueError: If the duration yields fewer than two samples, the + minimum for span-dependent components (ramps, sweeps, walks). + """ + length = round(self.duration_seconds * sample_rate) + if length < MINIMUM_RENDER_SAMPLES: + raise ValueError( + f"Voice of {self.duration_seconds}s at {sample_rate}Hz yields {length} samples; " + f"at least {MINIMUM_RENDER_SAMPLES} are required" + ) + + return np.arange(length, dtype=np.float64) / sample_rate diff --git a/src/sampletones_tools/tracker_playback/__init__.py b/src/sampletones_tools/tracker_playback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/command.py b/src/sampletones_tools/tracker_playback/command.py new file mode 100644 index 000000000..4d32fe4a0 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/command.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Final, List, Optional, Tuple + +from sampletones_shared.command import Command +from sampletones_tools.tracker_playback.commands.bitphase import BITPHASE +from sampletones_tools.tracker_playback.commands.face import TargetFace +from sampletones_tools.tracker_playback.commands.famitracker import FAMITRACKER + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.projects import CheckedProject + +NAME: Final[str] = "tracker-playback" +HELP: Final[str] = ( + "export projects to a tracker, play each file with the tracker's own code, " + "and report every tick a channel sounds differently from the app" +) +TARGET_FIELD: Final[str] = "target" +TARGET_METAVAR: Final[str] = "" +PROJECT_HELP: Final[str] = ( + "a project file (.stp) to check; repeat it to check several. Without it, the run checks the corpus " + "that comes with the package" +) +PROJECT_METAVAR: Final[str] = "" +OUTPUT_HELP: Final[str] = ( + "the directory the run writes into; without it, a timestamped directory under " + "Documents/SampleToNES/tracker-playback" +) +TARGETS: Final[Tuple[TargetFace, ...]] = (BITPHASE, FAMITRACKER) +TARGETS_BY_NAME: Final[Dict[str, TargetFace]] = {face.name: face for face in TARGETS} + + +@dataclass(frozen=True) +class PlaybackArguments: + """What a check is given beside its target's own options. + + Attributes: + target: The target's name. + projects: The project files to check, or none to check the corpus. + output: Where the run writes, or ``None`` for a timestamped directory. + """ + + target: str + projects: Tuple[Path, ...] + output: Optional[Path] + + +def configure(parser: ArgumentParser) -> None: + targets = parser.add_subparsers( + dest=TARGET_FIELD, + metavar=TARGET_METAVAR, + required=True, + ) + for face in TARGETS: + target = targets.add_parser( + face.name, + help=face.help, + description=face.help, + ) + face.configure(target) + target.add_argument( + "--project", + dest="projects", + type=Path, + action="append", + default=None, + metavar=PROJECT_METAVAR, + help=PROJECT_HELP, + ) + target.add_argument( + "--output", + "-o", + type=Path, + default=None, + help=OUTPUT_HELP, + ) + + +def run(arguments: Namespace) -> int: + """Plays the projects given, or the corpus, through the target the command names, and prints each verdict. + + The report's link follows the verdicts. + + Raises: + SystemExit: If a project file fails to open, the target's tracker or what runs it is missing, or + the tracker fails to play a file. + """ + given = PlaybackArguments( + target=arguments.target, + projects=tuple(arguments.projects) if arguments.projects is not None else (), + output=arguments.output, + ) + face = TARGETS_BY_NAME[given.target] + + from sampletones_tools.tracker_playback.report import result_label + from sampletones_tools.tracker_playback.session import ( + PlaybackRun, + check_projects, + default_output, + ) + from sampletones_tools.tracker_playback.settings import PlaybackSettings + from sampletones_tools.tracker_playback.targets.protocol import PlaybackError + + try: + target = face.locate(arguments) + outcome = check_projects( + checked_projects(given.projects), + PlaybackRun( + target=target, + output=given.output if given.output is not None else default_output(), + settings=PlaybackSettings.load(), + ), + ) + except PlaybackError as error: + raise SystemExit(str(error)) from error + + for project in outcome.outcomes: + print(f"{project.project.name}: {result_label(project.comparison)}") + + print(f"Report: {outcome.report.resolve().as_uri()}") + return 0 + + +def checked_projects(paths: Tuple[Path, ...]) -> List[CheckedProject]: + """The projects saved in ``paths``, or the corpus that comes with the package where none is given. + + Raises: + SystemExit: If a project file fails to open, naming why. + """ + from sampletones_shared.exceptions.project import LoadProjectError + from sampletones_tools.tracker_playback.corpus.build import comparison_corpus + from sampletones_tools.tracker_playback.corpus.spec import CorpusSpec + from sampletones_tools.tracker_playback.projects import loaded_projects + + if not paths: + return comparison_corpus(CorpusSpec.load()) + + try: + return loaded_projects(paths) + except (LoadProjectError, OSError) as error: + raise SystemExit(str(error)) from error + + +TRACKER_PLAYBACK: Final[Command] = Command( + name=NAME, + help=HELP, + configure=configure, + run=run, +) diff --git a/src/sampletones_tools/tracker_playback/commands/__init__.py b/src/sampletones_tools/tracker_playback/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/commands/bitphase.py b/src/sampletones_tools/tracker_playback/commands/bitphase.py new file mode 100644 index 000000000..e7c8dd16f --- /dev/null +++ b/src/sampletones_tools/tracker_playback/commands/bitphase.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from sampletones_tools.tracker_playback.commands.face import TargetFace + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget + +NAME: Final[str] = "bitphase" +HELP: Final[str] = ( + "export each project to a Bitphase document (.btp) and play it with the engine of Bitphase's source code" +) +DIRECTORY_HELP: Final[str] = ( + "the directory holding Bitphase's source code, its packages installed, whose engine plays the documents" +) + + +@dataclass(frozen=True) +class BitphaseArguments: + """What the Bitphase target is given: the directory of the source code whose engine plays the documents.""" + + directory: Path + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + "--directory", + type=Path, + required=True, + help=DIRECTORY_HELP, + ) + + +def locate(arguments: Namespace) -> PlaybackTarget: + """The Bitphase target playing documents with the source code in the directory the options name. + + Raises: + EngineError: If node is absent, or the directory lacks a file the trace loads. + """ + given = BitphaseArguments(directory=arguments.directory) + + from sampletones_tools.tracker_playback.targets.bitphase.target import BitphaseTarget + + return BitphaseTarget.located(given.directory) + + +BITPHASE: Final[TargetFace] = TargetFace( + name=NAME, + help=HELP, + configure=configure, + locate=locate, +) diff --git a/src/sampletones_tools/tracker_playback/commands/face.py b/src/sampletones_tools/tracker_playback/commands/face.py new file mode 100644 index 000000000..49a1c0d6a --- /dev/null +++ b/src/sampletones_tools/tracker_playback/commands/face.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget + + +@dataclass(frozen=True) +class TargetFace: + """The command-line face of one playback target: its name, its help, its options and how it is found. + + Listing the commands reads every face, so a face states its target's options and reaches the target + itself once a check runs. + + Attributes: + name: The target's name, as the command line spells it. + help: One line saying what the target exports to and what plays it. + configure: Adds the options the target needs, such as where its tracker lives. + locate: Builds the target from the parsed options, once its tracker is found. + """ + + name: str + help: str + configure: Callable[[ArgumentParser], None] + locate: Callable[[Namespace], PlaybackTarget] diff --git a/src/sampletones_tools/tracker_playback/commands/famitracker.py b/src/sampletones_tools/tracker_playback/commands/famitracker.py new file mode 100644 index 000000000..548c85134 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/commands/famitracker.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from sampletones_tools.tracker_playback.commands.face import TargetFace + +if TYPE_CHECKING: + from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget + +NAME: Final[str] = "famitracker" +HELP: Final[str] = ( + "export each project to a FamiTracker module (.ftm), have FamiTracker export it to an NSF, " + "and play the NSF's own driver on an emulated 6502" +) +EXECUTABLE_HELP: Final[str] = "FamiTracker.exe, which Wine runs on Linux and macOS" + + +@dataclass(frozen=True) +class FamiTrackerArguments: + """What the FamiTracker target is given: the program that exports the modules.""" + + executable: Path + + +def configure(parser: ArgumentParser) -> None: + parser.add_argument( + "--executable", + type=Path, + required=True, + help=EXECUTABLE_HELP, + ) + + +def locate(arguments: Namespace) -> PlaybackTarget: + """The FamiTracker target exporting with the program the options name. + + Raises: + FamiTrackerError: If the program is no Windows program file, or Wine is absent where it is needed. + """ + given = FamiTrackerArguments(executable=arguments.executable) + + from sampletones_tools.tracker_playback.targets.famitracker.target import FamiTrackerTarget + + return FamiTrackerTarget.located(given.executable) + + +FAMITRACKER: Final[TargetFace] = TargetFace( + name=NAME, + help=HELP, + configure=configure, + locate=locate, +) diff --git a/src/sampletones_tools/tracker_playback/comparison.py b/src/sampletones_tools/tracker_playback/comparison.py new file mode 100644 index 000000000..9cd69d6d9 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/comparison.py @@ -0,0 +1,263 @@ +from dataclasses import dataclass, replace +from enum import StrEnum +from typing import Dict, List, Optional, Self, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, SongTrace, TickPosition + + +class SoundField(StrEnum): + AUDIBLE = "audible" + PERIOD = "period" + VOLUME = "volume" + TIMBRE = "timbre" + + +@dataclass(frozen=True) +class DivergentTick: + """One tick a channel sounds differently on, with what each side sounds. + + Attributes: + tick: The tick, counted from the song's first. + position: Where the application places the tick. + application: What the application sounds. + engine: What the tracker sounds. + """ + + tick: int + position: TickPosition + application: ChannelSound + engine: ChannelSound + + +@dataclass(frozen=True) +class Divergence: + """One way a channel sounds differently in the tracker, from the first tick it shows onward. + + Ticks that differ in the same fields of the same channel share one divergence, so each field a + cause strikes is reported once with how often it strikes. Two causes striking the same field, + such as a pitch off by a step and a transpose left out, show apart in the examples: a tick joins + them where it opens a row the examples have yet to reach and the two sides sound values the last + example lacks. + + Attributes: + channel: The channel that sounds differently. + fields: What differs: whether it sounds at all, or which of its registers. + examples: The first tick it shows on, then the ticks that change what the sides sound. + ticks: How many ticks show the same difference. + """ + + channel: ChannelName + fields: Tuple[SoundField, ...] + examples: Tuple[DivergentTick, ...] + ticks: int + + @property + def first(self) -> DivergentTick: + """The first tick the difference shows on.""" + return self.examples[0] + + def counted( + self, + divergent: DivergentTick, + *, + examples: int, + ) -> Self: + """The divergence once one more tick shows it, that tick joining the examples where it tells more. + + Args: + divergent: The tick. + examples: How many examples the divergence keeps at most. + + Returns: + Self: The divergence, counting the tick. + """ + last = self.examples[-1] + telling = ( + len(self.examples) < examples + and divergent.position != last.position + and (divergent.application, divergent.engine) != (last.application, last.engine) + ) + return replace( + self, + examples=self.examples + (divergent,) if telling else self.examples, + ticks=self.ticks + 1, + ) + + +@dataclass(frozen=True) +class TimingDivergence: + """The first tick the two players place at different rows. + + Attributes: + tick: The tick. + position: Where the application places it. + engine_position: Where the tracker places it. + """ + + tick: int + position: TickPosition + engine_position: TickPosition + + +@dataclass(frozen=True) +class TraceComparison: + """How one pass through a song sounds in a tracker against how it sounds in the application. + + Attributes: + application_ticks: The ticks the application plays. + engine_ticks: The ticks the tracker plays. + timing: The first tick the two place at different rows, or ``None`` where every shared tick + falls on the same row. + divergences: Every way a channel sounds differently, in the order they first show. + """ + + application_ticks: int + engine_ticks: int + timing: Optional[TimingDivergence] + divergences: Tuple[Divergence, ...] + + @property + def matches(self) -> bool: + """Whether the tracker plays every tick the application plays, and sounds each alike.""" + return self.application_ticks == self.engine_ticks and self.timing is None and not self.divergences + + +def differing_fields( + application: ChannelSound, + engine: ChannelSound, +) -> Tuple[SoundField, ...]: + """What differs between two sounds of one channel on one tick. + + A channel silent on both sides sounds alike whatever its registers hold, and one that sounds on + one side alone differs in that and nothing further. The volume covers whether the chip holds the + level, so a channel its own envelope or counters move differs in volume from one it holds. + + Args: + application: What the application sounds. + engine: What the tracker sounds. + + Returns: + Tuple[SoundField, ...]: The fields that differ, empty where the two sound alike. + """ + if not application.audible and not engine.audible: + return () + + if application.audible != engine.audible: + return (SoundField.AUDIBLE,) + + compared = ( + (SoundField.PERIOD, application.period, engine.period), + (SoundField.VOLUME, (application.volume, application.held), (engine.volume, engine.held)), + (SoundField.TIMBRE, application.timbre, engine.timbre), + ) + return tuple(field for field, expected, played in compared if expected != played) + + +def first_timing_divergence( + application: SongTrace, + engine: SongTrace, +) -> Optional[TimingDivergence]: + """The first tick both players reach and place at different rows. + + Args: + application: What the application plays. + engine: What the tracker plays. + + Returns: + Optional[TimingDivergence]: That tick, or ``None`` where every shared tick falls alike. + """ + for tick, (position, engine_position) in enumerate(zip(application.positions, engine.positions)): + if position != engine_position: + return TimingDivergence( + tick=tick, + position=position, + engine_position=engine_position, + ) + + return None + + +def channel_divergences( + channel: ChannelName, + application: SongTrace, + engine: SongTrace, + *, + examples: int, +) -> List[Divergence]: + """Every way one channel sounds differently across the ticks both players reach. + + Args: + channel: The channel compared. + application: What the application plays. + engine: What the tracker plays. + examples: How many examples each divergence keeps at most. + + Returns: + List[Divergence]: One entry per set of differing fields, in the order they first show. + """ + found: Dict[Tuple[SoundField, ...], Divergence] = {} + shared = min(application.ticks, engine.ticks) + for tick in range(shared): + expected = application.channels[channel][tick] + played = engine.channels[channel][tick] + fields = differing_fields(expected, played) + if not fields: + continue + + divergent = DivergentTick( + tick=tick, + position=application.positions[tick], + application=expected, + engine=played, + ) + seen = found.get(fields) + found[fields] = ( + Divergence( + channel=channel, + fields=fields, + examples=(divergent,), + ticks=1, + ) + if seen is None + else seen.counted(divergent, examples=examples) + ) + + return list(found.values()) + + +def compare_traces( + application: SongTrace, + engine: SongTrace, + *, + examples: int, +) -> TraceComparison: + """Holds what a tracker plays of a song against what the application plays of it, tick by tick. + + Tick 0 is the song's first tick on both sides, so a tick index names the same moment wherever the + rows fall. Each tick of each channel is compared once both sides have been read into registers. + + Args: + application: What the application plays. + engine: What the tracker plays. + examples: How many examples each divergence keeps at most. + + Returns: + TraceComparison: The lengths, the first tick the rows part, and every divergence. + """ + divergences = [ + divergence + for channel in ChannelName.items() + for divergence in channel_divergences( + channel, + application, + engine, + examples=examples, + ) + ] + return TraceComparison( + application_ticks=application.ticks, + engine_ticks=engine.ticks, + timing=first_timing_divergence(application, engine), + divergences=tuple(sorted(divergences, key=lambda divergence: divergence.first.tick)), + ) diff --git a/src/sampletones_tools/tracker_playback/config/__init__.py b/src/sampletones_tools/tracker_playback/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/config/corpus.yaml b/src/sampletones_tools/tracker_playback/config/corpus.yaml new file mode 100644 index 000000000..557d38f0d --- /dev/null +++ b/src/sampletones_tools/tracker_playback/config/corpus.yaml @@ -0,0 +1,508 @@ +voices: + square: + kind: sample + channels: + pulse1: + - { count: 48, frame: { "on": true, pitch: 60, volume: 15, duty_cycle: 2 } } + pulse2: + - { count: 48, frame: { "on": true, pitch: 67, volume: 12, duty_cycle: 1 } } + bass: + kind: sample + channels: + triangle: + - { count: 100, frame: { "on": true, pitch: 48 } } + hiss: + kind: sample + channels: + noise: + - { count: 48, frame: { "on": true, period: 6, volume: 14, short: false } } + buzz: + kind: sample + channels: + noise: + - { count: 24, frame: { "on": true, period: 11, volume: 12, short: true } } + contour: + kind: sample + channels: + pulse1: + - { count: 2, frame: { "on": true, pitch: 60, volume: 15, duty_cycle: 2 } } + - { count: 2, frame: { "on": true, pitch: 64, volume: 14, duty_cycle: 2, detune: 3 } } + - { count: 2, frame: { "on": true, pitch: 67, volume: 13, duty_cycle: 1, detune: -5 } } + - { count: 2, frame: { "on": true, pitch: 72, volume: 12, duty_cycle: 0, coarse_detune: 1 } } + - { count: 2, frame: { "on": true, pitch: 67, volume: 11, duty_cycle: 3, detune: 7, coarse_detune: -1 } } + - { count: 1, frame: { "on": false, pitch: 67, volume: 0, duty_cycle: 3 } } + - { count: 3, frame: { "on": true, pitch: 55, volume: 10, duty_cycle: 2, detune: -2 } } + - { count: 4, frame: { "on": true, pitch: 62, volume: 9, duty_cycle: 1 } } + pulse2: + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: -8 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: -4 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: 4 } } + - { count: 2, frame: { "on": true, pitch: 69, volume: 12, duty_cycle: 2, detune: 8 } } + - { count: 2, frame: { "on": true, pitch: 71, volume: 10, duty_cycle: 1, coarse_detune: 2 } } + - { count: 2, frame: { "on": true, pitch: 71, volume: 10, duty_cycle: 1, coarse_detune: -2 } } + - { count: 4, frame: { "on": true, pitch: 64, volume: 8, duty_cycle: 0 } } + triangle: + - { count: 3, frame: { "on": true, pitch: 45 } } + - { count: 3, frame: { "on": true, pitch: 45, detune: 6 } } + - { count: 3, frame: { "on": true, pitch: 52, detune: -4 } } + - { count: 3, frame: { "on": true, pitch: 57, coarse_detune: 1 } } + - { count: 2, frame: { "on": false, pitch: 57 } } + - { count: 4, frame: { "on": true, pitch: 40 } } + noise: + - { count: 2, frame: { "on": true, period: 3, volume: 15, short: false } } + - { count: 2, frame: { "on": true, period: 5, volume: 13, short: false } } + - { count: 2, frame: { "on": true, period: 8, volume: 11, short: true } } + - { count: 2, frame: { "on": true, period: 12, volume: 9, short: true } } + - { count: 2, frame: { "on": true, period: 15, volume: 7, short: false } } + - { count: 2, frame: { "on": true, period: 0, volume: 5, short: false } } + - { count: 2, frame: { "on": false, period: 0, volume: 0, short: false } } + - { count: 4, frame: { "on": true, period: 9, volume: 10, short: true } } + low: + kind: sample + channels: + pulse1: + - { count: 24, frame: { "on": true, pitch: 36, volume: 15, duty_cycle: 2 } } + triangle: + - { count: 24, frame: { "on": true, pitch: 36 } } + edges: + kind: sample + channels: + pulse1: + - { count: 12, frame: { "on": true, pitch: 34, volume: 15, duty_cycle: 2, coarse_detune: 10 } } + - { count: 12, frame: { "on": true, pitch: 30, volume: 15, duty_cycle: 2, detune: 5 } } + triangle: + - { count: 12, frame: { "on": true, pitch: 110, detune: -30 } } + - { count: 12, frame: { "on": true, pitch: 119, coarse_detune: -1 } } + long: + kind: sample + channels: + pulse1: + - { count: 100, frame: { "on": true, pitch: 60, volume: 15, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 13, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 11, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 9, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 7, duty_cycle: 2 } } + - { count: 100, frame: { "on": true, pitch: 60, volume: 5, duty_cycle: 2 } } + lead: + kind: instrument + initial_pitch: 64 + initial_period: 5 + envelopes: + volume: { items: [15, 14, 13, 12, 11, 10, 9, 8] } + arpeggio: { items: [0, 4, 7], loop_point: 0 } + duty_cycle: { items: [0, 2], loop_point: 0 } + pitch: { items: [0, 3, 6, 3, 0, -3, -6, -3], loop_point: 0 } + pluck: + kind: instrument + initial_pitch: 57 + initial_period: 4 + envelopes: + volume: { items: [15, 12, 9, 6, 3, 0] } + hi_pitch: { items: [0, 1, 2] } + swell: + kind: instrument + initial_pitch: 52 + initial_period: 7 + envelopes: + arpeggio: { items: [0, 12], loop_point: 0 } + duty_cycle: { items: [1] } + +projects: + - name: every-channel + purpose: A note on every channel, each playing its frames out and falling silent once they end. + module: { title: Every channel, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss] + song: + rows_per_pattern: 12 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: { patterns: { 0: [{ row: 0, voice: square, volume: 15 }] } } + pulse2: { patterns: { 0: [{ row: 0, voice: square, volume: 15 }] } } + triangle: { patterns: { 0: [{ row: 0, voice: bass, volume: 15 }] } } + noise: { patterns: { 0: [{ row: 0, voice: hiss, volume: 15 }] } } + + - name: note-after-quiet-row + purpose: A note whose row states no volume, after a row that lowered the channel's volume. + module: { title: Note after a quiet row, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, pulse2: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 5 } + - { row: 4, voice: square } + pulse2: + patterns: + 0: + - { row: 0, voice: square } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 4 } + - { row: 4, voice: hiss } + + - name: volume-rows + purpose: Rows stating a volume alone, bending the level of a sounding note, silencing it and bringing it back. + module: { title: Volume rows, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 1, volume: 12 } + - { row: 2, volume: 7 } + - { row: 3, volume: 0 } + - { row: 4, volume: 10 } + - { row: 5, volume: 3 } + - { row: 6, volume: 1 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 1, volume: 12 } + - { row: 2, volume: 7 } + - { row: 3, volume: 0 } + - { row: 4, volume: 10 } + - { row: 5, volume: 3 } + - { row: 6, volume: 1 } + + - name: transpose-rows + purpose: Rows stating a transpose and no note, which move the pitch of the note already sounding. + module: { title: Transpose rows, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 2, transpose: 5 } + - { row: 4, transpose: -3 } + - { row: 6, transpose: 7, volume: 9 } + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 15 } + - { row: 3, transpose: 12 } + - { row: 5, transpose: -5 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 2, transpose: 4 } + - { row: 4, transpose: 12 } + + - name: triangle-volumes + purpose: A sounding triangle under every row volume from 0 to 15. + module: { title: Triangle volumes, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [bass] + song: + rows_per_pattern: 16 + order: + - { triangle: 0 } + channels: + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 0 } + - { row: 1, volume: 1 } + - { row: 2, volume: 2 } + - { row: 3, volume: 3 } + - { row: 4, volume: 4 } + - { row: 5, volume: 5 } + - { row: 6, volume: 6 } + - { row: 7, volume: 7 } + - { row: 8, volume: 8 } + - { row: 9, volume: 9 } + - { row: 10, volume: 10 } + - { row: 11, volume: 11 } + - { row: 12, volume: 12 } + - { row: 13, volume: 13 } + - { row: 14, volume: 14 } + - { row: 15, volume: 15 } + + - name: noise-periods + purpose: Noise notes at several periods in both modes, with transposes wrapping past either end of the sixteen periods. + module: { title: Noise periods, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [hiss, buzz] + song: + rows_per_pattern: 12 + order: + - { noise: 0 } + channels: + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 1, voice: hiss, transpose: 3, volume: 15 } + - { row: 2, voice: hiss, transpose: 9, volume: 15 } + - { row: 3, voice: hiss, transpose: 10, volume: 15 } + - { row: 4, voice: hiss, transpose: -6, volume: 15 } + - { row: 5, voice: hiss, transpose: -7, volume: 15 } + - { row: 6, voice: hiss, transpose: 25, volume: 15 } + - { row: 7, voice: hiss, transpose: -22, volume: 15 } + - { row: 8, voice: buzz, volume: 15 } + - { row: 9, voice: buzz, transpose: 4, volume: 15 } + - { row: 10, voice: buzz, transpose: 5, volume: 15 } + - { row: 11, voice: buzz, transpose: -12, volume: 15 } + + - name: note-offs + purpose: Note-offs on every channel, a volume row on a released channel, and notes after them. + module: { title: Note-offs, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 2, "off": true } + - { row: 3, voice: square, volume: 11 } + - { row: 5, "off": true } + - { row: 6, volume: 10 } + - { row: 7, voice: square, volume: 13 } + pulse2: + patterns: + 0: + - { row: 1, voice: square, volume: 15 } + - { row: 4, "off": true } + - { row: 6, voice: square, volume: 9 } + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 15 } + - { row: 2, "off": true } + - { row: 3, voice: bass, volume: 15 } + - { row: 5, "off": true } + - { row: 7, voice: bass, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 2, "off": true } + - { row: 3, voice: hiss, volume: 11 } + - { row: 5, "off": true } + - { row: 6, volume: 10 } + - { row: 7, voice: hiss, volume: 13 } + + - name: instrument-channels + purpose: Instruments on every channel, with looping envelopes, a release, and a volume left to the channel. + module: { title: Instruments on every channel, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [lead, pluck, swell] + song: + rows_per_pattern: 16 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: lead, volume: 15 } + - { row: 8, voice: pluck, volume: 15 } + pulse2: + patterns: + 0: + - { row: 0, voice: swell, volume: 12 } + - { row: 8, voice: lead, transpose: 5, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: lead, transpose: -12, volume: 15 } + - { row: 8, voice: swell, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: lead, volume: 15 } + - { row: 4, voice: pluck, volume: 15 } + - { row: 8, voice: swell, transpose: 3, volume: 10 } + - { row: 12, voice: lead, transpose: -4, volume: 15 } + + - name: carried-pitch + purpose: Instruments placed without a pitch, taking the pitch a transposed sample left on the channel and bent afterwards, or starting nothing after a note-off and on a silent channel. + module: { title: Carried pitch, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss, lead, swell] + song: + rows_per_pattern: 16 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, transpose: 3 } + - { row: 4, voice: lead } + - { row: 8, transpose: 2 } + - { row: 11, "off": true } + - { row: 13, voice: lead } + pulse2: + patterns: + 0: + - { row: 0, voice: lead } + - { row: 6, voice: square, transpose: -2 } + - { row: 10, voice: lead } + triangle: + patterns: + 0: + - { row: 0, voice: bass, transpose: 5 } + - { row: 6, voice: swell } + noise: + patterns: + 0: + - { row: 0, voice: hiss, transpose: 2 } + - { row: 6, voice: lead } + + - name: sample-contours + purpose: A sample whose frames carry arpeggio contours, bends, rests and duty changes on every channel, played as written and transposed. + module: { title: Sample contours, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [contour] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: 5, volume: 15 } + pulse2: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: -7, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: 12, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: contour, volume: 15 } + - { row: 4, voice: contour, transpose: 2, volume: 15 } + + - name: groove-order + purpose: A tempo whose rows last unequal ticks, under an order that revisits patterns and leaves a channel out of a frame. + module: { title: Groove and order, author: Tracker playback check, tempo: 210, speed: 6, nes_frequency: 60 } + voices: [square, bass, hiss, buzz] + song: + rows_per_pattern: 16 + order: + - { pulse1: 0, pulse2: 1, triangle: 0, noise: 0 } + - { pulse1: 1, pulse2: 0, triangle: 1, noise: 0 } + - { pulse1: 0, triangle: 0, noise: 1 } + - { pulse1: 1, pulse2: 1, triangle: 1, noise: 1 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: square, volume: 15 } + - { row: 8, voice: square, transpose: 7, volume: 12 } + 1: + - { row: 0, voice: square, transpose: -5, volume: 10 } + - { row: 12, "off": true } + pulse2: + patterns: + 0: + - { row: 4, voice: square, volume: 15 } + 1: + - { row: 0, voice: square, transpose: 12, volume: 11 } + - { row: 10, "off": true } + triangle: + patterns: + 0: + - { row: 0, voice: bass, volume: 15 } + 1: + - { row: 0, voice: bass, transpose: 7, volume: 15 } + - { row: 8, voice: bass, volume: 15 } + noise: + patterns: + 0: + - { row: 0, voice: hiss, volume: 15 } + - { row: 4, voice: hiss, volume: 12 } + - { row: 8, voice: hiss, volume: 15 } + - { row: 12, voice: hiss, volume: 12 } + 1: + - { row: 0, voice: buzz, volume: 15 } + - { row: 6, voice: buzz, transpose: 3, volume: 15 } + + - name: low-transpose + purpose: Transposes pushing a note below A-0, where every note sounds the longest timer the register holds. + module: { title: Low transpose, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [low] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, triangle: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: low, volume: 15 } + - { row: 2, voice: low, transpose: -5, volume: 15 } + - { row: 4, voice: low, transpose: -12, volume: 15 } + - { row: 6, voice: low, transpose: -3, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: low, transpose: -8, volume: 15 } + - { row: 4, voice: low, transpose: -2, volume: 15 } + + - name: bend-limits + purpose: Bends pushing a note past the longest and the shortest timer the register holds, where the note sounds that timer. + module: { title: Bend limits, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [edges] + song: + rows_per_pattern: 8 + order: + - { pulse1: 0, triangle: 0 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: edges, volume: 15 } + triangle: + patterns: + 0: + - { row: 0, voice: edges, volume: 15 } + + - name: long-slice + purpose: A slice whose volume changes over more values than a tracker instrument holds, which the export shortens and reports. + module: { title: Long slice, author: Tracker playback check, tempo: 150, speed: 6, nes_frequency: 60 } + voices: [long] + song: + rows_per_pattern: 64 + order: + - { pulse1: 0 } + - { pulse1: 1 } + channels: + pulse1: + patterns: + 0: + - { row: 0, voice: long, volume: 15 } + 1: [] + +arrangements: + - name: arrangement + purpose: The synthetic corpus arrangement, reconstructed from rendered audio, at its own tempo. + tempo: 150 + - name: arrangement-groove + purpose: The synthetic corpus arrangement at a tempo whose rows last unequal ticks. + tempo: 210 diff --git a/src/sampletones_tools/tracker_playback/config/settings.yaml b/src/sampletones_tools/tracker_playback/config/settings.yaml new file mode 100644 index 000000000..25a120a44 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/config/settings.yaml @@ -0,0 +1 @@ +examples_per_difference: 6 diff --git a/src/sampletones_tools/tracker_playback/corpus/__init__.py b/src/sampletones_tools/tracker_playback/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/corpus/build.py b/src/sampletones_tools/tracker_playback/corpus/build.py new file mode 100644 index 000000000..f0338b3d7 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/corpus/build.py @@ -0,0 +1,75 @@ +from typing import List + +from sampletones_core.project.project import Project +from sampletones_tools.corpus.build import build_project, build_synthetic_corpus +from sampletones_tools.samples.bitphase import at_tempo +from sampletones_tools.tracker_playback.corpus.spec import ArrangementSpec, CorpusSpec, ProjectSpec +from sampletones_tools.tracker_playback.corpus.voices import build_voice +from sampletones_tools.tracker_playback.projects import CheckedProject + + +def written_project( + spec: ProjectSpec, + corpus: CorpusSpec, +) -> CheckedProject: + """The project a spec writes out, holding fresh copies of the corpus voices it lists. + + Args: + spec: The project. + corpus: The corpus whose voices the project draws on. + + Returns: + CheckedProject: The project, with its name and purpose. + + Raises: + KeyError: If the project lists a voice the corpus lacks, or a row names a voice the project + leaves out. + """ + voices = {name: build_voice(name, corpus.voices[name]) for name in spec.voices} + return CheckedProject( + name=spec.name, + purpose=spec.purpose, + project=build_project( + voices, + spec.module, + spec.song, + ), + ) + + +def arrangement_project( + spec: ArrangementSpec, + arrangement: Project, +) -> CheckedProject: + """The synthetic corpus arrangement played at the tempo a spec names. + + Args: + spec: The tempo, the name and the purpose. + arrangement: The arrangement as the synthetic corpus builds it. + + Returns: + CheckedProject: The arrangement at that tempo. + """ + return CheckedProject( + name=spec.name, + purpose=spec.purpose, + project=at_tempo( + arrangement, + spec.tempo, + ), + ) + + +def comparison_corpus(corpus: CorpusSpec) -> List[CheckedProject]: + """Every project a tracker playback check plays: those written out, then the reconstructed arrangement. + + Args: + corpus: The corpus to build. + + Returns: + List[CheckedProject]: The written projects in the order the corpus lists them, then the + arrangement at each of its tempi. + """ + written = [written_project(spec, corpus) for spec in corpus.projects] + arrangement = build_synthetic_corpus().project + return written + [arrangement_project(spec, arrangement) for spec in corpus.arrangements] diff --git a/src/sampletones_tools/tracker_playback/corpus/spec.py b/src/sampletones_tools/tracker_playback/corpus/spec.py new file mode 100644 index 000000000..9d4dd51d3 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/corpus/spec.py @@ -0,0 +1,119 @@ +from typing import Annotated, Dict, List, Literal, Self, Union + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import SongSpec +from sampletones_tools.tracker_playback.paths import CORPUS_PATH + +FrameValue = Union[bool, int] + + +class FrameRun(BaseModel): + """Frames one channel of a sample plays alike, one after another. + + Attributes: + count: How many frames the run lasts. + frame: The instruction each of those frames plays, in the fields the channel's instruction takes. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + count: int = Field(..., ge=1) + frame: Dict[str, FrameValue] + + +class SampleSpec(BaseModel): + """A voice with a recording behind it, its frames written out channel by channel. + + Attributes: + kind: Marks the voice as a sample. + channels: The frames each channel plays, as runs of alike frames. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["sample"] + channels: Dict[ChannelName, List[FrameRun]] + + +class InstrumentSpec(BaseModel): + """An instrument: envelopes every channel reads its own way. + + Attributes: + kind: Marks the voice as an instrument. + initial_pitch: The note a tonal channel measures the arpeggio against. + initial_period: The period the noise channel measures the arpeggio against. + envelopes: The per-tick values every channel reads. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + kind: Literal["instrument"] + initial_pitch: int + initial_period: int + envelopes: InstrumentEnvelopes + + +VoiceSpec = Annotated[Union[SampleSpec, InstrumentSpec], Field(discriminator="kind")] + + +class ProjectSpec(BaseModel): + """One project of the corpus: what it exercises, its settings, its voices and its song. + + Attributes: + name: The name its files are written under. + purpose: What the project exercises, in one sentence. + module: The title, the author and the playback settings. + voices: The corpus voices the project holds, in the order it lists them. + song: The order and the patterns. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + purpose: str + module: ModuleConfig + voices: List[str] + song: SongSpec + + +class ArrangementSpec(BaseModel): + """The synthetic corpus arrangement played at one tempo. + + Attributes: + name: The name its files are written under. + purpose: What playing the arrangement at this tempo exercises, in one sentence. + tempo: The tempo it is played at. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str + purpose: str + tempo: int + + +class CorpusSpec(BaseModel): + """The projects a tracker playback check plays, and the voices they draw on. + + Attributes: + voices: Every voice a project may hold, by name. + projects: The projects written out here, each exercising one thing a song can do. + arrangements: The synthetic corpus arrangement, reconstructed from rendered audio, at each + tempo it is played at. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + voices: Dict[str, VoiceSpec] + projects: List[ProjectSpec] + arrangements: List[ArrangementSpec] + + @classmethod + def load(cls) -> Self: + """The corpus the package ships.""" + return load_yaml_model(CORPUS_PATH, cls) diff --git a/src/sampletones_tools/tracker_playback/corpus/voices.py b/src/sampletones_tools/tracker_playback/corpus/voices.py new file mode 100644 index 000000000..c29ae1642 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/corpus/voices.py @@ -0,0 +1,81 @@ +from pathlib import Path +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion +from sampletones_tools.corpus.written import written_reconstruction +from sampletones_tools.tracker_playback.corpus.spec import ( + FrameRun, + InstrumentSpec, + SampleSpec, + VoiceSpec, +) + +WRITTEN_COEFFICIENT: Final[float] = 1.0 +WRITTEN_SCALE: Final[float] = 1.0 +NO_RECORDINGS: Final[Tuple[Path, ...]] = () + + +def channel_frames( + channel: ChannelName, + runs: Sequence[FrameRun], +) -> List[InstructionUnion]: + """The frames one channel of a sample plays, each run read as the instruction that channel takes. + + Args: + channel: The channel the frames play on. + runs: The runs of alike frames, in order. + + Returns: + List[InstructionUnion]: One instruction per frame. + + Raises: + ValidationError: If a run's frame is no instruction the channel takes. + """ + instruction_type = CHANNEL_TO_EXPORTER_MAP[channel].get_instruction_type() + frames: List[InstructionUnion] = [] + for run in runs: + frame = instruction_type.model_validate(run.frame) + frames.extend(frame for _ in range(run.count)) + + return frames + + +def build_voice( + name: str, + spec: VoiceSpec, +) -> VoiceUnion: + """The voice a spec describes: a sample playing its written frames, or an instrument. + + Args: + name: The voice's name. + spec: What the voice plays. + + Returns: + VoiceUnion: The voice. + """ + match spec: + case SampleSpec(): + instructions: Dict[ChannelName, List[InstructionUnion]] = { + channel: channel_frames(channel, runs) for channel, runs in spec.channels.items() + } + return Sample( + name=name, + reconstruction=written_reconstruction( + instructions, + coefficient=WRITTEN_COEFFICIENT, + scale=WRITTEN_SCALE, + audio_filepath=NO_RECORDINGS, + ), + ) + case InstrumentSpec(): + return Instrument( + name=name, + initial_pitch=spec.initial_pitch, + initial_period=spec.initial_period, + envelopes=spec.envelopes, + ) diff --git a/src/sampletones_tools/tracker_playback/outcome.py b/src/sampletones_tools/tracker_playback/outcome.py new file mode 100644 index 000000000..e7a8b695d --- /dev/null +++ b/src/sampletones_tools/tracker_playback/outcome.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_tools.tracker_playback.comparison import TraceComparison +from sampletones_tools.tracker_playback.projects import CheckedProject + + +@dataclass(frozen=True) +class ProjectOutcome: + """How one checked project fared: what the tracker played of it, and what its export left out. + + Attributes: + project: The project compared. + comparison: How the tracker played it against the application. + skipped_rows: The rows the export wrote other than the song plays them, each with why. + truncation: The instruments a macro shortened, or ``None`` where every one fit. + """ + + project: CheckedProject + comparison: TraceComparison + skipped_rows: Tuple[SkippedRow, ...] + truncation: Optional[EnvelopeTruncation] diff --git a/src/sampletones_tools/tracker_playback/paths.py b/src/sampletones_tools/tracker_playback/paths.py new file mode 100644 index 000000000..aed46f3b4 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/paths.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.package import package_directory +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS + +CONFIG_DIRECTORY: Final[Path] = package_directory("sampletones_tools.tracker_playback.config") +CORPUS_PATH: Final[Path] = CONFIG_DIRECTORY / "corpus.yaml" +SETTINGS_PATH: Final[Path] = CONFIG_DIRECTORY / "settings.yaml" +BITPHASE_SCRIPT_DIRECTORY: Final[Path] = package_directory( + "sampletones_tools.tracker_playback.targets.bitphase.script", +) +BITPHASE_TRACE_SCRIPT_PATH: Final[Path] = BITPHASE_SCRIPT_DIRECTORY / "trace.mts" +OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "tracker-playback" +DOCUMENTS_DIRECTORY_NAME: Final[str] = "documents" +REPORT_FILENAME: Final[str] = "report.md" diff --git a/src/sampletones_tools/tracker_playback/projects.py b/src/sampletones_tools/tracker_playback/projects.py new file mode 100644 index 000000000..859d329df --- /dev/null +++ b/src/sampletones_tools/tracker_playback/projects.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Sequence, Set + +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project + +LOADED_PURPOSE: Final[str] = "The project saved in `{path}`." +COUNTED_NAME: Final[str] = "{stem}-{count}" +FIRST_COUNT: Final[int] = 2 + + +@dataclass(frozen=True) +class CheckedProject: + """One project a tracker playback check plays, with what it is. + + Attributes: + name: The name its files are written under. + purpose: What the project exercises, or the file it was loaded from, in one sentence. + project: The project itself. + """ + + name: str + purpose: str + project: Project + + +def loaded_projects(paths: Sequence[Path]) -> List[CheckedProject]: + """The projects saved in the files a check is given, each named after its file. + + A file whose name an earlier file already took is named with a count after it, so the files each + project writes stay apart. + + Args: + paths: The project files, in the order the check plays them. + + Returns: + List[CheckedProject]: One project per file, in the same order. + + Raises: + LoadProjectError: If a file holds no project the application opens. + OSError: If a file can't be read. + """ + taken: Set[str] = set() + projects: List[CheckedProject] = [] + for path in paths: + name = unique_name(path.stem, taken) + taken.add(name) + projects.append( + CheckedProject( + name=name, + purpose=LOADED_PURPOSE.format(path=path.resolve()), + project=ProjectContainer.load(path), + ) + ) + + return projects + + +def unique_name(stem: str, taken: Set[str]) -> str: + """``stem``, or ``stem`` with the lowest count from ``FIRST_COUNT`` up that no project took yet. + + Args: + stem: The name the file gives. + taken: The names earlier projects took. + + Returns: + str: A name none of them has. + """ + name = stem + count = FIRST_COUNT + while name in taken: + name = COUNTED_NAME.format(stem=stem, count=count) + count += 1 + + return name diff --git a/src/sampletones_tools/tracker_playback/report.py b/src/sampletones_tools/tracker_playback/report.py new file mode 100644 index 000000000..dbf278b08 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/report.py @@ -0,0 +1,282 @@ +from collections import Counter +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkipReason +from sampletones_shared.utils.tables import Table +from sampletones_tools.tracker_playback.comparison import ( + Divergence, + DivergentTick, + TimingDivergence, + TraceComparison, +) +from sampletones_tools.tracker_playback.outcome import ProjectOutcome +from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget +from sampletones_tools.tracker_playback.trace.sound import ChannelSound + +TITLE: Final[str] = "# What {tracker} plays of each project" +INTRODUCTION: Final[str] = ( + "Each project was exported to {tracker} with the application's own exporter and played by {player}. Every " + "engine tick of every channel is held against what the application plays, both read out of the registers " + "each writes to the sound chip. A channel that is silent on both sides counts as alike." +) +SUMMARY_COLUMNS: Final[Tuple[str, ...]] = ("Project", "Ticks in the app", "Ticks in the tracker", "Result") +DIVERGENCE_COLUMNS: Final[Tuple[str, ...]] = ( + "Channel", + "Differs in", + "Tick", + "Frame", + "Row", + "Application", + "Tracker", + "Ticks", +) +MATCHES: Final[str] = "matches" +DIFFERS_ONCE: Final[str] = "1 difference" +TIMING_ONLY: Final[str] = "the rows or the length differ" +DIFFERS: Final[str] = "{count} differences" +LENGTHS_DIFFER: Final[str] = "The application plays {application} ticks and the tracker plays {engine}." +TIMING_DIFFERS: Final[str] = ( + "The rows part at tick {tick}: the application is at frame {frame}, row {row}, " + "and the tracker at frame {engine_frame}, row {engine_row}." +) +ALL_ALIKE: Final[str] = "Every tick sounds alike." +SKIPPED_ROWS: Final[Dict[SkipReason, str]] = { + SkipReason.NO_INSTRUMENT: ( + "Rows the export wrote as note cuts, for lack of an instrument on their channel: {count}." + ), + SkipReason.UNREACHED_TRANSPOSE: ( + "Transpose rows the export wrote without their pitch change, " + "because the format can't make it there: {count}." + ), + SkipReason.CARRIED_PITCH: ( + "Instrument rows taking the pitch already playing that the export wrote " + "with what another frame of their pattern plays: {count}." + ), +} +SHORTENED: Final[str] = ( + "Instruments the export shortened to {frames} of their {source_frames} frames, " + "the most the format holds: {instruments}." +) +SILENT: Final[str] = "silent" +PULSE_SOUND: Final[str] = "timer {period}, volume {volume}, duty {timbre}" +TRIANGLE_SOUND: Final[str] = "timer {period}" +NOISE_SOUND: Final[str] = "period {period}, volume {volume}, {mode}" +COUNTED_DOWN: Final[str] = ", counted down" +SHORT_MODE: Final[str] = "short" +LONG_MODE: Final[str] = "long" +FIELD_SEPARATOR: Final[str] = ", " +CONTINUED: Final[str] = "" + + +def describe_sound( + channel: ChannelName, + sound: ChannelSound, +) -> str: + """One channel's sound on a tick, in the registers a reader looks for on that channel. + + A sound the chip's own envelope or counters move on from its registers closes with + ``COUNTED_DOWN``. + + Args: + channel: The channel sounding. + sound: What it sounds. + + Returns: + str: The sound, or that the channel is silent. + """ + if not sound.audible: + return SILENT + + closing = "" if sound.held else COUNTED_DOWN + return registers_text(channel, sound) + closing + + +def registers_text( + channel: ChannelName, + sound: ChannelSound, +) -> str: + """The registers a reader looks for on one channel, as a sounding tick holds them.""" + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: + return PULSE_SOUND.format( + period=sound.period, + volume=sound.volume, + timbre=sound.timbre, + ) + case ChannelName.TRIANGLE: + return TRIANGLE_SOUND.format(period=sound.period) + case ChannelName.NOISE: + mode = SHORT_MODE if sound.timbre else LONG_MODE + return NOISE_SOUND.format( + period=sound.period, + volume=sound.volume, + mode=mode, + ) + + +def result_label(comparison: TraceComparison) -> str: + """The verdict a project's line in the summary carries: a match, or how many ways it differs.""" + if comparison.matches: + return MATCHES + + count = len(comparison.divergences) + match count: + case 0: + return TIMING_ONLY + case 1: + return DIFFERS_ONCE + case _: + return DIFFERS.format(count=count) + + +def divergence_table(divergences: Sequence[Divergence]) -> Table: + """The table listing every way a project's channels sound differently, a line per example. + + A divergence's first line names the channel, the fields and how many ticks it strikes, and the + lines under it carry its further examples alone. + """ + return Table( + columns=DIVERGENCE_COLUMNS, + rows=tuple( + divergence_row(divergence, example, opening=index == 0) + for divergence in divergences + for index, example in enumerate(divergence.examples) + ), + ) + + +def divergence_row( + divergence: Divergence, + example: DivergentTick, + *, + opening: bool, +) -> Tuple[str, ...]: + """One line of the divergence table: an example, under the divergence it belongs to where it opens it.""" + return ( + divergence.channel.value if opening else CONTINUED, + FIELD_SEPARATOR.join(field.value for field in divergence.fields) if opening else CONTINUED, + str(example.tick), + str(example.position.frame), + str(example.position.row), + describe_sound(divergence.channel, example.application), + describe_sound(divergence.channel, example.engine), + str(divergence.ticks) if opening else CONTINUED, + ) + + +def timing_line(timing: TimingDivergence) -> str: + """Where the two players first place a tick at different rows.""" + return TIMING_DIFFERS.format( + tick=timing.tick, + frame=timing.position.frame, + row=timing.position.row, + engine_frame=timing.engine_position.frame, + engine_row=timing.engine_position.row, + ) + + +def export_notes(outcome: ProjectOutcome) -> List[str]: + """What the export itself reported leaving out, which accounts for a difference it expects. + + Args: + outcome: How the project fared. + + Returns: + List[str]: One line per thing left out. + """ + reasons = Counter(row.reason for row in outcome.skipped_rows) + notes = [SKIPPED_ROWS[reason].format(count=reasons[reason]) for reason in SkipReason if reasons[reason]] + + truncation = outcome.truncation + if truncation is not None: + notes.append( + SHORTENED.format( + instruments=truncation.instruments, + frames=truncation.frames, + source_frames=truncation.source_frames, + ) + ) + + return notes + + +def project_section(outcome: ProjectOutcome) -> List[str]: + """The report's section on one project: what it exercises, what the export left out, and every difference. + + Args: + outcome: How the project fared. + + Returns: + List[str]: The section's lines. + """ + comparison = outcome.comparison + lines = [f"## {outcome.project.name}", "", outcome.project.purpose, ""] + lines.extend(line for note in export_notes(outcome) for line in (note, "")) + if comparison.application_ticks != comparison.engine_ticks: + lines.extend( + ( + LENGTHS_DIFFER.format( + application=comparison.application_ticks, + engine=comparison.engine_ticks, + ), + "", + ) + ) + + if comparison.timing is not None: + lines.extend((timing_line(comparison.timing), "")) + + if comparison.divergences: + lines.extend(divergence_table(comparison.divergences).markdown_lines()) + lines.append("") + elif comparison.matches: + lines.extend((ALL_ALIKE, "")) + + return lines + + +def summary_table(outcomes: Sequence[ProjectOutcome]) -> Table: + """The table naming every project with its tick counts and its verdict.""" + return Table( + columns=SUMMARY_COLUMNS, + rows=tuple( + ( + outcome.project.name, + str(outcome.comparison.application_ticks), + str(outcome.comparison.engine_ticks), + result_label(outcome.comparison), + ) + for outcome in outcomes + ), + ) + + +def report_text( + target: PlaybackTarget, + outcomes: Sequence[ProjectOutcome], +) -> str: + """The whole report: the summary first, then a section per project. + + Args: + target: The tracker that played the projects. + outcomes: How each project fared, in the order they were played. + + Returns: + str: The report as Markdown. + """ + lines = [ + TITLE.format(tracker=target.title), + "", + INTRODUCTION.format( + tracker=target.title, + player=target.player, + ), + "", + ] + lines.extend(summary_table(outcomes).markdown_lines()) + lines.append("") + for outcome in outcomes: + lines.extend(project_section(outcome)) + + return "\n".join(lines) diff --git a/src/sampletones_tools/tracker_playback/session.py b/src/sampletones_tools/tracker_playback/session.py new file mode 100644 index 000000000..f5fe4a40f --- /dev/null +++ b/src/sampletones_tools/tracker_playback/session.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence, Tuple + +from sampletones_tools.runs import stamped_run_directory +from sampletones_tools.tracker_playback.comparison import compare_traces +from sampletones_tools.tracker_playback.outcome import ProjectOutcome +from sampletones_tools.tracker_playback.paths import ( + DOCUMENTS_DIRECTORY_NAME, + OUTPUT_ROOT, + REPORT_FILENAME, +) +from sampletones_tools.tracker_playback.projects import CheckedProject +from sampletones_tools.tracker_playback.report import report_text +from sampletones_tools.tracker_playback.settings import PlaybackSettings +from sampletones_tools.tracker_playback.targets.protocol import PlaybackTarget +from sampletones_tools.tracker_playback.trace.application import application_trace + + +@dataclass(frozen=True) +class PlaybackRun: + """What a check plays its projects through, where it writes, and how it reports. + + Attributes: + target: The tracker the projects are exported to and played by. + output: The directory the exported files, what their playing wrote and the report go into. + settings: How the report shows what it finds. + """ + + target: PlaybackTarget + output: Path + settings: PlaybackSettings + + +@dataclass(frozen=True) +class PlaybackOutcome: + """What a check found, and the report stating it. + + Attributes: + outcomes: How each project fared, in the order they were played. + report: The report written. + """ + + outcomes: Tuple[ProjectOutcome, ...] + report: Path + + +def default_output() -> Path: + """The directory a check given no output writes into: a timestamped one under Documents.""" + return stamped_run_directory(OUTPUT_ROOT) + + +def check_project( + checked: CheckedProject, + run: PlaybackRun, +) -> ProjectOutcome: + """Plays one project through the target and holds what it played against the application. + + Args: + checked: The project to check. + run: What plays it, where the files go, and how differences are shown. + + Returns: + ProjectOutcome: How the tracker played the project, and what the export reported leaving out. + + Raises: + PlaybackError: If the tracker fails to play the project. + """ + playback = run.target.play( + checked.project, + run.output / DOCUMENTS_DIRECTORY_NAME, + checked.name, + ) + return ProjectOutcome( + project=checked, + comparison=compare_traces( + application_trace(checked.project), + playback.trace, + examples=run.settings.examples_per_difference, + ), + skipped_rows=playback.skipped_rows, + truncation=playback.truncation, + ) + + +def check_projects( + projects: Sequence[CheckedProject], + run: PlaybackRun, +) -> PlaybackOutcome: + """Checks each project and writes the report on all of them. + + The output keeps every exported file, and whatever its playing wrote, beside the report, so a + difference can be followed into the file and the ticks that show it. + + Args: + projects: The projects to check. + run: What plays them, where the files go, and how differences are shown. + + Returns: + PlaybackOutcome: How each project fared, and the report. + + Raises: + PlaybackError: If the tracker fails to play a project. + """ + (run.output / DOCUMENTS_DIRECTORY_NAME).mkdir(parents=True, exist_ok=True) + outcomes = tuple(check_project(checked, run) for checked in projects) + report = run.output / REPORT_FILENAME + report.write_text( + report_text( + run.target, + outcomes, + ), + encoding="utf-8", + ) + return PlaybackOutcome( + outcomes=outcomes, + report=report, + ) diff --git a/src/sampletones_tools/tracker_playback/settings.py b/src/sampletones_tools/tracker_playback/settings.py new file mode 100644 index 000000000..0b91a55a8 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/settings.py @@ -0,0 +1,24 @@ +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_shared.utils.serialization import load_yaml_model +from sampletones_tools.tracker_playback.paths import SETTINGS_PATH + + +class PlaybackSettings(BaseModel): + """How a tracker playback check reports what it finds. + + Attributes: + examples_per_difference: How many ticks a difference shows as examples at most: the first it + strikes, then the later rows where both sides sound new values. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + examples_per_difference: int = Field(..., ge=1) + + @classmethod + def load(cls) -> Self: + """The settings the package ships.""" + return load_yaml_model(SETTINGS_PATH, cls) diff --git a/src/sampletones_tools/tracker_playback/targets/__init__.py b/src/sampletones_tools/tracker_playback/targets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/__init__.py b/src/sampletones_tools/tracker_playback/targets/bitphase/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/engine.py b/src/sampletones_tools/tracker_playback/targets/bitphase/engine.py new file mode 100644 index 000000000..264be7ac8 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/engine.py @@ -0,0 +1,172 @@ +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Self, Tuple + +from sampletones_shared.utils.system.programs import ( + locate_program, + missing_program_message, +) +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.paths import BITPHASE_TRACE_SCRIPT_PATH +from sampletones_tools.tracker_playback.targets.bitphase.trace import read_engine_trace +from sampletones_tools.tracker_playback.targets.protocol import PlaybackError +from sampletones_tools.tracker_playback.trace.sound import SongTrace + +NODE: Final[str] = "node" +NODE_PURPOSE: Final[str] = "the engine of Bitphase's source code plays the exported documents" +TSX_CLI: Final[Path] = Path("node_modules") / "tsx" / "dist" / "cli.mjs" +SOURCE_FILES: Final[Tuple[Path, ...]] = ( + Path("cli") / "btp-loader.ts", + Path("cli") / "resource-loader-node.ts", + Path("src") / "lib" / "chips" / "registry-core.ts", + Path("public") / "nes" / "nes-audio-driver.js", + Path("public") / "nes" / "nes-apu-engine.js", + Path("public") / "nes" / "nes_apu.wasm", + TSX_CLI, +) + +INSTALL_HINTS: Final[Dict[System, str]] = { + System.LINUX: "sudo apt install nodejs", + System.MACOS: "brew install node", + System.WINDOWS: "install Node.js from https://nodejs.org", +} + + +class EngineError(PlaybackError): + """Bitphase's engine could not play a document: node is absent, the source code lacks a file, or the run failed.""" + + +@dataclass(frozen=True) +class BitphaseSource: + """A directory holding Bitphase's source code, its packages installed, whose engine plays the documents. + + Attributes: + root: The source code's top directory. + """ + + root: Path + + @classmethod + def located(cls, root: Path) -> Self: + """The source code at ``root``, once every file the trace loads from it is found there. + + Args: + root: The source code's top directory. + + Returns: + Self: The source code. + + Raises: + EngineError: If a file the trace loads is missing, naming each one. + """ + missing = [str(relative) for relative in SOURCE_FILES if not (root / relative).is_file()] + if missing: + raise EngineError( + f"{root} holds no Bitphase source code with its packages installed; it lacks {', '.join(missing)}. " + "Clone or download https://github.com/paator/bitphase and run pnpm install there." + ) + + return cls(root=root) + + +@dataclass(frozen=True) +class BitphaseEngine: + """Bitphase's own engine, run through node over one copy of its source code. + + Attributes: + node: The node program. + source: The source code whose modules play the documents. + """ + + node: Path + source: BitphaseSource + + @classmethod + def located(cls, root: Path) -> Self: + """The engine of the source code at ``root``, played by the node this system has. + + Args: + root: The source code's top directory. + + Returns: + Self: The engine. + + Raises: + EngineError: If node is absent, naming how this system installs it, or the source code lacks + a file the trace loads. + """ + node = locate_program(NODE) + if node is None: + raise EngineError( + missing_program_message( + NODE, + NODE_PURPOSE, + INSTALL_HINTS, + ) + ) + + return cls( + node=node, + source=BitphaseSource.located(root), + ) + + def command( + self, + document: Path, + output: Path, + ) -> List[str]: + """The command that plays ``document`` and writes its trace to ``output``. + + The source code's own tsx runs the trace script, so the script loads Bitphase's TypeScript + modules the way its own command-line tools do. The script runs inside the source directory, + so the document and the trace are named by absolute paths. + + Args: + document: The `.btp` document to play. + output: Where the trace is written. + + Returns: + List[str]: The program and its arguments. + """ + return [ + str(self.node), + str(self.source.root / TSX_CLI), + str(BITPHASE_TRACE_SCRIPT_PATH), + str(self.source.root), + str(document.resolve()), + str(output.resolve()), + ] + + def trace( + self, + document: Path, + output: Path, + ) -> SongTrace: + """Plays a document through the engine and reads what every channel sounds on every tick. + + The trace holds every write the engine made to the chip's registers, and it is written to + ``output`` and kept there, so a run can be read again by hand. + + Args: + document: The `.btp` document to play. + output: Where the trace is written. + + Returns: + SongTrace: One pass through the document's song. + + Raises: + EngineError: If the engine fails to play the document. + """ + try: + subprocess.run( + self.command(document, output), + cwd=self.source.root, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as error: + raise EngineError(f"Bitphase failed to play {document}:\n{error.stderr}") from error + + return read_engine_trace(output.read_text(encoding="utf-8")) diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/script/__init__.py b/src/sampletones_tools/tracker_playback/targets/bitphase/script/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/script/trace.mts b/src/sampletones_tools/tracker_playback/targets/bitphase/script/trace.mts new file mode 100644 index 000000000..7d730a79c --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/script/trace.mts @@ -0,0 +1,167 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +type Timeline = { currentPatternOrderIndex: number; currentRow: number }; + +type TrackerState = { timeline: Timeline }; + +type UnitWrite = { unit: string; address: number; value: number }; + +type TracedTick = { frame: number; row: number; writes: UnitWrite[] }; + +type Write = (pointer: number, address: number, value: number) => unknown; + +type Engine = { + applyRegisterState(registers: unknown): void; + process(sampleRate: number): unknown; +}; + +type Constructor = new (...args: any[]) => T; + +type Module = Record; + +const USAGE = "Usage: trace.mts "; +const USAGE_STATUS = 2; +const PUBLIC_DIRECTORY = "public"; +const NES_CHIP = "nes"; +const SONG_INDEX = 0; +const ONE_PASS = 1; +const SILENT_OUTPUT = { left: 0, right: 0 }; +const STATE_MODULE = "nes/nes-state.js"; +const DRIVER_MODULE = "nes/nes-audio-driver.js"; +const ENGINE_MODULE = "nes/nes-apu-engine.js"; +const UNIT_WRITES: Record = { + apu: "nes_apu_Write", + dmc: "nes_dmc_Write" +}; + +class TickRecorder { + readonly ticks: TracedTick[] = []; + private pending: UnitWrite[] = []; + private state: TrackerState | null = null; + private driverPassed = false; + + watchState(state: TrackerState): void { + this.state = state; + } + + markDriverPass(): void { + this.driverPassed = true; + } + + write(unit: string, address: number, value: number): void { + this.pending.push({ unit, address, value }); + } + + // The engine writes the chip as it resets and once more before the first tick, so a tick is the + // apply that follows a pass of the driver over the instruments, carrying every write since the + // tick before. + record(): void { + if (!this.driverPassed || this.state === null) { + return; + } + this.driverPassed = false; + this.ticks.push({ + frame: this.state.timeline.currentPatternOrderIndex, + row: this.state.timeline.currentRow, + writes: this.pending + }); + this.pending = []; + } +} + +// The emulator splits the APU into two units, and the engine writes each through an export of its +// own on the module it is created with. The exports object is frozen, so the engine is handed a copy. +function recordingWasm(wasm: Module, recorder: TickRecorder): Module { + const recording: Module = { ...wasm }; + for (const [unit, name] of Object.entries(UNIT_WRITES)) { + const write = wasm[name] as Write; + recording[name] = (pointer: number, address: number, value: number): unknown => { + recorder.write(unit, address, value); + return write(pointer, address, value); + }; + } + return recording; +} + +function recordingModule(url: string, module: Module, recorder: TickRecorder): Module { + switch (url) { + case STATE_MODULE: { + const State = module.default as Constructor; + class WatchedState extends State { + constructor(...args: any[]) { + super(...args); + recorder.watchState(this); + } + } + return { ...module, default: WatchedState }; + } + case DRIVER_MODULE: { + const Driver = module.default as Constructor<{ + processInstruments(state: unknown, registers: unknown): void; + }>; + class WatchedDriver extends Driver { + processInstruments(state: unknown, registers: unknown): void { + super.processInstruments(state, registers); + recorder.markDriverPass(); + } + } + return { ...module, default: WatchedDriver }; + } + case ENGINE_MODULE: + return { + ...module, + createNesApuEngine(wasm: Module) { + const made = module.createNesApuEngine(recordingWasm(wasm, recorder)); + const engine = made.engine as Engine; + const apply = engine.applyRegisterState.bind(engine); + engine.applyRegisterState = (registers: unknown) => { + apply(registers); + recorder.record(); + }; + engine.process = () => SILENT_OUTPUT; + return made; + } + }; + default: + return module; + } +} + +async function main(): Promise { + const [root, documentPath, tracePath] = process.argv.slice(2); + if (root === undefined || documentPath === undefined || tracePath === undefined) { + console.error(USAGE); + process.exit(USAGE_STATUS); + } + + const upstream = (relative: string): Promise => + import(pathToFileURL(path.join(root, relative)).href); + const { loadBtpFromFile } = await upstream("cli/btp-loader.ts"); + const { FileSystemResourceLoader } = await upstream("cli/resource-loader-node.ts"); + const { ensureCoreRegistry, getChipByType } = await upstream("src/lib/chips/registry-core.ts"); + + await ensureCoreRegistry(); + const project = loadBtpFromFile(path.resolve(documentPath)); + const chip = getChipByType(NES_CHIP); + const files = new FileSystemResourceLoader(path.join(root, PUBLIC_DIRECTORY)); + const recorder = new TickRecorder(); + const loader = { + loadWasm: (url: string): Promise => files.loadWasm(url), + loadModule: async (url: string): Promise => + recordingModule(url, await files.loadModule(url), recorder) + }; + + const renderer = chip.createRenderer(loader, { + chipType: chip.type, + audioSlotKind: chip.audioSlotKind + }); + await renderer.render(project, SONG_INDEX, undefined, { + separateChannels: false, + loopCount: ONE_PASS + }); + fs.writeFileSync(tracePath, JSON.stringify({ ticks: recorder.ticks })); +} + +await main(); diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/target.py b/src/sampletones_tools/tracker_playback/targets/bitphase/target.py new file mode 100644 index 000000000..4bd3c885c --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/target.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Self + +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import build_bitphase +from sampletones_core.project.project import Project +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_tools.tracker_playback.targets.bitphase.engine import BitphaseEngine +from sampletones_tools.tracker_playback.targets.protocol import TargetPlayback + +TITLE: Final[str] = "Bitphase" +PLAYER: Final[str] = "the engine of the Bitphase source code at `{root}`" + + +@dataclass(frozen=True) +class BitphaseTarget: + """Bitphase as a playback target: a project exported to a `.btp` document and played by Bitphase's engine. + + Attributes: + engine: The engine of the source code that plays the documents. + """ + + engine: BitphaseEngine + + @classmethod + def located(cls, root: Path) -> Self: + """The target playing documents with the engine of the source code at ``root``. + + Args: + root: The top directory of Bitphase's source code. + + Returns: + Self: The target. + + Raises: + EngineError: If node is absent, or the source code lacks a file the trace loads. + """ + return cls(engine=BitphaseEngine.located(root)) + + @property + def title(self) -> str: + """The tracker's name, as the report prints it.""" + return TITLE + + @property + def player(self) -> str: + """The source code whose engine plays the documents, as the report introduces it.""" + return PLAYER.format(root=self.engine.source.root) + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + """Exports a project to a `.btp` document, plays it with Bitphase's engine and reads every tick. + + The document is built by the exporter the application's Bitphase export runs, and the trace the + engine writes is kept beside it. + + Args: + project: The project to export and play. + directory: Where the document and its trace are written. + name: The stem both files are written under. + + Returns: + TargetPlayback: The document, what Bitphase played of it, and what the export left out. + + Raises: + EngineError: If Bitphase fails to play the document. + """ + built = build_bitphase(project) + document = directory / f"{name}{EXT_FILE_BITPHASE}" + write_btp( + document, + built.document, + ) + return TargetPlayback( + document=document, + trace=self.engine.trace( + document, + directory / f"{name}{EXT_FILE_JSON}", + ), + skipped_rows=built.skipped_rows, + truncation=built.truncation, + ) diff --git a/src/sampletones_tools/tracker_playback/targets/bitphase/trace.py b/src/sampletones_tools/tracker_playback/targets/bitphase/trace.py new file mode 100644 index 000000000..6793fcaaa --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/bitphase/trace.py @@ -0,0 +1,137 @@ +from enum import StrEnum +from typing import Dict, Final, List, Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + APU_STATUS, + FIRST_CHANNEL_REGISTER, + MAX_REGISTER_VALUE, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.trace.decode import ( + CHANNEL_STATUS_BITS, + DMC_STATUS_BIT, + song_trace, +) +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import SongTrace, TickPosition + + +class EmulatorUnit(StrEnum): + """The two units Bitphase's emulator splits the APU into, each written through an export of its own. + + `apu` plays the two pulse channels and `dmc` plays the triangle, the noise and the DMC. Each + keeps its own copy of the status register and reads only its own channels' bits of it. + """ + + APU = "apu" + DMC = "dmc" + + +UNIT_STATUS_BITS: Final[Dict[EmulatorUnit, int]] = { + EmulatorUnit.APU: CHANNEL_STATUS_BITS[ChannelName.PULSE1] | CHANNEL_STATUS_BITS[ChannelName.PULSE2], + EmulatorUnit.DMC: ( + CHANNEL_STATUS_BITS[ChannelName.TRIANGLE] | CHANNEL_STATUS_BITS[ChannelName.NOISE] | DMC_STATUS_BIT + ), +} + + +class UnitWrite(BaseModel): + """One write Bitphase's engine makes to a unit of its emulated APU. + + Attributes: + unit: The unit the write reaches. + address: The register written. + value: The byte written. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + unit: EmulatorUnit + address: int = Field(..., ge=FIRST_CHANNEL_REGISTER, le=APU_FRAME_COUNTER) + value: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + def chip_write(self, status: int) -> RegisterWrite: + """The write as one whole APU takes it, given the status register standing before it. + + A status write reaches the bits of the channels its unit plays and leaves the other unit's + bits as they stand, which is what the two units enable between them. + + Args: + status: The status register before the write. + + Returns: + RegisterWrite: The write the whole chip takes. + """ + if self.address != APU_STATUS: + return RegisterWrite(self.address, self.value) + + bits = UNIT_STATUS_BITS[self.unit] + return RegisterWrite(APU_STATUS, (status & ~bits) | (self.value & bits)) + + +class EngineTick(BaseModel): + """Every write Bitphase's engine made to the chip since the tick before, and where in the song the tick falls. + + The first tick also carries the writes the engine makes as it resets the chip and before it + plays. + + Attributes: + frame: The order position being played. + row: The row of that position's pattern. + writes: The writes, in the order the engine made them. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + frame: int + row: int + writes: Tuple[UnitWrite, ...] + + +class EngineTrace(BaseModel): + """Every tick of one pass through a document, as the trace script writes it.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + ticks: Tuple[EngineTick, ...] + + @property + def positions(self) -> Tuple[TickPosition, ...]: + """Where each tick falls.""" + return tuple(TickPosition(frame=tick.frame, row=tick.row) for tick in self.ticks) + + def registers(self) -> Tuple[ChipRegisters, ...]: + """The chip's registers on every tick, each tick's writes laid over what the tick before left. + + Returns: + Tuple[ChipRegisters, ...]: The registers on each tick, one per tick. + """ + registers = ChipRegisters.power_up() + per_tick: List[ChipRegisters] = [] + for tick in self.ticks: + for write in tick.writes: + registers = registers.written((write.chip_write(registers.value(APU_STATUS)),)) + + per_tick.append(registers) + + return tuple(per_tick) + + +def read_engine_trace(text: str) -> SongTrace: + """What a trace script's output says every channel plays, read out of the registers the engine wrote. + + Args: + text: The JSON the trace script wrote. + + Returns: + SongTrace: One pass through the song. + + Raises: + ValidationError: If the text is no trace the script writes. + """ + trace = EngineTrace.model_validate_json(text) + return song_trace(trace.positions, trace.registers()) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/__init__.py b/src/sampletones_tools/tracker_playback/targets/famitracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/errors.py b/src/sampletones_tools/tracker_playback/targets/famitracker/errors.py new file mode 100644 index 000000000..6461e945f --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/errors.py @@ -0,0 +1,5 @@ +from sampletones_tools.tracker_playback.targets.protocol import PlaybackError + + +class FamiTrackerError(PlaybackError): + """FamiTracker could not play a project: it or Wine is missing, its export failed, or its NSF failed to run.""" diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/export.py b/src/sampletones_tools/tracker_playback/targets/famitracker/export.py new file mode 100644 index 000000000..3a43c73fc --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/export.py @@ -0,0 +1,56 @@ +import subprocess +from pathlib import Path +from typing import Final + +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import FamiTrackerProgram + +EXPORT_TIMEOUT_SECONDS: Final[int] = 120 +NO_LOG: Final[str] = "FamiTracker wrote no log." + + +def export_nsf( + program: FamiTrackerProgram, + module: Path, + nsf: Path, + log: Path, +) -> None: + """Has FamiTracker export a module to an NSF from its command line. + + FamiTracker quits the same way whether the export worked or not, so the NSF it leaves is what + tells the two apart. A file at ``nsf`` from an earlier run is removed first. + + Args: + program: FamiTracker, as this system runs it. + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Raises: + FamiTrackerError: If FamiTracker writes no NSF or runs past ``EXPORT_TIMEOUT_SECONDS``, with its log. + """ + nsf.unlink(missing_ok=True) + try: + completed = subprocess.run( + program.export_command(module, nsf, log), + env=program.environment(), + capture_output=True, + text=True, + timeout=EXPORT_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired as error: + raise FamiTrackerError( + f"FamiTracker ran for more than {EXPORT_TIMEOUT_SECONDS} seconds exporting {module}" + ) from error + + if not nsf.is_file(): + raise FamiTrackerError(f"FamiTracker wrote no NSF for {module}.\n{log_text(log)}\n{completed.stderr}".rstrip()) + + +def log_text(log: Path) -> str: + """What FamiTracker wrote to its log, or ``NO_LOG`` where it wrote none.""" + if not log.is_file(): + return NO_LOG + + return log.read_text(encoding="utf-8", errors="replace").strip() diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/markers.py b/src/sampletones_tools/tracker_playback/targets/famitracker/markers.py new file mode 100644 index 000000000..0773c9e5b --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/markers.py @@ -0,0 +1,83 @@ +from typing import Final, Tuple + +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule, OrderFrame +from sampletones_core.formats.famitracker.model.pattern import PatternData, RowCell +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.patterns import ( + EMPTY_INSTRUMENT, + EMPTY_NOTE, + EMPTY_VOLUME, + MIN_OCTAVE, + EffectId, +) + +MARKER_LEVELS: Final[int] = 4 + + +def row_marker(row_index: int) -> int: + """The level the marker of a row loads: its place in the song, wrapped into ``MARKER_LEVELS``. + + Two rows in a row always carry different levels, so every row's marker is a new write. The + DMC's level shifts how loud the triangle and the noise sound in the mix, and a jump between + levels clicks, so low levels keep both slight. + + Args: + row_index: The row's place in one pass through the song. + + Returns: + int: The level, below ``MARKER_LEVELS``. + """ + return row_index % MARKER_LEVELS + + +def marked_module(module: FamiTrackerModule) -> FamiTrackerModule: + """The module with a row marker on every row of its DPCM channel, the channel the application's export leaves empty. + + Each order frame plays a DPCM pattern of its own, and each of its rows loads the DMC's output + level with that row's marker. FamiTracker writes the level to ``$4011`` on the tick it plays the + row, so the NSF it exports says where every row starts. The other channels stay as they are. + + Args: + module: The module the application's export built. + + Returns: + FamiTrackerModule: The module with the markers. + """ + track = module.track + rows = track.rows_per_pattern + patterns = tuple(pattern for pattern in track.patterns if pattern.channel != ChannelId.DPCM) + markers = tuple(_marker_pattern(frame, rows) for frame in range(len(track.order))) + return module.model_copy( + update={ + "track": track.model_copy( + update={ + "order": tuple(_marked_frame(frame, entries) for frame, entries in enumerate(track.order)), + "patterns": patterns + markers, + } + ) + } + ) + + +def _marked_frame(frame: int, entries: OrderFrame) -> OrderFrame: + return tuple(frame if channel == ChannelId.DPCM else index for channel, index in zip(ChannelId, entries)) + + +def _marker_pattern(frame: int, rows: int) -> PatternData: + return PatternData( + channel=ChannelId.DPCM, + index=frame, + rows=tuple(_marker_cell(row, row_marker(frame * rows + row)) for row in range(rows)), + ) + + +def _marker_cell(row: int, level: int) -> RowCell: + effects: Tuple[Tuple[int, int], ...] = ((int(EffectId.DAC), level),) + return RowCell( + row_number=row, + note=EMPTY_NOTE, + octave=MIN_OCTAVE, + instrument=EMPTY_INSTRUMENT, + volume=EMPTY_VOLUME, + effects=effects, + ) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/factory.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/factory.py new file mode 100644 index 000000000..cf80f3ad5 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/factory.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from sampletones_shared.paths.extensions import EXT_FILE_WINDOWS_PROGRAM +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program.native import NativeProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import FamiTrackerProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import WineProgram + + +def located_program(executable: Path) -> FamiTrackerProgram: + """FamiTracker at ``executable``, run the way this system runs a Windows program. + + Windows runs it directly, and Linux and macOS run it through Wine. + + Args: + executable: The FamiTracker program file, ``FamiTracker.exe``. + + Returns: + FamiTrackerProgram: The program. + + Raises: + FamiTrackerError: If ``executable`` is no Windows program file, or Wine is absent where it is needed. + OSError: If the system is unsupported. + """ + if not executable.is_file() or executable.suffix.lower() != EXT_FILE_WINDOWS_PROGRAM: + raise FamiTrackerError(f"{executable} is no Windows program file. Give the path to FamiTracker.exe itself.") + + match System.current(): + case System.WINDOWS: + return NativeProgram(executable=executable) + case System.LINUX | System.MACOS: + return WineProgram.located(executable) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/native.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/native.py new file mode 100644 index 000000000..93c18eb22 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/native.py @@ -0,0 +1,48 @@ +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List + +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH + + +@dataclass(frozen=True) +class NativeProgram: + """FamiTracker run directly, the way Windows runs it. + + FamiTracker reads an argument starting with ``/`` or ``-`` as a switch, so every path is given + absolute: on Windows that opens with a drive letter. + + Attributes: + executable: The FamiTracker program file. + """ + + executable: Path + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + """The command that exports a module to an NSF and writes FamiTracker's log. + + Args: + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Returns: + List[str]: The program and its arguments. + """ + return [ + str(self.executable.resolve()), + str(module.resolve()), + EXPORT_SWITCH, + str(nsf.resolve()), + str(log.resolve()), + ] + + def environment(self) -> Dict[str, str]: + """The environment the export runs in: the one the check runs in.""" + return dict(os.environ) diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/protocol.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/protocol.py new file mode 100644 index 000000000..e842be57e --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/protocol.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Dict, Final, List, Protocol + +EXPORT_SWITCH: Final[str] = "-export" + + +class FamiTrackerProgram(Protocol): + """FamiTracker as this system runs it, so its command-line export turns a module into an NSF. + + FamiTracker exports from its command line, ``FamiTracker.exe -export ``, + writes what it did to the log and quits with no window shown. Each system reaches the Windows + program its own way, and each way is one implementation. + """ + + @property + def executable(self) -> Path: + """The FamiTracker program file.""" + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + """The command that exports a module to an NSF and writes FamiTracker's log. + + Args: + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Returns: + List[str]: The program and its arguments. + + Raises: + FamiTrackerError: If the paths cannot be put in the form FamiTracker reads. + """ + + def environment(self) -> Dict[str, str]: + """The environment the export runs in.""" diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/program/wine.py b/src/sampletones_tools/tracker_playback/targets/famitracker/program/wine.py new file mode 100644 index 000000000..fe857e325 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/program/wine.py @@ -0,0 +1,123 @@ +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Self, Sequence, Tuple + +from sampletones_shared.utils.system.programs import locate_program, missing_program_message +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH + +WINE: Final[str] = "wine" +WINE_PURPOSE: Final[str] = "FamiTracker is a Windows program, and Wine runs it on this system" +WINEPATH: Final[str] = "winepath" +WINDOWS_PATH_SWITCH: Final[str] = "-w" +WINE_DEBUG: Final[str] = "WINEDEBUG" +WINE_DEBUG_SILENT: Final[str] = "-all" +DISPLAY_VARIABLES: Final[Tuple[str, ...]] = ("DISPLAY", "WAYLAND_DISPLAY") +INSTALL_HINTS: Final[Dict[System, str]] = { + System.LINUX: "sudo apt install wine", + System.MACOS: "brew install --cask wine-stable", +} + + +@dataclass(frozen=True) +class WineProgram: + """FamiTracker run through Wine, the way Linux and macOS run a Windows program. + + FamiTracker reads an argument starting with ``/`` as a switch, so a path reaches it as the + Windows path Wine maps it to, which Wine's own ``winepath`` gives. The export shows no window, + so it runs with no display and with Wine's diagnostics silenced, and a run leaves the desktop + as it was. + + Attributes: + wine: The wine program. + executable: The FamiTracker program file. + """ + + wine: Path + executable: Path + + @classmethod + def located(cls, executable: Path) -> Self: + """FamiTracker at ``executable``, run by the Wine this system has. + + Args: + executable: The FamiTracker program file. + + Returns: + Self: The program. + + Raises: + FamiTrackerError: If Wine is absent, naming how this system installs it. + """ + wine = locate_program(WINE) + if wine is None: + raise FamiTrackerError(missing_program_message(WINE, WINE_PURPOSE, INSTALL_HINTS)) + + return cls(wine=wine, executable=executable) + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + """The command that exports a module to an NSF and writes FamiTracker's log, each path as Wine maps it. + + Args: + module: The `.ftm` module to export. + nsf: Where the NSF is written. + log: Where FamiTracker writes what it did. + + Returns: + List[str]: The program and its arguments. + + Raises: + FamiTrackerError: If Wine fails to map the paths. + """ + windows_module, windows_nsf, windows_log = self.windows_paths((module, nsf, log)) + return [ + str(self.wine), + str(self.executable), + windows_module, + EXPORT_SWITCH, + windows_nsf, + windows_log, + ] + + def windows_paths(self, paths: Sequence[Path]) -> Tuple[str, ...]: + """The Windows path Wine maps each path to, asked of Wine in one call. + + Args: + paths: The paths, absolute or relative to the working directory. + + Returns: + Tuple[str, ...]: One Windows path per path, in the same order. + + Raises: + FamiTrackerError: If Wine fails to map them. + """ + try: + completed = subprocess.run( + [str(self.wine), WINEPATH, WINDOWS_PATH_SWITCH, *(str(path.resolve()) for path in paths)], + env=self.environment(), + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as error: + raise FamiTrackerError(f"Wine failed to map the paths it hands FamiTracker:\n{error.stderr}") from error + + mapped = tuple(completed.stdout.splitlines()) + if len(mapped) != len(paths): + raise FamiTrackerError(f"Wine mapped {len(paths)} paths to {len(mapped)}: {completed.stdout!r}") + + return mapped + + def environment(self) -> Dict[str, str]: + """The environment the export runs in: the check's own, with no display and Wine's diagnostics silenced.""" + environment = {name: value for name, value in os.environ.items() if name not in DISPLAY_VARIABLES} + environment[WINE_DEBUG] = WINE_DEBUG_SILENT + return environment diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/target.py b/src/sampletones_tools/tracker_playback/targets/famitracker/target.py new file mode 100644 index 000000000..d4c1a8356 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/target.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Final, Self + +from sampletones_core.exporters.skipped import BuiltDocument +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.project.project import Project +from sampletones_shared.exceptions.validation import InvalidDataError +from sampletones_shared.paths.extensions import EXT_FILE_JSON, EXT_FILE_LOG, EXT_FILE_MODULE, EXT_FILE_NSF +from sampletones_tools.console.errors import ConsoleError +from sampletones_tools.console.machine import Console +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.export import export_nsf +from sampletones_tools.tracker_playback.targets.famitracker.markers import marked_module +from sampletones_tools.tracker_playback.targets.famitracker.program.factory import located_program +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import FamiTrackerProgram +from sampletones_tools.tracker_playback.targets.famitracker.trace import DriverTrace, recorded_trace +from sampletones_tools.tracker_playback.targets.protocol import TargetPlayback + +TITLE: Final[str] = "FamiTracker" +PLAYER: Final[str] = "FamiTracker's own sound driver, in the NSF `{executable}` exported, run on an emulated 6502" + + +@dataclass(frozen=True) +class FamiTrackerTarget: + """FamiTracker as a playback target: a project exported to a module, which FamiTracker exports to an NSF. + + The NSF carries FamiTracker's own sound driver, the code a console or an NSF player runs, so + running it on an emulated 6502 plays the module the way FamiTracker's exports play it. + + Attributes: + program: FamiTracker, as this system runs it. + """ + + program: FamiTrackerProgram + + @classmethod + def located(cls, executable: Path) -> Self: + """The target exporting with FamiTracker at ``executable``. + + Args: + executable: The FamiTracker program file. + + Returns: + Self: The target. + + Raises: + FamiTrackerError: If ``executable`` is no Windows program file, or Wine is absent where it is needed. + """ + return cls(program=located_program(executable)) + + @property + def title(self) -> str: + """The tracker's name, as the report prints it.""" + return TITLE + + @property + def player(self) -> str: + """The program whose NSF driver plays the modules, as the report introduces it.""" + return PLAYER.format(executable=self.program.executable) + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + """Exports a project to a module, has FamiTracker export it to an NSF, and reads every tick its driver plays. + + The module is built by the exporter the application's FamiTracker export runs and kept as + it is. FamiTracker exports a copy with a marker on every row (see :func:`marked_module`), + so its NSF says where each row starts. The NSF and every write its driver made are kept + beside the module. + + Args: + project: The project to export and play. + directory: Where the module, the NSF and the driver's writes are written. + name: The stem every file is written under. + + Returns: + TargetPlayback: The module, what FamiTracker played of it, and what the export left out. + + Raises: + FamiTrackerError: If the project exceeds what a module holds, FamiTracker writes no NSF, + or its NSF fails to play. + """ + built = _built_module(project, name) + document = directory / f"{name}{EXT_FILE_MODULE}" + document.write_bytes(module_to_ftm_bytes(built.document)) + nsf = directory / f"{name}{EXT_FILE_NSF}" + self._export_marked(built.document, nsf, name) + recorded = _played(nsf, built.document) + (directory / f"{name}{EXT_FILE_JSON}").write_text(recorded.model_dump_json(), encoding="utf-8") + return TargetPlayback( + document=document, + trace=recorded.song_trace(), + skipped_rows=built.skipped_rows, + truncation=built.truncation, + ) + + def _export_marked( + self, + module: FamiTrackerModule, + nsf: Path, + name: str, + ) -> None: + with TemporaryDirectory() as scratch: + marked = Path(scratch) / f"{name}{EXT_FILE_MODULE}" + marked.write_bytes(module_to_ftm_bytes(marked_module(module))) + export_nsf(self.program, marked, nsf, Path(scratch) / f"{name}{EXT_FILE_LOG}") + + +def _built_module(project: Project, name: str) -> BuiltDocument[FamiTrackerModule]: + try: + return build_module(project) + except ValueError as error: + raise FamiTrackerError(f"{name} doesn't fit a FamiTracker module: {error}") from error + + +def _played(nsf: Path, module: FamiTrackerModule) -> DriverTrace: + try: + return recorded_trace( + Console(nsf.read_bytes()), + frames=len(module.track.order), + rows=module.track.rows_per_pattern, + ) + except (ConsoleError, InvalidDataError) as error: + raise FamiTrackerError(f"The NSF FamiTracker exported to {nsf} failed to play: {error}") from error diff --git a/src/sampletones_tools/tracker_playback/targets/famitracker/trace.py b/src/sampletones_tools/tracker_playback/targets/famitracker/trace.py new file mode 100644 index 000000000..4fbe811e3 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/famitracker/trace.py @@ -0,0 +1,164 @@ +from typing import List, Protocol, Tuple + +from pydantic import BaseModel, ConfigDict, Field + +from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + DMC_DIRECT_LOAD, + FIRST_CHANNEL_REGISTER, + MAX_REGISTER_VALUE, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.markers import row_marker +from sampletones_tools.tracker_playback.trace.decode import song_trace +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import SongTrace, TickPosition + + +class PlayCalls(Protocol): + """What runs an NSF one routine at a time and answers with the APU writes each routine made.""" + + def initialize(self) -> Tuple[RegisterWrite, ...]: + """Runs the init routine.""" + + def play(self) -> Tuple[RegisterWrite, ...]: + """Runs one play call.""" + + +class DriverWrite(BaseModel): + """One write FamiTracker's NSF driver makes to the APU. + + Attributes: + address: The register written. + value: The byte written. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + address: int = Field(..., ge=FIRST_CHANNEL_REGISTER, le=APU_FRAME_COUNTER) + value: int = Field(..., ge=0, le=MAX_REGISTER_VALUE) + + +class DriverTick(BaseModel): + """Every write FamiTracker's NSF driver made on one tick, and where in the song the tick falls. + + The first tick also carries the writes of any play call before the song's first row. + + Attributes: + frame: The order frame being played. + row: The row of that frame's patterns. + writes: The writes, in the order the driver made them. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + frame: int + row: int + writes: Tuple[DriverWrite, ...] + + +class DriverTrace(BaseModel): + """One pass through a song as FamiTracker's NSF driver plays it, tick by tick. + + Attributes: + initialization: The writes the init routine made. + ticks: Every tick of the pass. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + initialization: Tuple[DriverWrite, ...] + ticks: Tuple[DriverTick, ...] + + def song_trace(self) -> SongTrace: + """What every channel sounds on every tick, read out of the registers the driver wrote. + + Returns: + SongTrace: The pass. + """ + registers = ChipRegisters.power_up().written(_register_writes(self.initialization)) + per_tick: List[ChipRegisters] = [] + for tick in self.ticks: + registers = registers.written(_register_writes(tick.writes)) + per_tick.append(registers) + + return song_trace( + tuple(TickPosition(frame=tick.frame, row=tick.row) for tick in self.ticks), + per_tick, + ) + + +def recorded_trace( + calls: PlayCalls, + *, + frames: int, + rows: int, +) -> DriverTrace: + """Plays one pass through a song whose rows carry markers, and splits the play calls into its ticks. + + A play call writing a marker starts a row, and every call up to the next marker plays that row. + The pass ends at the marker after the song's last row, where the order comes back round. No + project holds a row longer than ``MAX_TICKS_PER_ROW`` ticks, so a row lasting longer is a driver + that stopped moving through the song. + + Args: + calls: The NSF's routines. + frames: The order frames the song plays. + rows: The rows each pattern holds. + + Returns: + DriverTrace: The pass. + + Raises: + FamiTrackerError: If a row's marker carries a level other than its place in the song, or a row + lasts longer than ``MAX_TICKS_PER_ROW`` calls. + """ + initialization = _driver_writes(calls.initialize()) + song_rows = frames * rows + row_index = -1 + row_ticks = 0 + pending: List[DriverWrite] = [] + ticks: List[DriverTick] = [] + while True: + writes = calls.play() + markers = [write.value for write in writes if write.address == DMC_DIRECT_LOAD] + if markers: + row_index += 1 + row_ticks = 0 + if row_index == song_rows: + break + + _check_marker(markers[-1], row_index) + + row_ticks += 1 + if row_ticks > MAX_TICKS_PER_ROW: + raise FamiTrackerError( + f"FamiTracker's NSF driver held row {row_index} for more than {MAX_TICKS_PER_ROW} ticks" + ) + + pending.extend(_driver_writes(writes)) + if row_index < 0: + continue + + ticks.append(DriverTick(frame=row_index // rows, row=row_index % rows, writes=tuple(pending))) + pending = [] + + return DriverTrace(initialization=initialization, ticks=tuple(ticks)) + + +def _check_marker(level: int, row_index: int) -> None: + expected = row_marker(row_index) + if level != expected: + raise FamiTrackerError( + f"FamiTracker's NSF driver marked row {row_index} with {level}, where that row carries {expected}" + ) + + +def _driver_writes(writes: Tuple[RegisterWrite, ...]) -> Tuple[DriverWrite, ...]: + return tuple(DriverWrite(address=write.address, value=write.value) for write in writes) + + +def _register_writes(writes: Tuple[DriverWrite, ...]) -> Tuple[RegisterWrite, ...]: + return tuple(RegisterWrite(write.address, write.value) for write in writes) diff --git a/src/sampletones_tools/tracker_playback/targets/protocol.py b/src/sampletones_tools/tracker_playback/targets/protocol.py new file mode 100644 index 000000000..8be674ebe --- /dev/null +++ b/src/sampletones_tools/tracker_playback/targets/protocol.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Protocol, Tuple + +from sampletones_core.exporters.skipped import SkippedRow +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.project.project import Project +from sampletones_shared.exceptions import SampleToNESError +from sampletones_tools.tracker_playback.trace.sound import SongTrace + + +class PlaybackError(SampleToNESError): + """A target could not play a project: the tracker or what runs it is missing, or the run failed.""" + + +@dataclass(frozen=True) +class TargetPlayback: + """What a target made of one project, and what every channel sounded when the tracker played it. + + Attributes: + document: The file the export wrote, which the tracker played. + trace: What each channel sounded on every engine tick, read as the registers the chip takes. + skipped_rows: The rows the export wrote other than the song plays them, each with why. + truncation: The instruments the format's value limit shortened, or ``None`` where every one fit. + """ + + document: Path + trace: SongTrace + skipped_rows: Tuple[SkippedRow, ...] + truncation: Optional[EnvelopeTruncation] + + +class PlaybackTarget(Protocol): + """A tracker a project is exported to and played by, so what it plays can be held against the app. + + Each tracker is one implementation. It exports the project with the application's own exporter, + plays the file with the tracker's own playback code, and reads what every channel sounds on every + tick into a ``SongTrace``, whatever produced those ticks. + """ + + @property + def title(self) -> str: + """The tracker's name, as the report prints it.""" + + @property + def player(self) -> str: + """What plays the exported files, as the report introduces it.""" + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + """Exports a project, plays the file with the tracker, and reads every tick of every channel. + + Args: + project: The project to export and play. + directory: Where the exported file and whatever its playing writes are kept. + name: The stem every file of this project is written under. + + Returns: + TargetPlayback: The file exported, what it played, and what the export left out. + + Raises: + PlaybackError: If the tracker fails to play the file. + """ diff --git a/src/sampletones_tools/tracker_playback/trace/__init__.py b/src/sampletones_tools/tracker_playback/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_tools/tracker_playback/trace/application.py b/src/sampletones_tools/tracker_playback/trace/application.py new file mode 100644 index 000000000..1b6d583d7 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/application.py @@ -0,0 +1,90 @@ +from typing import List, Tuple + +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project +from sampletones_core.project.tuning import tuning_from_project +from sampletones_core.timers.utils import get_timer_table +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.builder import streams_from_instructions +from sampletones_player.registers.streams import ChannelStreams +from sampletones_tools.player.trace.trace import channel_writes, setup_writes +from sampletones_tools.tracker_playback.trace.decode import song_trace +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import SongTrace, TickPosition + + +def application_trace(project: Project) -> SongTrace: + """What the application plays of a project, tick by tick, read out of the registers the console holds. + + The song is walked once into the instructions each channel sounds, the same walk the sequencer + and the NES player read, and those instructions become register values through the player's own + encoding. The registers the driver writes are then read the way a tracker's are, so both sides + of a comparison come out of the chip's registers by one rule. + + Args: + project: The project whose song is played. + + Returns: + SongTrace: One pass through the song, the order played once. + """ + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + streams = streams_from_instructions( + song_instructions(project), + get_timer_table(tuning_from_project(project)), + ) + return song_trace( + song_positions( + timing, + project.song.order_length(), + ), + driver_registers( + streams, + timing.frame_tick(project.song.order_length()), + ), + ) + + +def driver_registers( + streams: ChannelStreams, + ticks: int, +) -> Tuple[ChipRegisters, ...]: + """The console's registers on every tick of a song as the NES player's driver leaves them. + + The driver's init routine sets the console up, and every tick writes each channel's registers + over what the tick before left. A channel past the end of its stream holds its final values. + + Args: + streams: The register values each channel writes on each tick. + ticks: The ticks the song lasts. + + Returns: + Tuple[ChipRegisters, ...]: The registers on each tick, one per tick. + """ + registers = ChipRegisters.power_up().written(setup_writes()) + per_tick: List[ChipRegisters] = [] + for tick in range(ticks): + registers = registers.written(channel_writes(streams.at(tick))) + per_tick.append(registers) + + return tuple(per_tick) + + +def song_positions( + timing: SongTiming, + frames: int, +) -> Tuple[TickPosition, ...]: + """Where each tick of a song falls, every frame lasting the groove its place in the song gives it. + + Args: + timing: How many ticks every row of the song lasts. + frames: The order frames the song plays. + + Returns: + Tuple[TickPosition, ...]: One position per tick, in order. + """ + positions: List[TickPosition] = [] + for frame in range(frames): + for row, row_ticks in enumerate(timing.groove(frame).ticks): + positions.extend(TickPosition(frame=frame, row=row) for _ in range(row_ticks)) + + return tuple(positions) diff --git a/src/sampletones_tools/tracker_playback/trace/decode.py b/src/sampletones_tools/tracker_playback/trace/decode.py new file mode 100644 index 000000000..fd05dde93 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/decode.py @@ -0,0 +1,219 @@ +from typing import Dict, Final, NamedTuple, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MAX_VOLUME, SILENT_VOLUME +from sampletones_player.specification.registers import ( + APU_STATUS, + CONSTANT_VOLUME, + DUTY_CYCLE_SHIFT, + MAX_TIMER_HIGH, + NOISE_CONTROL, + NOISE_MODE_SHIFT, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + PULSE2_TIMER_HIGH, + PULSE2_TIMER_LOW, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import ( + ABSENT_REGISTER, + ChannelSound, + SongTrace, + TickPosition, +) + +CHANNEL_STATUS_BITS: Final[Dict[ChannelName, int]] = { + ChannelName.PULSE1: 0x01, + ChannelName.PULSE2: 0x02, + ChannelName.TRIANGLE: 0x04, + ChannelName.NOISE: 0x08, +} +DMC_STATUS_BIT: Final[int] = 0x10 +LINEAR_COUNTER_RELOAD: Final[int] = 0x7F + + +class ToneAddresses(NamedTuple): + """The registers a pulse or triangle channel keeps its control byte and its timer in. + + Attributes: + control: The byte carrying the level and the duty cycle on a pulse channel, and the linear + counter on the triangle. + timer_low: The timer's low byte. + timer_high: The byte carrying the timer's top three bits under the length index. + """ + + control: int + timer_low: int + timer_high: int + + +TONE_ADDRESSES: Final[Dict[ChannelName, ToneAddresses]] = { + ChannelName.PULSE1: ToneAddresses( + control=PULSE1_CONTROL, + timer_low=PULSE1_TIMER_LOW, + timer_high=PULSE1_TIMER_HIGH, + ), + ChannelName.PULSE2: ToneAddresses( + control=PULSE2_CONTROL, + timer_low=PULSE2_TIMER_LOW, + timer_high=PULSE2_TIMER_HIGH, + ), + ChannelName.TRIANGLE: ToneAddresses( + control=TRIANGLE_LINEAR_COUNTER, + timer_low=TRIANGLE_TIMER_LOW, + timer_high=TRIANGLE_TIMER_HIGH, + ), +} + + +def song_trace( + positions: Tuple[TickPosition, ...], + registers: Sequence[ChipRegisters], +) -> SongTrace: + """What every channel plays on every tick of a song, read out of the registers the chip holds on each. + + Args: + positions: Where each tick falls. + registers: The chip's registers on each tick, one per position. + + Returns: + SongTrace: Each channel's sound on every tick. + + Raises: + ValueError: If the registers cover a different number of ticks than the positions. + """ + if len(registers) != len(positions): + raise ValueError(f"{len(positions)} ticks have {len(registers)} register states") + + return SongTrace( + positions=positions, + channels={ + channel: tuple(channel_sound(channel, tick_registers) for tick_registers in registers) + for channel in ChannelName.items() + }, + ) + + +def channel_sound( + channel: ChannelName, + registers: ChipRegisters, +) -> ChannelSound: + """What the chip plays of one channel from the registers it holds. + + Args: + channel: The channel read. + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + match channel: + case ChannelName.PULSE1 | ChannelName.PULSE2: + return pulse_sound(channel, registers) + case ChannelName.TRIANGLE: + return triangle_sound(registers) + case ChannelName.NOISE: + return noise_sound(registers) + + +def pulse_sound( + channel: ChannelName, + registers: ChipRegisters, +) -> ChannelSound: + """A pulse channel's sound: the duty cycle and the level ride its control byte, the timer its two timer bytes. + + Args: + channel: The pulse channel read. + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + addresses = TONE_ADDRESSES[channel] + control = registers.value(addresses.control) + return ChannelSound( + audible=is_enabled(channel, registers) and level_sounds(control), + period=timer(addresses, registers), + volume=control & MAX_VOLUME, + held=level_held(control), + timbre=control >> DUTY_CYCLE_SHIFT, + ) + + +def triangle_sound(registers: ChipRegisters) -> ChannelSound: + """The triangle's sound: the linear counter's reload says whether it sounds, the timer at what pitch. + + A reload of zero leaves the counter at zero, which stops the waveform. The control flag reloads + the counter every frame, holding the note for as long as the registers stay. + + Args: + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + addresses = TONE_ADDRESSES[ChannelName.TRIANGLE] + control = registers.value(addresses.control) + return ChannelSound( + audible=is_enabled(ChannelName.TRIANGLE, registers) and (control & LINEAR_COUNTER_RELOAD) > 0, + period=timer(addresses, registers), + volume=ABSENT_REGISTER, + held=bool(control & TRIANGLE_COUNTER_CONTROL), + timbre=ABSENT_REGISTER, + ) + + +def noise_sound(registers: ChipRegisters) -> ChannelSound: + """The noise channel's sound: the level rides its control byte, the period index and the mode its period byte. + + Args: + registers: The chip's registers. + + Returns: + ChannelSound: The channel's sound. + """ + control = registers.value(NOISE_CONTROL) + period = registers.value(NOISE_PERIOD) + return ChannelSound( + audible=is_enabled(ChannelName.NOISE, registers) and level_sounds(control), + period=period & MAX_PERIOD, + volume=control & MAX_VOLUME, + held=level_held(control), + timbre=period >> NOISE_MODE_SHIFT, + ) + + +def is_enabled( + channel: ChannelName, + registers: ChipRegisters, +) -> bool: + """Whether the status register enables a channel, the first thing a channel needs to sound.""" + return bool(registers.value(APU_STATUS) & CHANNEL_STATUS_BITS[channel]) + + +def timer( + addresses: ToneAddresses, + registers: ChipRegisters, +) -> int: + """The 11-bit timer a pulse or triangle channel's two timer bytes carry together.""" + high = registers.value(addresses.timer_high) & MAX_TIMER_HIGH + return (high << TIMER_HIGH_SHIFT) | registers.value(addresses.timer_low) + + +def level_sounds(control: int) -> bool: + """Whether a pulse or noise control byte sounds: a constant level above zero, or the envelope, opening at full.""" + return not (control & CONSTANT_VOLUME) or (control & MAX_VOLUME) > SILENT_VOLUME + + +def level_held(control: int) -> bool: + """Whether a pulse or noise control byte holds its level: a constant volume under a halted length counter.""" + return (control & SUSTAINED_LEVEL) == SUSTAINED_LEVEL diff --git a/src/sampletones_tools/tracker_playback/trace/registers.py b/src/sampletones_tools/tracker_playback/trace/registers.py new file mode 100644 index 000000000..095dcc3c5 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/registers.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Dict, Final, Iterable, Mapping, Self + +from sampletones_tools.player.trace.write import RegisterWrite + +POWER_UP_VALUE: Final[int] = 0 + + +@dataclass(frozen=True) +class ChipRegisters: + """The value standing in each APU register once a run of writes has landed. + + A register keeps the last value written to it, so the registers on a tick are those the tick + before left with the tick's own writes laid over them. That is what the chip plays from, + whichever registers a player chose to write on the tick. A register never written stands at + zero, the value the APU powers up with, which leaves every channel disabled. + + Attributes: + values: The last value written to each register, by address. + """ + + values: Mapping[int, int] + + @classmethod + def power_up(cls) -> Self: + """The registers before any write lands.""" + return cls(values={}) + + def written(self, writes: Iterable[RegisterWrite]) -> Self: + """The registers once ``writes`` land on them, in order, a later write to a register replacing an earlier one. + + Args: + writes: The writes, in the order the chip takes them. + + Returns: + Self: The registers after the writes. + """ + values: Dict[int, int] = dict(self.values) + for write in writes: + values[write.address] = write.value + + return type(self)(values=values) + + def value(self, address: int) -> int: + """The value standing in one register. + + Args: + address: The register. + + Returns: + int: The last value written to it, or ``POWER_UP_VALUE`` for a register never written. + """ + return self.values.get(address, POWER_UP_VALUE) diff --git a/src/sampletones_tools/tracker_playback/trace/sound.py b/src/sampletones_tools/tracker_playback/trace/sound.py new file mode 100644 index 000000000..5cff2cb91 --- /dev/null +++ b/src/sampletones_tools/tracker_playback/trace/sound.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from typing import Dict, Final, Tuple + +from sampletones_core.constants.enums import ChannelName + +ABSENT_REGISTER: Final[int] = 0 + + +@dataclass(frozen=True) +class ChannelSound: + """What one channel sounds on one engine tick, read out of the registers the chip holds. + + Both players are read into this one shape from the registers each writes, so a tick of the + application and a tick of a tracker compare field by field. A register the channel lacks reads + as ``ABSENT_REGISTER`` on both sides. + + Attributes: + audible: Whether the channel sounds at all. + period: The period register: the 11-bit timer on the pulse and triangle channels, and the + 4-bit period index on the noise channel. + volume: The level on the pulse and noise channels, and the envelope's decay period where the + chip runs its envelope. + held: Whether the chip holds the channel where its registers put it. On the pulse and noise + channels the level is constant and the length counter halted, and on the triangle the + control flag halts its linear counter and its length counter together. Where it is + clear, the chip's own envelope and counters move the channel on from there. + timbre: The duty cycle on the pulse channels, and the short mode on the noise channel. + """ + + audible: bool + period: int + volume: int + held: bool + timbre: int + + +@dataclass(frozen=True) +class TickPosition: + """Where in the song one engine tick falls. + + Attributes: + frame: The order frame being played. + row: The row of that frame's patterns. + """ + + frame: int + row: int + + +@dataclass(frozen=True) +class SongTrace: + """What every channel sounds on every engine tick of one pass through a song. + + Attributes: + positions: Where each tick falls, one per tick. + channels: Each channel's sound, one per tick. + """ + + positions: Tuple[TickPosition, ...] + channels: Dict[ChannelName, Tuple[ChannelSound, ...]] + + @property + def ticks(self) -> int: + """The engine ticks the pass lasts.""" + return len(self.positions) diff --git a/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..aaa5d2942 --- /dev/null +++ b/tests/benchmarks/conftest.py @@ -0,0 +1,11 @@ +from tests.integration.conftest import ( + instrument_catalog, + integration_project, + synthetic_corpus, +) + +__all__ = [ + "instrument_catalog", + "integration_project", + "synthetic_corpus", +] diff --git a/tests/benchmarks/history/__init__.py b/tests/benchmarks/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/benchmarks/history/conftest.py b/tests/benchmarks/history/conftest.py new file mode 100644 index 000000000..26c44ba30 --- /dev/null +++ b/tests/benchmarks/history/conftest.py @@ -0,0 +1,30 @@ +from typing import Final + +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 tests.suite.history.wiring import wired_history + +HISTORY_BUDGET: Final[int] = 500 + + +@pytest.fixture(name="controller") +def controller_fixture() -> ProjectController: + """A controller over a new project, which a module arranges before its history starts.""" + controller = ProjectController(ProjectManager()) + controller.new() + return controller + + +@pytest.fixture(name="history") +def history_fixture(arranged: ProjectController) -> HistoryManager: + """A history whose first entry is the arranged project. + + The history records the entries alone, as a user build's does, so a reading is what storing an + entry costs. The fingerprint a strict deployment adds to each commit is a cost of its own. + """ + history = wired_history(arranged, budget=HISTORY_BUDGET, strict=False) + history.reset() + return history diff --git a/tests/benchmarks/history/test_channel_edits.py b/tests/benchmarks/history/test_channel_edits.py new file mode 100644 index 000000000..41eca3ac3 --- /dev/null +++ b/tests/benchmarks/history/test_channel_edits.py @@ -0,0 +1,165 @@ +import copy +from pathlib import Path +from typing import Dict, Final, List + +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_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.constants.general import BYTES_PER_MEGABYTE +from tests.suite.memory import retained_bytes +from tests.suite.stems import single_entry_stems_data +from tests.suite.timing import seconds + +DOCUMENT_FRAMES: Final[int] = 4_000 +EDITS: Final[int] = 8 +SHARE_MARGIN: Final[float] = 1.5 +LOWEST_PITCH: Final[int] = 36 +PITCH_SPAN: Final[int] = 48 +VOLUMES: Final[int] = 16 +DUTY_CYCLES: Final[int] = 4 +NOISE_PERIODS: Final[int] = 16 +SAMPLE_NAME: Final[str] = "Long conversion" +EDITED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def _instruction( + channel_name: ChannelName, + frame: int, +) -> InstructionUnion: + match channel_name: + case ChannelName.PULSE1 | ChannelName.PULSE2: + return PulseInstruction( + on=True, + pitch=LOWEST_PITCH + frame % PITCH_SPAN, + volume=frame % VOLUMES, + duty_cycle=frame % DUTY_CYCLES, + ) + case ChannelName.TRIANGLE: + return TriangleInstruction(on=True, pitch=LOWEST_PITCH + frame % PITCH_SPAN) + case ChannelName.NOISE: + return NoiseInstruction(on=True, period=frame % NOISE_PERIODS, volume=frame % VOLUMES, short=False) + + +@pytest.fixture(scope="module", name="document") +def document_fixture() -> Reconstruction: + """A document shaped like a long conversion: every channel sounding, each frame an instruction of its own.""" + instructions: Dict[ChannelName, List[InstructionUnion]] = { + channel_name: [_instruction(channel_name, frame) for frame in range(DOCUMENT_FRAMES)] + for channel_name in ChannelName.items() + } + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data(ChannelName.items(), instructions), + ) + + +@pytest.fixture(name="arranged") +def arranged_fixture( + controller: ProjectController, + document: Reconstruction, +) -> ProjectController: + """The project holding the document as its one sample.""" + controller.add_sample(document, SAMPLE_NAME) + return controller + + +def _sample(controller: ProjectController) -> Sample: + return next(voice for voice in controller.project.voices if isinstance(voice, Sample)) + + +def _rebuilt_channel( + reconstruction: Reconstruction, + channel_name: ChannelName, +) -> List[InstructionUnion]: + """The channel's instructions built anew, the way a rebuild hands back every frame of the channel it redraws.""" + return [instruction.model_copy() for instruction in reconstruction.instructions[channel_name]] + + +def _land( + controller: ProjectController, + history: HistoryManager, + channel_name: ChannelName, + instructions: List[InstructionUnion], +) -> None: + sample = _sample(controller) + live = sample.reconstruction + updated = live.with_channel_data( + channel_name, + instructions, + live.initial_pitches[channel_name], + live.held_features[channel_name], + heard=live.recorded_stem_ids, + ) + with history.transaction(HistoryAction.EDIT_RECONSTRUCTION): + controller.replace_sample_reconstruction(sample.id, updated) + + +class TestAChannelEditHoldsOneChannel: + """An entry a channel edit leaves holds the instructions of the channel the edit rebuilt, and shares + every other channel with the entries beside it. + + The reference is what copying the whole document leaves allocated, which is what an entry held + while a snapshot copied the document. A reading near the whole document is an entry holding + channels its edit left alone. + """ + + def test_an_entry_holds_one_channels_share_of_the_document( + self, + arranged: ProjectController, + history: HistoryManager, + document: Reconstruction, + ) -> None: + channels = ChannelName.items() + whole = retained_bytes(lambda: copy.deepcopy(document)) + + def edits() -> None: + for edit in range(EDITS): + channel_name = channels[edit % len(channels)] + rebuilt = _rebuilt_channel(_sample(arranged).reconstruction, channel_name) + _land(arranged, history, channel_name, rebuilt) + + per_entry = retained_bytes(edits) / EDITS + share = whole / len(channels) + + assert per_entry < share * SHARE_MARGIN, ( + f"an entry {per_entry / BYTES_PER_MEGABYTE:.2f} MB, " + f"a channel's share {share / BYTES_PER_MEGABYTE:.2f} MB" + ) + + +class TestAChannelEditLandsAtOneChannelsCost: + """Landing a channel edit builds the document around the one channel the edit rebuilt, and the + history keeps the document it lands. + + The reference is copying the whole document, which is what a rebuild paid while it copied the + document to write one channel into it. Landing stays under one channel's share of that copy. + """ + + def test_landing_costs_one_channels_share_of_a_copy( + self, + arranged: ProjectController, + history: HistoryManager, + document: Reconstruction, + ) -> None: + rebuilt = _rebuilt_channel(document, EDITED_CHANNEL) + + copying = seconds(lambda: copy.deepcopy(document)) + landing = seconds(lambda: _land(arranged, history, EDITED_CHANNEL, rebuilt)) + share = copying / len(ChannelName.items()) + + assert landing < share, f"landing {landing:.4f} s, a channel's share of a copy {share:.4f} s" diff --git a/tests/benchmarks/history/test_row_edits.py b/tests/benchmarks/history/test_row_edits.py new file mode 100644 index 000000000..43bd2a665 --- /dev/null +++ b/tests/benchmarks/history/test_row_edits.py @@ -0,0 +1,108 @@ +import copy +from itertools import count +from typing import Final + +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_core.constants.enums import ChannelName +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_shared.constants.general import BYTES_PER_MEGABYTE +from tests.suite.memory import retained_bytes +from tests.suite.timing import seconds + +SONG_PATTERNS: Final[int] = 300 +ROWS_PER_PATTERN: Final[int] = 64 +VOLUMES: Final[int] = 16 +EDITS: Final[int] = 20 +ENTRY_SHARE: Final[float] = 0.01 +LANDING_SHARE: Final[float] = 0.1 +EDITED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def _written_pattern(frame: int) -> Pattern: + rows = {row: Row(volume=(row + frame) % VOLUMES) for row in range(ROWS_PER_PATTERN)} + return Pattern.empty(ROWS_PER_PATTERN).with_rows(rows) + + +@pytest.fixture(name="arranged") +def arranged_fixture(controller: ProjectController) -> ProjectController: + """A written song of SONG_PATTERNS patterns, each frame a pattern of its own on every channel.""" + song = controller.project.song + song.resize_patterns(ROWS_PER_PATTERN) + frames = SONG_PATTERNS // len(song.channels) + for frame in range(frames): + if frame >= song.order_length(): + song.append_frame() + for channel_name, channel in song.channels.items(): + channel.patterns[frame] = _written_pattern(frame) + song.set_order_entry(frame, channel_name, frame) + + return controller + + +def _land( + controller: ProjectController, + history: HistoryManager, + step: int, +) -> None: + """Writes a volume into one cell, a cell of its own for each step, each write changing the cell it reaches.""" + song = controller.project.song + pattern_index = step % song.order_length() + row_index = step % ROWS_PER_PATTERN + held = song.channels[EDITED_CHANNEL].get_row(pattern_index, row_index) + volume = ((held.volume or 0) + 1) % VOLUMES + with history.transaction(HistoryAction.EDIT_ROW): + controller.set_row(EDITED_CHANNEL, pattern_index, row_index, volume=volume) + + +class TestARowEditHoldsOnePattern: + """An entry a row edit leaves holds the one pattern the edit wrote, and shares every other pattern + and every row with the entries beside it. + + The reference is what copying the song leaves allocated, which is what an entry held while a + snapshot copied every row. An entry holds a sliver of it: the pattern it wrote, and the order and + the pattern pools around the patterns. + """ + + def test_an_entry_holds_a_sliver_of_the_song( + self, + arranged: ProjectController, + history: HistoryManager, + ) -> None: + song = arranged.project.song + steps = count(1) + whole = retained_bytes(lambda: copy.deepcopy(song)) + + def edits() -> None: + for _ in range(EDITS): + _land(arranged, history, next(steps)) + + per_entry = retained_bytes(edits) / EDITS + + assert ( + per_entry < whole * ENTRY_SHARE + ), f"an entry {per_entry / BYTES_PER_MEGABYTE:.3f} MB, the song {whole / BYTES_PER_MEGABYTE:.2f} MB" + + +class TestARowEditLandsAtOnePatternsCost: + """Landing a row edit writes one pattern and copies the order and the pools around the patterns. + + The reference is copying the song, which is what landing paid while a snapshot copied every row. + """ + + def test_landing_costs_a_sliver_of_a_copy( + self, + arranged: ProjectController, + history: HistoryManager, + ) -> None: + song = arranged.project.song + steps = count(1) + + copying = seconds(lambda: copy.deepcopy(song)) + landing = seconds(lambda: _land(arranged, history, next(steps))) + + assert landing < copying * LANDING_SHARE, f"landing {landing:.5f} s, copying the song {copying:.4f} s" diff --git a/tests/benchmarks/test_compression.py b/tests/benchmarks/test_compression.py new file mode 100644 index 000000000..0753900f3 --- /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 sampletones_tools.codec.report.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_converter_load.py b/tests/benchmarks/test_converter_load.py new file mode 100644 index 000000000..4d9024525 --- /dev/null +++ b/tests/benchmarks/test_converter_load.py @@ -0,0 +1,382 @@ +from itertools import count +from pathlib import Path +from typing import Callable, Final, Iterator, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.setup import batch_entries +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.converter.view import stem_rows +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.general import SUF_TEXT +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.gestures import ROW_WIDGET_KINDS +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import GATHERED_SOURCES +from sampletones_application.ui.elements.stems.tags import StemsTags +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 StemsListViewModel +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter.paths.utils import config_directory_path +from sampletones_core.reconstructions.converter.plan.batch import BatchConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.timing import seconds + +SMALL_FOLDER: Final[int] = 1_000 +LARGE_FOLDER: Final[int] = 10_000 +GROWTH_ALLOWANCE: Final[float] = 2.0 +UNREADABLE: Final[float] = float("inf") +NAMING_SHARE: Final[float] = 0.1 +SETTINGS: Final[StemSettings] = StemSettings(channels=[ChannelName.PULSE1], bends=[]) +SMALL_ROOT: Final[Path] = Path("/gathered/small") +LARGE_ROOT: Final[Path] = Path("/gathered/large") +ROOT_TAG: Final[str] = "load_root" + + +def folder_of(root: Path, count: int) -> Folder: + """A gathered folder of ``count`` recordings, each settled the way a fresh one arrives.""" + return Folder( + root=root, + recordings=tuple(Recording(path=root / f"{index:06d}.wav", settings=SETTINGS) for index in range(count)), + ) + + +def gathering_of(root: Path, count: int) -> Gathering: + """The setup a reader is left with after gathering one folder of ``count`` recordings.""" + return Gathering.empty().listing_folder(folder_of(root, count)) + + +def state_of(root: Path, count: int) -> ConverterState: + """The setup a per-recording run derives its entries from, one folder gathered into it.""" + return ConverterState( + settings=RunSettings( + joining=SETTINGS, + output=OutputKind.PER_RECORDING, + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + ), + gathering=gathering_of(root, count), + destination=Destination.unset(), + selected=None, + ) + + +def growth(small: Callable[[], object], large: Callable[[], object]) -> Tuple[float, float, str]: + """What each size costs, and a line naming both readings and the growth between them. + + A clock coarse enough to read the smaller run as nothing at all reports the growth as + unbounded, so the line prints and the bound fails on the reading rather than on the division. + """ + one = seconds(small) + many = seconds(large) + ratio = many / one if one > 0 else UNREADABLE + report = ( + f"{SMALL_FOLDER} recordings {one * 1000:.1f} ms, " + f"{LARGE_FOLDER} recordings {many * 1000:.1f} ms, " + f"{ratio:.1f}x for {LARGE_FOLDER // SMALL_FOLDER}x the recordings" + ) + print(report) + return one, many, report + + +def linear(one: float) -> float: + """The most a reading may cost while the work it does still follows the list's length. + + ``GROWTH_ALLOWANCE`` is the room the reading itself takes. The smaller run is the divisor and + is warmed by whatever ran before it, so the two readings differ in more than the work between + them. The shapes these bounds are here to catch — the list read again for each row, or once + per folder standing in it — read fifty times over at ten thousand, so the allowance is wide + enough for the warmth and narrow enough for those. + """ + return one * (LARGE_FOLDER / SMALL_FOLDER) * GROWTH_ALLOWANCE + + +class TestGatheringAFolder(BaseTestSuite): + """A folder joins the list at the cost of the recordings it brought in. + + The list takes over the loose recordings a folder covers, which asks what already stands + against what is arriving. Answering that recording by recording would make gathering cost the + square of what a folder holds, which is the shape this bound catches: at ten thousand it is + the difference between a moment and a minute. + """ + + def test_it_costs_what_the_recordings_it_holds_cost(self) -> None: + small = folder_of(SMALL_ROOT, SMALL_FOLDER) + large = folder_of(LARGE_ROOT, LARGE_FOLDER) + one, many, report = growth(lambda: SourceList().add_folder(small), lambda: SourceList().add_folder(large)) + + assert many < linear(one), report + + def test_a_folder_joining_a_list_that_holds_one_costs_the_same(self) -> None: + """Gathering a second folder reads what the first holds, which is the O(n²) door.""" + standing = SourceList().add_folder(folder_of(SMALL_ROOT, LARGE_FOLDER)) + small = folder_of(Path("/gathered/second/small"), SMALL_FOLDER) + large = folder_of(Path("/gathered/second/large"), LARGE_FOLDER) + one, many, report = growth(lambda: standing.add_folder(small), lambda: standing.add_folder(large)) + + assert many < linear(one), report + + +class TestReadingTheRowsAGestureLeaves(BaseTestSuite): + """Every gesture reads the gathered sources into the rows the list draws. + + A folder is read down to the recordings it holds, each becoming a row of its own and each + answered for on the disk, so this is the reading a reader waits through on every click. The + bound holds it to the length of the list rather than to the list times its channels. + """ + + def test_it_costs_what_the_list_holds(self) -> None: + small = gathering_of(SMALL_ROOT, SMALL_FOLDER) + large = gathering_of(LARGE_ROOT, LARGE_FOLDER) + one, many, report = growth( + lambda: stem_rows(small, mixes=False), + lambda: stem_rows(large, mixes=False), + ) + + assert many < linear(one), report + + def test_it_reads_a_row_for_every_recording_a_folder_holds(self) -> None: + """What the reading costs is what it builds, which is a row apiece and the folder's own.""" + rows = stem_rows(gathering_of(LARGE_ROOT, LARGE_FOLDER), mixes=False) + + assert len(rows) == 1 + assert rows[0].holds == LARGE_FOLDER + + +class TestReadingWhereEachRecordingIsWritten(BaseTestSuite): + """Every gesture derives the entries a per-recording run would write. + + Settling the setup follows it to the destination, which builds one entry per gathered recording + holding a channel and asks the list which folder each was gathered from. So this runs on every + click beside the row reading, and the bound holds it to the length of the list rather than to + the list times the folders standing in it. + """ + + def test_it_costs_what_the_list_holds(self) -> None: + small = state_of(SMALL_ROOT, SMALL_FOLDER) + large = state_of(LARGE_ROOT, LARGE_FOLDER) + one, many, report = growth(lambda: batch_entries(small), lambda: batch_entries(large)) + + assert many < linear(one), report + + def test_it_writes_an_entry_for_every_recording_a_folder_holds(self) -> None: + """What the derivation costs is what it builds, which is one entry per recording.""" + entries = batch_entries(state_of(LARGE_ROOT, LARGE_FOLDER)) + + assert len(entries) == LARGE_FOLDER + assert entries[0].base_directory == LARGE_ROOT + + +class TestReadingWhereABatchWrites(BaseTestSuite): + """Every gesture follows the setup to its destination, which reads where every recording is written. + + Recordings sharing their channels share one folder, and the folder's name carries a hash of the + settings. Naming that folder again for each recording costs a hash apiece, which is the shape this + bound catches. A reading of that shape costs the whole of naming a folder for every recording and + more, while one that names a folder per channel set and looks the rest up costs a small share of + it, so ``NAMING_SHARE`` stands between the two. + """ + + def test_it_names_a_folder_per_channel_set(self) -> None: + config = Config() + plan = BatchConversion(entries=batch_entries(state_of(LARGE_ROOT, LARGE_FOLDER))) + channels = plan.entries[0].stems.covered_channels + naming = seconds(lambda: config_directory_path(config, channels)) + reading = seconds(lambda: plan.destination(config)) + report = ( + f"one folder named {naming * 1_000_000:.1f} us, " + f"{LARGE_FOLDER} recordings read {reading * 1000:.1f} ms, " + f"{reading / (naming * LARGE_FOLDER):.3f} of naming a folder for each" + ) + print(report) + + assert reading < naming * LARGE_FOLDER * NAMING_SHARE, report + + +class TestSettlingAChannel(BaseTestSuite): + """One box on a folder settles every recording it stands for. + + A folder answers as one group, so the gesture writes the whole of what it holds. That is work + the length of the list by design; what the bound catches is a settle that reads the list again + for each recording it writes. + """ + + def test_it_costs_what_the_folder_holds(self) -> None: + small = gathering_of(SMALL_ROOT, SMALL_FOLDER).sources + large = gathering_of(LARGE_ROOT, LARGE_FOLDER).sources + one, many, report = growth( + lambda: small.toggled(SourceKey.folder(SMALL_ROOT), CHANNEL_SLOT, ChannelName.TRIANGLE), + lambda: large.toggled(SourceKey.folder(LARGE_ROOT), CHANNEL_SLOT, ChannelName.TRIANGLE), + ) + + assert many < linear(one), report + + def test_settling_one_recording_inside_a_folder_costs_the_same(self) -> None: + """A reader who opens a folder answers for one recording in it, and pays for that one.""" + small = gathering_of(SMALL_ROOT, SMALL_FOLDER).sources + large = gathering_of(LARGE_ROOT, LARGE_FOLDER).sources + one, many, report = growth( + lambda: small.toggled(SourceKey.recording(SMALL_ROOT / "000500.wav"), CHANNEL_SLOT, ChannelName.NOISE), + lambda: large.toggled(SourceKey.recording(LARGE_ROOT / "005000.wav"), CHANNEL_SLOT, ChannelName.NOISE), + ) + + assert many < linear(one), report + + +@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)) + with dpg.window(tag=ROOT_TAG): + yield + + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture(scope="module") +def small_listing() -> StemsListViewModel: + return listing_of(SMALL_ROOT, SMALL_FOLDER) + + +@pytest.fixture(scope="module") +def large_listing() -> StemsListViewModel: + return listing_of(LARGE_ROOT, LARGE_FOLDER) + + +def listing_of(root: Path, count: int) -> StemsListViewModel: + """The reading the panel hands the list after a folder of ``count`` recordings is gathered.""" + return StemsListViewModel( + rows=stem_rows(Gathering.empty().listing_folder(folder_of(root, count)), mixes=False), + channels_in_play=tuple(ChannelName.items()), + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=True, + selected_key=None, + ) + + +def list_drawn_as(prefix: str, layout_config: LayoutConfig) -> GUIStemsList: + """A converter's list of gathered sources, drawn into the window standing open.""" + built = GUIStemsList( + prefix=prefix, + layout=layout_config.general.stems, + ceiling=layout_config.general.stems.well_ceiling, + glyphs=layout_config.glyphs.common, + stem_colors=layout_config.general.colors.stems, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + offer=GATHERED_SOURCES, + ) + built.create(ROOT_TAG) + return built + + +def cleared(prefix: str, stems_list: GUIStemsList) -> None: + """Take down everything one drawn list stands as: its own well, and the registries it made.""" + tags = StemsTags(prefix=prefix) + dpg.delete_item(stems_list.tag) + for kind in ROW_WIDGET_KINDS: + dpg.delete_item(tags.handlers(kind)) + + +def rows_on_screen(prefix: str, listing: StemsListViewModel) -> int: + """How many of a folder's recordings the list put widgets on screen for.""" + folder_row = listing.rows[0] + tags = StemsTags(prefix=prefix) + return sum(1 for held in folder_row.held if dpg.does_item_exist(tags.row(held.key, SUF_TEXT))) + + +class TestDrawingAGatheredFolder(BaseTestSuite): + """What an open folder builds is what a reader can see, however many recordings it holds. + + The rows outside the window stand as reserved room rather than as widgets, so the interface a + folder of ten thousand costs is the interface a folder of ten costs. Before a row has been + measured the region is generous, which is the window this counts against. + """ + + def test_it_builds_what_a_reader_can_see( + self, + dpg_context: None, + layout_config: LayoutConfig, + small_listing: StemsListViewModel, + large_listing: StemsListViewModel, + ) -> None: + layout = layout_config.general.stems + window = RowGeometry.opening_at(overscan=layout.window_overscan, opening=float(layout.name_height)).size( + float(layout.folder_ceiling) + ) + drawn = tuple( + self._opened(prefix, layout_config, listing) + for prefix, listing in (("load.small", small_listing), ("load.large", large_listing)) + ) + + assert drawn == (window, window) + + def test_opening_it_costs_what_a_reader_can_see( + self, + dpg_context: None, + layout_config: LayoutConfig, + small_listing: StemsListViewModel, + large_listing: StemsListViewModel, + ) -> None: + """The whole gesture, from the reading the panel hands over to the widgets on screen.""" + runs = count() + one = seconds(lambda: self._opened(f"load.timed.{next(runs)}", layout_config, small_listing)) + many = seconds(lambda: self._opened(f"load.timed.{next(runs)}", layout_config, large_listing)) + report = f"{SMALL_FOLDER} recordings {one * 1000:.1f} ms, {LARGE_FOLDER} recordings {many * 1000:.1f} ms" + print(report) + + assert many < linear(one), report + + @staticmethod + def _opened(prefix: str, layout_config: LayoutConfig, listing: StemsListViewModel) -> int: + """Draw the listing, open the folder standing in it, and count the rows that reached screen. + + What the gesture built is taken down once it has been counted, so a batch of them costs + what one costs: the window each reading is taken in holds the widgets of that reading + alone, and the small run is measured on the same window as the large one. + """ + stems_list = list_drawn_as(prefix, layout_config) + stems_list.update_view(listing) + stems_list.toggle_folder(listing.rows[0].key) + reached = rows_on_screen(prefix, listing) + cleared(prefix, stems_list) + return reached diff --git a/tests/benchmarks/test_document_load.py b/tests/benchmarks/test_document_load.py new file mode 100644 index 000000000..2a79e080c --- /dev/null +++ b/tests/benchmarks/test_document_load.py @@ -0,0 +1,36 @@ +from typing import Final + +from sampletones_core.reconstructions import Reconstruction +from tests.suite.performance import make_pulse_reconstruction +from tests.suite.timing import seconds + +SHORT_DOCUMENT: Final[int] = 500 +LONG_DOCUMENT: Final[int] = 5_000 +GROWTH_ALLOWANCE: Final[float] = 2.0 + + +def linear(one: float) -> float: + """What the long document may cost, given the short one: its frames' share, and room for the reading.""" + return one * (LONG_DOCUMENT / SHORT_DOCUMENT) * GROWTH_ALLOWANCE + + +class TestADocumentCostsWhatItsFramesCost: + """Storing and reading a reconstruction follows the frames it holds, each stored the way a list stores it.""" + + def test_writing(self) -> None: + short = make_pulse_reconstruction(count=SHORT_DOCUMENT) + long = make_pulse_reconstruction(count=LONG_DOCUMENT) + + one = seconds(short.serialize) + many = seconds(long.serialize) + + assert many < linear(one), f"{SHORT_DOCUMENT} frames: {one:.4f} s, {LONG_DOCUMENT} frames: {many:.4f} s" + + def test_reading(self) -> None: + short = make_pulse_reconstruction(count=SHORT_DOCUMENT).serialize() + long = make_pulse_reconstruction(count=LONG_DOCUMENT).serialize() + + one = seconds(lambda: Reconstruction.deserialize(short)) + many = seconds(lambda: Reconstruction.deserialize(long)) + + assert many < linear(one), f"{SHORT_DOCUMENT} frames: {one:.4f} s, {LONG_DOCUMENT} frames: {many:.4f} s" diff --git a/tests/benchmarks/test_pitch_bend.py b/tests/benchmarks/test_pitch_bend.py new file mode 100644 index 000000000..fd1887d61 --- /dev/null +++ b/tests/benchmarks/test_pitch_bend.py @@ -0,0 +1,53 @@ +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 +from tests.suite.timing import seconds + +FRAMES: Final[int] = 6000 +PITCH: Final[int] = 60 +VOLUME: Final[int] = 12 +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: + """What one render of the stream costs, the reading least disturbed by other load.""" + return seconds(lambda: render_instructions(instructions, ChannelName.PULSE1, config)) + + +@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/benchmarks/test_timing.py b/tests/benchmarks/test_timing.py new file mode 100644 index 000000000..aa1203dc2 --- /dev/null +++ b/tests/benchmarks/test_timing.py @@ -0,0 +1,55 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample +from tests.suite.timing import seconds + +ROWS_PER_PATTERN: Final[int] = 64 +FRAMES: Final[int] = 32 +UNEVEN_TEMPO: Final[int] = 251 +SAMPLE_FRAMES: Final[int] = 4 +TIMING_SHARE_LIMIT: Final[float] = 0.1 + + +@pytest.fixture(scope="module", name="project") +def project_fixture() -> Project: + """A song whose row rate never divides into whole ticks, so every frame plans its own bars.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=SAMPLE_FRAMES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=ProjectSettings(tempo=UNEVEN_TEMPO, speed=6, nes_frequency=60), + ) + for row_index in range(0, ROWS_PER_PATTERN, 4): + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=row_index, sample=sample) + + for _ in range(FRAMES - project.song.order_length()): + project.song.append_frame() + + return project + + +def _every_row(project: Project) -> int: + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + return sum(timing.row_ticks(frame, row) for frame in range(FRAMES) for row in range(ROWS_PER_PATTERN)) + + +class TestTheTimingCostsNextToNothing: + """A player asks how long a row lasts as it reaches the row, so the answer has to cost a sliver of + what sounding the row costs. + + The reading is a ratio against the walk that plays the same song into instructions, since what a + machine walks a song in is its own. What the bound catches is a timing that replans its bars per + row or walks the song from its start to answer one row. + """ + + def test_every_rows_length_costs_a_sliver_of_the_walk(self, project: Project) -> None: + lookups = seconds(lambda: _every_row(project)) + walk = seconds(lambda: song_instructions(project)) + + assert lookups < walk * TIMING_SHARE_LIMIT, f"lookups {lookups:.4f}s, walk {walk:.4f}s" diff --git a/tests/conftest.py b/tests/conftest.py index 49d3471d1..042e1cc76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,84 @@ -from typing import Callable, Iterator, TypeAlias +from typing import Callable, Final, Iterator, TypeAlias import pytest +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.utils.callbacks.failures import UnhandledFailures +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.gui.palette.palette import PaletteBindings -from sampletones_core.constants.enums import GeneratorName +from sampletones_application.utils.gui.render_thread import reset_render_thread +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.constants.general import BYTES_PER_MEGABYTE from tests.suite.sequencer import sample_reconstruction ReconstructionFactory: TypeAlias = Callable[[], Reconstruction] +RENDER_BUDGET: Final[int] = 64 * BYTES_PER_MEGABYTE + + +@pytest.fixture(autouse=True) +def modal_queue() -> Iterator[None]: + """Gives each test an empty modal line. + + The line holds the window standing on the screen and the ones waiting for it, and it outlives + any one context, so each test starts with the screen free and leaves nothing waiting. + """ + ModalQueue.clear() + yield + ModalQueue.clear() + + +@pytest.fixture(autouse=True) +def frame_callbacks() -> Iterator[None]: + """Gives each test no callbacks waiting for a frame. + + The callbacks wait in a heap that outlives any one context, and a suite renders no frame to run + them, so each test starts with none and leaves none a later frame count would make due. + """ + FrameCallbackManager.clear() + yield + FrameCallbackManager.clear() + + +@pytest.fixture(autouse=True) +def callback_queue() -> Iterator[None]: + """Gives each test an empty, live callback queue. + + The queue carries a worker's results to the render thread and outlives any one context, and a + worker a test started can post after the test's context is gone, so each test starts with nothing + queued and leaves nothing for a later drain to run. + """ + CallbackQueue.clear() + yield + CallbackQueue.clear() + + +@pytest.fixture(autouse=True) +def unhandled_failures() -> Iterator[None]: + """Gives each test a failure channel with no presenter attached. + + An application built by a test attaches its presenter to the process-wide channel, together with + the thread hook, so each test starts with failures logged alone and leaves the hook it found. + """ + UnhandledFailures.detach() + yield + UnhandledFailures.detach() + + +@pytest.fixture(autouse=True) +def render_thread() -> Iterator[None]: + """Gives each test a context no run has claimed. + + The thread a run claims outlives the run, and a test taking an application down leaves it named, + so each test starts where an interface is built and leaves the next one the same. + """ + reset_render_thread() + yield + reset_render_thread() + @pytest.fixture(autouse=True) def palette_bindings() -> Iterator[None]: @@ -26,6 +96,12 @@ 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 + + +@pytest.fixture +def renders() -> RenderCache: + """A render cache of the case's own, so every render a case makes leaves with it.""" + return RenderCache(budget_bytes=RENDER_BUDGET) diff --git a/tests/data/compatibility/README.md b/tests/data/compatibility/README.md new file mode 100644 index 000000000..5e5e24cbf --- /dev/null +++ b/tests/data/compatibility/README.md @@ -0,0 +1,75 @@ +# The compatibility corpus + +One stored document per format per shipped data version, written by the build that shipped it. + +The upgrade steps in `sampletones_core/compatibility` carry a stored file from the version a +release wrote to the version this build reads. These files are what that claim is held to: a step +tested only against payloads a test builds itself is held only to the fields whoever wrote the test +thought to name, and a field that changed shape while no step named it passes unnoticed. + +A library another version wrote is rebuilt, and it is archived all the same: a library a release +wrote is held to reading as out of date, and a library archived at the version this build writes +is held to what this build generates for the same tones. + +## Layout + +A file is named after the **data version it was written at**, in the spelling the step modules use, +so a step and the file it reads are named alike — `compatibility/reconstruction/v2_2.py` carries +`reconstruction/v2_1.stn` forward. + +``` +reconstruction/v2_1.stn written by 0.3.1 +library/v2_0.ins written by 0.3.1 +project/v1_0.stp written by 0.3.1 +generators/ the code each set was produced with +``` + +The release that wrote a file is inside it, as `metadata.version`. + +## What a document holds + +Small, and complete in shape: every field the format can store, with as little data in each as +states it. The reconstruction sounds all four channels, three frames each — one sounding, one +silent, one sounding — because rest and silence naming the same frames is a rule a stored record +is held to. Its recording is named by a relative path, so the file reads the same on every machine. +The project arranges one reconstruction under two voices, which is what holds a stored project to +keeping a single copy of it, and its embedded reconstruction names no file, the way a project +detaches a recording before storing it. + +The library holds two measured tones at a low sample rate, which keeps the file small. + +## Adding a version + +At each release, from a checkout of that release: + +``` +uv run sampletones compatibility +``` + +It writes one document per format at the versions that build states, and leaves alone any version +already archived — replacing a file would restate history under a name that already means +something. The corpus grows by one file per format per version, and never changes underneath. + +A version that shipped before the command existed is backfilled once, and the release that shipped it +is what writes the file: a document the current build produced would prove nothing about what the old +build stored. So that release's code has to run. It runs against a generator written for it, which is +why the generators live here rather than at the tag — each one postdates the release it writes for. + +The tag is checked out beside the repository rather than in it, so the work in progress and the +development environment both stay as they are: + +``` +git worktree add / +cd / && uv sync --frozen +cp /tests/data/compatibility/generators/.py . +uv run python .py --output /tests/data/compatibility +cd && git worktree remove --force / +``` + +`uv sync --frozen` builds that tag's own environment from the lockfile it shipped with, so the +document is written by the dependencies the release was built on, and the version stamped inside it is +the one the installed package reports. The output lands in this directory, which leaves the worktree +disposable as soon as the generator has run. + +Why the corpus exists, and what an upgrade step owes it, is +[Data compatibility](../../../docs/development/release/compatibility.md). diff --git a/tests/data/compatibility/generators/v0_3_1.py b/tests/data/compatibility/generators/v0_3_1.py new file mode 100644 index 000000000..504678ca7 --- /dev/null +++ b/tests/data/compatibility/generators/v0_3_1.py @@ -0,0 +1,214 @@ +"""Writes the archived corpus using the API of the v0.3.1 tree, which is the release it belongs to. + +This is the backward port of ``sampletones_tools/compatibility/documents.py`` and ``writer.py``. +It runs inside a worktree at the tag, never from the current tree, and nothing imports it: it is +kept beside the files it wrote so a reader can see exactly how each of them was produced. + + git worktree add /v0.3.1 v0.3.1 + cd /v0.3.1 && uv sync --frozen + cp /tests/data/compatibility/generators/v0_3_1.py . + uv run python v0_3_1.py --output /tests/data/compatibility + cd && git worktree remove --force /v0.3.1 +""" + +import argparse +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import PulseGenerator +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.library import InstructionLibraryFragment +from sampletones_core.library.data import InstructionLibraryData +from sampletones_core.project.container import ProjectContainer +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 Row +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.reconstructions import Reconstruction + +SOURCE_PATH: Final[Path] = Path("samples") / "kick.wav" +COEFFICIENT: Final[float] = 0.75 +ROWS_PER_PATTERN: Final[int] = 4 +PATTERN_NAME: Final[str] = "verse" +FIRST_SAMPLE_NAME: Final[str] = "kick" +SECOND_SAMPLE_NAME: Final[str] = "kick again" +PROJECT_TITLE: Final[str] = "Corpus" +PROJECT_AUTHOR: Final[str] = "Archivist" +PROJECT_COMMENT: Final[str] = "A document kept to be read back" +PROJECT_TEMPO: Final[int] = 132 +ROW_TRANSPOSE: Final[int] = 3 +ROW_VOLUME: Final[int] = 9 +LIBRARY_SAMPLE_RATE: Final[int] = 11025 +LIBRARY_GAMMA: Final[int] = 50 +EMBEDDED_GENERATOR: Final[GeneratorName] = GeneratorName.PULSE2 +APPROXIMATION_SAMPLES: Final[int] = 8 +STORED_DRIVE: Final[float] = 1.5 + + +def corpus_instructions() -> Dict[GeneratorName, List[InstructionUnion]]: + """Three frames on every channel: one sounding, one silent, one sounding.""" + return { + GeneratorName.PULSE1: [ + PulseInstruction(on=True, pitch=45, volume=11, duty_cycle=2), + PulseInstruction(on=False, pitch=45, volume=0, duty_cycle=0), + PulseInstruction(on=True, pitch=48, volume=7, duty_cycle=1), + ], + GeneratorName.PULSE2: [ + PulseInstruction(on=True, pitch=52, volume=9, duty_cycle=0), + PulseInstruction(on=False, pitch=52, volume=0, duty_cycle=0), + PulseInstruction(on=True, pitch=52, volume=9, duty_cycle=3), + ], + GeneratorName.TRIANGLE: [ + TriangleInstruction(on=True, pitch=33), + TriangleInstruction(on=False, pitch=33), + TriangleInstruction(on=True, pitch=35), + ], + GeneratorName.NOISE: [ + NoiseInstruction(on=True, period=5, volume=6, short=False), + NoiseInstruction(on=False, period=5, volume=0, short=False), + NoiseInstruction(on=True, period=9, volume=12, short=True), + ], + } + + +def _audio() -> np.ndarray: + """A few samples standing for the rendered audio a 2.1 file carried beside its instructions.""" + return np.linspace(-0.5, 0.5, APPROXIMATION_SAMPLES, dtype=np.float32) + + +def _reconstruction_config(generators: List[GeneratorName]) -> Config: + """The configuration a 2.1 run recorded: the channels it handed out, and how hard it drove them.""" + base = Config() + generation = base.generation.model_copy(update={"generators": generators, "drive": STORED_DRIVE}) + return base.model_copy(update={"generation": generation}) + + +def corpus_reconstruction() -> Reconstruction: + """A reconstruction naming every channel and the recording it was built from.""" + instructions = corpus_instructions() + generators = list(instructions) + return Reconstruction.create( + approximation=_audio(), + approximations={generator_name: _audio() for generator_name in generators}, + instructions=instructions, + config=_reconstruction_config(generators), + coefficient=COEFFICIENT, + audio_filepath=SOURCE_PATH, + ) + + +def embedded_reconstruction() -> Reconstruction: + """The reconstruction a project carries, sounding one channel while the rest stand by.""" + instructions: Dict[GeneratorName, List[InstructionUnion]] = { + EMBEDDED_GENERATOR: [ + PulseInstruction(on=True, pitch=40, volume=15, duty_cycle=1), + PulseInstruction(on=False, pitch=40, volume=0, duty_cycle=0), + ] + } + return Reconstruction.create( + approximation=_audio(), + approximations={EMBEDDED_GENERATOR: _audio()}, + instructions=instructions, + config=_reconstruction_config([EMBEDDED_GENERATOR]), + coefficient=COEFFICIENT, + audio_filepath=SOURCE_PATH, + ) + + +def library_config() -> Config: + """The configuration the archived library is analyzed under.""" + base = Config() + library = base.library.model_copy( + update={ + "spectrum_method": SpectrumMethod.FFT, + "transformation_gamma": LIBRARY_GAMMA, + "sample_rate": LIBRARY_SAMPLE_RATE, + } + ) + return base.model_copy(update={"library": library}) + + +def library_tones() -> Tuple[PulseInstruction, ...]: + """The tones the archived library holds, each measured and stored.""" + return ( + PulseInstruction(on=True, pitch=57, volume=12, duty_cycle=2), + PulseInstruction(on=True, pitch=45, volume=8, duty_cycle=1), + ) + + +def corpus_library() -> InstructionLibraryData: + """A library holding a couple of measured tones, small enough to keep.""" + config = library_config() + extractor = get_feature_extractor(config, Window.from_config(config)) + generator = PulseGenerator(config, GeneratorName.PULSE1) + fragments = { + instruction: InstructionLibraryFragment.create(generator, instruction, extractor) + for instruction in library_tones() + } + return InstructionLibraryData.create(config, fragments) + + +def corpus_project(reconstruction: Reconstruction) -> Project: + """A project arranging one reconstruction under two samples.""" + settings = ProjectSettings(tempo=PROJECT_TEMPO) + project = Project.create( + title=PROJECT_TITLE, + author=PROJECT_AUTHOR, + comment=PROJECT_COMMENT, + rows_per_pattern=ROWS_PER_PATTERN, + settings=settings, + ) + first = Sample(name=FIRST_SAMPLE_NAME, reconstruction=reconstruction) + second = Sample(name=SECOND_SAMPLE_NAME, reconstruction=reconstruction) + project.samples.append(first) + project.samples.append(second) + project.song.channels[EMBEDDED_GENERATOR].patterns[0] = Pattern( + name=PATTERN_NAME, + rows=[ + Row(command=Instrument(sample_id=first.id, generator_name=EMBEDDED_GENERATOR)), + Row( + command=Instrument(sample_id=second.id, generator_name=EMBEDDED_GENERATOR), + transpose=ROW_TRANSPOSE, + volume=ROW_VOLUME, + ), + Row(command=NoteOff()), + Row(), + ], + ) + return project + + +def main() -> None: + parser = argparse.ArgumentParser(description="write the v0.3.1 compatibility corpus") + parser.add_argument("--output", required=True, help="the corpus directory the files land in") + arguments = parser.parse_args() + root = Path(arguments.output) + + for kind, name in (("reconstruction", "v2_1.stn"), ("library", "v2_0.ins"), ("project", "v1_0.stp")): + (root / kind).mkdir(parents=True, exist_ok=True) + + corpus_reconstruction().save(root / "reconstruction" / "v2_1.stn") + corpus_library().save(root / "library" / "v2_0.ins") + ProjectContainer.save(corpus_project(embedded_reconstruction()), root / "project" / "v1_0.stp") + + for kind, name in (("reconstruction", "v2_1.stn"), ("library", "v2_0.ins"), ("project", "v1_0.stp")): + path = root / kind / name + print(f"wrote {path} ({path.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/tests/data/compatibility/library/v2_0.ins b/tests/data/compatibility/library/v2_0.ins new file mode 100644 index 000000000..fc928e792 Binary files /dev/null and b/tests/data/compatibility/library/v2_0.ins differ diff --git a/tests/data/compatibility/project/v1_0.stp b/tests/data/compatibility/project/v1_0.stp new file mode 100644 index 000000000..f89b4edd8 Binary files /dev/null and b/tests/data/compatibility/project/v1_0.stp differ diff --git a/tests/data/compatibility/reconstruction/v2_1.stn b/tests/data/compatibility/reconstruction/v2_1.stn new file mode 100644 index 000000000..f67ca29d3 Binary files /dev/null and b/tests/data/compatibility/reconstruction/v2_1.stn differ diff --git a/tests/integration/assets/module_config.py b/tests/integration/assets/module_config.py deleted file mode 100644 index 3b52384d5..000000000 --- a/tests/integration/assets/module_config.py +++ /dev/null @@ -1,20 +0,0 @@ -from pydantic import BaseModel, ConfigDict - -from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.serialization import load_yaml - - -class ModuleConfig(BaseModel): - """Module identity and playback settings that shape the exported ``.ftm``.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - title: str - author: str - tempo: int - speed: int - nes_frequency: int - - -def load_module_config(path: Pathlike) -> ModuleConfig: - return ModuleConfig.model_validate(load_yaml(path)) diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py deleted file mode 100644 index 662e9a07e..000000000 --- a/tests/integration/assets/reconstruction.py +++ /dev/null @@ -1,155 +0,0 @@ -from pathlib import Path -from typing import Any, Dict, Final, FrozenSet, List - -import numpy as np - -from sampletones_core.audio import write_wave -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.fft import Window -from sampletones_core.fft.features import get_feature_extractor -from sampletones_core.generators import get_generators_by_names -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.reconstructions import Reconstruction, Reconstructor -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, -] - - -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 - 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) - - data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {} - for generator in generators.values(): - candidates = list(generator.get_possible_instructions()) - stride = max(1, len(candidates) // per_generator) - for instruction in candidates[::stride][:per_generator]: - data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor) - - library = InstructionLibrary() - library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) - return library - - -def reconstruct_sample( - audio: np.ndarray, - config: Config, - library: InstructionLibrary, - *, - tmp_dir: Pathlike, - name: str, -) -> Reconstruction: - """Runs the real reconstruction pipeline on ``audio`` via a temp WAV.""" - path = Path(tmp_dir) / f"{name}.wav" - write_wave(path, config.library.sample_rate, audio) - reconstruction = Reconstructor(config, library=library)(path) - if reconstruction is None: - raise AssertionError(f"Reconstruction of '{name}' produced no result") - - return reconstruction - - -def make_sample( - name: str, - audio: np.ndarray, - config: Config, - library: InstructionLibrary, - *, - tmp_dir: Pathlike, - expected_slices: FrozenSet[GeneratorName], - 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) - if played != expected_slices: - raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") - - return Sample(name=name, reconstruction=reconstruction, loop=loop) - - -def load_instrument_catalog( - spec_path: Pathlike, - synth_config: SynthConfig, - *, - tmp_dir: Pathlike, -) -> Dict[str, Sample]: - """Builds the reconstructed sample catalog described by a JSON spec. - - The spec fixes the reconstruction settings (spectrum method, gamma, library - size) shared by one in-memory library, and per instrument the synth to run and - the channel slices to cover. Synth parameters come from ``synth_config``. - Returns a name -> `Sample` mapping. - """ - spec = load_yaml(spec_path) - settings = spec["reconstruction"] - library_config = InstructionsLibraryConfig( - spectrum_method=SpectrumMethod(settings["spectrum_method"]), - transformation_gamma=settings["transformation_gamma"], - ) - sample_rate = library_config.sample_rate - library = build_mini_library( - Config(library=library_config), - per_generator=settings["instructions_per_generator"], - ) - - 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)) - audio = _render_instrument(synth_config, entry["synth"], sample_rate=sample_rate) - catalog[entry["name"]] = make_sample( - entry["name"], - audio, - config, - library, - tmp_dir=tmp_dir, - expected_slices=frozenset(generators), - ) - - return catalog - - -def _render_instrument( - synth_config: SynthConfig, - name: str, - *, - sample_rate: int, -) -> np.ndarray: - """ - Render a named voice at peak level 1.0. - - A fresh generator seeded from the synth configuration keeps every instrument - reproducible independently of catalog order. - """ - voice = synth_config.voices[name] - generator = np.random.default_rng(synth_config.seed) - instrument: np.ndarray = normalize( - voice.render( - sample_rate=sample_rate, - generator=generator, - ) - ) - return instrument diff --git a/tests/integration/assets/song_loader.py b/tests/integration/assets/song_loader.py deleted file mode 100644 index 03079cdc5..000000000 --- a/tests/integration/assets/song_loader.py +++ /dev/null @@ -1,94 +0,0 @@ -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.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_shared.types.path import Pathlike -from sampletones_shared.utils.serialization import load_yaml - -RowSpec = Dict[str, Any] - - -def _order( - order_specs: List[Dict[str, int]], -) -> List[Dict[GeneratorName, Optional[int]]]: - frames: List[Dict[GeneratorName, Optional[int]]] = [] - for spec in order_specs: - frames.append({generator: spec.get(generator.value) for generator in GeneratorName.items()}) - - return frames - - -def _row(spec: RowSpec, generator: GeneratorName, samples_by_name: Mapping[str, Sample]) -> Row: - transpose = spec.get("transpose") - volume = spec.get("volume") - - if spec.get("off"): - return Row(command=NoteOff(), volume=volume) - - sample_name = spec.get("sample") - if sample_name is None: - 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") - - command = Instrument(sample_id=sample.id, generator_name=generator) - return Row(command=command, transpose=transpose, volume=volume) - - -def _pattern( - row_specs: List[RowSpec], - rows_per_pattern: int, - generator: GeneratorName, - 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) - - return Pattern(rows=rows) - - -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] = {} - for name, spec in channels_spec.items(): - generator = GeneratorName(name) - patterns = { - int(index): _pattern( - row_specs, - rows_per_pattern, - generator, - samples_by_name, - ) - for index, row_specs in spec["patterns"].items() - } - channels[generator] = Channel( - generator=generator, - patterns=patterns, - ) - - for generator in GeneratorName.items(): - channels.setdefault(generator, Channel(generator=generator, patterns={})) - - return channels - - -def load_song(path: Pathlike, samples_by_name: Mapping[str, Sample]) -> Song: - document = load_yaml(path) - rows_per_pattern = document["rows_per_pattern"] - return Song( - rows_per_pattern=rows_per_pattern, - order=_order(document["order"]), - channels=_channels(document["channels"], rows_per_pattern, samples_by_name), - ) diff --git a/tests/integration/assets/synth_config.py b/tests/integration/assets/synth_config.py deleted file mode 100644 index 1cff14660..000000000 --- a/tests/integration/assets/synth_config.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Dict - -from pydantic import BaseModel, ConfigDict - -from sampletones_shared.types.path import Pathlike -from sampletones_shared.utils.serialization import load_yaml_model -from sampletones_synthesis.voice.voice import Voice - - -class SynthConfig(BaseModel): - """The named synthesizer voices of the integration suite and their shared noise seed.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - seed: int - voices: Dict[str, Voice] - - -def load_synth_config(path: Pathlike) -> SynthConfig: - return load_yaml_model(path, SynthConfig) diff --git a/tests/integration/assets/test_demo.py b/tests/integration/assets/test_demo.py new file mode 100644 index 000000000..0b5359c48 --- /dev/null +++ b/tests/integration/assets/test_demo.py @@ -0,0 +1,108 @@ +from pathlib import Path +from typing import Final, Tuple + +import numpy as np +import pytest +import soundfile + +from assets.demo.paths import CONFIG_FILE, LIBRARY_FOLDER, PROJECTS_FOLDER, RECONSTRUCTIONS_FOLDER, RECORDINGS_FOLDER +from assets.demo.specification import DemoSpecification +from assets.demo.tree import build_demo +from sampletones_core.configs import Config +from sampletones_core.project import ProjectContainer +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT, EXT_FILE_RECONSTRUCTION, EXT_FILE_WAVE + +SPECIFICATION: Final[DemoSpecification] = DemoSpecification.load() +AUDIBLE_RMS: Final[float] = 0.01 + + +@pytest.fixture(name="tree", scope="session") +def tree_fixture(tmp_path_factory: pytest.TempPathFactory) -> Tuple[Path, Tuple[Path, ...]]: + """The demo tree built once for the session, with every file the build reported.""" + destination = tmp_path_factory.mktemp("demo") / "tree" + return destination, build_demo(destination) + + +def documents(root: Path) -> Tuple[Path, ...]: + return tuple(sorted((root / RECONSTRUCTIONS_FOLDER).rglob(f"*{EXT_FILE_RECONSTRUCTION}"))) + + +class TestTheTree: + """The demo tree holds a documents folder a person can run the application from, with recordings beside it.""" + + def test_every_file_reported_stands(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + _, written = tree + + assert written + assert all(path.is_file() for path in written) + + def test_every_recording_sounds(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + root, _ = tree + recordings = sorted((root / RECORDINGS_FOLDER).rglob(f"*{EXT_FILE_WAVE}")) + expected = len(SPECIFICATION.recordings.hits) + len(SPECIFICATION.recordings.piece.stems) + + assert len(recordings) == expected + for recording in recordings: + audio, _ = soundfile.read(recording) + assert float(np.sqrt(np.mean(np.square(audio)))) > AUDIBLE_RMS, recording.name + + def test_the_configuration_names_the_tree_s_own_folders(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + root, _ = tree + config = Config.load(root / CONFIG_FILE) + + assert config.library_directory == root / LIBRARY_FOLDER + assert config.output_directory == root / RECONSTRUCTIONS_FOLDER + assert any((root / LIBRARY_FOLDER).iterdir()) + + def test_every_document_names_its_recordings_relative_to_the_tree( + self, tree: Tuple[Path, Tuple[Path, ...]] + ) -> None: + root, _ = tree + found = documents(root) + + assert len(found) == len(SPECIFICATION.recordings.hits) + 1 + for document in found: + paths = Reconstruction.load(document).audio_filepath + assert paths, document.name + assert all(not path.is_absolute() and (root / path).is_file() for path in paths), document.name + + def test_every_hit_plays_within_the_channels_it_was_given(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + root, _ = tree + for hit in SPECIFICATION.recordings.hits: + document = next(path for path in documents(root) if path.stem == hit.name) + playing = frozenset(Reconstruction.load(document).playing_channels) + + assert playing, hit.name + assert playing <= frozenset(SPECIFICATION.conversion.hits[hit.name]), hit.name + + def test_the_piece_is_converted_from_every_stem_on_its_own_level(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + root, _ = tree + piece = SPECIFICATION.recordings.piece + document = next(path for path in documents(root) if path.stem == piece.name) + stems_data = Reconstruction.load(document).stems_data + + assert len(stems_data.config.entries) == len(piece.stems) + assert len(stems_data.config.hierarchy.levels) == len(SPECIFICATION.conversion.piece.levels) + assert [stems_data.named(entry.id) for entry in stems_data.config.entries] == [ + stem.name for stem in piece.stems + ] + + def test_the_project_plays_the_hits_and_the_instruments(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + root, _ = tree + title = SPECIFICATION.project.module.title + project = ProjectContainer.load(root / PROJECTS_FOLDER / f"{title}{EXT_FILE_PROJECT}") + names = {voice.name for voice in project.voices} + expected = {hit.name for hit in SPECIFICATION.recordings.hits} | set(SPECIFICATION.project.instruments) + + assert project.info.title == title + assert project.info.created == SPECIFICATION.project.created + assert project.info.modified == SPECIFICATION.project.created + assert names == expected + assert len(project.song.order) == len(SPECIFICATION.project.song.order) + + def test_a_folder_holding_files_is_refused(self, tree: Tuple[Path, Tuple[Path, ...]]) -> None: + root, _ = tree + + with pytest.raises(FileExistsError): + build_demo(root) diff --git a/tests/integration/bitphase/conftest.py b/tests/integration/bitphase/conftest.py index d1dd846e8..a8a978194 100644 --- a/tests/integration/bitphase/conftest.py +++ b/tests/integration/bitphase/conftest.py @@ -1,29 +1,29 @@ from pathlib import Path -from typing import Optional import pytest -from tests.integration.output import resolve_output_directory, resolve_output_path -from tests.integration.paths import ( - BTP_OUTPUT_ENV, - DOCUMENT_FILENAME, - GROOVE_DOCUMENT_FILENAME, -) - - -@pytest.fixture(scope="session") -def btp_output_dir() -> Optional[Path]: - """The persistent output directory ``SAMPLETONES_BTP_OUTPUT_DIR`` names.""" - return resolve_output_directory(BTP_OUTPUT_ENV) +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS +from sampletones_core.project.project import Project +from sampletones_tools.samples.bitphase import DOCUMENT_FILENAME, GROOVE_DOCUMENT_FILENAME +from tests.suite.bitphase import LoadedProject, parse_btp @pytest.fixture -def document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path: +def document_path(tmp_path: Path) -> Path: """Where a produced ``.btp`` is written.""" - return resolve_output_path(btp_output_dir, tmp_path, DOCUMENT_FILENAME) + return tmp_path / DOCUMENT_FILENAME @pytest.fixture -def groove_document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path: +def groove_document_path(tmp_path: Path) -> Path: """Where the document carrying a groove is written, beside the one at the song's own tempo.""" - return resolve_output_path(btp_output_dir, tmp_path, GROOVE_DOCUMENT_FILENAME) + return tmp_path / GROOVE_DOCUMENT_FILENAME + + +@pytest.fixture +def document(integration_project: Project, document_path: Path) -> LoadedProject: + """The synthetic project written as a document and read back the way Bitphase loads it.""" + write_btp(document_path, project_to_bitphase(integration_project)) + return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index 6882361a6..b2dc2b7ea 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -4,7 +4,7 @@ import pytest from sampletones_core.formats.bitphase.btp import write_btp -from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.builder import BITPHASE_TICK_BOUNDS, project_to_bitphase from sampletones_core.formats.bitphase.specification.channels import ( CHANNEL_COUNT, CHANNEL_LABELS, @@ -21,8 +21,6 @@ ChipVariant, ) from sampletones_core.formats.bitphase.specification.effects import ( - NO_EFFECT_PARAMETER, - SPEED_EFFECT_DELAY, EffectId, ) from sampletones_core.formats.bitphase.specification.instruments import ( @@ -30,7 +28,12 @@ MAX_VOLUME_OR_RATE, MIN_PULSE_WIDTH, MIN_VOLUME_OR_RATE, - SUSTAINED_SOUND_LENGTH, + SILENT_VOLUME, +) +from sampletones_core.formats.bitphase.specification.macros import ( + MAX_MACRO_LENGTH, + MIN_MACRO_LENGTH, + NesMacroField, ) from sampletones_core.formats.bitphase.specification.patterns import ( FIRST_OCTAVE, @@ -44,19 +47,19 @@ NoteName, ) from sampletones_core.project.project import Project -from sampletones_core.timing import Metre, RowRate, calculate_groove +from sampletones_core.timing import SongTiming +from sampletones_tools.samples.bitphase import GROOVE_TEMPO, at_tempo from tests.suite.bitphase import ( BITPHASE_NO_EFFECTS, - LoadedEffect, + BITPHASE_SPEED_CLOCK_TEMPO, LoadedNote, LoadedProject, LoadedRow, - LoadedTable, parse_btp, + row_speeds, ) EXPECTED_INSTRUMENT_COUNT: Final[int] = 5 -GROOVE_TEMPO: Final[int] = 210 PLAYED_CHANNELS: Final[List[int]] = [ int(ChannelIndex.SQUARE1), int(ChannelIndex.SQUARE2), @@ -75,23 +78,6 @@ def note_index(note: LoadedNote) -> int: return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE -def at_tempo(project: Project, tempo: int) -> Project: - """The same project played at another tempo, leaving the session-wide fixture as it is.""" - return Project( - metadata=project.metadata, - info=project.info, - settings=project.settings.model_copy(update={"tempo": tempo}), - samples=project.samples, - song=project.song, - ) - - -@pytest.fixture -def document(integration_project: Project, document_path: Path) -> LoadedProject: - write_btp(document_path, project_to_bitphase(integration_project)) - return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) - - @pytest.fixture def groove_document(integration_project: Project, groove_document_path: Path) -> LoadedProject: project = at_tempo(integration_project, GROOVE_TEMPO) @@ -138,7 +124,11 @@ def test_patterns_cover_the_played_channels(self, document: LoadedProject) -> No assert triggered == set(PLAYED_CHANNELS) def test_the_document_carries_audible_volume(self, document: LoadedProject) -> None: - assert any(row.volume_or_rate > 0 for instrument in document.instruments for row in instrument.rows) + assert any( + value > SILENT_VOLUME + for instrument in document.instruments + for value in instrument.macro(NesMacroField.VOLUME_OR_RATE).values + ) class TestTheLoaderReadsWhatWasWritten: @@ -150,7 +140,7 @@ class TestTheLoaderReadsWhatWasWritten: def test_the_song_names_the_chip_it_drives(self, document: LoadedProject) -> None: assert document.songs[0].chip_type == CHIP_TYPE_NES - def test_every_instrument_names_the_chip_whose_rows_it_holds(self, document: LoadedProject) -> None: + def test_every_instrument_names_the_chip_whose_fields_it_holds(self, document: LoadedProject) -> None: assert {instrument.chip_type for instrument in document.instruments} == {CHIP_TYPE_NES} def test_the_song_carries_the_clock_its_tuning_was_built_from(self, document: LoadedProject) -> None: @@ -182,7 +172,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) @@ -228,69 +218,52 @@ def test_every_volume_column_stays_within_the_channel_range(self, document: Load class TestTheGrooveReachesTheFile: - """A tempo the speed column cannot state travels as a table of per-row tick counts and a - trigger that names it, so the file has to hold the groove the calculator produced and - re-trigger it wherever the order takes playback. + """A tempo the speed column cannot state travels as speed effects on the silent channel, set + wherever a row lasts differently from the row before it, so every row the engine loads plays the + ticks the song's timing gives it. """ - @pytest.fixture(name="groove_table") - def groove_table_fixture(self, groove_document: LoadedProject) -> LoadedTable: - return groove_document.tables[-1] - - def test_the_groove_takes_the_table_above_the_slices( + def test_every_row_plays_the_ticks_the_songs_timing_gives_it( self, + integration_project: Project, groove_document: LoadedProject, - groove_table: LoadedTable, ) -> None: - assert groove_table.id == len(groove_document.instruments) + project = at_tempo(integration_project, GROOVE_TEMPO) + timing = SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS) + expected = [list(timing.groove(frame).ticks) for frame in range(project.song.order_length())] - def test_the_table_holds_one_entry_per_pattern_row( - self, - groove_document: LoadedProject, - groove_table: LoadedTable, - ) -> None: - lengths = {pattern.length for pattern in groove_document.songs[0].patterns} - assert lengths == {len(groove_table.rows)} + assert row_speeds(groove_document) == expected - def test_every_entry_is_a_speed_the_engine_reads(self, groove_table: LoadedTable) -> None: - assert all(MIN_INITIAL_SPEED <= ticks <= MAX_INITIAL_SPEED for ticks in groove_table.rows) + def test_the_song_plays_its_rows_on_the_speed_clock(self, groove_document: LoadedProject) -> None: + """The speed clock plays each row the ticks its speed effects state. A tempo above it spreads + the ticks by FamiTracker's accumulator. + """ + assert groove_document.songs[0].tempo == BITPHASE_SPEED_CLOCK_TEMPO + + def test_every_speed_is_one_the_engine_reads(self, groove_document: LoadedProject) -> None: + speeds = [ + effect.parameter + for pattern in groove_document.songs[0].patterns + for row in pattern.channels[int(ChannelIndex.DPCM)].rows + for effect in row.effects + if effect is not None and effect.effect == int(EffectId.SPEED) + ] - def test_the_table_holds_the_groove_the_project_plays( - self, - integration_project: Project, - groove_table: LoadedTable, - ) -> None: - project = at_tempo(integration_project, GROOVE_TEMPO) - groove = calculate_groove( - RowRate.from_settings(project.settings), - Metre.from_settings(project.settings, rows=project.song.rows_per_pattern), - minimum_ticks=MIN_INITIAL_SPEED, - maximum_ticks=MAX_INITIAL_SPEED, - ) - assert groove_table.rows == list(groove.ticks) + assert speeds + assert all(MIN_INITIAL_SPEED <= speed <= MAX_INITIAL_SPEED for speed in speeds) + + def test_the_document_holds_one_table_per_slice(self, groove_document: LoadedProject) -> None: + assert len(groove_document.tables) == len(groove_document.instruments) def test_the_song_starts_on_the_ticks_its_first_row_lasts( self, + integration_project: Project, groove_document: LoadedProject, - groove_table: LoadedTable, ) -> None: - assert groove_document.songs[0].initial_speed == groove_table.rows[0] + project = at_tempo(integration_project, GROOVE_TEMPO) + timing = SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS) - def test_every_pattern_triggers_the_groove_on_its_first_row( - self, - groove_document: LoadedProject, - groove_table: LoadedTable, - ) -> None: - trigger = LoadedEffect( - effect=int(EffectId.SPEED), - delay=SPEED_EFFECT_DELAY, - parameter=NO_EFFECT_PARAMETER, - table_index=groove_table.id, - ) - triggers = [ - pattern.channels[int(ChannelIndex.DPCM)].rows[0].effects for pattern in groove_document.songs[0].patterns - ] - assert triggers == [[trigger]] * len(triggers) + assert groove_document.songs[0].initial_speed == timing.row_ticks(0, 0) def test_a_tempo_the_speed_column_states_leaves_every_effect_column_empty( self, @@ -303,37 +276,45 @@ def test_a_tempo_the_speed_column_states_leaves_every_effect_column_empty( assert all(row.effects == list(BITPHASE_NO_EFFECTS) for row in every_row(document)) -class TestTheInstrumentRowsArePlayable: - def test_every_row_holds_a_waveform_the_channel_reads(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(MIN_PULSE_WIDTH <= row.pulse_width <= MAX_PULSE_WIDTH for row in rows) - - def test_every_row_holds_a_level_the_channel_reads(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(MIN_VOLUME_OR_RATE <= row.volume_or_rate <= MAX_VOLUME_OR_RATE for row in rows) +class TestTheInstrumentMacrosArePlayable: + def test_every_waveform_value_is_one_the_channel_reads(self, document: LoadedProject) -> None: + values = [ + value for instrument in document.instruments for value in instrument.macro(NesMacroField.PULSE_WIDTH).values + ] + assert all(MIN_PULSE_WIDTH <= value <= MAX_PULSE_WIDTH for value in values) - def test_every_row_reads_its_level_as_a_literal_volume(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(row.envelope is False for row in rows) + def test_every_level_is_one_the_channel_reads(self, document: LoadedProject) -> None: + values = [ + value + for instrument in document.instruments + for value in instrument.macro(NesMacroField.VOLUME_OR_RATE).values + ] + assert all(MIN_VOLUME_OR_RATE <= value <= MAX_VOLUME_OR_RATE for value in values) - def test_every_row_holds_the_note_for_as_long_as_the_envelope_runs(self, document: LoadedProject) -> None: - rows = [row for instrument in document.instruments for row in instrument.rows] - assert all(row.sound_length == SUSTAINED_SOUND_LENGTH for row in rows) + def test_the_fields_a_reconstruction_leaves_alone_are_left_out(self, document: LoadedProject) -> None: + """Bitphase reads a field an instrument states no macro for at that field's own default, + which is the literal volume, the sustained note and the silent sweep a reconstruction asks + for. + """ + written = {field for instrument in document.instruments for field in instrument.macros} + assert written.isdisjoint({"envelope", "soundLength", "sweep", "toneAccumulation"}) - def test_every_instrument_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: - """Playback returns to the loop row once it runs off the end, so a loop point - past the last row would leave the instrument nowhere to resume from. + def test_every_macro_circles_from_a_value_it_holds(self, document: LoadedProject) -> None: + """Playback circles from the loop index once the values run out, so a point past + them would leave the field nowhere to resume from. """ - assert all(instrument.loop < len(instrument.rows) for instrument in document.instruments) + assert all( + macro.loop < len(macro.values) + for instrument in document.instruments + for macro in instrument.macros.values() + ) def test_every_table_loops_on_a_row_it_holds(self, document: LoadedProject) -> None: assert all(table.loop < len(table.rows) for table in document.tables) - def test_each_instrument_runs_as_long_as_its_table(self, document: LoadedProject) -> None: - """The rows and the table advance on their own per-tick counters, so a length - they share is what keeps the volume envelope aligned with the pitch contour. - """ - lengths = [ - (len(instrument.rows), len(table.rows)) for instrument, table in zip(document.instruments, document.tables) - ] - assert all(rows == table_rows for rows, table_rows in lengths) + def test_every_macro_holds_the_values_a_bitphase_instrument_stores(self, document: LoadedProject) -> None: + assert all( + MIN_MACRO_LENGTH <= len(macro.values) <= MAX_MACRO_LENGTH + for instrument in document.instruments + for macro in instrument.macros.values() + ) diff --git a/tests/integration/bitphase/test_playback_rules.py b/tests/integration/bitphase/test_playback_rules.py new file mode 100644 index 000000000..8aee2e801 --- /dev/null +++ b/tests/integration/bitphase/test_playback_rules.py @@ -0,0 +1,242 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import HI_PITCH_FACTOR, NUM_PERIODS +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.implementation.noise import NoiseExporter +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.btp import write_btp +from sampletones_core.formats.bitphase.builder import instrument_to_bitphase, sample_to_bitphase +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, ChannelIndex +from sampletones_core.formats.bitphase.specification.chip import PERIOD_OVER_TIMER +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_VOLUME_OR_RATE, + MIN_VOLUME_OR_RATE, +) +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + NOTE_RANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.instructions import NoiseInstruction +from sampletones_core.timers.arithmetic import bent_timer +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_shared.constants.music import OCTAVE_SEMITONES +from sampletones_shared.music import Tuning +from tests.suite.bitphase import ( + BITPHASE_MAX_PERIOD, + BITPHASE_SILENT_PERIOD, + LoadedInstrument, + LoadedNote, + LoadedProject, + LoadedTable, + noise_register, + parse_btp, + reached_note, + sounded_period, +) +from tests.suite.case import BaseRegularTestCase + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 +VOLUME_ENVELOPE: Final[Tuple[int, ...]] = (15, 12, 9, 6, 3, 0) +PITCH_CONTOUR: Final[Tuple[int, ...]] = (0, 0, 5, 5, 7, 7) +BEND_STEPS: Final[Tuple[int, ...]] = (0, -4, -9, -15, -22, -30) +COARSE_STEPS: Final[Tuple[int, ...]] = (0, 0, 0, 1, 1, 2) +TICKS_SAMPLED: Final[int] = 64 +LOWERED_A4_FREQUENCY: Final[float] = 432.0 +C5_PITCH: Final[int] = 72 +SEMITONES_FROM_A4_TO_C5: Final[int] = 3 +NOISE_WALK: Final[Tuple[int, ...]] = (3, 4, 9, 15, 0, 1, 12, 7, 2) +NOISE_VOLUME: Final[int] = 12 +NOISE_PERIOD_BITS: Final[int] = NUM_PERIODS - 1 + + +@dataclass(frozen=True, kw_only=True) +class TuningCase(BaseRegularTestCase): + tuning: Tuning + + +TUNING_CASES: Final[Tuple[TuningCase, ...]] = ( + TuningCase( + tuning=Tuning(), + label="concert", + ), + TuningCase( + tuning=Tuning(a4_frequency=LOWERED_A4_FREQUENCY), + label="lowered", + ), + TuningCase( + tuning=Tuning( + a4_frequency=LOWERED_A4_FREQUENCY * 2 ** (SEMITONES_FROM_A4_TO_C5 / OCTAVE_SEMITONES), + a4_pitch=C5_PITCH, + ), + label="named_at_c5", + ), +) + + +@pytest.fixture( + name="test_case", + params=TUNING_CASES, + ids=lambda test_case: test_case.label, +) +def test_case_fixture(request: pytest.FixtureRequest) -> TuningCase: + """The tuning a reconstruction was built against, once per case.""" + test_case: TuningCase = request.param + return test_case + + +def bent_slice(channel: ChannelName, tuning: Tuning) -> InstrumentExport: + """One bent channel slice, moving its note by a contour and its period by a bend.""" + return InstrumentExport( + name=f"Bent ({channel})", + channel=channel, + features=Features( + initial_pitch=REFERENCE_PITCH, + volume=Envelope[int](items=VOLUME_ENVELOPE), + arpeggio=Envelope[int](items=PITCH_CONTOUR), + pitch=Envelope[int](items=BEND_STEPS), + hi_pitch=Envelope[int](items=COARSE_STEPS), + duty_cycle=None, + ), + nes_frequency=NES_FREQUENCY, + tuning=tuning, + ) + + +@pytest.fixture(name="bent_document") +def bent_document_fixture(tmp_path: Path, test_case: TuningCase) -> LoadedProject: + request = SampleExport( + name="Bent", + instruments=( + bent_slice(ChannelName.PULSE1, test_case.tuning), + bent_slice(ChannelName.TRIANGLE, test_case.tuning), + ), + nes_frequency=NES_FREQUENCY, + tuning=test_case.tuning, + ) + destination = tmp_path / "Bent.btp" + write_btp(destination, sample_to_bitphase(request)) + return parse_btp(destination.read_bytes(), list(CHANNEL_LABELS)) + + +def voices(document: LoadedProject) -> List[Tuple[LoadedInstrument, LoadedTable]]: + return list(zip(document.instruments, document.tables)) + + +class TestAPeriodTheEngineResolves: + """Bitphase reads a period out of three readings at once — the note the pattern names, the + step its table stands at, and the offset its instrument's tone macro holds — so the period + a tick sounds is what those three make of the document as written, at whatever tuning the + reconstruction was built against. + """ + + def test_every_tick_sounds_the_divider_the_reconstruction_renders( + self, + bent_document: LoadedProject, + test_case: TuningCase, + ) -> None: + timers = get_timer_table(test_case.tuning) + table = bent_document.songs[0].tuning_table + note_index = pitch_to_note_index(REFERENCE_PITCH) + + for instrument, contour in voices(bent_document): + for tick, (step, steps, coarse) in enumerate(zip(PITCH_CONTOUR, BEND_STEPS, COARSE_STEPS)): + rendered = bent_timer(timers[REFERENCE_PITCH + step], steps + HI_PITCH_FACTOR * coarse) + sounded = sounded_period(table, note_index, instrument, contour, tick) + assert sounded == rendered + PERIOD_OVER_TIMER + + def test_a_channel_goes_on_sounding_for_as_long_as_the_note_does( + self, + bent_document: LoadedProject, + ) -> None: + """A period of zero silences a channel, so every tick resolves above it.""" + table = bent_document.songs[0].tuning_table + note_index = pitch_to_note_index(REFERENCE_PITCH) + + for instrument, contour in voices(bent_document): + periods = [sounded_period(table, note_index, instrument, contour, tick) for tick in range(TICKS_SAMPLED)] + assert all(BITPHASE_SILENT_PERIOD < period <= BITPHASE_MAX_PERIOD for period in periods) + + +class TestWhatEveryTickOfADocumentReads: + def test_every_level_sampled_is_one_the_channel_reads(self, document: LoadedProject) -> None: + levels = [ + instrument.value("volumeOrRate", tick) + for instrument in document.instruments + for tick in range(TICKS_SAMPLED) + ] + assert all(MIN_VOLUME_OR_RATE <= level <= MAX_VOLUME_OR_RATE for level in levels) + + def test_a_note_played_past_its_envelope_holds_what_the_slice_ends_on( + self, + bent_document: LoadedProject, + ) -> None: + """Every dimension circles from the value it states, so a tick past the end reads the + value the slice rests on. + """ + instrument = bent_document.instruments[0] + assert instrument.value("volumeOrRate", len(VOLUME_ENVELOPE) + 10) == VOLUME_ENVELOPE[-1] + + def test_every_macro_holds_the_values_a_bitphase_instrument_stores(self, document: LoadedProject) -> None: + assert all( + len(macro.values) <= MAX_MACRO_LENGTH + for instrument in document.instruments + for macro in instrument.macros.values() + ) + + +def noise_walk() -> List[NoiseInstruction]: + """A noise stream stepping through periods on both sides of where it opens, wrapping past either end.""" + return [NoiseInstruction(on=True, period=period, volume=NOISE_VOLUME, short=False) for period in NOISE_WALK] + + +def cell_note_index(note: LoadedNote) -> int: + """The tuning-table index Bitphase's pattern processor reads back from a note cell.""" + return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE + + +class TestANoisePeriodTheEngineWrites: + """The project counts noise periods from the slowest and the register counts them from the + fastest, so the NSF player writes a period as its complement. A document sounds the same + register on every tick of a noise slice, the base note and the table step read together. + """ + + @pytest.fixture(name="noise_document") + def noise_document_fixture(self, tmp_path: Path) -> LoadedProject: + instructions = noise_walk() + initial_period = NoiseExporter.derive_initial_pitch(instructions) + request = InstrumentExport( + name="Walk", + channel=ChannelName.NOISE, + features=NoiseExporter.to_features(instructions, initial_period, ()), + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + destination = tmp_path / "Walk.btp" + write_btp(destination, instrument_to_bitphase(request)) + return parse_btp(destination.read_bytes(), list(CHANNEL_LABELS)) + + def test_every_tick_writes_the_register_the_nsf_player_writes(self, noise_document: LoadedProject) -> None: + song = noise_document.songs[0] + rows = song.patterns[0].channels[int(ChannelIndex.NOISE)].rows + trigger = next(row for row in rows if row.note.name != int(NoteName.NONE)) + table = noise_document.tables[trigger.table - TABLE_COLUMN_OFFSET] + note_index = cell_note_index(trigger.note) + + written = [ + noise_register(reached_note(song.tuning_table, note_index, table, tick)) for tick in range(len(NOISE_WALK)) + ] + played = [registers.period & NOISE_PERIOD_BITS for registers in NoiseRegisters.from_instructions(noise_walk())] + + assert written == played[: len(NOISE_WALK)] diff --git a/tests/integration/compatibility/__init__.py b/tests/integration/compatibility/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/compatibility/test_corpus.py b/tests/integration/compatibility/test_corpus.py new file mode 100644 index 000000000..436c2aff9 --- /dev/null +++ b/tests/integration/compatibility/test_corpus.py @@ -0,0 +1,62 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import CURRENT_VERSIONS, UPDATES +from sampletones_shared.deployment.version import compare_versions +from tests.suite.compatibility import ( + ARCHIVED_VERSIONS, + WRITING_RELEASE, + archived, + stored_version, + writing_release, +) + +KINDS: Final[tuple[ObjectKind, ...]] = tuple(ARCHIVED_VERSIONS) + + +class TestWhatTheCorpusHolds: + """Each archived file is what its name says, written before the version this build reads.""" + + @pytest.mark.parametrize("kind", KINDS, ids=lambda kind: kind.value) + def test_every_format_keeps_an_archived_file(self, kind: ObjectKind) -> None: + assert archived(kind, ARCHIVED_VERSIONS[kind]).is_file() + + @pytest.mark.parametrize("kind", KINDS, ids=lambda kind: kind.value) + def test_a_file_states_the_version_it_is_named_after(self, kind: ObjectKind) -> None: + """A file regenerated by a newer build would leave the chain idle while the tests stayed green.""" + version = ARCHIVED_VERSIONS[kind] + + assert stored_version(kind, archived(kind, version)) == version + + @pytest.mark.parametrize("kind", KINDS, ids=lambda kind: kind.value) + def test_a_file_stands_before_the_version_this_build_writes(self, kind: ObjectKind) -> None: + assert compare_versions(ARCHIVED_VERSIONS[kind], CURRENT_VERSIONS[kind]) < 0 + + @pytest.mark.parametrize("kind", KINDS, ids=lambda kind: kind.value) + def test_a_file_names_the_release_that_wrote_it(self, kind: ObjectKind) -> None: + assert writing_release(archived(kind, ARCHIVED_VERSIONS[kind])) == WRITING_RELEASE + + +class TestWhatEveryStepIsHeldTo: + """A registered step reads a version a release shipped, so the corpus holds a file at it.""" + + @pytest.mark.parametrize("kind", KINDS, ids=lambda kind: kind.value) + def test_every_step_reads_a_version_the_corpus_keeps(self, kind: ObjectKind) -> None: + """Opening a step without archiving the file it reads leaves the step held to nothing. + + A version reads as three components where a step states it and as two where a release + names it, so the two are compared as versions rather than as the strings they spell. + """ + kept = [version for version in ARCHIVED_VERSIONS.values() if archived(kind, version).is_file()] + for update in UPDATES.get(kind, ()): + assert any(compare_versions(str(update.base), version) == 0 for version in kept) + + @pytest.mark.parametrize("kind", KINDS, ids=lambda kind: kind.value) + def test_the_corpus_keeps_no_file_outside_its_format(self, kind: ObjectKind) -> None: + directory = archived(kind, ARCHIVED_VERSIONS[kind]).parent + extension = archived(kind, ARCHIVED_VERSIONS[kind]).suffix + + assert all(path.suffix == extension for path in directory.iterdir() if path.is_file()) diff --git a/tests/integration/compatibility/test_library.py b/tests/integration/compatibility/test_library.py new file mode 100644 index 000000000..394adf397 --- /dev/null +++ b/tests/integration/compatibility/test_library.py @@ -0,0 +1,72 @@ +from typing import Final + +import numpy as np +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import CURRENT_VERSIONS +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.library import LibraryHeader, LibraryState, library_state +from sampletones_core.library.data import InstructionLibraryData +from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION +from sampletones_shared.exceptions import IncompatibleLibraryDataVersionError +from sampletones_tools.compatibility.documents import corpus_library +from tests.suite.compatibility import LIBRARY_VERSION, archived + +STORED_SAMPLE_RATE: Final[int] = 11025 +STORED_GAMMA: Final[int] = 50 +GENERATION_TOLERANCE: Final[float] = 1e-6 + + +class TestTheLibraryTheLastReleaseWrote: + """A library the last release wrote reads as out of date, which is what has it rebuilt.""" + + def test_it_reads_as_out_of_date(self) -> None: + assert library_state(archived(ObjectKind.LIBRARY, LIBRARY_VERSION)) is LibraryState.OUTDATED + + def test_it_states_the_settings_it_was_built_for(self) -> None: + """The settings a rebuild takes up, so the rebuilt library lands in the file's own place.""" + config = LibraryHeader.read(archived(ObjectKind.LIBRARY, LIBRARY_VERSION)).config + + assert config is not None + assert (config.spectrum_method, config.transformation_gamma, config.sample_rate) == ( + SpectrumMethod.FFT, + STORED_GAMMA, + STORED_SAMPLE_RATE, + ) + + def test_a_load_refuses_it_naming_both_versions(self) -> None: + with pytest.raises(IncompatibleLibraryDataVersionError) as refused: + InstructionLibraryData.load(archived(ObjectKind.LIBRARY, LIBRARY_VERSION), fast=False) + + assert (refused.value.actual_version, refused.value.expected_version) == ( + LIBRARY_VERSION, + SAMPLETONES_LIBRARY_DATA_VERSION, + ) + + +class TestALibraryArchivedAtTheVersionThisBuildWrites: + """A library written at the version this build writes is held to what this build generates. + + A library reads as current by its version alone, so a change to the generators or to feature + extraction bumps the library version, which is what has every stored library rebuilt. Once a + release archives a library at the version this build writes, this holds that version to the + output it names. + """ + + def test_it_is_what_this_build_generates_for_the_same_tones(self) -> None: + path = archived(ObjectKind.LIBRARY, CURRENT_VERSIONS[ObjectKind.LIBRARY]) + if not path.is_file(): + pytest.skip("a release archives a library at the version this build writes first") + + stored = InstructionLibraryData.load(path, fast=False) + generated = corpus_library() + + assert (stored.config, set(stored.data)) == (generated.config, set(generated.data)) + for instruction, fragment in generated.data.items(): + np.testing.assert_array_equal(stored.data[instruction].sample.array, fragment.sample.array) + np.testing.assert_allclose( + stored.data[instruction].feature.values, + fragment.feature.values, + rtol=GENERATION_TOLERANCE, + ) diff --git a/tests/integration/compatibility/test_project.py b/tests/integration/compatibility/test_project.py new file mode 100644 index 000000000..d1a915973 --- /dev/null +++ b/tests/integration/compatibility/test_project.py @@ -0,0 +1,156 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.patterns.pitch import Step +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +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.application import ( + SAMPLETONES_PROJECT_DATA_VERSION, + SAMPLETONES_RECONSTRUCTION_DATA_VERSION, +) +from sampletones_shared.exceptions.project import IncompatibleProjectVersionError +from tests.suite.compatibility import PROJECT_VERSION, archived, restated_document, stored_version + +VOICE_NAMES: Final[List[str]] = ["kick", "kick again"] +PATTERN_NAME: Final[str] = "verse" +PATTERN_CHANNEL: Final[ChannelName] = ChannelName.PULSE2 +ROWS_PER_PATTERN: Final[int] = 4 +PROJECT_TITLE: Final[str] = "Corpus" +PROJECT_AUTHOR: Final[str] = "Archivist" +PROJECT_COMMENT: Final[str] = "A document kept to be read back" +PROJECT_TEMPO: Final[int] = 132 +ROW_TRANSPOSE: Final[int] = 3 +ROW_VOLUME: Final[int] = 9 +UNREACHED_VERSION: Final[str] = "0.9" +UNREACHED_FILENAME: Final[str] = "unreached.stp" + + +@pytest.fixture(name="loaded") +def loaded_fixture() -> Project: + """The archived project read the way a build opens one.""" + return ProjectContainer.load(archived(ObjectKind.PROJECT, PROJECT_VERSION)) + + +def _rows(project: Project) -> List[Row]: + pattern = project.song.pattern(PATTERN_CHANNEL, 0) + assert pattern is not None + return pattern.rows + + +class TestTheVersionAnUpgradedProjectOpensFrom: + """A document states its version at the root, which the load contract reads and then spends.""" + + def test_the_archived_document_stands_at_an_older_version(self) -> None: + path = archived(ObjectKind.PROJECT, PROJECT_VERSION) + + assert stored_version(ObjectKind.PROJECT, path) == PROJECT_VERSION + assert PROJECT_VERSION != SAMPLETONES_PROJECT_DATA_VERSION + + def test_it_opens_all_the_same(self, loaded: Project) -> None: + """The contract refuses a version no chain reaches, so opening is the chain having run.""" + assert loaded.voices + + +class TestAVersionNoStepReaches: + """A document stating a version no chain reaches is refused by the load contract, which names both versions.""" + + def test_a_load_refuses_it_naming_both_versions(self, tmp_path: Path) -> None: + path = tmp_path / UNREACHED_FILENAME + path.write_bytes( + restated_document(archived(ObjectKind.PROJECT, PROJECT_VERSION), ObjectKind.PROJECT, UNREACHED_VERSION) + ) + + with pytest.raises(IncompatibleProjectVersionError) as refused: + ProjectContainer.load(path) + + assert (refused.value.actual_version, refused.value.expected_version) == ( + UNREACHED_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + ) + + +class TestTheVoicesAnUpgradedProjectHolds: + """What a 1.0 document called its samples are the voices a project holds now.""" + + def test_every_sample_reads_as_a_voice(self, loaded: Project) -> None: + assert [voice.name for voice in loaded.voices] == VOICE_NAMES + assert all(isinstance(voice, Sample) for voice in loaded.voices) + + def test_the_voices_share_one_reconstruction(self, loaded: Project) -> None: + """A document stores a reconstruction once however many voices name it.""" + assert len({id(voice.reconstruction) for voice in loaded.voices}) == 1 + + +class TestTheArrangementAnUpgradedProjectKeeps: + """The order, the patterns and every note column a row holds stand as they were written.""" + + def test_the_song_keeps_its_shape(self, loaded: Project) -> None: + assert loaded.song.rows_per_pattern == ROWS_PER_PATTERN + assert loaded.song.order_length() == 1 + + def test_the_pattern_keeps_the_name_it_was_given(self, loaded: Project) -> None: + pattern = loaded.song.pattern(PATTERN_CHANNEL, 0) + + assert pattern is not None + assert pattern.name == PATTERN_NAME + + def test_a_row_naming_a_sample_reads_as_a_note_on(self, loaded: Project) -> None: + """A 1.0 row named a sample and a generator, which the step reads as the voice to start.""" + command = _rows(loaded)[0].command + + assert isinstance(command, NoteOn) + assert command.voice_id == loaded.voices[0].id + + def test_a_row_carries_the_columns_beside_its_note(self, loaded: Project) -> None: + row = _rows(loaded)[1] + + assert isinstance(row.command, NoteOn) + assert row.command.voice_id == loaded.voices[1].id + assert row.pitch == Step(value=ROW_TRANSPOSE) + assert row.volume == ROW_VOLUME + + def test_a_row_letting_a_note_go_reads_as_one(self, loaded: Project) -> None: + assert isinstance(_rows(loaded)[2].command, NoteOff) + + def test_a_row_holding_nothing_stands_empty(self, loaded: Project) -> None: + assert _rows(loaded)[3].is_empty() + + +class TestWhatAnUpgradedProjectSaysOfItself: + def test_it_keeps_what_it_was_named(self, loaded: Project) -> None: + assert loaded.info.title == PROJECT_TITLE + assert loaded.info.author == PROJECT_AUTHOR + assert loaded.info.comment == PROJECT_COMMENT + + def test_it_keeps_the_tempo_it_plays_at(self, loaded: Project) -> None: + assert loaded.settings.tempo == PROJECT_TEMPO + + +class TestTheReconstructionAnUpgradedProjectCarries: + """A project carries its reconstructions through the same chain a stored file travels.""" + + def test_the_embedded_reconstruction_states_the_version_this_build_reads(self, loaded: Project) -> None: + reconstruction = loaded.voices[0].reconstruction + + assert reconstruction.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION + + def test_it_sounds_the_channel_it_was_written_with(self, loaded: Project) -> None: + reconstruction = loaded.voices[0].reconstruction + + assert frozenset(reconstruction.playing_channels) == frozenset({PATTERN_CHANNEL}) + + def test_a_silent_frame_answers_to_rest(self, loaded: Project) -> None: + reconstruction = loaded.voices[0].reconstruction + owners = reconstruction.stems_data.assignments_by_channel[PATTERN_CHANNEL] + stream = reconstruction.instructions[PATTERN_CHANNEL] + + assert [owner == RESTING_STEM_ID for owner in owners] == [not item.on for item in stream] diff --git a/tests/integration/compatibility/test_reconstruction.py b/tests/integration/compatibility/test_reconstruction.py new file mode 100644 index 000000000..86e88440a --- /dev/null +++ b/tests/integration/compatibility/test_reconstruction.py @@ -0,0 +1,218 @@ +from pathlib import Path +from typing import AbstractSet, Any, Dict, Final, List + +import msgpack +import pytest + +from sampletones_core.compatibility.fields import ( + GENERATOR_NAME, + INSTRUCTION, + INSTRUCTIONS, + INSTRUCTIONS_DATA, + ON, + UNION_DATA, +) +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_mix +from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION +from tests.suite.compatibility import RECONSTRUCTION_VERSION, archived, stored_document + +SOURCE_PATH: Final[Path] = Path("samples") / "kick.wav" +SOURCE_NAME: Final[str] = "kick" +SINGLE_STEM_ID: Final[int] = 0 +FRAMES: Final[int] = 3 +STORED_DRIVE: Final[float] = 1.5 +SILENCED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +@pytest.fixture(name="validated") +def validated_fixture() -> Reconstruction: + """The archived file read with every rule the model states held to it.""" + return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, RECONSTRUCTION_VERSION), fast=False) + + +@pytest.fixture(name="loaded") +def loaded_fixture() -> Reconstruction: + """The archived file read the way the application reads one, which asks for no validation.""" + return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, RECONSTRUCTION_VERSION)) + + +class TestTheVersionAnUpgradedFileStates: + """A file carried forward states the version its shape now matches, inside and out.""" + + def test_the_file_states_the_version_this_build_reads(self, validated: Reconstruction) -> None: + assert validated.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION + + def test_the_configuration_it_carries_states_it_too(self, validated: Reconstruction) -> None: + assert validated.config.metadata.reconstruction_data_version == SAMPLETONES_RECONSTRUCTION_DATA_VERSION + + +class TestWhatAnUpgradedFileDescribes: + """The channels, the frames and the instructions the archived file was written with stand.""" + + def test_every_channel_it_was_written_with_plays(self, validated: Reconstruction) -> None: + assert frozenset(validated.playing_channels) == frozenset(ChannelName.items()) + + def test_each_channel_keeps_the_frames_it_was_written_with(self, validated: Reconstruction) -> None: + assert all(len(validated.instructions[channel]) == FRAMES for channel in validated.playing_channels) + + def test_a_stream_reads_as_its_channel_sounds(self, validated: Reconstruction) -> None: + """The streams were keyed by generator before 2.2, so this is the rename holding.""" + stream = validated.instructions[ChannelName.PULSE1] + + assert [instruction.on for instruction in stream] == [True, False, True] + + def test_the_audio_it_describes_renders(self, validated: Reconstruction) -> None: + """A 2.2 file carries no audio, so the upgraded document must still be able to sound.""" + assert len(rendered_mix(validated)) == FRAMES * validated.config.frame_length + + +class TestTheRecordAnUpgradedFileGains: + """A file written before the stems record gains the one a classic conversion would have written.""" + + def test_the_record_names_one_recording(self, validated: Reconstruction) -> None: + entries = validated.stems_data.config.entries + + assert len(entries) == 1 + assert entries[0].id == SINGLE_STEM_ID + + def test_the_recording_takes_the_channels_the_run_handed_out(self, validated: Reconstruction) -> None: + settings = validated.stems_data.config.entries[0].settings + + assert frozenset(settings.channels) == frozenset(ChannelName.items()) + assert settings.channel_cap == ALL_STEMS_CHANNEL_CAP + + def test_the_drive_the_run_stored_reaches_every_channel(self, validated: Reconstruction) -> None: + settings = validated.stems_data.config.entries[0].settings + + assert settings.drives == {channel_name: STORED_DRIVE for channel_name in settings.channels} + + def test_each_channel_names_one_owner_per_frame(self, validated: Reconstruction) -> None: + for channel_name in validated.playing_channels: + owners = validated.stems_data.assignments_by_channel[channel_name] + assert len(owners) == len(validated.instructions[channel_name]) + + def test_a_silent_frame_answers_to_rest(self, validated: Reconstruction) -> None: + """Rest and silence name the same frames, which is the rule a stored record is held to.""" + for channel_name in validated.playing_channels: + owners = validated.stems_data.assignments_by_channel[channel_name] + stream = validated.instructions[channel_name] + assert [owner == RESTING_STEM_ID for owner in owners] == [not item.on for item in stream] + + def test_a_sounding_frame_answers_to_the_recording(self, validated: Reconstruction) -> None: + sounding: List[int] = [ + owner + for channel_name in validated.playing_channels + for owner in validated.stems_data.assignments_by_channel[channel_name] + if owner != RESTING_STEM_ID + ] + + assert sounding + assert set(sounding) == {SINGLE_STEM_ID} + + +class TestTheScaleAnUpgradedFileStates: + """A file the last release wrote kept no scale, and its one recording reads at its own peak.""" + + def test_the_record_states_no_scale(self, validated: Reconstruction) -> None: + assert validated.stems_data.scale is None + + def test_the_application_reads_the_same(self, loaded: Reconstruction) -> None: + assert loaded.stems_data.scale is None + + +class TestTheRecordingAnUpgradedFileNames: + """Where the audio came from travels onto the record, which is where a 2.2 file keeps it.""" + + def test_the_file_still_names_its_recording(self, validated: Reconstruction) -> None: + assert validated.audio_filepath == (SOURCE_PATH,) + + def test_the_record_names_the_recording_behind_the_one_entry(self, validated: Reconstruction) -> None: + sources = validated.stems_data.sources + + assert [source.stem_id for source in sources] == [SINGLE_STEM_ID] + assert validated.stems_data.named(SINGLE_STEM_ID) == SOURCE_NAME + + +class TestWhatTheApplicationGets: + """The application asks for no validation, so what it gets is held to the rules by hand.""" + + def test_it_reads_the_same_record(self, loaded: Reconstruction, validated: Reconstruction) -> None: + assert loaded.stems_data.assignments_by_channel == validated.stems_data.assignments_by_channel + + def test_it_reads_the_same_recording(self, loaded: Reconstruction) -> None: + assert loaded.audio_filepath == (SOURCE_PATH,) + + +def _silenced(document: Dict[str, Any], channel_names: AbstractSet[ChannelName]) -> Dict[str, Any]: + """The stored document with every frame of the named channels written down to rests.""" + for stream in document[INSTRUCTIONS_DATA]: + if stream[GENERATOR_NAME] in channel_names: + for frame in stream[INSTRUCTIONS]: + frame[INSTRUCTION][UNION_DATA][ON] = False + + return document + + +def _loaded_silenced(path: Path, channel_names: AbstractSet[ChannelName]) -> Reconstruction: + """The archived file with the named channels resting through every frame, read with every rule held.""" + document = _silenced(stored_document(archived(ObjectKind.RECONSTRUCTION, RECONSTRUCTION_VERSION)), channel_names) + path.write_bytes(msgpack.packb(document, use_bin_type=True)) + return Reconstruction.load(path, fast=False) + + +@pytest.fixture(name="silenced") +def silenced_fixture(tmp_path: Path) -> Reconstruction: + """The archived file with its first pulse resting through every frame.""" + return _loaded_silenced(tmp_path / "silenced.stn", frozenset({SILENCED_CHANNEL})) + + +class TestAnArchivedChannelWrittenDownToRests: + """A channel the last release stored resting through every frame reads as standing by.""" + + def test_the_channel_stands_by(self, silenced: Reconstruction) -> None: + assert SILENCED_CHANNEL not in silenced.playing_channels + assert silenced.instructions[SILENCED_CHANNEL] == [] + + def test_the_record_names_it_nowhere(self, silenced: Reconstruction) -> None: + assert SILENCED_CHANNEL not in silenced.stems_data.assignments_by_channel + + def test_it_keeps_the_reference_it_stored(self, silenced: Reconstruction, validated: Reconstruction) -> None: + assert silenced.initial_pitches[SILENCED_CHANNEL] == validated.initial_pitches[SILENCED_CHANNEL] + assert silenced.held_features[SILENCED_CHANNEL] == validated.held_features[SILENCED_CHANNEL] + + def test_the_channels_beside_it_play_as_they_were_written( + self, + silenced: Reconstruction, + validated: Reconstruction, + ) -> None: + for channel_name in validated.playing_channels: + if channel_name != SILENCED_CHANNEL: + assert silenced.instructions[channel_name] == validated.instructions[channel_name] + + +class TestAnArchivedFileSoundingNothing: + """A file the last release stored resting on every channel keeps the recording it names.""" + + @pytest.fixture(name="quiet") + def quiet_fixture(self, tmp_path: Path) -> Reconstruction: + return _loaded_silenced(tmp_path / "quiet.stn", frozenset(ChannelName.items())) + + def test_every_channel_stands_by(self, quiet: Reconstruction) -> None: + assert quiet.playing_channels == () + + def test_the_recording_stays_on_the_record(self, quiet: Reconstruction) -> None: + assert [entry.id for entry in quiet.stems_data.config.entries] == [SINGLE_STEM_ID] + assert quiet.audio_filepath == (SOURCE_PATH,) + + +class TestTheStoredPayload: + """A document writes back the payload it reads from, so storing it again changes nothing it holds.""" + + def test_a_loaded_document_writes_back_the_same_bytes(self, loaded: Reconstruction) -> None: + payload = loaded.serialize() + + assert Reconstruction.deserialize(payload).serialize() == payload diff --git a/tests/integration/config/reconstruction.yaml b/tests/integration/config/reconstruction.yaml deleted file mode 100644 index b87f08954..000000000 --- a/tests/integration/config/reconstruction.yaml +++ /dev/null @@ -1,8 +0,0 @@ -reconstruction: - spectrum_method: cqt - 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] } diff --git a/tests/integration/config/song.yaml b/tests/integration/config/song.yaml deleted file mode 100644 index 92ec89c05..000000000 --- a/tests/integration/config/song.yaml +++ /dev/null @@ -1,33 +0,0 @@ -rows_per_pattern: 8 -order: - - { pulse1: 0, pulse2: 0, triangle: 0, noise: 0 } - - { pulse1: 0, pulse2: 0, triangle: 1, noise: 1 } -channels: - pulse1: - patterns: - 0: - - { row: 0, sample: kick, transpose: 0, volume: 15 } - - { row: 2, "off": true } - - { row: 4, sample: kick, transpose: 12, volume: 12 } - - { row: 6, volume: 8 } - pulse2: - patterns: - 0: - - { row: 0, sample: lead, transpose: 0, volume: 12 } - - { row: 4, sample: lead, transpose: 7, volume: 10 } - triangle: - patterns: - 0: - - { row: 0, sample: kick, transpose: -12, volume: 15 } - 1: - - { row: 0, sample: kick, transpose: 7, volume: 10 } - - { row: 4, sample: kick, transpose: 0, volume: 10 } - noise: - patterns: - 0: - - { row: 0, sample: hihat, volume: 15 } - - { row: 2, sample: hihat, volume: 10 } - - { row: 4, sample: hihat, volume: 15 } - - { row: 6, sample: hihat, volume: 8 } - 1: - - { row: 0, sample: hihat, transpose: 3, volume: 12 } diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 43170357f..9b5566dac 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,56 +1,23 @@ -from pathlib import Path from typing import Dict 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.structures import IdentifiedCollection -from tests.integration.assets.module_config import ModuleConfig, load_module_config -from tests.integration.assets.reconstruction import load_instrument_catalog -from tests.integration.assets.song_loader import load_song -from tests.integration.assets.synth_config import SynthConfig, load_synth_config -from tests.integration.paths import ( - MODULE_CONFIG_PATH, - RECONSTRUCTION_CONFIG_PATH, - SONG_PATH, - SYNTH_CONFIG_PATH, -) +from sampletones_core.project.voices.sample import Sample +from sampletones_tools.corpus.build import Corpus, build_synthetic_corpus @pytest.fixture(scope="session") -def audio_directory(tmp_path_factory: pytest.TempPathFactory) -> Path: - return tmp_path_factory.mktemp("integration_audio") +def synthetic_corpus() -> Corpus: + """The corpus the emitters and the report are built from, built once for the session.""" + return build_synthetic_corpus() @pytest.fixture(scope="session") -def synth_config() -> SynthConfig: - return load_synth_config(SYNTH_CONFIG_PATH) +def instrument_catalog(synthetic_corpus: Corpus) -> Dict[str, Sample]: + return synthetic_corpus.catalog @pytest.fixture(scope="session") -def module_config() -> ModuleConfig: - return load_module_config(MODULE_CONFIG_PATH) - - -@pytest.fixture(scope="session") -def instrument_catalog(audio_directory: Path, synth_config: SynthConfig) -> Dict[str, Sample]: - return load_instrument_catalog(RECONSTRUCTION_CONFIG_PATH, synth_config, tmp_dir=audio_directory) - - -@pytest.fixture(scope="session") -def integration_project(instrument_catalog: Dict[str, Sample], module_config: ModuleConfig) -> Project: - samples: IdentifiedCollection[Sample] = IdentifiedCollection() - for sample in instrument_catalog.values(): - samples.append(sample) - - settings = ProjectSettings( - tempo=module_config.tempo, - speed=module_config.speed, - nes_frequency=module_config.nes_frequency, - ) - project = Project.create(title=module_config.title, author=module_config.author, settings=settings) - project.samples = samples - project.song = load_song(SONG_PATH, instrument_catalog) - return project +def integration_project(synthetic_corpus: Corpus) -> Project: + return synthetic_corpus.project diff --git a/tests/integration/docs/__init__.py b/tests/integration/docs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/docs/test_documentation.py b/tests/integration/docs/test_documentation.py new file mode 100644 index 000000000..4101300c8 --- /dev/null +++ b/tests/integration/docs/test_documentation.py @@ -0,0 +1,95 @@ +import re +from pathlib import Path +from typing import Dict, Final, List, Set, Tuple + +import pytest +from PIL import Image + +from sampletones_shared.paths.source import REPOSITORY_ROOT + +ENCODING: Final[str] = "utf-8" +LINK: Final[re.Pattern[str]] = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") +IMAGE: Final[re.Pattern[str]] = re.compile(r"]*)>") +ATTRIBUTE: Final[re.Pattern[str]] = re.compile(r'(\w+)="([^"]*)"') +HEADING: Final[re.Pattern[str]] = re.compile(r"^#{1,6}\s+(.*?)\s*$", re.MULTILINE) +EXTERNAL: Final[Tuple[str, ...]] = ("http://", "https://", "mailto:") +DOCUMENTATION: Final[Path] = REPOSITORY_ROOT / "docs" +INDEX: Final[Path] = DOCUMENTATION / "index.md" +ROOT_PAGES: Final[Tuple[str, ...]] = ("README.md", "CHANGELOG.md") + + +def anchor(heading: str) -> str: + """The fragment GitHub gives a heading: punctuation dropped, every remaining space a hyphen.""" + text = re.sub(r"[`*]", "", heading).strip().lower() + return re.sub(r"[^\w\s-]", "", text).replace(" ", "-") + + +def text(page: Path) -> str: + """The page as it is stored, which is UTF-8 whatever encoding the platform prefers.""" + return page.read_text(encoding=ENCODING) + + +def anchors(page: Path) -> Set[str]: + return {anchor(heading) for heading in HEADING.findall(text(page))} + + +def images(page: Path) -> List[Dict[str, str]]: + """The attributes of every picture the page embeds as an HTML image.""" + return [dict(ATTRIBUTE.findall(attributes)) for attributes in IMAGE.findall(text(page))] + + +def pages() -> List[Path]: + return sorted(DOCUMENTATION.rglob("*.md")) + [REPOSITORY_ROOT / name for name in ROOT_PAGES] + + +def identifier(page: Path) -> str: + return page.relative_to(REPOSITORY_ROOT).as_posix() + + +class TestEveryInternalLinkResolves: + """A link between documents names a file that exists and, where it names one, a heading it holds.""" + + @pytest.mark.parametrize("page", pages(), ids=identifier) + def test_the_page_links_only_to_files_and_headings_that_exist(self, page: Path) -> None: + for target in LINK.findall(text(page)): + if target.startswith(EXTERNAL): + continue + + path, _, fragment = target.partition("#") + destination = (page.parent / path).resolve() if path else page + assert destination.exists(), f"{identifier(page)} links to a missing file: {target}" + + if fragment and destination.suffix == ".md": + assert fragment in anchors(destination), f"{identifier(page)} links to a missing heading: {target}" + + +class TestEveryPictureStandsAtItsOwnWidth: + """A picture a page embeds exists and is shown at the width of its file, as the pictures maker drew it.""" + + @pytest.mark.parametrize("page", pages(), ids=identifier) + def test_the_page_shows_each_picture_it_holds_at_the_width_of_its_file(self, page: Path) -> None: + for attributes in images(page): + source = attributes["src"] + if source.startswith(EXTERNAL): + continue + + picture = (page.parent / source).resolve() + assert picture.exists(), f"{identifier(page)} shows a missing picture: {source}" + + with Image.open(picture) as image: + shown = attributes.get("width") + assert shown == str( + image.width + ), f"{identifier(page)} shows {source} at {shown} px; it is {image.width} px wide" + + +class TestTheIndexListsEveryDocument: + """`docs/index.md` is the map of the documentation, so a page is reachable from it.""" + + def test_every_page_is_listed(self) -> None: + listed = {(INDEX.parent / target.partition("#")[0]).resolve() for target in LINK.findall(text(INDEX))} + missing = [ + identifier(page) for page in DOCUMENTATION.rglob("*.md") if page != INDEX and page.resolve() not in listed + ] + + assert not missing, f"documents missing from docs/index.md: {missing}" diff --git a/tests/integration/famitracker/conftest.py b/tests/integration/famitracker/conftest.py index b10b93fe7..1ffa52897 100644 --- a/tests/integration/famitracker/conftest.py +++ b/tests/integration/famitracker/conftest.py @@ -1,19 +1,11 @@ from pathlib import Path -from typing import Optional import pytest -from tests.integration.output import resolve_output_directory, resolve_output_path -from tests.integration.paths import FTM_OUTPUT_ENV, MODULE_FILENAME - - -@pytest.fixture(scope="session") -def ftm_output_dir() -> Optional[Path]: - """The persistent output directory ``SAMPLETONES_FTM_OUTPUT_DIR`` names.""" - return resolve_output_directory(FTM_OUTPUT_ENV) +from sampletones_tools.samples.famitracker import MODULE_FILENAME @pytest.fixture -def module_path(ftm_output_dir: Optional[Path], tmp_path: Path) -> Path: +def module_path(tmp_path: Path) -> Path: """Where a produced ``.ftm`` is written.""" - return resolve_output_path(ftm_output_dir, tmp_path, MODULE_FILENAME) + return tmp_path / MODULE_FILENAME 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/history/__init__.py b/tests/integration/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/history/conftest.py b/tests/integration/history/conftest.py new file mode 100644 index 000000000..88e06fa15 --- /dev/null +++ b/tests/integration/history/conftest.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Iterator + +import pytest + +from tests.suite.history.session import HistorySession, history_session + + +@pytest.fixture +def session(tmp_path: Path) -> Iterator[HistorySession]: + """The every-part project open in a headless application, its history held by the audit.""" + with history_session(tmp_path) as opened: + yield opened diff --git a/tests/integration/history/test_every_action.py b/tests/integration/history/test_every_action.py new file mode 100644 index 000000000..85efd44d0 --- /dev/null +++ b/tests/integration/history/test_every_action.py @@ -0,0 +1,52 @@ +import pytest + +from tests.suite.history.gestures import GESTURES, Gesture, perform +from tests.suite.history.projects import LEAD +from tests.suite.history.session import HistorySession + + +def _label(gesture: Gesture) -> str: + return gesture.label + + +class TestEveryGestureAlone: + """Each gesture records one entry, and every way through the history reproduces each state.""" + + @pytest.mark.parametrize("gesture", GESTURES, ids=_label) + def test_one_entry_its_undo_and_redo_restore( + self, + session: HistorySession, + gesture: Gesture, + ) -> None: + perform(session, gesture) + + assert [entry.action for entry in session.history.entries][1:] == [gesture.action] + session.audit.walk() + + @pytest.mark.parametrize("gesture", GESTURES, ids=_label) + def test_a_save_stores_the_state_and_an_undo_returns_to_the_file( + self, + session: HistorySession, + gesture: Gesture, + ) -> None: + opened = session.stored_fingerprint() + perform(session, gesture) + + session.save() + assert session.stored_fingerprint() == session.audit.model.live.fingerprint + + session.audit.undo() + session.save() + assert session.stored_fingerprint() == opened + + @pytest.mark.parametrize("gesture", GESTURES, ids=_label) + def test_the_open_sample_follows_every_step( + self, + session: HistorySession, + gesture: Gesture, + ) -> None: + session.open_voice(LEAD) + + perform(session, gesture) + + session.audit.walk() diff --git a/tests/integration/history/test_interleavings.py b/tests/integration/history/test_interleavings.py new file mode 100644 index 000000000..3763520b0 --- /dev/null +++ b/tests/integration/history/test_interleavings.py @@ -0,0 +1,209 @@ +from contextlib import contextmanager +from typing import Final, Iterator, List +from unittest.mock import patch + +import pytest + +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.rewrites.steps import StemRemovalRequest +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.sample import Sample +from tests.suite.application import HeldQueue +from tests.suite.history.audit import immediately +from tests.suite.history.gestures import GESTURES_BY_LABEL, REMOVED_STEM_NAME, RETUNED_RATE, perform +from tests.suite.history.projects import BASS, LEAD +from tests.suite.history.session import HistorySession +from tests.suite.stems import STEM_B_ID + +FIRST_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(11, 9)) +REFUSED_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(1, 1)) +SECOND_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(6, 5)) +LATE_RETUNE: Final[str] = ( + "Retune results landing after another commit record an entry of their own, so one undo leaves the new rate " + "over samples at the old one" +) + + +@contextmanager +def held_reports(session: HistorySession) -> Iterator[HeldQueue]: + """Holds what the workers report until the case drains it, each gesture meanwhile landing what it does at once.""" + queue = HeldQueue() + with patch.object(CallbackQueue, "add", queue.add), session.audit.settling(immediately): + yield queue + + +def _edit_lead( + session: HistorySession, + channel_name: ChannelName, + envelope: Envelope[int], +) -> None: + session.instruments.handle_envelope_changed(channel_name, FeatureKey.VOLUME, envelope) + + +def _actions(session: HistorySession) -> List[HistoryAction]: + return [entry.action for entry in session.history.entries] + + +def _lead_volumes( + session: HistorySession, + channel_name: ChannelName, +) -> List[int]: + return [instruction.volume for instruction in session.sample(LEAD).reconstruction.instructions[channel_name]] + + +class TestARebuildStillOnItsWay: + """A channel rebuild lands after the gestures made while it ran, and a whole-document gesture waits for it.""" + + def test_a_tracker_edit_lands_first(self, session: HistorySession) -> None: + session.open_voice(LEAD) + lead = session.voice_id(LEAD) + + with held_reports(session) as queue: + _edit_lead(session, ChannelName.PULSE1, FIRST_ENVELOPE) + SingleThreadExecutor.join_all() + session.audit.check() + perform(session, GESTURES_BY_LABEL["type a volume"]) + queue.drain() + + session.audit.adopt_landed([(HistoryAction.EDIT_RECONSTRUCTION, (lead,))]) + session.audit.check() + assert _actions(session) == [HistoryAction.INITIAL, HistoryAction.EDIT_ROW, HistoryAction.EDIT_RECONSTRUCTION] + session.audit.walk() + + def test_an_undo_waits_and_undoes_the_edit(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["set the tempo"]) + session.open_voice(LEAD) + lead = session.voice_id(LEAD) + + with held_reports(session) as queue: + _edit_lead(session, ChannelName.PULSE1, FIRST_ENVELOPE) + SingleThreadExecutor.join_all() + session.sequencer.undo() + session.audit.check() + queue.drain() + + session.audit.adopt_landed([(HistoryAction.EDIT_RECONSTRUCTION, (lead,))]) + session.audit.model.undo() + session.audit.check() + assert session.history.cursor == 1 + session.audit.walk() + + def test_a_save_waits_and_writes_the_edit(self, session: HistorySession) -> None: + session.open_voice(LEAD) + lead = session.voice_id(LEAD) + opened = session.stored_fingerprint() + + with held_reports(session) as queue: + _edit_lead(session, ChannelName.PULSE1, FIRST_ENVELOPE) + SingleThreadExecutor.join_all() + session.save_door() + assert session.stored_fingerprint() == opened + queue.drain() + + session.audit.adopt_landed([(HistoryAction.EDIT_RECONSTRUCTION, (lead,))]) + session.audit.model.save() + session.audit.check() + assert session.stored_fingerprint() == session.audit.model.live.fingerprint + + def test_a_change_drawn_while_a_removal_waits_goes_nowhere(self, session: HistorySession) -> None: + session.open_voice(LEAD) + lead = session.voice_id(LEAD) + + with held_reports(session) as queue: + _edit_lead(session, ChannelName.PULSE1, FIRST_ENVELOPE) + session.app._reconstruction_coordinator.request_rewrite( + StemRemovalRequest(stem_id=STEM_B_ID, stem_name=REMOVED_STEM_NAME) + ) + _edit_lead(session, ChannelName.PULSE1, REFUSED_ENVELOPE) + SingleThreadExecutor.join_all() + queue.drain() + + session.audit.adopt_landed( + [ + (HistoryAction.EDIT_RECONSTRUCTION, (lead,)), + (HistoryAction.EDIT_RECONSTRUCTION, None), + ] + ) + session.audit.check() + assert REFUSED_ENVELOPE.items[0] not in _lead_volumes(session, ChannelName.PULSE1) + session.audit.walk() + + session.audit.perform( + lambda: _edit_lead(session, ChannelName.PULSE1, SECOND_ENVELOPE), + action=HistoryAction.EDIT_RECONSTRUCTION, + target=(lead,), + ) + assert SECOND_ENVELOPE.items[0] in _lead_volumes(session, ChannelName.PULSE1) + + +class TestEditsOfOneSampleRunTogether: + """Consecutive edits of one sample make one entry whatever channels they move, until another gesture comes.""" + + def test_two_channels_make_one_entry(self, session: HistorySession) -> None: + session.open_voice(LEAD) + lead = session.voice_id(LEAD) + + for channel_name in (ChannelName.PULSE1, ChannelName.PULSE2): + session.audit.perform( + lambda: _edit_lead(session, channel_name, SECOND_ENVELOPE), + action=HistoryAction.EDIT_RECONSTRUCTION, + target=(lead,), + ) + + assert _actions(session) == [HistoryAction.INITIAL, HistoryAction.EDIT_RECONSTRUCTION] + session.audit.walk() + + def test_an_edit_of_another_sample_between_keeps_them_apart(self, session: HistorySession) -> None: + lead = session.voice_id(LEAD) + bass = session.voice_id(BASS) + steps = ((LEAD, lead, ChannelName.PULSE1), (BASS, bass, ChannelName.NOISE), (LEAD, lead, ChannelName.PULSE2)) + + for name, voice_id, channel_name in steps: + session.open_voice(name) + session.audit.perform( + lambda: session.instruments.handle_envelope_changed(channel_name, FeatureKey.VOLUME, SECOND_ENVELOPE), + action=HistoryAction.EDIT_RECONSTRUCTION, + target=(voice_id,), + ) + + assert len(session.history.entries) == 4 + session.audit.walk() + + +class TestARateChangeAndTheRetuneAfterIt: + """A rate change records one entry that the retuned samples join, so one undo restores the rate and the audio.""" + + def test_the_retune_joins_the_rate_change(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["retune the project"]) + + session.audit.undo() + + assert session.history.cursor == 0 + assert all( + sample.reconstruction.config.nes_frequency == session.project.settings.nes_frequency + for sample in session.project.voices + if isinstance(sample, Sample) + ) + + @pytest.mark.xfail(strict=True, reason=LATE_RETUNE) + def test_a_tracker_edit_before_the_retune_lands(self, session: HistorySession) -> None: + session.sequencer._nes_frequency_change_acknowledged = True + + with held_reports(session) as queue: + session.audit.perform( + lambda: session.sequencer._sequencer_module_panel.on_nes_frequency(RETUNED_RATE), + action=HistoryAction.SET_NES_FREQUENCY, + target=(RETUNED_RATE,), + ) + perform(session, GESTURES_BY_LABEL["type a volume"]) + SingleThreadExecutor.join_all() + queue.drain() + + assert _actions(session) == [ + HistoryAction.INITIAL, + HistoryAction.SET_NES_FREQUENCY, + HistoryAction.EDIT_ROW, + ] diff --git a/tests/integration/history/test_pairs.py b/tests/integration/history/test_pairs.py new file mode 100644 index 000000000..cd77d62c3 --- /dev/null +++ b/tests/integration/history/test_pairs.py @@ -0,0 +1,52 @@ +from itertools import product +from pathlib import Path +from typing import Final, Iterator, List, Tuple + +import pytest + +from sampletones_application.logic.history.action import HistoryAction +from tests.suite.history.gestures import GESTURES_BY_LABEL, PAIR_KINDS, Gesture, attempt, perform +from tests.suite.history.session import HistorySession, history_session + +PAIRS: Final[List[Tuple[HistoryAction, HistoryAction]]] = list(product(PAIR_KINDS, repeat=2)) + + +def _pair_label(pair: Tuple[HistoryAction, HistoryAction]) -> str: + return f"{pair[0]} then {pair[1]}" + + +@pytest.fixture(scope="module") +def shared_session(tmp_path_factory: pytest.TempPathFactory) -> Iterator[HistorySession]: + """One application per worker, the every-part project opened afresh for each pair.""" + with history_session(tmp_path_factory.mktemp("pairs")) as opened: + yield opened + + +def _pair( + first: HistoryAction, + second: HistoryAction, +) -> Tuple[Gesture, Gesture]: + opening, repeating = PAIR_KINDS[second] + return GESTURES_BY_LABEL[PAIR_KINDS[first][0]], GESTURES_BY_LABEL[repeating if first is second else opening] + + +class TestEveryPairOfActions: + """Two gestures in a row, then every way back and forth, then a commit over the redo branch.""" + + @pytest.mark.parametrize("pair", PAIRS, ids=_pair_label) + def test_the_pair_restores_every_state( + self, + shared_session: HistorySession, + pair: Tuple[HistoryAction, HistoryAction], + ) -> None: + session = shared_session + session.reopen() + first, second = _pair(*pair) + + perform(session, first) + attempt(session, second) + session.audit.walk() + session.audit.jump_to(0) + perform(session, second) + + assert session.history.cursor == 1 diff --git a/tests/integration/history/test_refused.py b/tests/integration/history/test_refused.py new file mode 100644 index 000000000..60e54875e --- /dev/null +++ b/tests/integration/history/test_refused.py @@ -0,0 +1,88 @@ +from typing import Final + +from sampletones_application.logic.history.action import HistoryAction +from tests.suite.history.gestures import GESTURES_BY_LABEL, LEAD_REGION, perform +from tests.suite.history.projects import LEAD +from tests.suite.history.session import HistorySession + +BLANK_NAME: Final[str] = " " + + +class TestAGestureChangingNothingRecordsNothing: + """A gesture that leaves the project as it stands adds no entry; the same path acting afterwards adds one.""" + + def test_the_properties_confirmed_unchanged(self, session: HistorySession) -> None: + info = session.project.info + settings = session.project.settings + + session.audit.refused( + lambda: session.app.project_properties_window.on_commit( + info.title, + info.author, + info.comment, + settings.first_highlight, + settings.second_highlight, + ) + ) + + perform(session, GESTURES_BY_LABEL["edit the properties"]) + assert len(session.history.entries) == 2 + + def test_the_rate_the_project_runs_at(self, session: HistorySession) -> None: + session.sequencer._nes_frequency_change_acknowledged = True + + session.audit.refused( + lambda: session.sequencer._sequencer_module_panel.on_nes_frequency( + session.project.settings.nes_frequency, + ) + ) + + perform(session, GESTURES_BY_LABEL["retune the project"]) + assert len(session.history.entries) == 2 + + def test_a_blank_name(self, session: HistorySession) -> None: + voices = session.sequencer._sequencer_voices_panel + + session.audit.refused(lambda: voices.on_rename_committed(session.voice_id(LEAD), BLANK_NAME)) + + perform(session, GESTURES_BY_LABEL["rename a sample"]) + assert len(session.history.entries) == 2 + + def test_a_copy_reading_the_tracker(self, session: HistorySession) -> None: + tracker = session.sequencer._sequencer_tracker_panel + cut = GESTURES_BY_LABEL["cut a tracker block"] + + session.audit.refused(lambda: tracker.on_copy_block(LEAD_REGION)) + + perform(session, cut) + assert [entry.action for entry in session.history.entries][1:] == [HistoryAction.CUT_BLOCK] + + +class TestAStepPastEitherEnd: + """Undo at the first entry and redo at the last change nothing; inside the stack they move.""" + + def test_an_undo_at_the_start(self, session: HistorySession) -> None: + session.audit.undo() + perform(session, GESTURES_BY_LABEL["set the tempo"]) + + session.audit.undo() + + assert session.history.cursor == 0 + + def test_a_redo_at_the_end(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["set the tempo"]) + session.audit.redo() + + session.audit.undo() + session.audit.redo() + + assert session.history.cursor == 1 + + def test_a_jump_past_the_end_and_onto_the_cursor(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["set the tempo"]) + session.audit.jump_to(len(session.history.entries)) + session.audit.jump_to(session.history.cursor) + + session.audit.jump_to(0) + + assert session.history.cursor == 0 diff --git a/tests/integration/history/test_sessions.py b/tests/integration/history/test_sessions.py new file mode 100644 index 000000000..dd18b9132 --- /dev/null +++ b/tests/integration/history/test_sessions.py @@ -0,0 +1,219 @@ +from pathlib import Path +from typing import Final, Iterator, List +from unittest.mock import PropertyMock, patch + +import pytest + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.history.action import HistoryAction +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample +from tests.suite.history.gestures import EDITED_ENVELOPE, GESTURES_BY_LABEL, perform +from tests.suite.history.projects import BASS, LEAD, PAD +from tests.suite.history.session import HistorySession, history_session + +TIGHT_BUDGET: Final[int] = 5 +LEAD_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(13, 10)) +COPY_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(3, 2)) +ORIGINAL_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(8, 4)) + + +@pytest.fixture +def tight_session(tmp_path: Path) -> Iterator[HistorySession]: + """The every-part project under a history keeping the last few entries alone.""" + with ( + patch.object(SessionManager, "history_budget", new_callable=PropertyMock, return_value=TIGHT_BUDGET), + history_session(tmp_path) as opened, + ): + yield opened + + +def _gesture( + session: HistorySession, + label: str, +) -> None: + perform(session, GESTURES_BY_LABEL[label]) + + +def _edit( + session: HistorySession, + sample: Sample, + channel_name: ChannelName, + envelope: Envelope[int], +) -> None: + session.open_voice_id(sample.id) + session.audit.perform( + lambda: session.instruments.handle_envelope_changed(channel_name, FeatureKey.VOLUME, envelope), + action=HistoryAction.EDIT_RECONSTRUCTION, + target=(sample.id,), + ) + + +def _rows_naming( + session: HistorySession, + voice_id: str, +) -> int: + return sum( + 1 + for channel in session.project.song.channels.values() + for pattern in channel.patterns.values() + for row in pattern.rows + if isinstance(row.command, NoteOn) and row.command.voice_id == voice_id + ) + + +def _last_sample(session: HistorySession) -> Sample: + voice = list(session.project.voices)[-1] + assert isinstance(voice, Sample) + return voice + + +def _end_with_a_reload(session: HistorySession) -> None: + session.save() + assert session.stored_fingerprint() == session.audit.model.live.fingerprint + + +class TestAReconstructionsTabSession: + """Edits of one sample's channels, a removal and a rate between sequencer typing, undone and redone.""" + + def test_every_state_comes_back(self, session: HistorySession) -> None: + lead = session.sample(LEAD) + _edit(session, lead, ChannelName.PULSE1, LEAD_ENVELOPE) + _edit(session, lead, ChannelName.PULSE2, LEAD_ENVELOPE) + _gesture(session, "type a volume") + _gesture(session, "remove a recording") + _gesture(session, "retune the project with a sample open") + _gesture(session, "edit an instrument's envelope") + assert [entry.action for entry in session.history.entries] == [ + HistoryAction.INITIAL, + HistoryAction.EDIT_RECONSTRUCTION, + HistoryAction.EDIT_ROW, + HistoryAction.EDIT_RECONSTRUCTION, + HistoryAction.SET_NES_FREQUENCY, + HistoryAction.EDIT_INSTRUMENT, + ] + + for _ in range(3): + session.audit.undo() + session.audit.redo() + _gesture(session, "type a step") + + assert len(session.history.entries) == 5 + session.audit.walk() + _end_with_a_reload(session) + + +class TestADuplicateEditedApart: + """A copy edited apart from its original keeps the two documents apart through every step.""" + + def _diverge(self, session: HistorySession) -> None: + _gesture(session, "duplicate a sample") + _edit(session, _last_sample(session), ChannelName.NOISE, COPY_ENVELOPE) + session.audit.undo() + _edit(session, session.sample(BASS), ChannelName.NOISE, ORIGINAL_ENVELOPE) + + def test_every_state_comes_back(self, session: HistorySession) -> None: + self._diverge(session) + + session.audit.walk() + + assert session.sample(BASS).reconstruction is not _last_sample(session).reconstruction + + def test_a_save_keeps_both_documents(self, session: HistorySession) -> None: + self._diverge(session) + + _end_with_a_reload(session) + + +class TestTheSavePointThroughASession: + """The saved entry reads clean wherever the cursor meets it, until a commit truncates it away.""" + + def test_clean_and_dirty_follow_the_saved_entry(self, session: HistorySession) -> None: + _gesture(session, "set the tempo") + session.save() + _gesture(session, "set the tempo") + session.audit.undo() + assert not session.controller.is_dirty + session.audit.undo() + assert session.controller.is_dirty + session.audit.redo() + _gesture(session, "set the speed") + session.audit.undo() + assert not session.controller.is_dirty + + session.audit.jump_to(0) + _gesture(session, "edit the properties") + session.audit.walk() + + assert session.audit.model.saved is None + assert session.controller.is_dirty + _end_with_a_reload(session) + + +class TestATightBudget: + """The budget keeps the newest entries, each still restoring its state, and drops the opened state.""" + + def test_the_kept_entries_restore_their_states(self, tight_session: HistorySession) -> None: + session = tight_session + for label in ( + "set the tempo", + "type a note with an instrument", + "insert a frame", + "rename a sample", + "set the speed", + "add an instrument", + "edit the properties", + ): + _gesture(session, label) + + assert len(session.history.entries) == TIGHT_BUDGET + assert session.history.entries[0].action is not HistoryAction.INITIAL + session.audit.walk() + session.audit.jump_to(0) + _gesture(session, "set the tempo") + assert len(session.history.entries) == 2 + + +class TestAVoiceTheSongNames: + """Removing a voice clears the rows naming it, and an undo brings both the voice and its rows back.""" + + def test_the_rows_return_with_the_voice(self, session: HistorySession) -> None: + lead = session.voice_id(LEAD) + named: List[int] = [_rows_naming(session, lead)] + + _gesture(session, "remove a sample") + named.append(_rows_naming(session, lead)) + session.audit.undo() + named.append(_rows_naming(session, lead)) + session.audit.redo() + named.append(_rows_naming(session, lead)) + + assert named[0] > 0 + assert named == [named[0], 0, named[0], 0] + _end_with_a_reload(session) + + +class TestTheProjectLifecycle: + """A new, an opened and a closed project each start the stack over from what they put in place.""" + + def test_each_transition_starts_over(self, session: HistorySession) -> None: + _gesture(session, "set the tempo") + + session.audit.transition(session.app._project_coordinator.new_project) + assert len(session.history.entries) == 1 + session.audit.perform( + lambda: session.sequencer._sequencer_module_panel.on_speed(4), + action=HistoryAction.SET_SPEED, + target=(), + ) + + session.audit.transition(session.app._project_coordinator.close_project) + assert len(session.history.entries) == 0 + + session.reopen() + assert len(session.history.entries) == 1 + _gesture(session, "edit an instrument's envelope") + session.audit.walk() + assert session.instrument(PAD).envelopes.volume == EDITED_ENVELOPE diff --git a/tests/integration/history/test_sharing.py b/tests/integration/history/test_sharing.py new file mode 100644 index 000000000..654b0c94c --- /dev/null +++ b/tests/integration/history/test_sharing.py @@ -0,0 +1,80 @@ +from typing import Dict + +from sampletones_core.constants.enums import ChannelName +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.reconstructions import Reconstruction +from tests.suite.history.gestures import GESTURES_BY_LABEL, perform +from tests.suite.history.projects import FIRST_PATTERN, LEAD, PAD +from tests.suite.history.session import HistorySession + + +def _documents(project: Project) -> Dict[str, Reconstruction]: + return {voice.id: voice.reconstruction for voice in project.voices if isinstance(voice, Sample)} + + +def _entry( + session: HistorySession, + index: int, +) -> Project: + return session.history.entries[index].project + + +class TestAnEntryOwnsWhatItsGestureReplaced: + """Consecutive entries share every part of the project the gesture between them left alone.""" + + def test_a_tracker_edit_shares_every_document(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["type a note with a sample"]) + + before, after = _documents(_entry(session, 0)), _documents(_entry(session, 1)) + + assert all(after[voice_id] is document for voice_id, document in before.items()) + + def test_a_channel_edit_shares_the_other_samples_and_channels(self, session: HistorySession) -> None: + lead = session.voice_id(LEAD) + + perform(session, GESTURES_BY_LABEL["edit a channel's envelope"]) + + before, after = _documents(_entry(session, 0)), _documents(_entry(session, 1)) + assert all(after[voice_id] is document for voice_id, document in before.items() if voice_id != lead) + assert after[lead] is not before[lead] + assert after[lead].streams[ChannelName.PULSE2] is before[lead].streams[ChannelName.PULSE2] + + def test_a_retune_shares_every_stream(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["retune the project"]) + + before, after = _documents(_entry(session, 0)), _documents(_entry(session, 1)) + for voice_id, document in before.items(): + assert after[voice_id] is not document + assert all( + left is right + for left, right in zip(after[voice_id].instructions_data, document.instructions_data, strict=True) + ) + + def test_a_tracker_edit_shares_every_other_pattern(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["type a note with a sample"]) + + before, after = _entry(session, 0).song.channels, _entry(session, 1).song.channels + for name, channel in after.items(): + for index, pattern in channel.patterns.items(): + edited = name is ChannelName.PULSE1 and index == FIRST_PATTERN + assert (pattern is before[name].patterns[index]) is not edited + + def test_an_instrument_edit_shares_every_other_voice(self, session: HistorySession) -> None: + pad = session.voice_id(PAD) + + perform(session, GESTURES_BY_LABEL["edit an instrument's envelope"]) + + before = {voice.id: voice for voice in _entry(session, 0).voices} + after = {voice.id: voice for voice in _entry(session, 1).voices} + for voice_id, voice in after.items(): + match voice: + case Instrument(): + held = before[voice_id] + assert isinstance(held, Instrument) + assert (voice.envelopes is held.envelopes) is (voice_id != pad) + case Sample(): + held = before[voice_id] + assert isinstance(held, Sample) + assert voice.reconstruction is held.reconstruction diff --git a/tests/integration/history/test_stored_identity.py b/tests/integration/history/test_stored_identity.py new file mode 100644 index 000000000..82bc7d450 --- /dev/null +++ b/tests/integration/history/test_stored_identity.py @@ -0,0 +1,87 @@ +from pathlib import Path +from typing import Final + +from sampletones_application.logic.history.action import HistoryAction +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.voices.sample import Sample +from tests.suite.history.audit import fresh_fingerprint +from tests.suite.history.gestures import GESTURES_BY_LABEL, perform, write_arriving_sample +from tests.suite.history.projects import BASS, LEAD +from tests.suite.history.session import HistorySession + +COPY_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(3, 2)) +SHARED_NAME: Final[str] = "shared.stp" + + +def _edit_a_channel( + session: HistorySession, + sample: Sample, + channel_name: ChannelName, +) -> None: + """Writes a volume into one channel of ``sample`` from the Reconstructions tab.""" + session.open_voice_id(sample.id) + session.audit.perform( + lambda: session.instruments.handle_envelope_changed(channel_name, FeatureKey.VOLUME, COPY_ENVELOPE), + action=HistoryAction.EDIT_RECONSTRUCTION, + target=(sample.id,), + ) + + +def _last_sample(session: HistorySession) -> Sample: + voice = list(session.project.voices)[-1] + assert isinstance(voice, Sample) + return voice + + +class TestEachSampleKeepsItsOwnDocumentThroughASave: + """A save writes what every sample plays, so a reload gives each sample back its own document.""" + + def test_an_edit_of_one_sample_survives_a_save(self, session: HistorySession) -> None: + _edit_a_channel(session, session.sample(BASS), ChannelName.NOISE) + + session.save() + + assert session.stored_fingerprint() == session.audit.model.live.fingerprint + + def test_a_duplicate_edited_apart_from_its_original(self, session: HistorySession) -> None: + perform(session, GESTURES_BY_LABEL["duplicate a sample"]) + _edit_a_channel(session, _last_sample(session), ChannelName.NOISE) + + session.save() + + assert session.stored_fingerprint() == session.audit.model.live.fingerprint + + def test_a_file_added_twice_and_edited_once(self, session: HistorySession) -> None: + arriving = write_arriving_sample(session) + for _ in range(2): + session.audit.perform( + lambda: session.sequencer.import_reconstruction(arriving), + action=HistoryAction.ADD_SAMPLE, + target=None, + ) + + _edit_a_channel(session, _last_sample(session), ChannelName.PULSE2) + + session.save() + + assert session.stored_fingerprint() == session.audit.model.live.fingerprint + + def test_a_project_whose_samples_share_a_document( + self, + session: HistorySession, + tmp_path: Path, + ) -> None: + project = ProjectContainer.load(session.project_file) + lead = next(voice for voice in project.voices if isinstance(voice, Sample) and voice.name == LEAD) + twin = Sample(name=f"{LEAD} twin", reconstruction=lead.reconstruction) + project.voices.append(twin) + shared = tmp_path / SHARED_NAME + ProjectContainer.save(project, shared) + session.audit.transition(lambda: session.app._project_coordinator.load_project_safely(shared)) + _edit_a_channel(session, _last_sample(session), ChannelName.PULSE1) + + session.save() + + assert fresh_fingerprint(ProjectContainer.load(shared)) == session.audit.model.live.fingerprint 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..b460fc303 --- /dev/null +++ b/tests/integration/nsf/conftest.py @@ -0,0 +1,37 @@ +from pathlib import Path +from typing import Dict, Final + +import pytest + +from sampletones_core.project.voices.sample import Sample +from sampletones_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.driver.image import DriverImage +from sampletones_player.song import Song +from sampletones_shared.paths.extensions import EXT_FILE_NSF + +EXPORTED_SAMPLE: Final[str] = "lead" + + +@pytest.fixture +def nsf_paths(tmp_path: Path, instrument_catalog: Dict[str, Sample]) -> Dict[str, Path]: + """Where each sample's produced ``.nsf`` is written.""" + return {name: 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, scheme=CompressionScheme.SEARCH) 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..f368450b8 --- /dev/null +++ b/tests/integration/nsf/console/instructions.py @@ -0,0 +1,221 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Mapping, Self, 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_PITCH, MAX_VOLUME, MIN_PITCH +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_core.timers.arithmetic import bent_timer +from sampletones_core.timers.nearest import NearestPitch, nearest_pitches +from sampletones_core.timers.utils import get_timer_table +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_shared.music import Tuning +from sampletones_tools.player.trace.trace import RegisterTrace +from tests.integration.nsf.console.registers 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 + + +@dataclass(frozen=True) +class DividerReading: + """The note and bend each divider the console sounds reads back as. + + A bent tick sounds a divider no pitch may own, so a divider reads back as the pitch lying + nearest it and the detune left over, over the pitches an instruction names. Many splits of + pitch, fine and coarse steps reach the same divider, and stating every instruction by the + divider it sounds is what puts both sides of a comparison in one form. + + Attributes: + timer_table: The timer register value each pitch sounds at. + nearest: The pitch and detune each divider reads back as, indexed by the divider. + """ + + timer_table: Mapping[int, int] + nearest: Tuple[NearestPitch, ...] + + @classmethod + def from_tuning(cls, tuning: Tuning) -> Self: + """The reading of the dividers a song built under ``tuning`` sounds.""" + timer_table = get_timer_table(tuning) + return cls( + timer_table=timer_table, + nearest=nearest_pitches({pitch: timer_table[pitch] for pitch in range(MIN_PITCH, MAX_PITCH + 1)}), + ) + + def sounded(self, instruction: InstructionUnion) -> InstructionUnion: + """An instruction stated by what the console makes of it. + + A sounding note becomes the divider it sounds, read back. A rest becomes the canonical + silent instruction: a stream holds a channel's pitch and timbre through a rest so the + driver leaves the timer's high byte alone, and what a rest carries beyond its silence is + the channel's own history. + """ + if not instruction.on: + silent: InstructionUnion = type(instruction).null_instruction() + return silent + + match instruction: + case PulseInstruction() | TriangleInstruction(): + named = self.nearest[bent_timer(self.timer_table[instruction.pitch], instruction.timer_offset)] + return instruction.model_copy(update={"pitch": named.pitch, "detune": named.offset, "coarse_detune": 0}) + case _: + return instruction + + +def pulse_instruction( + registers: Mapping[int, int], + channel: ChannelName, + reading: DividerReading, +) -> 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. + reading: The note and bend each divider reads back as. + + Returns: + PulseInstruction: The frame the channel plays. + """ + control, timer_low, timer_high = channel_values(registers, channel) + volume = control & MAX_VOLUME + named = reading.nearest[timer_value(timer_low, timer_high)] + return PulseInstruction( + on=volume > 0, + pitch=named.pitch, + detune=named.offset, + volume=volume, + duty_cycle=control >> DUTY_CYCLE_SHIFT, + ) + + +def triangle_instruction(registers: Mapping[int, int], reading: DividerReading) -> 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. + reading: The note and bend each divider reads back as. + + Returns: + TriangleInstruction: The frame the channel plays. + """ + linear_counter, timer_low, timer_high = channel_values(registers, ChannelName.TRIANGLE) + named = reading.nearest[timer_value(timer_low, timer_high)] + return TriangleInstruction( + on=linear_counter == TRIANGLE_SOUNDING, + pitch=named.pitch, + detune=named.offset, + ) + + +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], reading: DividerReading) -> 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. + reading: The note and bend each divider reads back as. + + Returns: + Dict[ChannelName, InstructionUnion]: One instruction per channel. + """ + return { + ChannelName.PULSE1: pulse_instruction(registers, ChannelName.PULSE1, reading), + ChannelName.PULSE2: pulse_instruction(registers, ChannelName.PULSE2, reading), + ChannelName.TRIANGLE: triangle_instruction(registers, reading), + ChannelName.NOISE: noise_instruction(registers), + } + + +def instructions_from_trace( + trace: RegisterTrace, + reading: DividerReading, +) -> 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. + reading: The note and bend each divider reads back as. + + Returns: + Dict[ChannelName, List[InstructionUnion]]: Each channel's stream, one instruction per + tick the run sounded. + """ + streams: Dict[ChannelName, List[InstructionUnion]] = {channel: [] for channel in ChannelName.items()} + + for registers in register_file(trace): + for channel, instruction in instructions_at(registers, reading).items(): + streams[channel].append(instruction) + + return streams + + +def sounded_approximation(reconstruction: Reconstruction) -> np.ndarray: + """A reconstruction's own waveform, rendered from its frames on the engine it was built on. + + Args: + reconstruction: The reconstruction the console is playing. + + Returns: + np.ndarray: The waveform its frames sound as. + """ + streams = {channel_name: list(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/registers.py b/tests/integration/nsf/console/registers.py new file mode 100644 index 000000000..5b52cca53 --- /dev/null +++ b/tests/integration/nsf/console/registers.py @@ -0,0 +1,32 @@ +from typing import Dict, List + +from sampletones_tools.player.trace.trace import RegisterTrace + + +def register_file(trace: RegisterTrace) -> List[Dict[int, int]]: + """The APU as the driver leaves it after initialization and after every call that sounds. + + A tick reaches the hardware as the values standing in the registers once its writes land, and + the three registers written only on change keep the value an earlier tick left there. Reading + the whole file back after each sounding call is therefore what recovers a tick's full state + from a trace that states only what changed. + + Args: + trace: The writes a run of the driver made. + + Returns: + List[Dict[int, int]]: One register file per tick the run sounded, in order. + """ + registers: Dict[int, int] = {} + ticks: List[Dict[int, int]] = [] + + for writes in (trace.initialization, *trace.play_calls): + if not writes: + continue + + for write in writes: + registers[write.address] = write.value + + ticks.append(dict(registers)) + + return ticks diff --git a/tests/integration/nsf/console/session.py b/tests/integration/nsf/console/session.py new file mode 100644 index 000000000..fa34bd862 --- /dev/null +++ b/tests/integration/nsf/console/session.py @@ -0,0 +1,114 @@ +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_tools.console.machine import Console +from sampletones_tools.player.trace.trace import RegisterTrace + +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. + """ + return Console(data).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/header.py b/tests/integration/nsf/header.py new file mode 100644 index 000000000..6f93dbf76 --- /dev/null +++ b/tests/integration/nsf/header.py @@ -0,0 +1,8 @@ +from sampletones_player.nsf.information import NSFInformation +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.samples.nsf import exported_information + + +def sample_information(name: str) -> NSFInformation: + """The header text ``nsf samples`` gives a corpus sample: its name, by the corpus's author.""" + return exported_information(name, ModuleConfig.load().author) diff --git a/tests/integration/nsf/test_backend.py b/tests/integration/nsf/test_backend.py new file mode 100644 index 000000000..d2eba1872 --- /dev/null +++ b/tests/integration/nsf/test_backend.py @@ -0,0 +1,354 @@ +from pathlib import Path +from typing import Dict, Final, List + +import numpy as np +import pytest + +from sampletones_core.audio.mixing import mix +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exporters.naming import instrument_slice_name +from sampletones_core.exports.request import ( + InstrumentExport, + 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.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.builder import ( + SONG_START, + instructions_from_instruments, + song_from_project, + song_from_sample, +) +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram +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 ( + DividerReading, + instructions_from_trace, + sounded_approximation, +) +from tests.integration.nsf.console.session import ( + captured_file_trace, + captured_run, + play_calls_reaching, +) + +ChannelInstructions = Dict[ChannelName, List[InstructionUnion]] + +PROJECT_ARTIFACT: Final[str] = "song" +LOOP_FRAME: Final[int] = 1 + + +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.stated() + + +@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(): + program = NSFProgram.for_sample(requests[name]) + song = song_from_sample( + requests[name], + channels=program.channels, + loop_tick=program.loop_tick, + scheme=program.scheme, + ) + trace = captured_file_trace(destination.read_bytes(), song) + played[name] = instructions_from_trace( + trace, + DividerReading.from_tuning(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 divider reads back as the pitch lying nearest it and a detune, so both sides are stated by + the divider each frame sounds — see :class:`DividerReading`. + """ + + 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(): + reading = DividerReading.from_tuning(request.tuning) + for channel, instructions in instructions_from_instruments(request.instruments).items(): + sounded = played[name][channel][: len(instructions)] + assert [reading.sounded(instruction) for instruction in sounded] == [ + reading.sounded(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, + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) + + +@pytest.fixture(scope="module") +def project_file( + backend: NSFBackend, + integration_project: Project, + 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 = 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), + ) + reading = DividerReading.from_tuning(tuning_from_project(integration_project)) + played = instructions_from_trace(trace, reading) + for channel, instructions in song_instructions(integration_project).items(): + sounded = played[channel][: len(instructions)] + assert [reading.sounded(instruction) for instruction in sounded] == [ + reading.sounded(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)) + reading = DividerReading.from_tuning(tuning_from_project(integration_project)) + played = instructions_from_trace(trace, reading) + for channel, sounded in played.items(): + assert len(sounded) > ticks + assert [reading.sounded(instruction) for instruction in sounded[ticks : 2 * ticks]] == [ + reading.sounded(instruction) for instruction in sounded[:ticks] + ] + + +def chosen_file( + backend: NSFBackend, + project: Project, + program: NSFProgram, + directory: Path, +) -> Path: + """The integration project written as ``program`` through the backend the application registers.""" + destination = directory / f"{PROJECT_ARTIFACT}{EXT_FILE_NSF}" + backend.choosing(program).write_project(destination, ProjectExport(project=project)) + return destination + + +def sounded_program( + data: bytes, + project_song: Song, + project: Project, + ticks: int, +) -> ChannelInstructions: + """What the console sounds over ``ticks`` of a written program, read back as instructions.""" + trace = captured_run(data, play_calls_reaching(project_song, ticks)) + return instructions_from_trace(trace, DividerReading.from_tuning(tuning_from_project(project))) + + +class TestTheConsoleSoundsTheChosenProgram: + """A program the user settled reaches the APU as it was settled.""" + + def test_a_channel_left_out_rests_while_the_others_play_the_arrangement( + self, + backend: NSFBackend, + integration_project: Project, + project_song: Song, + tmp_path: Path, + ) -> None: + program = NSFProgram.for_project(integration_project).model_copy( + update={"channels": ALL_CHANNELS - {ChannelName.NOISE}} + ) + data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() + played = sounded_program(data, project_song, integration_project, project_song.ticks) + reading = DividerReading.from_tuning(tuning_from_project(integration_project)) + + for channel, instructions in song_instructions(integration_project).items(): + sounded = played[channel][: len(instructions)] + if channel == ChannelName.NOISE: + assert not any(instruction.on for instruction in sounded) + else: + assert [reading.sounded(instruction) for instruction in sounded] == [ + reading.sounded(instruction) for instruction in instructions + ] + + def test_a_song_written_without_compression_sounds_the_arrangement( + self, + backend: NSFBackend, + integration_project: Project, + project_song: Song, + tmp_path: Path, + ) -> None: + program = NSFProgram.for_project(integration_project).model_copy(update={"scheme": CompressionScheme.NONE}) + data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() + played = sounded_program(data, project_song, integration_project, project_song.ticks) + reading = DividerReading.from_tuning(tuning_from_project(integration_project)) + + for channel, instructions in song_instructions(integration_project).items(): + assert [reading.sounded(instruction) for instruction in played[channel][: len(instructions)]] == [ + reading.sounded(instruction) for instruction in instructions + ] + + def test_a_song_repeating_from_a_frame_comes_round_to_that_frame( + self, + backend: NSFBackend, + integration_project: Project, + project_song: Song, + tmp_path: Path, + ) -> None: + """The calls past the arrangement's end sound the frame the program returns to, onward.""" + loop_tick = SongTiming.from_project(integration_project, bounds=SONG_TICK_BOUNDS).frame_tick(LOOP_FRAME) + program = NSFProgram.for_project(integration_project).model_copy(update={"loop_tick": loop_tick}) + data = chosen_file(backend, integration_project, program, tmp_path).read_bytes() + ticks = project_song.ticks + played = sounded_program(data, project_song, integration_project, 2 * ticks) + reading = DividerReading.from_tuning(tuning_from_project(integration_project)) + + repeated = ticks - loop_tick + for sounded in played.values(): + assert [reading.sounded(instruction) for instruction in sounded[ticks : ticks + repeated]] == [ + reading.sounded(instruction) for instruction in sounded[loop_tick: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..ad0d08041 --- /dev/null +++ b/tests/integration/nsf/test_compression_report.py @@ -0,0 +1,129 @@ +import csv +from math import ceil +from typing import Dict, List, Tuple + +import pytest + +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.planes.rebuild import streams_from_planes +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.compression import MAX_LITERAL_BYTES +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_shared.utils.tables import Table +from sampletones_tools.codec.report import session +from sampletones_tools.codec.report.corpus import LONG_ARRANGEMENT +from sampletones_tools.codec.report.encoding import ( + LITERALS, + PLANE_VARIANTS, + RECORDS, + REGISTER_PLANES, + SEARCH, + SPLIT_CONTROL, + Encoding, +) +from sampletones_tools.codec.report.session import CompressionReport, run_report +from sampletones_tools.codec.report.songs import available_bytes +from sampletones_tools.corpus.build import Corpus + + +@pytest.fixture(scope="module") +def report(synthetic_corpus: Corpus, tmp_path_factory: pytest.TempPathFactory) -> CompressionReport: + """The report ``codec report`` writes, over the session's corpus in place of a build of its own.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(session, "build_synthetic_corpus", lambda: synthetic_corpus) + return run_report(tmp_path_factory.mktemp("compression_report")) + + +@pytest.fixture(scope="module") +def encodings(report: CompressionReport) -> Tuple[Encoding, ...]: + """Every corpus song compressed under every variant of the codec.""" + return report.encodings + + +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_holds_the_baselines_then_every_variant_per_song(self, report: CompressionReport) -> None: + with report.csv_path.open(encoding="utf-8", newline="") as handle: + header, *rows = list(csv.reader(handle)) + + variants: Dict[str, List[str]] = {} + for row in rows: + variants.setdefault(row[header.index("corpus")], []).append(row[header.index("variant")]) + + assert list(variants) == [entry.name for entry in report.entries] + assert all( + listed == [RECORDS, REGISTER_PLANES, SPLIT_CONTROL, *(name for name, _ in PLANE_VARIANTS)] + for listed in variants.values() + ) + table = Table(columns=tuple(header), rows=tuple(tuple(row) for row in rows)).markdown_lines() + document = report.markdown_path.read_text(encoding="utf-8").splitlines() + assert document[document.index(table[0]) : document.index(table[0]) + len(table)] == table diff --git a/tests/integration/nsf/test_driver_audio.py b/tests/integration/nsf/test_driver_audio.py new file mode 100644 index 000000000..58d550260 --- /dev/null +++ b/tests/integration/nsf/test_driver_audio.py @@ -0,0 +1,116 @@ +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_player.builder import song_from_reconstruction +from sampletones_player.compression.scheme import CompressionScheme +from tests.integration.nsf.console.instructions import ( + DividerReading, + instructions_from_trace, + sounded_approximation, +) +from tests.integration.nsf.console.session import captured_trace +from tests.integration.nsf.header import sample_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, scheme=CompressionScheme.SEARCH) + trace = captured_trace(song, sample_information(sample.name)) + return instructions_from_trace(trace, DividerReading.from_tuning(sample.reconstruction.config.tuning)) + + +@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 divider reads back as the pitch lying nearest it and a detune, so both sides are stated by + the divider each frame sounds — see :class:`DividerReading`. + """ + + 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(): + reading = DividerReading.from_tuning(sample.reconstruction.config.tuning) + for channel, instructions in sample.reconstruction.instructions.items(): + sounded = played[name][channel][: len(instructions)] + assert [reading.sounded(instruction) for instruction in sounded] == [ + reading.sounded(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. + + Both sides render on the reconstruction's own engine, so the console is held against the + reconstruction's waveform itself — 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..1ffe63892 --- /dev/null +++ b/tests/integration/nsf/test_driver_bend.py @@ -0,0 +1,251 @@ +from dataclasses import dataclass +from typing import Final, List, Self, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH, PITCH_BEND_MAX, PITCH_BEND_MIN +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.timers.arithmetic import bent_timer +from sampletones_player.builder import song_from_reconstruction, streams_from_instructions +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.song import Song +from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_player.specification.planes import PlaneRole, plane_index +from sampletones_player.specification.registers import PULSE1_TIMER_HIGH, TIMER_HIGH_SHIFT +from sampletones_tools.player.trace.trace import RegisterTrace +from tests.integration.nsf.console.instructions import channel_values, timer_value +from tests.integration.nsf.console.registers import register_file +from tests.integration.nsf.console.session import ( + captured_trace, + captured_trace_over, + play_calls_covering, + play_calls_reaching, +) +from tests.integration.nsf.header import sample_information +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_PITCHES, + PLAYER_REFERENCE_PITCH, + PLAYER_TIMER_TABLE, + bent_song, + player_reconstruction, + player_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 +VIBRATO: Final[Tuple[int, ...]] = (0, 3, 6, 3, 0, -3, -6, -3, 0) +SLIDE: Final[Tuple[int, ...]] = tuple(range(0, -70, -10)) +COARSE_STEPS: Final[int] = 10 + + +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, sample_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 plane + outright, bends past the room a nearest pitch leaves included, 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, sample_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, sample_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 + + +class TestTheConsoleSoundsTheBendAFrameCarries(BaseTestSuite): + """Bent frames exported the way a reconstruction is, played on py65 and read back per tick. + + This is the whole path a bend takes: the encoders write the divider the generators render, + the planes split it into a note and a bend, the codec compresses both, and the driver adds + them back. Every tick is held to the divider the sequencer sounds the frame at. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + """A run of bent frames on the first pulse channel. + + Attributes: + name: What the run shows. + frames: The frames the channel plays. + """ + + expected: None = None + name: str + frames: Tuple[PulseInstruction, ...] + + @classmethod + def bent(cls, name: str, pitch: int, bends: Tuple[Tuple[int, int], ...]) -> Self: + """A case holding one note while each frame bends it by a fine and a coarse step count.""" + return cls( + name=name, + frames=tuple( + PulseInstruction( + on=True, + pitch=pitch, + volume=PLAYER_FULL_VOLUME, + duty_cycle=0, + detune=detune, + coarse_detune=coarse_detune, + ) + for detune, coarse_detune in bends + ), + ) + + @property + def label(self) -> str: + return self.name + + test_cases = ( + TestCase.bent("vibrato", PLAYER_REFERENCE_PITCH, tuple((bend, 0) for bend in VIBRATO)), + TestCase.bent("slide into the next note", PLAYER_REFERENCE_PITCH, tuple((bend, 0) for bend in SLIDE)), + TestCase.bent( + "coarse bend past a byte", PLAYER_REFERENCE_PITCH, ((0, 0), (0, COARSE_STEPS), (1, COARSE_STEPS)) + ), + TestCase.bent("clamped at the slowest divider", MIN_PITCH, ((0, 0), (PITCH_BEND_MAX, PITCH_BEND_MAX))), + TestCase.bent("clamped at the fastest divider", MAX_PITCH, ((0, 0), (PITCH_BEND_MIN, PITCH_BEND_MIN))), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_every_tick_sounds_the_divider_the_sequencer_renders(self, test_case: TestCase) -> None: + instructions: List[InstructionUnion] = list(test_case.frames) + song = song_from_reconstruction( + player_reconstruction({ChannelName.PULSE1: instructions}, NTSC_RATE), + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + expected = [bent_timer(PLAYER_TIMER_TABLE[frame.pitch], frame.timer_offset) for frame in test_case.frames] + assert sounded_dividers(song)[: len(expected)] == expected + + +PULSE1_BEND: Final[int] = plane_index(ChannelName.PULSE1, PlaneRole.BEND) + + +class TestABentSongComingRound: + """A song returning to a tick re-enters each bend plane past the flags played before it.""" + + LOOP_TICK: Final[int] = 5 + ROUNDS: Final[int] = 3 + + @pytest.fixture + def repeating(self) -> Song: + frames = [ + PulseInstruction( + on=True, pitch=PLAYER_REFERENCE_PITCH, volume=PLAYER_FULL_VOLUME, duty_cycle=0, detune=bend + ) + for bend in VIBRATO * 2 + ] + streams = streams_from_instructions({ChannelName.PULSE1: frames}, PLAYER_TIMER_TABLE) + return player_song(streams, NTSC_RATE, loop_tick=self.LOOP_TICK) + + def test_the_loop_tick_falls_after_flagged_ticks(self, repeating: Song) -> None: + planes = decode_planes(repeating.planes) + assert planes.positions(self.LOOP_TICK)[PULSE1_BEND] > 0 + + def test_the_console_writes_what_the_model_states_across_its_loops(self, repeating: Song) -> None: + calls = play_calls_reaching(repeating, self.ROUNDS * repeating.ticks) + trace = captured_trace_over(repeating, sample_information(SONG_NAME), calls) + assert trace == RegisterTrace.from_song(repeating, calls) diff --git a/tests/integration/nsf/test_driver_trace.py b/tests/integration/nsf/test_driver_trace.py new file mode 100644 index 000000000..b6f42d077 --- /dev/null +++ b/tests/integration/nsf/test_driver_trace.py @@ -0,0 +1,227 @@ +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.compression.scheme import CompressionScheme +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +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_tools.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.header import sample_information +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, +) + +HALF_RATE: Final[int] = 30 +DOUBLE_RATE: Final[int] = 120 +NTSC_RATE: Final[int] = 60 +FAST_RATE: Final[int] = 300 +ROUNDS: Final[int] = 4 +ABSENT_SONG_NAME: Final[str] = "absent" + + +@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, sample_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, scheme=CompressionScheme.SEARCH) + + trace = captured_trace(song, sample_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, scheme=CompressionScheme.SEARCH), available + ) + + assert block[STEP_FRACTION_OFFSET + WORD_SIZE :] == reclocked_block[STEP_FRACTION_OFFSET + WORD_SIZE :] + assert block[:STEP_WHOLE_OFFSET] == reclocked_block[:STEP_WHOLE_OFFSET] + + +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, scheme=CompressionScheme.SEARCH).ticks + loop_tick = ticks - test_case.remaining if test_case.remaining else ticks // 2 + return song_from_reconstruction(reclocked, loop_tick=loop_tick, scheme=CompressionScheme.SEARCH) + + @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, sample_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, sample_information(sample.name), calls) + assert any(writes for writes in trace.play_calls[-TRAILING_CALLS:]) + + +class TestAPlaneTheBlockLeavesOut: + """A plane holding zero throughout is absent: the header states a sentinel and the driver + leaves it standing at zero, on the first pass and on every pass a loop brings round. + """ + + FIGURE: Final = ( + pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER), + pulse_tick(PLAYER_FULL_VOLUME, 1, PLAYER_OCTAVE_UP_TIMER), + pulse_tick(PLAYER_SILENT_VOLUME, 1, PLAYER_OCTAVE_UP_TIMER), + ) + LOOP_TICK: Final[int] = 1 + + @pytest.fixture + def unbent(self) -> Song: + """A repeating song on one pulse channel, none of its tone channels bending.""" + return player_song(resting_streams(self.FIGURE * ROUNDS), NTSC_RATE, loop_tick=self.LOOP_TICK) + + def test_the_song_leaves_its_bend_planes_out(self, unbent: Song) -> None: + streams = unbent.planes.streams + assert not streams.pulse1_bend + assert not streams.pulse2_bend + assert not streams.triangle_bend + + def test_the_console_writes_what_the_model_states_across_its_loops(self, unbent: Song) -> None: + calls = play_calls_reaching(unbent, ROUNDS * unbent.ticks) + trace = captured_trace_over(unbent, sample_information(ABSENT_SONG_NAME), calls) + assert trace == RegisterTrace.from_song(unbent, 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..22c7e00e8 --- /dev/null +++ b/tests/integration/nsf/test_nsf_pipeline.py @@ -0,0 +1,165 @@ +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.compression.scheme import CompressionScheme +from sampletones_player.driver.image import DriverImage +from sampletones_player.nsf.file import write_nsf +from sampletones_player.nsf.song import song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.nsf import ( + HEADER_SIZE, + NSF_MAGIC, + PROGRAM_SIZE, +) +from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_player.specification.song import ( + ABSENT_STREAM, + 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.header import sample_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, ...]: + """Where each stream the block holds begins, in plane order, an absent plane left out.""" + offsets = (read_word(block, STREAM_OFFSETS_OFFSET + WORD_SIZE * plane) for plane in range(PLANE_COUNT)) + return tuple(offset for offset in offsets if offset != ABSENT_STREAM) + + +@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, sample_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, scheme=CompressionScheme.SEARCH) + write_nsf(nsf_paths[name], song, sample_information(name), driver_image) + assert nsf_paths[name].read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC + + 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, + scheme=CompressionScheme.SEARCH, + ) + + information = sample_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..699203ed1 --- /dev/null +++ b/tests/integration/nsf/test_song_export.py @@ -0,0 +1,80 @@ +import struct + +import pytest + +from sampletones_core.constants.enums import ALL_CHANNELS +from sampletones_core.project.project import Project +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.builder import song_from_project +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.driver.image import DriverImage +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 sampletones_tools.codec.report.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, + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + + +class TestTheProjectReachesTheConsole: + """A whole arrangement flattened into the streams the driver already plays.""" + + def test_the_song_lasts_the_ticks_the_projects_timing_gives_its_order( + self, + integration_project: Project, + project_song: Song, + ) -> None: + timing = SongTiming.from_project(integration_project, bounds=SONG_TICK_BOUNDS) + assert project_song.ticks == timing.frame_tick(integration_project.song.order_length()) + + def test_every_channel_the_order_plays_sounds(self, project_song: Song) -> None: + """The fixture's arrangement fills all four channels, so none of them rests throughout.""" + 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/nsf/test_song_timing.py b/tests/integration/nsf/test_song_timing.py new file mode 100644 index 000000000..0ea7b3023 --- /dev/null +++ b/tests/integration/nsf/test_song_timing.py @@ -0,0 +1,44 @@ +from fractions import Fraction +from typing import Final + +import pytest + +from sampletones_core.timing import SONG_TICK_BOUNDS, Meter, RowRate, SongTiming +from sampletones_player.clock.schedule import PlaySchedule +from sampletones_player.specification.clock import NTSC_FRAME_RATE +from tests.suite.groove import bar_rows + +COMMON_TIME: Final[Meter] = Meter(rows=16, first_highlight=4, second_highlight=16) +FRAMES: Final[int] = 100 +HALF: Final[Fraction] = Fraction(1, 2) +CALL_SECONDS: Final[Fraction] = 1 / Fraction(NTSC_FRAME_RATE) + + +class TestABarLineReachesTheConsoleOnTime: + """Rows are planned in engine ticks, and the console's call re-clocks the ticks onto its own rate. + + Each layer carries its own remainder, so a bar line the console plays lands within half a tick + of its exact moment plus one call, at any engine rate and however long the song runs. The drift + the driver's rounded step builds over the run is counted on top. + """ + + @pytest.mark.parametrize("nes_frequency", (15, 41, 60, 300)) + @pytest.mark.parametrize("tempo", (97, 125, 210, 251)) + def test_every_bar_line_lands_within_half_a_tick_and_one_call(self, nes_frequency: int, tempo: int) -> None: + timing = SongTiming( + rate=RowRate.from_parameters(tempo=tempo, speed=6, nes_frequency=nes_frequency), + meter=COMMON_TIME, + bounds=SONG_TICK_BOUNDS, + ) + schedule = PlaySchedule.from_parameters(nes_frequency) + calls = 0 + tick_seconds = 1 / Fraction(nes_frequency) + drift = schedule.maximum_drift(int(timing.frame_tick(FRAMES) / schedule.ticks_per_play_call) + 1) + allowed = (HALF + drift) * tick_seconds + CALL_SECONDS + for row in bar_rows(COMMON_TIME, FRAMES): + tick = timing.tick_at(*divmod(row, COMMON_TIME.rows)) + while schedule.ticks_at(calls + 1) < tick: + calls += 1 + + exact = row * timing.exact_row_ticks * tick_seconds + assert abs(calls * CALL_SECONDS - exact) <= allowed diff --git a/tests/integration/output.py b/tests/integration/output.py deleted file mode 100644 index 423b89d30..000000000 --- a/tests/integration/output.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -import shutil -from pathlib import Path -from typing import Optional - -from tests.integration.paths import REPO_ROOT - - -def resolve_output_directory(variable: str) -> Optional[Path]: - """Reads the persistent output directory an environment variable names. - - Emission is opt-in so an ordinary (and parallel) run writes only to ``tmp_path``. - A named directory is cleaned once per session, so each run leaves a fresh set of - files there. - - Args: - variable: Environment variable naming the directory. - - Returns: - Optional[Path]: The prepared directory, or ``None`` while emission is unasked for. - """ - configured = os.environ.get(variable) - if not configured: - return None - - directory = Path(configured) - if not directory.is_absolute(): - directory = REPO_ROOT / directory - - if directory.exists(): - shutil.rmtree(directory) - - directory.mkdir(parents=True, exist_ok=True) - return directory - - -def resolve_output_path(output_directory: Optional[Path], tmp_path: Path, filename: str) -> Path: - """Locates a produced file: the persistent directory where one is named, else ``tmp_path``. - - Args: - output_directory: The persistent directory, or ``None`` while emission is unasked for. - tmp_path: The test's own temporary directory. - filename: Name the produced file carries. - - Returns: - Path: Where the file is written. - """ - base = output_directory if output_directory is not None else tmp_path - return base / filename diff --git a/tests/integration/paths.py b/tests/integration/paths.py deleted file mode 100644 index dc63ef711..000000000 --- a/tests/integration/paths.py +++ /dev/null @@ -1,27 +0,0 @@ -from pathlib import Path -from typing import Final - - -def _repo_root() -> Path: - for parent in Path(__file__).resolve().parents: - if (parent / "pyproject.toml").exists(): - return parent - - raise RuntimeError("Could not locate the repository root") - - -REPO_ROOT: Final[Path] = _repo_root() -INTEGRATION_ROOT: Final[Path] = Path(__file__).parent - -CONFIG_DIRECTORY: Final[Path] = INTEGRATION_ROOT / "config" -SYNTH_CONFIG_PATH: Final[Path] = CONFIG_DIRECTORY / "synth.yaml" -RECONSTRUCTION_CONFIG_PATH: Final[Path] = CONFIG_DIRECTORY / "reconstruction.yaml" -MODULE_CONFIG_PATH: Final[Path] = CONFIG_DIRECTORY / "module.yaml" -SONG_PATH: Final[Path] = CONFIG_DIRECTORY / "song.yaml" - -MODULE_FILENAME: Final[str] = "drums.ftm" -FTM_OUTPUT_ENV: Final[str] = "SAMPLETONES_FTM_OUTPUT_DIR" - -DOCUMENT_FILENAME: Final[str] = "drums.btp" -GROOVE_DOCUMENT_FILENAME: Final[str] = "drums-groove.btp" -BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_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..167432b13 --- /dev/null +++ b/tests/integration/reconstruction/test_conversion_jobs.py @@ -0,0 +1,152 @@ +from pathlib import Path + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + 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.reconstructor.stems.configs.settings import StemSettings +from sampletones_core.reconstructions.stage import ReconstructionStage +from sampletones_shared.exceptions import OperationCanceled +from sampletones_shared.utils.progress import silent_reporter +from sampletones_tools.corpus.catalog import build_mini_library +from tests.suite.progress import FIRST_REPORT, RecordingReporter, reported_stages +from tests.suite.stems import ( + THREE_STEM_CHANNELS, + three_stem_config, + three_stem_reconstruction_config, + write_three_stem_recordings, +) + + +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, frozenset(THREE_STEM_CHANNELS), 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, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_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, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + recordings = tmp_path / "recordings" + recordings.mkdir() + sources = write_three_stem_recordings(config, recordings) + stems = StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1]).with_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, frozenset(THREE_STEM_CHANNELS), 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, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_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, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_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, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + source = write_three_stem_recordings(config, tmp_path)[0] + stems = StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_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..4bcfbf4a6 --- /dev/null +++ b/tests/integration/reconstruction/test_conversion_progress.py @@ -0,0 +1,109 @@ +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.constants.enums import ( + DEFAULT_CHANNELS, +) +from sampletones_core.reconstructions.converter import GroupConversion, ReconstructionConverter +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +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(StemSettings.covering(list(DEFAULT_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..66b110c6b --- /dev/null +++ b/tests/integration/reconstruction/test_decoding.py @@ -0,0 +1,92 @@ +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 ( + DEFAULT_CHANNELS, + ChannelName, + SelectorName, +) +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_tools.corpus.catalog 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, frozenset(DEFAULT_CHANNELS), 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_frame_objective.py b/tests/integration/reconstruction/test_frame_objective.py new file mode 100644 index 000000000..dab018ae0 --- /dev/null +++ b/tests/integration/reconstruction/test_frame_objective.py @@ -0,0 +1,102 @@ +from pathlib import Path +from typing import Any, Dict, Final, FrozenSet, List + +import numpy as np +import pytest + +from sampletones_core.audio import write_wave +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import get_generators_by_channels +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.library import InstructionLibrary, InstructionLibraryData, InstructionLibraryFragment +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.analysis import analyzed_config + +EVERY_CHANNEL: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] +TONE_FREQUENCY: Final[float] = 220.0 +TONE_AMPLITUDE: Final[float] = 0.5 +TONE_SECONDS: Final[float] = 0.5 +TRIANGLE_PITCHES: Final[range] = range(67, 72) +PULSE_PITCHES: Final[FrozenSet[int]] = frozenset({*range(55, 60), *range(67, 72)}) +VOLUMES: Final[FrozenSet[int]] = frozenset({1, 8, 15}) +NOISE_PERIODS: Final[FrozenSet[int]] = frozenset({0, 4, 8, 12, 15}) +EDGE_FRAMES: Final[int] = 2 + + +def _library(config: Config) -> InstructionLibrary: + """The notes around a tone on both tone channels beside the noise, at a spread of volumes and periods.""" + window = Window.from_config(config) + extractor = get_feature_extractor(config, window) + + data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {} + for generator in get_generators_by_channels(config, EVERY_CHANNEL).values(): + for instruction in generator.get_possible_instructions(): + if _kept(instruction): + data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor) + + library = InstructionLibrary() + library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) + return library + + +def _kept(instruction: InstructionUnion) -> bool: + if not instruction.on: + return True + + match instruction: + case TriangleInstruction(): + return instruction.pitch in TRIANGLE_PITCHES + case PulseInstruction(): + return instruction.pitch in PULSE_PITCHES and instruction.volume in VOLUMES + case NoiseInstruction(): + return instruction.volume in VOLUMES and instruction.period in NOISE_PERIODS + case _: + return False + + +def _tone(path: Path, config: Config) -> Path: + sample_rate = config.library.sample_rate + time = np.arange(int(sample_rate * TONE_SECONDS)) / sample_rate + write_wave(path, sample_rate, TONE_AMPLITUDE * np.sin(2.0 * np.pi * TONE_FREQUENCY * time)) + return path + + +@pytest.fixture( + scope="module", + params=[SpectrumMethod.FFT, SpectrumMethod.CQT], + ids=lambda method: method.value, +) +def reconstructed_tone(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory) -> Reconstruction: + config = analyzed_config(request.param, gamma=Config().library.transformation_gamma) + path = _tone(tmp_path_factory.mktemp("tone") / "tone.wav", config) + reconstructor = Reconstructor(config, frozenset(EVERY_CHANNEL), library=_library(config)) + reconstruction = reconstructor.reconstruct( + [path], StemsConfig.single_entry(StemSettings(channels=EVERY_CHANNEL, bends=[])) + ) + assert reconstruction is not None + return reconstruction + + +class TestASineBecomesOneTriangle: + """ + A steady sine at the working level is what the triangle renders whole, so the frame's cost + falls to its lowest with the triangle alone: a pulse or the noise added beside it raises the + cost, and those channels hold their silence through the tone. + """ + + def test_the_triangle_sounds_through_the_tone(self, reconstructed_tone: Reconstruction) -> None: + interior = reconstructed_tone.instructions[ChannelName.TRIANGLE][EDGE_FRAMES:-EDGE_FRAMES] + assert all(instruction.on for instruction in interior) + + def test_no_other_channel_sounds_within_the_tone(self, reconstructed_tone: Reconstruction) -> None: + for channel_name in reconstructed_tone.playing_channels: + if channel_name == ChannelName.TRIANGLE: + continue + + interior = reconstructed_tone.instructions[channel_name][EDGE_FRAMES:-EDGE_FRAMES] + assert not any(instruction.on for instruction in interior), channel_name diff --git a/tests/integration/reconstruction/test_noise_matching.py b/tests/integration/reconstruction/test_noise_matching.py new file mode 100644 index 000000000..702d4b776 --- /dev/null +++ b/tests/integration/reconstruction/test_noise_matching.py @@ -0,0 +1,234 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, 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.constants.algorithm import STEM_ACTIVITY_FLOOR +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +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.reconstructions import Reconstructor +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_tools.synthesis.oscillators.walk_noise import WalkNoiseOscillator +from tests.suite.analysis import analyzed_config +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +NOISE_ONLY: Final[List[ChannelName]] = [ChannelName.NOISE] +EVERY_CHANNEL: Final[List[ChannelName]] = ChannelName.items() +LIBRARY_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] +TONAL_PITCH: Final[int] = 69 +TONAL_NEIGHBORHOOD: Final[range] = range(TONAL_PITCH - 2, TONAL_PITCH + 3) +NOISE_RECORDING_SEED: Final[int] = 11 +WHITE_NOISE_DEVIATION: Final[float] = 0.3 +NOISE_SECONDS: Final[float] = 1.0 +DARK_NOISE_PEAK: Final[float] = 0.9 +EDGE_FRAMES: Final[int] = 1 +QUIET_VOLUMES: Final[range] = range(1, 4) +LOUD_VOLUMES: Final[range] = range(12, 16) +SHORTEST_PERIODS: Final[range] = range(NUM_PERIODS - 4, NUM_PERIODS) +NOISE_SEED: Final[int] = 5 +BURST_FRAMES: Final[int] = 12 +GAP_FRAMES: Final[int] = 6 +HISS_FRAMES: Final[int] = 24 +BURST_DEVIATION: Final[float] = 0.3 +HISS_DEVIATION: Final[float] = BURST_DEVIATION / 11.0 +HISS: Final[slice] = slice(BURST_FRAMES + GAP_FRAMES, BURST_FRAMES + GAP_FRAMES + HISS_FRAMES) + + +NoiseBuilder = Callable[[np.ndarray, np.random.Generator], np.ndarray] + + +@dataclass(frozen=True) +class Converted: + hiss_frames: np.ndarray + instructions: List[InstructionUnion] + + +@dataclass(frozen=True) +class Analysis: + config: Config + library: InstructionLibrary + + +def _config(method: SpectrumMethod) -> Config: + return analyzed_config(method, gamma=Config().library.transformation_gamma) + + +def _library(config: Config) -> InstructionLibrary: + """The noise channel's silence and its quietest and loudest instructions at the shortest periods, + so a burst and a hiss both find the noise standing nearest them.""" + window = Window.from_config(config) + extractor = get_feature_extractor(config, window) + generator = get_generators_by_channels(config, NOISE_ONLY)[ChannelName.NOISE] + + data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = { + instruction: InstructionLibraryFragment.create(generator, instruction, extractor) + for instruction in generator.get_possible_instructions() + if not instruction.on + or (instruction.volume in (*QUIET_VOLUMES, *LOUD_VOLUMES) and instruction.period in SHORTEST_PERIODS) + } + + library = InstructionLibrary() + library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) + return library + + +def _burst_then_hiss(path: Path, config: Config) -> Path: + """A noise burst setting the working level, a silent gap, then a hiss far quieter than the burst.""" + frame_length = config.library.frame_length + generator = np.random.default_rng(NOISE_SEED) + audio = np.concatenate( + [ + generator.normal(0.0, BURST_DEVIATION, frame_length * BURST_FRAMES), + np.zeros(frame_length * GAP_FRAMES), + generator.normal(0.0, HISS_DEVIATION, frame_length * HISS_FRAMES), + ] + ) + + write_wave(path, config.library.sample_rate, audio) + return path + + +def _every_channel_library(config: Config) -> InstructionLibrary: + """Every noise instruction beside the notes around one pitch, so a tonal answer stands within reach. + + Both pulse channels play the pulse instructions, so the first pulse channel's instructions + serve the second. + """ + window = Window.from_config(config) + extractor = get_feature_extractor(config, window) + + data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = {} + for channel_name, generator in get_generators_by_channels(config, LIBRARY_CHANNELS).items(): + for instruction in generator.get_possible_instructions(): + if channel_name != ChannelName.NOISE and instruction.on and instruction.pitch not in TONAL_NEIGHBORHOOD: + continue + + data[instruction] = InstructionLibraryFragment.create(generator, instruction, extractor) + + library = InstructionLibrary() + library.data[library.create_key(config, window)] = InstructionLibraryData.create(config, data) + return library + + +def _white_noise(time: np.ndarray, generator: np.random.Generator) -> np.ndarray: + return generator.normal(0.0, WHITE_NOISE_DEVIATION, time.shape[0]) + + +def _dark_noise(time: np.ndarray, generator: np.random.Generator) -> np.ndarray: + return DARK_NOISE_PEAK * WalkNoiseOscillator(kind="walk_noise").render(time, generator=generator) + + +def _noise_recording(path: Path, config: Config, noise: NoiseBuilder) -> Path: + sample_rate = config.library.sample_rate + time = np.arange(int(sample_rate * NOISE_SECONDS)) / sample_rate + write_wave(path, sample_rate, noise(time, np.random.default_rng(NOISE_RECORDING_SEED))) + return path + + +@pytest.fixture( + scope="module", + params=list(SpectrumMethod), + ids=lambda method: method.value, +) +def converted( + request: pytest.FixtureRequest, + tmp_path_factory: pytest.TempPathFactory, +) -> Converted: + """A burst, a gap and a hiss reconstructed on the noise channel alone, once per spectrum method.""" + config = _config(request.param) + path = _burst_then_hiss(tmp_path_factory.mktemp("hiss") / "hiss.wav", config) + reconstructor = Reconstructor(config, frozenset(NOISE_ONLY), library=_library(config)) + reconstruction = reconstructor.reconstruct( + [path], StemsConfig.single_entry(StemSettings(channels=NOISE_ONLY, bends=[])) + ) + assert reconstruction is not None + + frame_length = config.library.frame_length + working_level = reconstructor.load_audio(path) / reconstruction.coefficient + return Converted( + hiss_frames=working_level[frame_length * HISS.start : frame_length * HISS.stop].reshape(HISS_FRAMES, -1), + instructions=reconstruction.instructions[ChannelName.NOISE], + ) + + +@pytest.fixture( + scope="module", + params=list(SpectrumMethod), + ids=lambda method: method.value, +) +def every_channel_analysis(request: pytest.FixtureRequest) -> Analysis: + """Every channel's library under one spectrum method, shared by every noise reconstructed with it.""" + config = _config(request.param) + return Analysis(config=config, library=_every_channel_library(config)) + + +class TestNoiseSoundsOnTheNoiseChannel(BaseTestSuite): + """ + A recording built from noise reconstructs with the noise channel sounding, with every channel + offered and under every spectrum method. Noise resembles the noise channel's contribution on + average, so the noise channel carries it through, whether the noise is white or dark. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + noise: NoiseBuilder + + test_cases = ( + TestCase(label="white", noise=_white_noise), + TestCase(label="dark", noise=_dark_noise), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_noise_channel_sounds( + self, + test_case: TestCase, + every_channel_analysis: Analysis, + tmp_path: Path, + ) -> None: + config = every_channel_analysis.config + path = _noise_recording(tmp_path / f"{test_case.label}.wav", config, test_case.noise) + reconstructor = Reconstructor(config, frozenset(EVERY_CHANNEL), library=every_channel_analysis.library) + + reconstruction = reconstructor.reconstruct( + [path], StemsConfig.single_entry(StemSettings(channels=EVERY_CHANNEL, bends=[])) + ) + + assert reconstruction is not None + interior = reconstruction.instructions[ChannelName.NOISE][EDGE_FRAMES:-EDGE_FRAMES] + assert all(instruction.on for instruction in interior) + + +class TestAHissAfterABurst: + """ + The working level brings the burst to the noise channel's full-scale level, and the hiss keeps + its distance below it, which lands among the quietest volumes the channel plays. The gap before + it leaves the channel resting, so the hiss is what decides whether the channel sounds. + """ + + def test_every_hiss_frame_passes_the_activity_gate(self, converted: Converted) -> None: + assert float(np.abs(converted.hiss_frames).max(axis=1).min()) >= STEM_ACTIVITY_FLOOR + + def test_the_burst_sounds_on_the_noise_channel(self, converted: Converted) -> None: + assert all(instruction.on for instruction in converted.instructions[:BURST_FRAMES]) + + def test_the_hiss_sounds_quieter_than_the_burst(self, converted: Converted) -> None: + burst_volume = max(instruction.volume for instruction in converted.instructions[:BURST_FRAMES]) + hiss = converted.instructions[HISS] + + assert all(instruction.on for instruction in hiss) + assert all(instruction.volume < burst_volume for instruction in hiss) diff --git a/tests/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py new file mode 100644 index 000000000..569dfabfe --- /dev/null +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -0,0 +1,270 @@ +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 +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + 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 +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +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] +BENDING: Final[List[ChannelName]] = [ChannelName.PULSE1] +UNBENT: Final[List[ChannelName]] = [] + + +def _config() -> Config: + return Config() + + +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, bends: List[ChannelName]) -> Reconstruction: + """The conversion of one recording by a stem carrying the channels ``bends`` names.""" + reconstructor = Reconstructor(config, frozenset(PULSE_ONLY), library=_library(config)) + reconstruction = reconstructor.reconstruct( + [audio_path], + StemsConfig.single_entry(StemSettings(channels=PULSE_ONLY, bends=bends)), + ) + 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 config() -> Config: + return _config() + + +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_toward_its_own_pitch( + self, + config: Config, + tmp_path: Path, + ) -> None: + reconstruction = _reconstruct(config, _square_path(tmp_path / "sharp.wav", config, DETUNE_CENTS), BENDING) + + sounding = _sounding(reconstruction) + assert sounding + measured = float(np.median([_cents_off(config, 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, + config: Config, + tmp_path: Path, + ) -> None: + reconstruction = _reconstruct(config, _square_path(tmp_path / "flat.wav", config, 0.0), BENDING) + + sounding = _sounding(reconstruction) + assert sounding + measured = float(np.median([_cents_off(config, frame) for frame in sounding])) + + assert abs(measured) < CENTS_TOLERANCE + + def test_the_bend_holds_rather_than_wandering(self, config: 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(config, _square_path(tmp_path / "steady.wav", config, DETUNE_CENTS), BENDING) + + 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_stem_carrying_no_channel_bends_nothing( + self, + config: Config, + tmp_path: Path, + ) -> None: + reconstruction = _reconstruct(config, _square_path(tmp_path / "unrefined.wav", config, DETUNE_CENTS), UNBENT) + + assert all(not frame.bent for frame in _sounding(reconstruction)) + + def test_a_stem_carrying_no_channel_records_the_bend_as_the_channels( + self, + config: Config, + tmp_path: Path, + ) -> None: + """Nothing bent means nothing chosen, so both dimensions stay the channel's own.""" + reconstruction = _reconstruct(config, _square_path(tmp_path / "held.wav", config, DETUNE_CENTS), UNBENT) + 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, + config: Config, + tmp_path: Path, + ) -> None: + """Noise states no fundamental, so no frame of it earns a bend.""" + reconstruction = _reconstruct(config, _noise_path(tmp_path / "noise.wav", config), BENDING) + + 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, + config: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readings = self._counted(monkeypatch) + _reconstruct(config, _square_path(tmp_path / "read.wav", config, DETUNE_CENTS), BENDING) + + assert len(readings) == 1 + + def test_a_longer_source_is_read_no_more_often( + self, + config: 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(config, _square_path(tmp_path / "short.wav", config, DETUNE_CENTS), BENDING) + short = len(readings) + + readings.clear() + _reconstruct(config, _square_path(tmp_path / "long.wav", config, DETUNE_CENTS, seconds=SECONDS * 3), BENDING) + + assert len(readings) == short + + def test_a_stem_carrying_no_channel_reads_nothing( + self, + config: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readings = self._counted(monkeypatch) + _reconstruct(config, _square_path(tmp_path / "unread.wav", config, DETUNE_CENTS), UNBENT) + + assert not readings diff --git a/tests/integration/reconstruction/test_stems_editing.py b/tests/integration/reconstruction/test_stems_editing.py new file mode 100644 index 000000000..9c5e4f860 --- /dev/null +++ b/tests/integration/reconstruction/test_stems_editing.py @@ -0,0 +1,581 @@ +from pathlib import Path +from typing import AbstractSet, Dict, Final, FrozenSet, List, Sequence, Tuple + +import numpy as np +import pytest + +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import playing_channels +from sampletones_core.formats.famitracker.footprint import reconstruction_footprints +from sampletones_core.instructions import InstructionUnion, TriangleInstruction +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels +from sampletones_core.reconstructions.reconstruction.stems.filter import filter_approximations +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem +from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection +from sampletones_tools.corpus.catalog import build_mini_library +from tests.suite.stems import ( + STEM_A_ID, + STEM_B_ID, + STEM_C_ID, + THREE_STEM_CHANNELS, + three_stem_config, + three_stem_reconstruction_config, + write_three_stem_recordings, +) + +EVERY_STEM: Final[FrozenSet[int]] = frozenset({STEM_A_ID, STEM_B_ID, STEM_C_ID}) +LOUDEST_VOLUME: Final[int] = 15 +PITCH_STEP: Final[int] = 1 + + +@pytest.fixture(scope="module") +def converted(tmp_path_factory: pytest.TempPathFactory) -> Reconstruction: + """One real three-stem conversion, the document every scenario below starts from.""" + config = three_stem_reconstruction_config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=library) + paths = write_three_stem_recordings(config, tmp_path_factory.mktemp("recordings")) + + reconstruction = reconstructor.reconstruct(list(paths), three_stem_config()) + + assert reconstruction is not None + return reconstruction + + +@pytest.fixture +def reconstruction(converted: Reconstruction) -> Reconstruction: + return converted.model_copy(deep=True) + + +def _owners(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + return reconstruction.stems_data.assignments_by_channel[channel_name] + + +def _holders(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + """The recordings holding a frame of this channel, in id order.""" + return sorted(set(_owners(reconstruction, channel_name)) - {RESTING_STEM_ID, AUTHORED_STEM_ID}) + + +def _contested_channel(reconstruction: Reconstruction) -> ChannelName: + """A channel two recordings share, which is where an edit's scope is worth asking about.""" + for channel_name in reconstruction.playing_channels: + if len(_holders(reconstruction, channel_name)) > 1: + return channel_name + + raise AssertionError("The conversion gave no channel to two recordings") + + +def _channel_with_a_rest(reconstruction: Reconstruction) -> ChannelName: + """A channel holding both a recording's frame and a resting one, where writing into silence reaches.""" + for channel_name in reconstruction.playing_channels: + owners = _owners(reconstruction, channel_name) + if RESTING_STEM_ID in owners and _holders(reconstruction, channel_name): + return channel_name + + raise AssertionError("The conversion left no channel holding both a recording and a rest") + + +def _earliest_ending_holder(reconstruction: Reconstruction, channel_name: ChannelName) -> int: + """The recording whose last frame on this channel comes first, so hearing it alone reads shorter.""" + owners = _owners(reconstruction, channel_name) + return min( + _holders(reconstruction, channel_name), + key=lambda stem_id: max(frame for frame, owner in enumerate(owners) if owner == stem_id), + ) + + +def _latest_starting_holder(reconstruction: Reconstruction, channel_name: ChannelName) -> int: + """The recording whose first frame on this channel comes last, so the frames before it rest.""" + owners = _owners(reconstruction, channel_name) + return max( + _holders(reconstruction, channel_name), + key=lambda stem_id: min(frame for frame, owner in enumerate(owners) if owner == stem_id), + ) + + +def _frames_of(reconstruction: Reconstruction, channel_name: ChannelName, stem_id: int) -> List[int]: + return [frame for frame, held in enumerate(_owners(reconstruction, channel_name)) if held == stem_id] + + +def _edited( + reconstruction: Reconstruction, + channel_name: ChannelName, + instructions: Sequence[InstructionUnion], + *, + heard: AbstractSet[int] = EVERY_STEM, +) -> Reconstruction: + edited = reconstruction.model_copy(deep=True) + edited = edited.with_channel_data( + channel_name, + list(instructions), + initial_pitch=reconstruction.initial_pitches[channel_name], + held_features=reconstruction.held_features[channel_name], + heard=heard, + ) + return edited + + +def _changed(instruction: InstructionUnion) -> InstructionUnion: + """The same frame sounding differently, whichever dimensions its channel reads. + + A silent frame comes back as it stands, so a stream reshaped through this keeps resting + where it rested and states the reader's change everywhere it sounds. + """ + if not instruction.on: + return instruction + + match instruction: + case TriangleInstruction(): + return instruction.model_copy(update={"pitch": instruction.pitch + PITCH_STEP}) + case _: + return instruction.model_copy(update={"volume": LOUDEST_VOLUME}) + + +def _changed_stream(reconstruction: Reconstruction, channel_name: ChannelName) -> List[InstructionUnion]: + return [_changed(instruction) for instruction in reconstruction.instructions[channel_name]] + + +def _stream_with( + reconstruction: Reconstruction, + channel_name: ChannelName, + changes: Dict[int, InstructionUnion], +) -> List[InstructionUnion]: + return [ + changes.get(frame, instruction) for frame, instruction in enumerate(reconstruction.instructions[channel_name]) + ] + + +def _first_sounding(reconstruction: Reconstruction, channel_name: ChannelName) -> InstructionUnion: + return next(instruction for instruction in reconstruction.instructions[channel_name] if instruction.on) + + +class TestAnEditOnAFrameARecordingHolds: + def test_every_frame_keeps_the_recording_that_held_it(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + before = _owners(reconstruction, channel_name) + + edited = _edited(reconstruction, channel_name, _changed_stream(reconstruction, channel_name)) + + assert _owners(edited, channel_name) == before + + def test_the_edit_reaches_the_frames_it_names(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + proposed = _changed_stream(reconstruction, channel_name) + + edited = _edited(reconstruction, channel_name, proposed) + + assert edited.instructions[channel_name] == proposed + + def test_the_setup_and_the_sources_stand_as_they_did(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + + edited = _edited(reconstruction, channel_name, _changed_stream(reconstruction, channel_name)) + + assert edited.stems_data.config == reconstruction.stems_data.config + assert edited.audio_filepath == reconstruction.audio_filepath + assert edited.coefficient == reconstruction.coefficient + + def test_the_channels_beside_it_are_left_alone(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + before = dict(reconstruction.stems_data.assignments_by_channel) + + edited = _edited(reconstruction, channel_name, _changed_stream(reconstruction, channel_name)) + + for name, stem_ids in edited.stems_data.assignments_by_channel.items(): + if name != channel_name: + assert stem_ids == before[name] + + +class TestAnEditOutsideWhatIsHeard: + def test_a_frame_of_a_recording_left_out_stands_as_it_is(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + unheard = _holders(reconstruction, channel_name)[0] + before = list(reconstruction.instructions[channel_name]) + + edited = _edited( + reconstruction, + channel_name, + _changed_stream(reconstruction, channel_name), + heard=EVERY_STEM - {unheard}, + ) + + for frame in _frames_of(reconstruction, channel_name, unheard): + assert edited.instructions[channel_name][frame] == before[frame] + assert _owners(edited, channel_name)[frame] == unheard + + def test_the_recordings_the_reader_hears_take_the_edit(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + holders = _holders(reconstruction, channel_name) + heard = frozenset(holders[1:]) + proposed = _changed_stream(reconstruction, channel_name) + + edited = _edited(reconstruction, channel_name, proposed, heard=heard) + + for stem_id in heard: + for frame in _frames_of(reconstruction, channel_name, stem_id): + assert edited.instructions[channel_name][frame] == proposed[frame] + assert _owners(edited, channel_name)[frame] == stem_id + + +class TestAnEditThatChangesWhoSounds: + def test_a_resting_frame_written_into_play_is_the_readers_own(self, reconstruction: Reconstruction) -> None: + channel_name = _channel_with_a_rest(reconstruction) + frame = _frames_of(reconstruction, channel_name, RESTING_STEM_ID)[0] + proposed = _stream_with(reconstruction, channel_name, {frame: _first_sounding(reconstruction, channel_name)}) + + edited = _edited(reconstruction, channel_name, proposed) + + assert _owners(edited, channel_name)[frame] == AUTHORED_STEM_ID + assert edited.instructions[channel_name][frame].on + + def test_the_frames_beside_it_keep_their_recordings(self, reconstruction: Reconstruction) -> None: + channel_name = _channel_with_a_rest(reconstruction) + frame = _frames_of(reconstruction, channel_name, RESTING_STEM_ID)[0] + before = _owners(reconstruction, channel_name) + proposed = _stream_with(reconstruction, channel_name, {frame: _first_sounding(reconstruction, channel_name)}) + + edited = _edited(reconstruction, channel_name, proposed) + + after = _owners(edited, channel_name) + assert [owner for index, owner in enumerate(after) if index != frame] == [ + owner for index, owner in enumerate(before) if index != frame + ] + + def test_a_held_frame_quieted_rests(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + held = _holders(reconstruction, channel_name)[0] + frame = _frames_of(reconstruction, channel_name, held)[0] + silence = type(reconstruction.instructions[channel_name][frame]).null_instruction() + + edited = _edited(reconstruction, channel_name, _stream_with(reconstruction, channel_name, {frame: silence})) + + assert _owners(edited, channel_name)[frame] == RESTING_STEM_ID + assert not edited.instructions[channel_name][frame].on + + +class TestAChannelAnEditEmpties: + def test_the_channel_stands_by_and_leaves_the_record(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + + edited = _edited(reconstruction, channel_name, []) + + assert channel_name not in edited.playing_channels + assert channel_name not in edited.stems_data.assignments_by_channel + assert channel_name not in rendered_channels(edited) + + def test_the_recordings_holding_frames_elsewhere_stay_on_the_record(self, reconstruction: Reconstruction) -> None: + """A recording holding no frame leaves, so what stays is what still sounds somewhere.""" + channel_name = _contested_channel(reconstruction) + elsewhere = { + stem_id + for other in reconstruction.playing_channels + if other != channel_name + for stem_id in _holders(reconstruction, other) + } + located = dict(zip(reconstruction.stems_data.config.entries_by_id, reconstruction.audio_filepath)) + + edited = _edited(reconstruction, channel_name, []) + + assert frozenset(edited.stems_data.config.entries_by_id) == frozenset(elsewhere) + assert edited.audio_filepath == tuple(located[entry.id] for entry in edited.stems_data.config.entries) + + def test_writing_it_back_into_play_makes_it_the_readers_own(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + stream = [_first_sounding(reconstruction, channel_name)] * 3 + + edited = _edited(_edited(reconstruction, channel_name, []), channel_name, stream) + + assert _owners(edited, channel_name) == (AUTHORED_STEM_ID,) * len(stream) + + +class TestTheRowTheReadersOwnFramesAnswerTo: + """The frames a reader wrote follow their own row, so quieting it puts them out of reach.""" + + @staticmethod + def _with_authored_frame(reconstruction: Reconstruction) -> Tuple[ChannelName, int, Reconstruction]: + """A channel carrying one frame the reader wrote into a rest, which the next edit measures.""" + channel_name = _channel_with_a_rest(reconstruction) + frame = _frames_of(reconstruction, channel_name, RESTING_STEM_ID)[0] + proposed = _stream_with(reconstruction, channel_name, {frame: _first_sounding(reconstruction, channel_name)}) + written = _edited(reconstruction, channel_name, proposed) + assert _owners(written, channel_name)[frame] == AUTHORED_STEM_ID + return channel_name, frame, written + + def test_an_edit_reaches_them_while_their_row_is_heard(self, reconstruction: Reconstruction) -> None: + channel_name, frame, written = self._with_authored_frame(reconstruction) + proposed = _stream_with(written, channel_name, {frame: _changed(written.instructions[channel_name][frame])}) + + edited = _edited(written, channel_name, proposed, heard=EVERY_STEM | {AUTHORED_STEM_ID}) + + assert edited.instructions[channel_name][frame] == proposed[frame] + + def test_an_edit_leaves_them_once_their_row_is_quiet(self, reconstruction: Reconstruction) -> None: + """A row out of the edit's reach keeps what it holds, so nothing the reader wrote is lost.""" + channel_name, frame, written = self._with_authored_frame(reconstruction) + stood = written.instructions[channel_name][frame] + proposed = _stream_with(written, channel_name, {frame: _changed(stood)}) + + edited = _edited(written, channel_name, proposed, heard=EVERY_STEM) + + assert edited.instructions[channel_name][frame] == stood + assert _owners(edited, channel_name)[frame] == AUTHORED_STEM_ID + + +class TestAChannelClearedWhileOneRecordingIsHeard: + """Clearing a channel reaches the recording the reader hears and leaves the others playing. + + An edit shortens a channel as far as its scope reaches, so the stream runs through the last + frame a recording the reader left out still holds. + """ + + @staticmethod + def _cleared(reconstruction: Reconstruction) -> Tuple[ChannelName, int, Reconstruction]: + """The contested channel cleared while the recording ending earliest is heard alone. + + Another recording therefore holds the channel's last frame, so the stream keeps running + and the frames the edit reached rest inside it. + """ + channel_name = _contested_channel(reconstruction) + heard = _earliest_ending_holder(reconstruction, channel_name) + return channel_name, heard, _edited(reconstruction, channel_name, [], heard=frozenset({heard})) + + def test_the_recordings_left_out_keep_every_frame_they_held(self, reconstruction: Reconstruction) -> None: + channel_name, heard, edited = self._cleared(reconstruction) + before = _owners(reconstruction, channel_name) + standing = [frame for frame, owner in enumerate(before) if owner not in (heard, RESTING_STEM_ID)] + + assert standing + assert all(_owners(edited, channel_name)[frame] == before[frame] for frame in standing) + assert all( + edited.instructions[channel_name][frame] == reconstruction.instructions[channel_name][frame] + for frame in standing + ) + + def test_the_recording_the_reader_heard_rests_where_the_stream_runs_on( + self, + reconstruction: Reconstruction, + ) -> None: + channel_name, heard, edited = self._cleared(reconstruction) + owners = _owners(edited, channel_name) + released = [frame for frame in _frames_of(reconstruction, channel_name, heard) if frame < len(owners)] + + assert released + assert all(owners[frame] == RESTING_STEM_ID for frame in released) + assert not any(edited.instructions[channel_name][frame].on for frame in released) + + def test_the_channel_runs_through_the_last_frame_left_standing(self, reconstruction: Reconstruction) -> None: + channel_name, heard, edited = self._cleared(reconstruction) + before = _owners(reconstruction, channel_name) + last = max(frame for frame, owner in enumerate(before) if owner not in (heard, RESTING_STEM_ID)) + + assert len(_owners(edited, channel_name)) == last + 1 + assert channel_name in edited.playing_channels + + +class TestARecordingTakenOutAfterAnEdit: + def test_the_frames_it_held_are_released_all_the_same(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + removed = _holders(reconstruction, channel_name)[0] + frames = _frames_of(reconstruction, channel_name, removed) + edited = _edited(reconstruction, channel_name, _changed_stream(reconstruction, channel_name)) + + remaining = without_stem(edited, removed) + + assert removed not in remaining.stems_data.config.entries_by_id + for frame in frames: + assert _owners(remaining, channel_name)[frame] == RESTING_STEM_ID + assert not remaining.instructions[channel_name][frame].on + + def test_the_frames_the_reader_wrote_stand_through_the_removal(self, reconstruction: Reconstruction) -> None: + channel_name = _channel_with_a_rest(reconstruction) + frame = _frames_of(reconstruction, channel_name, RESTING_STEM_ID)[0] + removed = _holders(reconstruction, channel_name)[0] + proposed = _stream_with(reconstruction, channel_name, {frame: _first_sounding(reconstruction, channel_name)}) + edited = _edited(reconstruction, channel_name, proposed) + + remaining = without_stem(edited, removed) + + assert _owners(remaining, channel_name)[frame] == AUTHORED_STEM_ID + assert remaining.instructions[channel_name][frame].on + + def test_the_recordings_that_stay_keep_their_frames(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + holders = _holders(reconstruction, channel_name) + removed, kept = holders[0], holders[1] + edited = _edited(reconstruction, channel_name, _changed_stream(reconstruction, channel_name)) + + remaining = without_stem(edited, removed) + + for frame in _frames_of(reconstruction, channel_name, kept): + assert _owners(remaining, channel_name)[frame] == kept + assert remaining.instructions[channel_name][frame] == edited.instructions[channel_name][frame] + + +class TestADetachedDocument: + def test_it_keeps_every_recording_it_was_built_from(self, reconstruction: Reconstruction) -> None: + detached = reconstruction.model_copy(deep=True) + + detached = detached.detached() + + assert detached.audio_filepath == () + assert detached.stems_data.assignments_by_channel == reconstruction.stems_data.assignments_by_channel + assert [entry.id for entry in detached.stems_data.config.entries] == [ + entry.id for entry in reconstruction.stems_data.config.entries + ] + + def test_it_edits_the_way_an_attached_one_does(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + detached = reconstruction.model_copy(deep=True) + detached = detached.detached() + + edited = _edited(detached, channel_name, _changed_stream(detached, channel_name)) + + assert _owners(edited, channel_name) == _owners(reconstruction, channel_name) + + +class TestWhatASelectionLeaves: + def test_a_recording_heard_alone_keeps_its_own_frames_and_quiets_the_rest( + self, + reconstruction: Reconstruction, + ) -> None: + channel_name = _contested_channel(reconstruction) + held = _holders(reconstruction, channel_name)[0] + frame_length = reconstruction.config.library.frame_length + selection = StemSelection(channels={channel_name: frozenset({held})}) + + filtered = filter_approximations( + reconstruction.stems_data, + rendered_channels(reconstruction), + selection, + frame_length, + ) + + for frame, owner in enumerate(_owners(reconstruction, channel_name)): + window = slice(frame * frame_length, (frame + 1) * frame_length) + expected = ( + rendered_channels(reconstruction)[channel_name][window] + if owner == held + else np.zeros(frame_length, dtype=filtered[channel_name].dtype) + ) + np.testing.assert_array_equal(filtered[channel_name][window], expected) + + +class TestTheEnvelopesASelectionShows: + """The envelopes read the heard part of a channel, which is what an export then writes.""" + + @staticmethod + def _selection(channel_name: ChannelName, stem_ids: AbstractSet[int]) -> StemSelection: + return StemSelection( + channels={name: frozenset(stem_ids) if name == channel_name else EVERY_STEM for name in ChannelName.items()} + ) + + def test_every_sounding_channel_reads_the_document_itself(self, reconstruction: Reconstruction) -> None: + """A reader hearing everything reads each sounding channel as the document writes it.""" + whole = reconstruction.export() + sounding = {name: features for name, features in whole.items() if features.has_frames} + assert sounding + + heard = reconstruction.export_heard(StemSelection.everywhere(EVERY_STEM, ChannelName.items())) + + assert {name: heard[name] for name in sounding} == sounding + + def test_a_recording_heard_alone_shows_its_own_frames(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + held = _earliest_ending_holder(reconstruction, channel_name) + whole = reconstruction.export()[channel_name] + + heard = reconstruction.export_heard(self._selection(channel_name, frozenset({held})))[channel_name] + + assert heard.has_frames + assert heard.frame_count < whole.frame_count + assert heard.volume.items[0] == whole.volume.items[0] + + def test_the_frames_before_a_recording_starts_read_as_rest(self, reconstruction: Reconstruction) -> None: + """A recording heard alone keeps its own frames and rests where another one played.""" + channel_name = _contested_channel(reconstruction) + held = _latest_starting_holder(reconstruction, channel_name) + owned = _frames_of(reconstruction, channel_name, held) + whole = reconstruction.export()[channel_name] + assert owned[0] > 0 + + heard = reconstruction.export_heard(self._selection(channel_name, frozenset({held})))[channel_name] + + assert heard.volume.at(0) == 0 + assert heard.volume.at(owned[0]) == whole.volume.at(owned[0]) + + def test_a_channel_with_nothing_heard_reads_as_standing_by(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + + heard = reconstruction.export_heard(self._selection(channel_name, frozenset())) + + assert not heard[channel_name].has_frames + assert channel_name in playing_channels(reconstruction.export()) + + def test_the_channels_beside_it_read_as_they_stand(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + whole = reconstruction.export() + + heard = reconstruction.export_heard(self._selection(channel_name, frozenset())) + + assert all(heard[name] == whole[name] for name in ChannelName.items() if name != channel_name) + + +class TestAChannelWrittenDownToRests: + """A channel an edit leaves resting through every frame stands by, and every reader agrees.""" + + @staticmethod + def _silenced(reconstruction: Reconstruction, channel_name: ChannelName) -> Reconstruction: + """The document with one channel written down to rests, frame for frame.""" + stream = [type(instruction).null_instruction() for instruction in reconstruction.instructions[channel_name]] + return _edited(reconstruction, channel_name, stream) + + def test_the_channel_stands_by(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + + edited = self._silenced(reconstruction, channel_name) + + assert edited.instructions[channel_name] == [] + assert channel_name not in rendered_channels(edited) + + def test_the_record_names_it_no_more(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + + edited = self._silenced(reconstruction, channel_name) + + assert channel_name not in edited.stems_data.assignments_by_channel + + def test_every_reader_counts_it_out(self, reconstruction: Reconstruction) -> None: + """The document, the export, the heard export and the footprint leave out one and the same channel.""" + channel_name = _contested_channel(reconstruction) + edited = self._silenced(reconstruction, channel_name) + everywhere = StemSelection.everywhere(EVERY_STEM, ChannelName.items()) + + assert channel_name not in edited.playing_channels + assert channel_name not in playing_channels(edited.export()) + assert channel_name not in playing_channels(edited.export_heard(everywhere)) + assert channel_name not in reconstruction_footprints(edited) + + def test_it_keeps_the_reference_it_was_edited_from(self, reconstruction: Reconstruction) -> None: + channel_name = _contested_channel(reconstruction) + + edited = self._silenced(reconstruction, channel_name) + + assert edited.initial_pitches[channel_name] == reconstruction.initial_pitches[channel_name] + assert edited.held_features[channel_name] == reconstruction.held_features[channel_name] + + +class TestTheDocumentThroughAFile: + def test_an_edited_document_round_trips_whole(self, reconstruction: Reconstruction, tmp_path: Path) -> None: + channel_name = _contested_channel(reconstruction) + edited = _edited(reconstruction, channel_name, _changed_stream(reconstruction, channel_name)) + path = tmp_path / "edited.stn" + + edited.save(path) + restored = Reconstruction.load(path) + + assert restored.stems_data.assignments_by_channel == edited.stems_data.assignments_by_channel + assert restored.stems_data.config == edited.stems_data.config + for name in edited.playing_channels: + np.testing.assert_array_equal(rendered_channels(restored)[name], rendered_channels(edited)[name]) diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py new file mode 100644 index 000000000..29b07f97e --- /dev/null +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -0,0 +1,872 @@ +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_application.logic.shared.renders import RenderCache +from sampletones_core.audio import mix, mix_scale, read_stems, scale_stems, write_wave +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import MAX_DRIVE, MIN_DRIVE, RESTING_STEM_ID, UNIT_DRIVE +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + HierarchyMode, + bending_channels, +) +from sampletones_core.generators import render_channels +from sampletones_core.reconstructions import Reconstruction, Reconstructor +from sampletones_core.reconstructions.converter import GroupConversion, reconstruct_job +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +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 sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.utils.progress import silent_reporter +from sampletones_tools.corpus.catalog import build_mini_library +from tests.suite.stems import ( + STEM_A_ID, + STEM_B_ID, + STEM_C_ID, + STEM_RECORDING_DURATION_SECONDS, + THREE_STEM_CHANNELS, + 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 +_LOUD_DRIVE: Final[float] = 2.0 +_SWEPT_DRIVES: Final[Tuple[float, ...]] = (MIN_DRIVE, UNIT_DRIVE, _LOUD_DRIVE, MAX_DRIVE) +_COMPETING_TONES: Final[Tuple[float, ...]] = (440.0, 659.0) +_COMPETING_AMPLITUDES: Final[Tuple[float, ...]] = (0.5, 0.4) + + +def _classic_stems(config: Config, *, channel_cap: int) -> StemsConfig: + """One stem over every configured channel, carrying each of them that reads a bend.""" + return StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_CHANNELS)).with_channel_cap(channel_cap)) + + +def _frame_count(config: Config, duration_seconds: float) -> int: + return int(config.library.sample_rate * duration_seconds) // config.library.frame_length + + +def _stems_config(tone_drive: float = UNIT_DRIVE) -> StemsConfig: + """A tone stem on the first pulse and a noise stem on the noise, each sounding one channel.""" + tone = StemSettings.covering([ChannelName.PULSE1]).with_channel_cap(1).with_drive(ChannelName.PULSE1, tone_drive) + return StemsConfig( + entries=[ + StemEntry(id=0, settings=tone), + StemEntry(id=1, settings=StemSettings.covering([ChannelName.NOISE]).with_channel_cap(1)), + ], + hierarchy=StemsHierarchy( + levels=[[0], [1]], + mode=HierarchyMode.STRICT, + ), + ) + + +def _competing_stems(drive: float) -> StemsConfig: + """Two recordings on one level, each reaching for the first pulse, the first pushed to ``drive``.""" + held = [ChannelName.PULSE1] + return StemsConfig( + entries=[ + StemEntry(id=0, settings=StemSettings.covering(held).with_channel_cap(1).with_drive(held[0], drive)), + StemEntry(id=1, settings=StemSettings.covering(held).with_channel_cap(1)), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + ) + + +def _competing_recordings(tmp_path: Path, config: Config) -> Tuple[Path, Path]: + """Two steady tones of one length, written as the recordings that reach for one channel.""" + sample_rate = config.library.sample_rate + count = int(sample_rate * _DURATION_SECONDS) + time = np.arange(count) / sample_rate + + paths = [] + for index, (frequency, amplitude) in enumerate(zip(_COMPETING_TONES, _COMPETING_AMPLITUDES)): + path = tmp_path / f"tone_{index}.wav" + write_wave(path, sample_rate, amplitude * np.sin(2 * np.pi * frequency * time)) + paths.append(path) + + return paths[0], paths[1] + + +def _energy(samples: np.ndarray) -> float: + """How much sound a rendered channel carries, which is what a drive lifts.""" + return float(np.sum(np.square(np.asarray(samples, dtype=np.float64)))) + + +def _disjoint_recordings(tmp_path: Path, config: Config) -> Tuple[Path, Path, int]: + """A steady tone and a noise burst of one length, written as two recordings.""" + 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) + return tone_path, noise_path, count + + +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, frozenset(DEFAULT_CHANNELS), library=library) + tone_path, noise_path, count = _disjoint_recordings(tmp_path, config) + + 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_a_driven_channel_sounds_exactly_what_its_instructions_render(self, tmp_path: Path) -> None: + """A drive settles which instruction a frame records, and the frame then sounds that instruction.""" + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) + tone_path, noise_path, _ = _disjoint_recordings(tmp_path, config) + + reconstruction = reconstructor.reconstruct( + [tone_path, noise_path], + _stems_config(_LOUD_DRIVE), + ) + + assert reconstruction is not None + rendered = render_channels(reconstruction.instructions, config) + np.testing.assert_allclose( + rendered_channels(reconstruction)[ChannelName.PULSE1], + rendered[ChannelName.PULSE1], + atol=_MIX_TOLERANCE, + ) + np.testing.assert_allclose( + rendered_channels(reconstruction)[ChannelName.NOISE], + rendered[ChannelName.NOISE], + atol=_MIX_TOLERANCE, + ) + + def test_a_driven_recording_is_recorded_louder_and_leaves_the_one_beside_it(self, tmp_path: Path) -> None: + """A drive reaches for louder instructions on the channel its own recording holds, and there alone.""" + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) + tone_path, noise_path, _ = _disjoint_recordings(tmp_path, config) + + standing = reconstructor.reconstruct([tone_path, noise_path], _stems_config()) + driven = reconstructor.reconstruct([tone_path, noise_path], _stems_config(_LOUD_DRIVE)) + + assert standing is not None + assert driven is not None + assert _energy(rendered_channels(driven)[ChannelName.PULSE1]) > _energy( + rendered_channels(standing)[ChannelName.PULSE1] + ) + np.testing.assert_array_equal( + rendered_channels(driven)[ChannelName.NOISE], + rendered_channels(standing)[ChannelName.NOISE], + ) + + def test_requires_one_path_per_entry(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) + + with pytest.raises(ValueError, match="stem paths"): + reconstructor.reconstruct( + [tmp_path / "only_one.wav"], + _stems_config(), + ) + + +class TestADriveLeavesTheStemsCompeting: + """Two recordings reaching for one channel keep it where it stood, whatever drive one is given. + + A drive settles what a channel plays, so it reaches the instructions the frames record and + leaves the ownership beneath them standing. This is the contract ``docs/concepts/stems.md`` + states of a drive, read over a whole conversion. + """ + + def test_the_channel_holds_its_recording_across_the_whole_range(self, tmp_path: Path) -> None: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) + paths = list(_competing_recordings(tmp_path, config)) + + owners: Dict[float, List[int]] = {} + for drive in _SWEPT_DRIVES: + reconstruction = reconstructor.reconstruct(paths, _competing_stems(drive)) + assert reconstruction is not None + owners[drive] = list(reconstruction.stems_data.assignments_by_channel[ChannelName.PULSE1]) + + standing = owners[UNIT_DRIVE] + assert set(standing) == {0} + assert owners == {drive: standing for drive in _SWEPT_DRIVES} + + def test_the_drive_lifts_the_channel_it_holds_up_to_what_the_channel_reaches(self, tmp_path: Path) -> None: + """A rising drive reaches for louder instructions and settles at the loudest the channel holds.""" + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) + paths = list(_competing_recordings(tmp_path, config)) + + energies: Dict[float, float] = {} + for drive in _SWEPT_DRIVES: + reconstruction = reconstructor.reconstruct(paths, _competing_stems(drive)) + assert reconstruction is not None + energies[drive] = _energy(rendered_channels(reconstruction)[ChannelName.PULSE1]) + + assert energies[MIN_DRIVE] < energies[UNIT_DRIVE] < energies[_LOUD_DRIVE] + assert energies[MAX_DRIVE] == pytest.approx(energies[_LOUD_DRIVE]) + + +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, frozenset(THREE_STEM_CHANNELS), 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, frozenset(THREE_STEM_CHANNELS), 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(rendered_channels(reconstruction)[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, frozenset(THREE_STEM_CHANNELS), 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, renders: RenderCache) -> 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, frozenset(THREE_STEM_CHANNELS), 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 = rendered_channels(data.reconstruction)[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(renders) + full = data.waveform_data(renders, 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(renders, channels, heard(all_stem_ids)), + data.get_partials(renders, 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(renders, 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(renders, 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(renders, heard(selected)) + for channel in stems_data.assignments_by_channel: + np.testing.assert_array_equal( + waveform.approximations[channel], + np.zeros_like(rendered_channels(data.reconstruction)[channel]), + ) + np.testing.assert_allclose(waveform.approximation, np.zeros_like(rendered_mix(data.reconstruction))) + np.testing.assert_allclose( + data.partials_for(renders, channels, heard(selected)), + np.zeros_like(rendered_mix(data.reconstruction)), + ) + np.testing.assert_allclose( + data.original_mix_for(heard(selected)), + np.zeros_like(rendered_mix(data.reconstruction)), + ) + + +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, frozenset(THREE_STEM_CHANNELS), library=library) + paths = write_three_stem_recordings(config, tmp_path) + reconstruction = reconstructor.reconstruct(list(paths), three_stem_config()) + assert reconstruction is not None + assert len(reconstruction.stems_data.config.entries) == 3 + 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 reaches the removed recording's frames alone. + + A released frame states silence and rests; a frame that stays keeps the instruction it + played and the recording that held it. The samples a frame sounds are read afresh from + the stream it now sits in, so the oscillator runs through the silence the removal left + rather than through the notes it took away. + """ + reconstruction, _paths, config = self._three_stems(tmp_path) + frame_length = config.library.frame_length + assignments = reconstruction.stems_data.assignments_by_channel + before = {channel: list(stream) for channel, stream in reconstruction.instructions.items()} + + remaining = without_stem(reconstruction, STEM_C_ID) + + for channel, stem_ids in assignments.items(): + if channel not in remaining.playing_channels: + continue + + audio = rendered_channels(remaining)[channel] + for frame_index, stem_id in enumerate(stem_ids): + span = slice(frame_index * frame_length, (frame_index + 1) * frame_length) + if stem_id == STEM_C_ID: + assert not remaining.instructions[channel][frame_index].on + np.testing.assert_array_equal(audio[span], np.zeros(frame_length, dtype=np.float32)) + else: + assert remaining.instructions[channel][frame_index] == before[channel][frame_index] + + assert list(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 stem removed is one that alone sounded some channel, read from the recorded assignment, + so the test states what a removal does to such channels 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_by = { + stem_id: tuple( + channel for channel, stem_ids in assignments.items() if set(stem_ids) - {RESTING_STEM_ID} == {stem_id} + ) + for stem_id in (STEM_A_ID, STEM_B_ID, STEM_C_ID) + } + removed_id, held_alone = next((stem_id, held) for stem_id, held in held_alone_by.items() if held) + + remaining = without_stem(reconstruction, removed_id) + + assert set(remaining.playing_channels) == set(assignments) - set(held_alone) + for channel in held_alone: + assert channel not in rendered_channels(remaining) + assert channel not in remaining.stems_data.assignments_by_channel + + 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(rendered_mix(loaded), rendered_mix(remaining), atol=_MIX_TOLERANCE) + + def test_what_stays_plays_what_it_played_before(self, tmp_path: Path) -> None: + """A removal leaves every remaining recording playing the frames it played. + + What a frame sounds is read from the stream it sits in, so a channel the removal + reached renders afresh around the silence it left; what the document states about the + recordings that stay — their frames and the recording behind each of them — carries over + whole. + """ + reconstruction, _paths, _config = self._three_stems(tmp_path) + before = {channel: list(stream) for channel, stream in reconstruction.instructions.items()} + assignments = reconstruction.stems_data.assignments_by_channel + + remaining = without_stem(reconstruction, STEM_C_ID) + + for channel, stem_ids in assignments.items(): + if channel not in remaining.playing_channels: + continue + + for frame_index, stem_id in enumerate(stem_ids): + if stem_id in (STEM_A_ID, STEM_B_ID): + assert remaining.instructions[channel][frame_index] == before[channel][frame_index] + assert remaining.stems_data.assignments_by_channel[channel][frame_index] == stem_id + + +class TestAConversionLetsGoOfIdleRecordings: + """A recording the conversion gives no frame leaves the document it writes.""" + + @staticmethod + def _silent_beside_a_tone(tmp_path: Path, config: Config) -> Tuple[Path, Path]: + """A steady tone and a recording of silence, of one length.""" + tone_path, _noise_path, count = _disjoint_recordings(tmp_path, config) + silent_path = tmp_path / "silence.wav" + write_wave(silent_path, config.library.sample_rate, np.zeros(count)) + return tone_path, silent_path + + def test_a_silent_recording_leaves_the_document(self, tmp_path: Path) -> None: + config = Config() + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + tone_path, silent_path = self._silent_beside_a_tone(tmp_path, config) + + reconstruction = reconstructor.reconstruct([tone_path, silent_path], _competing_stems(UNIT_DRIVE)) + + assert reconstruction is not None + assert [entry.id for entry in reconstruction.stems_data.config.entries] == [0] + assert reconstruction.audio_filepath == (tone_path,) + + def test_a_recording_outranked_on_every_frame_leaves_the_document(self, tmp_path: Path) -> None: + """Two recordings reach for one channel and one of them takes every frame of it.""" + config = Config() + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + paths = _competing_recordings(tmp_path, config) + + reconstruction = reconstructor.reconstruct(list(paths), _competing_stems(UNIT_DRIVE)) + + assert reconstruction is not None + assert set(reconstruction.stems_data.assignments_by_channel[ChannelName.PULSE1]) == {0} + assert [entry.id for entry in reconstruction.stems_data.config.entries] == [0] + assert reconstruction.stems_data.config.hierarchy.levels == ((0,),) + assert reconstruction.audio_filepath == (paths[0],) + + def test_the_document_is_written_where_the_job_names(self, tmp_path: Path) -> None: + """The file is named from every recording the job read, whichever of them stayed.""" + general = Config().general.model_copy(update={"reconstructions_directory": str(tmp_path / "out")}) + config = Config().model_copy(update={"general": general}) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + sources = self._silent_beside_a_tone(tmp_path, config) + job = GroupConversion(sources=sources, stems=_competing_stems(UNIT_DRIVE)).jobs(config)[0] + + written = reconstruct_job((reconstructor, job, silent_reporter)) + + assert written == job.output_path + assert Reconstruction.load(written).audio_filepath == (sources[0],) + + +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, frozenset(DEFAULT_CHANNELS), 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 == save_path.stem + 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) + + def test_the_conversion_records_the_scale_it_read_the_set_at(self, tmp_path: Path) -> None: + config = Config() + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + paths = _competing_recordings(tmp_path, config) + + reconstruction = reconstructor.reconstruct(list(paths), _competing_stems(UNIT_DRIVE)) + + assert reconstruction is not None + read = read_stems(paths, target_sample_rate=config.library.sample_rate) + assert reconstruction.stems_data.scale == mix_scale(read, normalize=config.general.normalize) + + def test_a_recording_keeps_its_level_once_another_leaves(self, tmp_path: Path) -> None: + """A removal saved and opened again reads the recordings that stay as the session held them.""" + config = three_stem_reconstruction_config() + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=build_mini_library(config)) + paths = write_three_stem_recordings(config, tmp_path) + reconstruction = reconstructor.reconstruct(list(paths), three_stem_config()) + assert reconstruction is not None + converted_path = tmp_path / "three.stn" + reconstruction.save(converted_path) + data = ReconstructionData.load(converted_path) + + in_session = data.with_reconstruction(without_stem(data.reconstruction, STEM_A_ID)) + removed_path = tmp_path / "two.stn" + in_session.reconstruction.save(removed_path) + reopened = ReconstructionData.load(removed_path) + + assert len(reopened.stem_audios) == len(in_session.stem_audios) == 2 + for loaded, held in zip(reopened.stem_audios, in_session.stem_audios): + np.testing.assert_array_equal(loaded, held) + + def test_a_pruned_conversion_keeps_the_rest_at_their_level(self, tmp_path: Path) -> None: + """A recording outranked on every frame leaves, and the one that stays keeps its level in the mix.""" + config = Config() + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) + paths = _competing_recordings(tmp_path, config) + reconstruction = reconstructor.reconstruct(list(paths), _competing_stems(UNIT_DRIVE)) + assert reconstruction is not None + assert reconstruction.audio_filepath == (paths[0],) + save_path = tmp_path / "pruned.stn" + reconstruction.save(save_path) + + (loaded,) = ReconstructionData.load(save_path).stem_audios + + read = read_stems(paths, target_sample_rate=config.library.sample_rate) + expected = scale_stems( + read, + scale=mix_scale(read, normalize=config.general.normalize), + quantize=config.general.quantize, + quantization_levels=config.general.quantization_levels, + )[0] + assert np.max(np.abs(loaded)) < 1.0 + np.testing.assert_array_equal(loaded, expected) + + +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, frozenset(DEFAULT_CHANNELS), 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].settings == StemSettings.covering(list(DEFAULT_CHANNELS)) + for channel, stem_ids in stems_data.assignments_by_channel.items(): + assert set(stem_ids) <= {0, RESTING_STEM_ID} + 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, frozenset(DEFAULT_CHANNELS), 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 0 in stem_ids[: len(sounding)] + 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( + rendered_channels(reconstruction)[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: + """At most 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, frozenset(DEFAULT_CHANNELS), library=library) + tone_path = self._tone_path(tmp_path, config) + + reconstruction = reconstructor.reconstruct( + [tone_path], + _classic_stems(config, 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 max(sounding) == 1 + + +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, settings=StemSettings(channels=list(channels), bends=bending_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, + ), + ) + + def _reconstruct(self, tmp_path: Path) -> Tuple[Reconstruction, List[range], Config]: + config = Config() + library = build_mini_library(config) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) + paths, spans = self._recordings(config, tmp_path) + + reconstruction = reconstructor.reconstruct(list(paths), self._stems_config(DEFAULT_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, renders: RenderCache) -> 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( + renders, 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/samples/__init__.py b/tests/integration/samples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/samples/test_emitters.py b/tests/integration/samples/test_emitters.py new file mode 100644 index 000000000..99e8bc6f4 --- /dev/null +++ b/tests/integration/samples/test_emitters.py @@ -0,0 +1,53 @@ +from pathlib import Path + +import pytest + +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS +from sampletones_player.specification.nsf import ARTIST_OFFSET, NSF_MAGIC, STRING_FIELD_SIZE +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from sampletones_tools.corpus.build import Corpus +from sampletones_tools.samples import bitphase, emit, famitracker, nsf +from sampletones_tools.samples.emit import emit_samples +from tests.suite.bitphase import parse_btp +from tests.suite.famitracker import parse_ftm + + +def _artist(data: bytes) -> str: + return data[ARTIST_OFFSET : ARTIST_OFFSET + STRING_FIELD_SIZE].split(b"\x00", 1)[0].decode("utf-8") + + +@pytest.fixture(name="corpus") +def corpus_fixture(monkeypatch: pytest.MonkeyPatch, synthetic_corpus: Corpus) -> Corpus: + """The session's corpus, which the samples commands hand their emitter in place of a build of their own.""" + monkeypatch.setattr(emit, "build_synthetic_corpus", lambda: synthetic_corpus) + return synthetic_corpus + + +class TestTheEmittersWriteTheCorpus: + """Each format's emitter writes the files a player of that format opens.""" + + def test_the_nsf_emitter_writes_every_sample_then_the_arrangement(self, corpus: Corpus, tmp_path: Path) -> None: + written = emit_samples(tmp_path, nsf.write_samples) + + assert [path.name for path in written] == [ + *(f"{name}{EXT_FILE_NSF}" for name in corpus.catalog), + f"{nsf.SONG_NAME}{EXT_FILE_NSF}", + ] + assert all(path.read_bytes()[: len(NSF_MAGIC)] == NSF_MAGIC for path in written) + assert all(_artist(path.read_bytes()) == corpus.project.info.author for path in written) + + def test_the_famitracker_emitter_writes_one_module(self, corpus: Corpus, tmp_path: Path) -> None: + written = emit_samples(tmp_path, famitracker.write_samples) + + assert written == [tmp_path / famitracker.MODULE_FILENAME] + assert parse_ftm(written[0].read_bytes()).instruments + + def test_the_bitphase_emitter_writes_the_song_at_its_tempo_and_as_a_groove( + self, + corpus: Corpus, + tmp_path: Path, + ) -> None: + written = emit_samples(tmp_path, bitphase.write_samples) + + assert written == [tmp_path / bitphase.DOCUMENT_FILENAME, tmp_path / bitphase.GROOVE_DOCUMENT_FILENAME] + assert all(parse_btp(path.read_bytes(), list(CHANNEL_LABELS)).songs for path in written) diff --git a/tests/integration/sampletones_application/logic/__init__.py b/tests/integration/sampletones_application/logic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_application/logic/reconstruction/__init__.py b/tests/integration/sampletones_application/logic/reconstruction/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/__init__.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py new file mode 100644 index 000000000..a3ce31308 --- /dev/null +++ b/tests/integration/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -0,0 +1,140 @@ +import time +from pathlib import Path +from typing import Final, Iterator, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.coordinators.tabs.reconstruction import ReconstructionTabCoordinator +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ( + ChannelChange, + RateChange, + StemRemovalRequest, +) +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.reconstructions import Reconstruction +from tests.conftest import RENDER_BUDGET +from tests.suite.application import draw_frame, live_queue, scheduling +from tests.suite.stems import ( + SHARED_CHANNEL, + SHARED_OWNERS, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + TAKING_TURNS_PITCH, + taking_turns_file, +) + +__all__ = ["live_queue", "scheduling", "taking_turns_file"] + +DRAG: Final[Tuple[Tuple[int, int], ...]] = ((1, 1), (2, 2), (3, 3), (4, 4), (6, 6)) +ARPEGGIO: Final[Tuple[int, int]] = (4, 3) +RETUNED_FREQUENCY: Final[int] = 50 +SETTLE_SECONDS: Final[float] = 10.0 +POLL_SECONDS: Final[float] = 0.005 + + +@pytest.fixture +def coordinator( + live_queue: None, + scheduling: SchedulingBehavior, + taking_turns_file: Path, +) -> Iterator[ReconstructionCoordinator]: + """The coordinator over the two-recording document opened from its file, rebuilt on the real worker.""" + manager = ReconstructionManager(scheduling=scheduling, renders=RenderCache(budget_bytes=RENDER_BUDGET)) + coordinator = ReconstructionCoordinator( + manager, + MagicMock(), + ReconstructionRewrites(manager, RegenerationService()), + MagicMock(), + MagicMock(), + MagicMock(), + dialogs=MagicMock(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=MagicMock(), + ) + coordinator.set_reconstructions_tab(MagicMock(spec=ReconstructionTabCoordinator)) + manager.load_reconstruction(taking_turns_file) + yield coordinator + + +def _settle(coordinator: ReconstructionCoordinator) -> None: + """Draws frames until every step has landed, the way the render loop drains the results.""" + deadline = time.monotonic() + SETTLE_SECONDS + while coordinator._rewrites.is_busy: + assert time.monotonic() < deadline, "the steps never settled" + draw_frame() + time.sleep(POLL_SECONDS) + + +def _open_document(coordinator: ReconstructionCoordinator) -> Reconstruction: + reconstruction = coordinator._reconstruction_manager.reconstruction + assert reconstruction is not None + return reconstruction + + +class TestEditingAFileBackedDocumentAtSpeed: + """A drag, an arpeggio, a removal and a rate change made faster than the worker rebuilds all land, in order.""" + + @pytest.fixture + def settled(self, coordinator: ReconstructionCoordinator) -> ReconstructionCoordinator: + for volume in DRAG: + coordinator.request_rewrite( + ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.VOLUME, + envelopes={FeatureKey.VOLUME: Envelope[int](items=volume)}, + initial_pitch=None, + ) + ) + coordinator.request_rewrite( + ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.ARPEGGIO, + envelopes={FeatureKey.ARPEGGIO: Envelope[int](items=ARPEGGIO)}, + initial_pitch=None, + ) + ) + coordinator.request_rewrite(StemRemovalRequest(stem_id=STEM_B_ID, stem_name="b")) + coordinator.request_rewrite(RateChange(nes_frequency=RETUNED_FREQUENCY)) + _settle(coordinator) + return coordinator + + def test_the_drag_lands_where_it_ended(self, settled: ReconstructionCoordinator) -> None: + kept = _open_document(settled).instructions[SHARED_CHANNEL][SHARED_OWNERS.index(STEM_A_ID)] + + assert kept.volume == DRAG[-1][0] + + def test_the_arpeggio_lands_beside_it(self, settled: ReconstructionCoordinator) -> None: + kept = _open_document(settled).instructions[SHARED_CHANNEL][SHARED_OWNERS.index(STEM_A_ID)] + + assert kept.pitch == TAKING_TURNS_PITCH + ARPEGGIO[0] + + def test_the_recording_taken_out_stays_out(self, settled: ReconstructionCoordinator) -> None: + reconstruction = _open_document(settled) + + assert list(reconstruction.stems_data.config.entries_by_id) == [STEM_A_ID] + assert SOLE_CHANNEL not in reconstruction.playing_channels + + def test_the_recording_that_stays_keeps_its_audio(self, settled: ReconstructionCoordinator) -> None: + data = settled._reconstruction_manager.current_reconstruction + + assert data is not None + assert len(data.stem_audios) == 1 + + def test_the_document_runs_at_the_new_rate(self, settled: ReconstructionCoordinator) -> None: + assert _open_document(settled).config.nes_frequency == RETUNED_FREQUENCY + + def test_the_waveform_fades_for_the_whole_span(self, settled: ReconstructionCoordinator) -> None: + dims = [entry.args[0] for entry in settled._tab.set_reconstruction_dimmed.call_args_list] + + assert dims == [True, False] diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index 10db4394d..57566afda 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -4,14 +4,17 @@ import pytest 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 ( + DEFAULT_CHANNELS, + 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"] @@ -44,23 +47,23 @@ def pulse_features(pulse_instructions) -> Features: 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}, + 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_CHANNELS), + {ChannelName.PULSE1: pulse_instructions}, + ), ) @pytest.fixture def reconstruction_data(default_config, minimal_reconstruction) -> ReconstructionData: - feature_data = FeatureData.load(minimal_reconstruction) return ReconstructionData( config=default_config, reconstruction=minimal_reconstruction, - original_audio=np.zeros(256, dtype=np.float32), - feature_data=feature_data, + stem_audios=(), 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..839a0ca92 100644 --- a/tests/integration/sampletones_application/services/test_conversion.py +++ b/tests/integration/sampletones_application/services/test_conversion.py @@ -1,49 +1,73 @@ +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.constants.enums import ( + DEFAULT_CHANNELS, +) +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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def _stems(config: Config) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_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..2dc604099 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 SONG_TICK_BOUNDS, SongTiming +from sampletones_player.export.backend import NSFBackend +from sampletones_player.specification.nsf import NSF_MAGIC, PROGRAM_SIZE +from sampletones_shared.music import Tuning +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.stated() + + +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 + ] + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + assert walked + assert walked[-1].total == timing.frame_tick(project.song.order_length()) diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py index eaa09995e..79b43f351 100644 --- a/tests/integration/sampletones_application/services/test_regeneration.py +++ b/tests/integration/sampletones_application/services/test_regeneration.py @@ -5,14 +5,26 @@ import numpy as np import pytest -from sampletones_application.logic.reconstruction.feature import FeatureData -from sampletones_application.services.regeneration import RegenerationService +from automation.scenario import BaseTestScenario, ScenarioStep +from sampletones_application.logic.reconstruction.envelopes import heard_envelopes +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_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +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 +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels +from tests.suite.stems import everything_heard + + +def _heard_features(reconstruction: Reconstruction) -> ChannelEnvelopesViewModel: + """The envelopes of the whole document, which is what a fresh reader hears.""" + return heard_envelopes(reconstruction, everything_heard(reconstruction)) + _real_queue_add = CallbackQueue.add @@ -29,7 +41,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.with_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 +54,9 @@ def test_run_emits_service_success(self, reconstruction_data, pulse_features) -> service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - pulse_features.volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) assert len(results) == 1 @@ -58,31 +69,29 @@ def test_run_emits_new_reconstruction_carrying_the_edit(self, reconstruction_dat service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - pulse_features.volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) 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 emitted.feature_key is FeatureKey.VOLUME + assert ( + len(rendered_channels(emitted.reconstruction).get(ChannelName.PULSE1, np.array([], dtype=np.float32))) > 0 + ) def test_run_updates_reconstruction_approximation(self, reconstruction_data, pulse_features) -> None: service = RegenerationService() service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - pulse_features.volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) - approximation = reconstruction_data.reconstruction.approximations.get( - GeneratorName.PULSE1, np.array([], dtype=np.float32) + approximation = rendered_channels(reconstruction_data.reconstruction).get( + ChannelName.PULSE1, np.array([], dtype=np.float32) ) assert len(approximation) > 0 @@ -91,28 +100,35 @@ def test_run_updates_reconstruction_instructions(self, reconstruction_data, puls service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - pulse_features.volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) - 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) + 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, + Envelope(items=(0,) * len(pulse_features.volume.items)), + ) service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) service._run( reconstruction_data.reconstruction, - GeneratorName.PULSE1, - pulse_features, - FeatureKey.VOLUME, - new_volume, + ChannelName.PULSE1, + silenced, + reconstruction_data.reconstruction.recorded_stem_ids, ) - 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 +137,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), + reconstruction_data.reconstruction.recorded_stem_ids, ) assert len(results) == 1 @@ -137,10 +152,9 @@ def test_start_completes_through_full_pipeline(self, reconstruction_data, pulse_ service.start( reconstruction_data.reconstruction, - GeneratorName.PULSE1, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - pulse_features.volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) assert len(results) == 1 @@ -164,12 +178,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, - FeatureKey.ARPEGGIO, - arpeggio, + ChannelName.PULSE1, + edited, + context.reconstruction.recorded_stem_ids, ) assert len(results) == 1 @@ -179,7 +196,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 +213,30 @@ def build() -> ArpeggioEditContext: reconstruction = reconstruction_data.reconstruction return ArpeggioEditContext( reconstruction=reconstruction, - features=FeatureData.load(reconstruction)[GeneratorName.PULSE1], + features=_heard_features(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 = _heard_features(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 = _heard_features(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 +304,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, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - pulse_features.volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) # The real queue defers delivery until a frame is pumped; nothing has run yet. This also fails @@ -315,13 +331,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, + ChannelName.PULSE1, pulse_features, - FeatureKey.VOLUME, - new_volume, + reconstruction_data.reconstruction.recorded_stem_ids, ) for _ in range(DELIVERY_BUDGET_FRAMES): @@ -333,5 +348,3 @@ def test_edit_reaches_subscriber_within_frame_budget(self, reconstruction_data, assert len(delivered) == 1 regenerated = delivered[0].value assert regenerated.reconstruction is not reconstruction_data.reconstruction - assert regenerated.generator_name is GeneratorName.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_pool_teardown.py b/tests/integration/sampletones_core/parallelization/test_pool_teardown.py new file mode 100644 index 000000000..0b3fb0f47 --- /dev/null +++ b/tests/integration/sampletones_core/parallelization/test_pool_teardown.py @@ -0,0 +1,176 @@ +import multiprocessing +import threading +from pathlib import Path +from typing import Any, Final, Iterator, List, Tuple + +import pytest + +from sampletones_core.parallelization import processor +from sampletones_core.parallelization.processor import TaskProcessor +from tests.suite.parallelization import ( + CountingProcessor, + FailingProcessor, + ProgressRecorder, + stands_partway, +) + +WORKER_NAME: Final[str] = "pebble_pool_worker" +MONITOR_NAME: Final[str] = "TaskProcessorMonitor" +READING_TIMEOUT: Final[float] = 60.0 +POOL_TIMEOUT: Final[float] = 120.0 +SEVERAL_TASKS: Final[int] = 3 +TWO_WORKERS: Final[int] = 2 +RELEASE_NAME: Final[str] = "release" +JOIN: Final[str] = "join" + + +def _live_workers() -> List[str]: + return [process.name for process in multiprocessing.active_children() if process.name == WORKER_NAME] + + +class WorkersAtTheAnnouncement: + """Writes down which pool workers are alive at the moment a run announces how it ended.""" + + def __init__(self) -> None: + self.seen: List[List[str]] = [] + self.announced = threading.Event() + + def __call__(self, *_arguments: Any) -> None: + self.seen.append(_live_workers()) + self.announced.set() + + +class RecordingPool(processor.ProcessPool): + """A process pool that writes down every call made to end it, and the thread each came from. + + Pebble's own join stops and joins a closed pool again from inside, so only the calls made from + outside the pool are written down. + """ + + calls: List[Tuple[str, str]] = [] + + def __init__(self, *arguments: Any, **keywords: Any) -> None: + super().__init__(*arguments, **keywords) + self._depth = 0 + + def close(self) -> None: + self._record("close") + super().close() + + def stop(self) -> None: + self._record("stop") + super().stop() + + def join(self, timeout: Any = None) -> None: + self._record(JOIN) + self._depth += 1 + try: + super().join(timeout) + finally: + self._depth -= 1 + + def _record(self, call: str) -> None: + if self._depth == 0: + RecordingPool.calls.append((call, threading.current_thread().name)) + + +@pytest.fixture +def release_path(tmp_path: Path) -> Path: + return tmp_path / RELEASE_NAME + + +@pytest.fixture +def recorded_pool(monkeypatch: pytest.MonkeyPatch) -> Iterator[List[Tuple[str, str]]]: + RecordingPool.calls = [] + monkeypatch.setattr(processor, "ProcessPool", RecordingPool) + yield RecordingPool.calls + + +def _completed_run(release_path: Path, witness: WorkersAtTheAnnouncement) -> TaskProcessor[int]: + release_path.touch() + run = CountingProcessor(SEVERAL_TASKS, release_path, max_workers=TWO_WORKERS) + run.set_callbacks(on_completed=witness) + run.start() + return run + + +def _canceled_run(release_path: Path, witness: WorkersAtTheAnnouncement) -> TaskProcessor[int]: + recorder = ProgressRecorder() + run = CountingProcessor(SEVERAL_TASKS, release_path, max_workers=TWO_WORKERS) + run.set_callbacks(on_progress=recorder, on_canceled=witness) + run.start() + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + run.cancel() + return run + + +def _failed_run(release_path: Path, witness: WorkersAtTheAnnouncement) -> TaskProcessor[int]: + run = FailingProcessor(SEVERAL_TASKS, release_path, max_workers=TWO_WORKERS) + run.set_callbacks(on_error=witness) + run.start() + return run + + +ENDINGS: Final = pytest.mark.parametrize( + "ending", + [_completed_run, _canceled_run, _failed_run], + ids=["completed", "canceled", "failed"], +) + + +class TestARunEndsItsPoolBeforeItAnnounces: + """Whoever hears how a run ended meets a run with no worker left. + + A conversion starts right after its own library generation, and the application exits right + after a run is let go, so a worker still being reaped when the outcome goes out is a process + two owners end up waiting on at once. + """ + + @ENDINGS + def test_no_worker_is_alive_when_the_outcome_goes_out(self, ending: Any, release_path: Path) -> None: + witness = WorkersAtTheAnnouncement() + run = ending(release_path, witness) + try: + assert witness.announced.wait(POOL_TIMEOUT) + assert witness.seen == [[]] + finally: + release_path.touch(exist_ok=True) + run.shutdown() + + @ENDINGS + def test_the_monitor_ends_the_pool_exactly_once( + self, + ending: Any, + release_path: Path, + recorded_pool: List[Tuple[str, str]], + ) -> None: + witness = WorkersAtTheAnnouncement() + run = ending(release_path, witness) + try: + assert witness.announced.wait(POOL_TIMEOUT) + run.shutdown() + + joins = [thread for call, thread in recorded_pool if call == JOIN] + assert joins == [MONITOR_NAME] + assert {thread for _, thread in recorded_pool} == {MONITOR_NAME} + finally: + release_path.touch(exist_ok=True) + run.shutdown() + + +class TestShuttingARunDown: + def test_a_run_shut_down_mid_task_has_no_worker_left_on_return(self, release_path: Path) -> None: + recorder = ProgressRecorder() + run = CountingProcessor(SEVERAL_TASKS, release_path, max_workers=TWO_WORKERS) + run.set_callbacks(on_progress=recorder) + run.start() + try: + assert recorder.wait_for(stands_partway, READING_TIMEOUT) + + run.shutdown() + + assert _live_workers() == [] + assert not run.is_running() + finally: + release_path.touch(exist_ok=True) + run.shutdown() 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..a79892ae1 --- /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 afterward. + + 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 afterward 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.CANCELING) is not None + assert not processor.is_running() diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py index a265cea4b..69f756c6f 100644 --- a/tests/integration/tooling/test_check_commands.py +++ b/tests/integration/tooling/test_check_commands.py @@ -1,69 +1,53 @@ from pathlib import Path -from typing import Dict, Final, List, Optional +from typing import Dict, Final, List import yaml +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import build_parser from sampletones_shared.paths.source import REPOSITORY_ROOT +from sampletones_tools.checks.registry import GATES PRE_COMMIT_CONFIG: Final[Path] = REPOSITORY_ROOT / ".pre-commit-config.yaml" -MAKEFILE: Final[Path] = REPOSITORY_ROOT / "Makefile" FILE_ENCODING: Final[str] = "utf-8" LOCAL_REPOSITORY: Final[str] = "local" -CHECK_SCRIPTS: Final[str] = "scripts/checks/" -TARGET_PREFIX: Final[str] = "check-" -SCRIPT_SUFFIX: Final[str] = ".py" -RECIPE_PREFIX: Final[str] = "\t" -TARGET_SUFFIX: Final[str] = ":" +ENTRY_PREFIX: Final[str] = "uv run sampletones " +CHECK_PREFIX: Final[str] = f"{ENTRY_PREFIX}check " def check_hooks() -> List[Dict[str, object]]: - """Every local hook running one of the check scripts, as the configuration declares it.""" + """Every local hook running one of the checks, as the configuration declares it.""" config = yaml.safe_load(PRE_COMMIT_CONFIG.read_text(encoding=FILE_ENCODING)) return [ hook for repository in config["repos"] if repository["repo"] == LOCAL_REPOSITORY for hook in repository["hooks"] - if CHECK_SCRIPTS in str(hook["entry"]) + if str(hook["entry"]).startswith(CHECK_PREFIX) ] -def check_targets() -> Dict[str, str]: - """The command each `check-*` target of the Makefile runs, keyed by target name.""" - targets: Dict[str, str] = {} - target: Optional[str] = None - for line in MAKEFILE.read_text(encoding=FILE_ENCODING).splitlines(): - if line.startswith(TARGET_PREFIX) and line.endswith(TARGET_SUFFIX): - target = line.removesuffix(TARGET_SUFFIX) - elif target is not None and line.startswith(RECIPE_PREFIX): - targets[target] = line.strip() - target = None - - return targets - - -def script_path(entry: str) -> Path: - """The check script an entry runs, taken from the words the entry is written with.""" - return REPOSITORY_ROOT / next(word for word in entry.split() if word.endswith(SCRIPT_SUFFIX)) +def hook_gate(hook: Dict[str, object]) -> str: + """The check a hook runs, taken from the words the entry is written with.""" + return str(hook["entry"]).removeprefix(CHECK_PREFIX).split()[0] class TestCheckHooks: - def test_the_configuration_declares_a_hook_for_every_check_script(self) -> None: - scripts = {path.name for path in (REPOSITORY_ROOT / CHECK_SCRIPTS).glob(f"*{SCRIPT_SUFFIX}")} + def test_the_configuration_declares_a_hook_for_every_check(self) -> None: + hooks = check_hooks() + + assert GATES + assert {hook_gate(hook) for hook in hooks} == {gate.name for gate in GATES} + assert len(hooks) == len(GATES) - assert {script_path(str(hook["entry"])).name for hook in check_hooks()} == scripts + def test_every_check_hook_parses_as_the_entry_runs_it(self) -> None: + """A hook's entry is a command line the entry accepts, so a renamed option fails here.""" + parser = build_parser(COMMANDS) - def test_every_check_hook_names_a_script_that_is_there(self) -> None: - assert all(script_path(str(hook["entry"])).is_file() for hook in check_hooks()) + for hook in check_hooks(): + parser.parse_args(str(hook["entry"]).removeprefix(ENTRY_PREFIX).split()) def test_every_check_hook_sweeps_the_whole_tree(self) -> None: """A hook handed the staged files checks the staged subset, which passes what it never reads.""" assert all(hook["pass_filenames"] is False for hook in check_hooks()) - - -class TestCheckTargets: - def test_each_hook_and_its_make_target_run_the_same_command(self) -> None: - commands = {f"{TARGET_PREFIX}{hook['id']}": str(hook["entry"]) for hook in check_hooks()} - - assert check_targets() == commands diff --git a/tests/integration/tooling/test_codec_study.py b/tests/integration/tooling/test_codec_study.py new file mode 100644 index 000000000..7f8802b5b --- /dev/null +++ b/tests/integration/tooling/test_codec_study.py @@ -0,0 +1,99 @@ +import csv +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_shared.exceptions.project import NotAValidArchiveError +from sampletones_shared.utils.tables import Table +from sampletones_tools.codec.study.manifest import StudyManifest, StudySource +from sampletones_tools.codec.study.plan import StudyPlan, plan_study +from sampletones_tools.codec.study.report import rows as songs +from sampletones_tools.codec.study.report import verdicts +from sampletones_tools.codec.study.report.run import MANIFEST_JSON, REPORT_CSV, REPORT_MARKDOWN, VERDICTS_CSV +from sampletones_tools.codec.study.session import run_study + +VARIANT: Final[str] = "wide-hold" +BASELINE_VARIANT: Final[str] = "baseline" +LENGTHEN_SECONDS: Final[int] = 2 + + +def _read_csv(path: Path) -> List[List[str]]: + with path.open(newline="", encoding="utf-8") as handle: + return list(csv.reader(handle)) + + +@pytest.fixture(name="plan") +def plan_fixture(integration_project: Project, tmp_path: Path) -> StudyPlan: + project = tmp_path / "synthetic.stp" + ProjectContainer.save(integration_project, project) + return plan_study( + StudyManifest( + projects=(StudySource.at(project),), + reconstructions=(), + lengthen_seconds=LENGTHEN_SECONDS, + variants=(VARIANT,), + ) + ) + + +class TestStudyRun: + def test_a_run_writes_its_tables_and_the_manifest_that_repeats_it(self, plan: StudyPlan, tmp_path: Path) -> None: + directory = run_study(plan, tmp_path / "run") + + assert StudyManifest.load(directory / MANIFEST_JSON) == plan.manifest + + header, *measured = _read_csv(directory / REPORT_CSV) + assert tuple(header) == songs.COLUMNS + assert {row[header.index("variant")] for row in measured} >= {variant.name for variant in plan.variants} + + verdict_header, *verdict_rows = _read_csv(directory / VERDICTS_CSV) + assert tuple(verdict_header) == verdicts.COLUMNS + + markdown = (directory / REPORT_MARKDOWN).read_text(encoding="utf-8").split("\n") + table = Table(columns=verdicts.COLUMNS, rows=tuple(tuple(row) for row in verdict_rows)).markdown_lines() + start = markdown.index(table[0]) + assert markdown[start : start + len(table)] == table + + def test_a_run_reads_every_encoding_back_as_the_song_it_was_written_from( + self, + integration_project: Project, + tmp_path: Path, + ) -> None: + """A run refuses a variant whose streams do not play back, so completing is the assertion.""" + project = tmp_path / "packed.stp" + ProjectContainer.save(integration_project, project) + plan = plan_study( + StudyManifest( + projects=(StudySource.at(project),), + reconstructions=(), + lengthen_seconds=LENGTHEN_SECONDS, + variants=(BASELINE_VARIANT,), + ) + ) + + directory = run_study(plan, tmp_path / "run") + + header, *measured = _read_csv(directory / REPORT_CSV) + variant = header.index("variant") + assert {row[variant] for row in measured} >= {BASELINE_VARIANT} + + def test_a_source_that_fails_to_read_leaves_the_output_untouched(self, tmp_path: Path) -> None: + broken = tmp_path / "broken.stp" + broken.write_bytes(b"") + output = tmp_path / "run" + plan = plan_study( + StudyManifest( + projects=(StudySource.at(broken),), + reconstructions=(), + lengthen_seconds=LENGTHEN_SECONDS, + variants=(VARIANT,), + ) + ) + + with pytest.raises(NotAValidArchiveError): + run_study(plan, output) + + assert not output.exists() diff --git a/tests/integration/tooling/test_screen_homes.py b/tests/integration/tooling/test_screen_homes.py new file mode 100644 index 000000000..0b1c0a567 --- /dev/null +++ b/tests/integration/tooling/test_screen_homes.py @@ -0,0 +1,67 @@ +import os +from pathlib import Path +from typing import Final + +import pytest + +from automation import homes +from automation.paths import HOMES_PREFIX, PROCESSES_DIRECTORY + +GONE_PROCESS: Final[int] = 2**22 + 1 +MISSING_FOLDER: Final[str] = "no-processes" +TAIL: Final[str] = "tail" + +requires_the_process_folder = pytest.mark.skipif( + not PROCESSES_DIRECTORY.is_dir(), + reason="the sweep reads which processes run from the process folder Linux keeps", +) + + +@pytest.fixture(name="temporary") +def temporary_fixture(tmp_path: Path) -> Path: + """The folder the worker's homes are made in, standing apart from the machine's own.""" + return tmp_path + + +def left_homes(parent: Path, process: int) -> Path: + """A homes folder a worker of ``process`` made and left behind.""" + folder = parent / f"{HOMES_PREFIX}{process}{homes.PROCESS_SEPARATOR}{TAIL}" + folder.mkdir() + return folder + + +class TestTheStaleHomesSweep: + """A worker's first scenario lets go of the homes a gone worker left, and keeps a running worker's. + + The process id beyond the largest one Linux hands out names a worker that has gone. + """ + + @requires_the_process_folder + def test_the_homes_of_a_gone_worker_go(self, temporary: Path) -> None: + stale = left_homes(temporary, GONE_PROCESS) + + homes.make_worker_homes(temporary) + + assert not stale.exists() + + @requires_the_process_folder + def test_the_homes_of_a_running_worker_stay(self, temporary: Path) -> None: + running = left_homes(temporary, os.getppid()) + + made = homes.make_worker_homes(temporary) + + assert running.exists() + assert made.exists() + + def test_every_homes_folder_stays_where_the_process_folder_is_missing( + self, + temporary: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A machine with no process folder tells no worker gone, so the sweep leaves every folder as it is.""" + monkeypatch.setattr(homes, "PROCESSES_DIRECTORY", temporary / MISSING_FOLDER) + left = [left_homes(temporary, GONE_PROCESS), left_homes(temporary, os.getppid())] + + homes.make_worker_homes(temporary) + + assert all(folder.exists() for folder in left) diff --git a/tests/integration/tooling/test_screen_layers.py b/tests/integration/tooling/test_screen_layers.py new file mode 100644 index 000000000..62901cd76 --- /dev/null +++ b/tests/integration/tooling/test_screen_layers.py @@ -0,0 +1,50 @@ +import ast +import sys +from pathlib import Path +from typing import Final, FrozenSet, Iterator + +from automation.paths import DEARPYGUI_LAYER_DIRECTORY + +LAYER_PACKAGE: Final[str] = "automation.dearpygui" +MODULE_SEPARATOR: Final[str] = "." +THIRD_PARTY: Final[FrozenSet[str]] = frozenset({"dearpygui", "pytest", "pyvirtualdisplay", "Xlib"}) + + +def imported_modules(path: Path) -> Iterator[str]: + """Every module ``path`` imports by its absolute name.""" + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + yield from (alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None: + yield node.module + + +def is_allowed(module: str) -> bool: + top = module.split(MODULE_SEPARATOR, 1)[0] + return ( + top in sys.stdlib_module_names + or top in THIRD_PARTY + or module == LAYER_PACKAGE + or module.startswith(LAYER_PACKAGE + MODULE_SEPARATOR) + ) + + +class TestTheDearPyGuiLayer: + """The automation unit's DearPyGui layer knows DearPyGui and pytest, and nothing of SampleToNES. + + The layer drives any DearPyGui application, and SampleToNES builds on it from outside, so the + layer moves to a project of its own as it stands the day a second application wants it. + """ + + def test_every_import_is_dearpygui_pytest_x11_or_the_standard_library(self) -> None: + strays = [ + f"{path.name} imports {module}" + for path in sorted(DEARPYGUI_LAYER_DIRECTORY.rglob("*.py")) + for module in imported_modules(path) + if not is_allowed(module) + ] + + assert strays == [] + + def test_the_layer_holds_modules_to_read(self) -> None: + assert any(DEARPYGUI_LAYER_DIRECTORY.rglob("*.py")) diff --git a/tests/integration/tooling/test_screen_layout.py b/tests/integration/tooling/test_screen_layout.py new file mode 100644 index 000000000..30396edc0 --- /dev/null +++ b/tests/integration/tooling/test_screen_layout.py @@ -0,0 +1,75 @@ +import ast +from pathlib import Path +from typing import Final, Iterator, List + +from assets.pictures.paths import SCENES_DIRECTORY +from automation.paths import AUTOMATION_DIRECTORY +from sampletones_shared.paths.source import REPOSITORY_ROOT + +SCENARIO_FILE_PREFIX: Final[str] = "test_" +CASES_MODULE: Final[str] = "cases.py" +SCENARIOS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "screens" +MATERIAL_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" +SCREEN_TIER_DIRECTORIES: Final[List[Path]] = [ + SCENARIOS_DIRECTORY, + MATERIAL_DIRECTORY, + AUTOMATION_DIRECTORY, + SCENES_DIRECTORY, +] + + +def screen_tier_modules() -> Iterator[Path]: + for directory in SCREEN_TIER_DIRECTORIES: + yield from sorted(directory.rglob("*.py")) + + +def relative(path: Path) -> str: + return path.relative_to(REPOSITORY_ROOT).as_posix() + + +def constants_after_code(path: Path) -> List[int]: + """The lines of module-level assignments that follow the module's first function or class.""" + lines: List[int] = [] + code_started = False + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + code_started = True + elif code_started and isinstance(node, (ast.Assign, ast.AnnAssign)): + lines.append(node.lineno) + + return lines + + +def imported_scenario_modules(path: Path) -> Iterator[str]: + """Every scenario file (``test_*.py``) that ``path`` imports by its absolute name.""" + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module is not None: + if node.module.rsplit(".", 1)[-1].startswith(SCENARIO_FILE_PREFIX): + yield node.module + + +class TestTheScreenTierLayout: + """The screen tier's modules read the same way: constants under the imports, packages that import, scenarios that stand alone.""" + + def test_constants_stand_under_the_imports(self) -> None: + offenders = { + relative(path): constants_after_code(path) + for path in screen_tier_modules() + if path.name != CASES_MODULE and constants_after_code(path) + } + + assert offenders == {} + + def test_every_folder_of_modules_is_a_package(self) -> None: + folders = {path.parent for path in screen_tier_modules()} + + assert [relative(folder) for folder in sorted(folders) if not (folder / "__init__.py").exists()] == [] + + def test_no_module_imports_a_scenario_file(self) -> None: + importers = { + relative(path): list(imported_scenario_modules(path)) + for path in screen_tier_modules() + if list(imported_scenario_modules(path)) + } + + assert importers == {} diff --git a/tests/screens/__init__.py b/tests/screens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/__init__.py b/tests/screens/application/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/closing/__init__.py b/tests/screens/application/closing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/closing/constants.py b/tests/screens/application/closing/constants.py new file mode 100644 index 000000000..8ac5b6dd2 --- /dev/null +++ b/tests/screens/application/closing/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +NEW_TITLE: Final[str] = "Closing title" +CONVERSION_RUNNING: Final[str] = "global.dialog.message.exit_conversion_in_progress" +RUN_TIMEOUT_SECONDS: Final[float] = 60.0 diff --git a/tests/screens/application/closing/steps.py b/tests/screens/application/closing/steps.py new file mode 100644 index 000000000..7f9e2ab05 --- /dev/null +++ b/tests/screens/application/closing/steps.py @@ -0,0 +1,14 @@ +from automation.screen import Screen +from automation.views.prompts import Prompt +from tests.screens.application.closing.constants import CONVERSION_RUNNING + + +def conversion_question(screen: Screen) -> Prompt: + """The prompt Exit shows while a conversion runs, in the window the reconstruction's question uses.""" + return screen.reconstructions.unsaved_prompt + + +def asks_about_the_conversion(screen: Screen) -> bool: + """Tells whether the shared prompt is shown with the words about a conversion in progress.""" + prompt = conversion_question(screen) + return prompt.is_shown() and screen.words(CONVERSION_RUNNING) in prompt.words() diff --git a/tests/screens/application/closing/test_during_work.py b/tests/screens/application/closing/test_during_work.py new file mode 100644 index 000000000..1fb6aa096 --- /dev/null +++ b/tests/screens/application/closing/test_during_work.py @@ -0,0 +1,264 @@ +import operator +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.holds.conversion import ConversionHold +from automation.holds.export import ExportHold +from automation.holds.regeneration import RegenerationHold +from automation.holds.scan import ScanHold +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.steps.reconstructions import expect_open, raise_the_first_level_while_held +from automation.vocabulary.playback import PAUSE +from automation.worlds.home import World, lived_in_world +from automation.written import written_state +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.extensions import EXT_FILE_MODULE +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.closing.constants import RUN_TIMEOUT_SECONDS +from tests.screens.application.closing.steps import asks_about_the_conversion, conversion_question +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.recordings import ( + BASS, + OPEN_RECONSTRUCTION, + PLAYABLE_RECONSTRUCTION, + documents_world, + playing_world, +) +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + +SETTLING_FRAMES: Final[int] = 30 +FOLDER: Final[str] = "Many" +FOLDER_RECORDINGS: Final[int] = 130 +RECORDING_SECONDS: Final[float] = 0.05 +RECORDING_FREQUENCY: Final[float] = 330.0 + + +def close_and_leave(screen: Screen, tab: Tab) -> None: + """Closes the window with ``tab`` in front, waits for the application to stop, and checks the session + written. + """ + screen.close_window() + + assert screen.wait_for_exit() + assert written_state().current.tab is tab + + +class TestClosingWhilePlaying: + """Closing the window while a reconstruction plays leaves at once and writes the session. + + A playable reconstruction is open at start. The scenario presses Play, waits for the Play entry to read + Pause, brings the Instructions tab forward, closes the window, and expects the application to stop with + Instructions written as the tab in front. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a playable reconstruction and a session ready for playback.""" + return playing_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the playable reconstruction at start.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_it_leaves_while_the_sound_goes_on(self, screen: Screen) -> None: + """The window closes at once and the session names the tab in front.""" + + def play(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.INSTRUCTIONS) + + screen.scenario(play, close).run() + + +class TestClosingDuringAConversion: + """Closing the window while a conversion runs asks first: Cancel keeps both going, Exit stops the run and + leaves. + + The scenario gathers a recording, presses the converter's action, and waits for progress. The first + close asks about the run; Cancel takes the question back and leaves the run going. The second close + asks again; Exit stops the run and leaves, and the session is written with the Main tab in front and no + reconstruction in the reconstructions folder. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world: recordings to gather and stored documents.""" + return documents_world() + + def test_it_asks_and_then_stops_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Cancel keeps the run and the application; Exit leaves with the run's output absent.""" + converter = screen.main.converter + prompt = conversion_question(screen) + + def start_a_held_run(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def cancel_keeps_it_running(screen: Screen) -> None: + screen.close_window() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert converter.progress() > 0 + + def exit_stops_it_and_leaves(screen: Screen) -> None: + screen.close_window() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question again") + + prompt.confirm() + + assert screen.wait_for_exit() + assert written_state().current.tab is Tab.MAIN + assert not list(RECONSTRUCTIONS_DIRECTORY.rglob(f"{home_path(BASS).stem}.*")) + + screen.scenario(start_a_held_run, cancel_keeps_it_running, exit_stops_it_and_leaves).run() + + +class TestClosingDuringAFolderRead: + """Closing the window while a folder is read leaves, the read given up, and writes the session. + + The home holds a folder of many short recordings. The scenario Ctrl-clicks the folder to start a read, + waits until the converter shows the scan, closes the window, and expects the application to stop with + the Main tab written. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a lived-in session and a folder of many short recordings.""" + lived_in = lived_in_world() + return World( + state=lived_in.state, + application_config=None, + config=None, + files=tuple( + Recording( + destination=home_path(FOLDER) / f"take{index:03d}.wav", + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + for index in range(FOLDER_RECORDINGS) + ), + ) + + def test_it_leaves_the_read_behind(self, screen: Screen, scan_hold: ScanHold) -> None: + """The application stops after the close while the folder read is in progress.""" + converter = screen.main.converter + + def start_reading(screen: Screen) -> None: + row = screen.expect_item( + lambda: screen.explorer.file_row(home_path(FOLDER)), + description="the folder's row", + ) + screen.explorer.ctrl_click(row) + screen.expect(converter.scan_shown, bool, description="the folder being read") + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.MAIN) + + screen.scenario(start_reading, close).run() + + +class TestClosingDuringAnExport: + """Closing the window while an export runs leaves, the export stopped with no file written. + + An arranged project is open. The scenario starts a FamiTracker export whose writing is held, closes the + window, and expects the application to stop with the Sequencer tab written and the export's destination + absent. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a Sequencer world with an arranged project.""" + return sequencer_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the arranged project at start.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_it_leaves_and_writes_nothing(self, screen: Screen, export_hold: ExportHold) -> None: + """The application stops and the export's destination stays absent.""" + destination = PROJECTS_DIRECTORY / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_a_held_export(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.SAVE, destination) + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + screen.expect(screen.exports.progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def close(screen: Screen) -> None: + close_and_leave(screen, Tab.SEQUENCER) + + assert not destination.exists() + + screen.scenario(start_a_held_export, close).run() + + +class TestClosingDuringARebuild: + """Closing the window while an edit is on its way waits for it to land, then asks about the edited + document. + + An open reconstruction receives an edit whose regeneration is held. The first close keeps the + application running with no question. Once the edit lands, the question about the edited reconstruction + appears; confirming it leaves. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world with a reconstruction ready to open.""" + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the reconstruction at start.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_it_waits_and_then_asks(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The close waits for the held edit, then the unsaved reconstruction prompt appears.""" + prompt = screen.reconstructions.unsaved_prompt + + def edit_while_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_waits_for_the_edit(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert not prompt.is_shown() + + def the_edit_lands_and_the_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about the edited reconstruction") + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(edit_while_held, close_waits_for_the_edit, the_edit_lands_and_the_question_comes).run() diff --git a/tests/screens/application/closing/test_exit_chain.py b/tests/screens/application/closing/test_exit_chain.py new file mode 100644 index 000000000..2717a2ffd --- /dev/null +++ b/tests/screens/application/closing/test_exit_chain.py @@ -0,0 +1,88 @@ +from typing import List + +import pytest + +from automation.application.startup import Startup +from automation.holds.conversion import ConversionHold +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.steps.project import retitle_project +from automation.steps.reconstructions import expect_open, raise_the_first_level_while_held +from automation.worlds.home import World +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.application.closing.constants import NEW_TITLE, RUN_TIMEOUT_SECONDS +from tests.screens.application.closing.steps import asks_about_the_conversion, conversion_question +from tests.suite.screens.worlds.recordings import BASS, OPEN_RECONSTRUCTION, SONG, documents_world + + +class TestExitWithEverythingPending: + """Exit with an unsaved project, an unsaved reconstruction and a conversion running asks about each, in + that order, and leaves once the last is answered. + + The scenario retitles the project, edits the reconstruction, and starts a held conversion. Exit then + asks about the project, the reconstruction and the conversion in turn; each is confirmed, and the + application stops. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the documents world: a project, a reconstruction and recordings.""" + return documents_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the reconstruction and the project at start.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_questions_come_in_order( + self, + screen: Screen, + conversion_hold: ConversionHold, + regeneration_hold: RegenerationHold, + ) -> None: + """The three questions appear as project, reconstruction, conversion.""" + converter = screen.main.converter + asked: List[str] = [] + + def change_both_documents_and_start_a_run(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + retitle_project(screen, NEW_TITLE) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + regeneration_hold.release() + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + converter.press_action() + screen.bridge.expect( + converter.progress, + lambda progress: progress > 0, + description="the run under way", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def answer_each_question(screen: Screen) -> None: + project = screen.project.unsaved_prompt + reconstruction = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project.is_shown, bool, description="the question about the project") + asked.append("project") + project.confirm() + screen.expect( + lambda: reconstruction.is_shown() and not asks_about_the_conversion(screen), + bool, + description="the question about the reconstruction", + ) + asked.append("reconstruction") + reconstruction.confirm() + screen.expect(lambda: asks_about_the_conversion(screen), bool, description="the question about the run") + asked.append("conversion") + conversion_question(screen).confirm() + + assert screen.wait_for_exit() + assert asked == ["project", "reconstruction", "conversion"] + + screen.scenario(change_both_documents_and_start_a_run, answer_each_question).run() diff --git a/tests/screens/application/closing/test_idle_and_unsaved.py b/tests/screens/application/closing/test_idle_and_unsaved.py new file mode 100644 index 000000000..58365edfa --- /dev/null +++ b/tests/screens/application/closing/test_idle_and_unsaved.py @@ -0,0 +1,101 @@ +import operator +from typing import Final + +from automation.screen import Screen +from automation.steps.project import retitle_project, save_project_as, saved_project_title +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.application.closing.constants import NEW_TITLE + +PROJECT_FILENAME: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" + + +def save_a_titled_project(screen: Screen) -> None: + """Creates a project, gives it a title, and saves it in the projects folder.""" + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +def change_the_title(screen: Screen) -> None: + """Retitles the open project, leaving a change to save.""" + retitle_project(screen, NEW_TITLE) + + +def close_asks_first(screen: Screen) -> None: + """Closes the window and waits for the unsaved project prompt.""" + screen.close_window() + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + """Reads the title stored in the saved project file.""" + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +class TestClosingTheWindow: + """The close button on the window's title bar leaves the application the way Exit does.""" + + def test_an_application_with_nothing_unsaved_leaves_at_once(self, screen: Screen) -> None: + """The window closes and the application stops at once.""" + screen.close_window() + + assert screen.wait_for_exit() + + +class TestClosingTheWindowOverAnUnsavedProject: + """Closing the window with an unsaved project asks first, and asks again at each close. + + A saved project is retitled and the window closed; Save writes the new title and leaves, Cancel keeps + the file and the application, and the next close asks once more with one prompt on screen. Exit then + discards the change and leaves, with the file still holding the saved title. + """ + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_and_the_file_and_the_next_close_asks_again(self, screen: Screen) -> None: + """The file keeps its saved title through Cancel, the second prompt and the discarding exit.""" + prompt = screen.project.unsaved_prompt + + def cancel(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the prompt gone") + + def close_again(screen: Screen) -> None: + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the unsaved project prompt again") + assert len(prompt.shown_windows()) == 1 + assert screen.is_running() + assert saved_title() == SAVED_TITLE + + def discard_and_leave(screen: Screen) -> None: + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() == SAVED_TITLE + + screen.scenario( + save_a_titled_project, + change_the_title, + close_asks_first, + cancel, + close_again, + discard_and_leave, + ).run() diff --git a/tests/screens/application/leaving/__init__.py b/tests/screens/application/leaving/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/leaving/test_exit_shortcut.py b/tests/screens/application/leaving/test_exit_shortcut.py new file mode 100644 index 000000000..a6a5c909c --- /dev/null +++ b/tests/screens/application/leaving/test_exit_shortcut.py @@ -0,0 +1,130 @@ +import operator +from typing import Final + +import pytest + +from automation.boundaries.audio import OutputDevice +from automation.screen import Screen +from automation.steps.project import retitle_project, save_new_project, saved_project_title +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import PROJECTS_DIRECTORY + +PROJECT_FILENAME: Final[str] = "Leaving.stp" +NEW_TITLE: Final[str] = "Leaving title" +SETTLING_FRAMES: Final[int] = 10 + + +def save_a_new_project(screen: Screen) -> None: + """Creates a project and saves it in the projects folder.""" + save_new_project(screen, PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +def retitle_the_project(screen: Screen) -> None: + """Retitles the open project, leaving a change to save.""" + retitle_project(screen, NEW_TITLE) + + +def ask_to_leave(screen: Screen) -> None: + """Presses the Exit shortcut and waits for the unsaved project prompt.""" + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(screen.project.unsaved_prompt.is_shown, bool, description="the unsaved project prompt") + + +def saved_title() -> str: + """Reads the title stored in the saved project file.""" + return saved_project_title(PROJECTS_DIRECTORY / PROJECT_FILENAME) + + +class TestLeavingWithAnUnsavedProject: + """Leaving the application with changes to a saved project asks about them first. + + A new project is saved and retitled, and the Exit shortcut pressed. Save writes the new title and + leaves. Cancel keeps the application open with the file as it was; a second Exit and a confirmation + then leave with the file still holding the earlier title. + """ + + def test_save_writes_the_change_and_leaves(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_and_leave(screen: Screen) -> None: + screen.project.unsaved_prompt.save() + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + save_and_leave, + ).run() + + def test_cancel_keeps_the_application_open_and_the_change_unsaved(self, screen: Screen) -> None: + """The application keeps running after Cancel and the file keeps its earlier title.""" + + def cancel(screen: Screen) -> None: + screen.project.unsaved_prompt.cancel() + + screen.expect(screen.project.unsaved_prompt.is_shown, operator.not_, description="the prompt gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert saved_title() != NEW_TITLE + + def leave_without_saving(screen: Screen) -> None: + ask_to_leave(screen) + + screen.project.unsaved_prompt.confirm() + + assert screen.wait_for_exit() + assert saved_title() != NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + cancel, + leave_without_saving, + ).run() + + +class TestLeavingFromTheKeyboard: + """The exit's questions answer to the keys alone, from the first press to the last. + + After the Exit shortcut brings up the question, the next-control key and the activate key choose Save, + write the new title and leave. + """ + + def test_tab_and_enter_save_the_change_and_leave(self, screen: Screen) -> None: + """The file holds the new title once the application stops.""" + + def save_from_the_keyboard(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + assert screen.wait_for_exit() + assert saved_title() == NEW_TITLE + + screen.scenario( + save_a_new_project, + retitle_the_project, + ask_to_leave, + save_from_the_keyboard, + ).run() + + +class TestLeavingWhereNothingCanPlay: + """A machine offering no output device opens the application and leaves it cleanly. + + The scenario waits for the Main tab and then ends the test, which closes the application. + """ + + @pytest.fixture + def output_device(self) -> OutputDevice: + """The machine offers no output device.""" + return OutputDevice.NONE + + def test_the_application_leaves_without_an_error(self, screen: Screen) -> None: + """The application starts and stops cleanly with no output device.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") diff --git a/tests/screens/application/no_output/__init__.py b/tests/screens/application/no_output/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/no_output/conftest.py b/tests/screens/application/no_output/conftest.py new file mode 100644 index 000000000..1c4224f76 --- /dev/null +++ b/tests/screens/application/no_output/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from automation.boundaries.audio import OutputDevice + + +@pytest.fixture +def output_device() -> OutputDevice: + """The machine offers no output device.""" + return OutputDevice.NONE diff --git a/tests/screens/application/no_output/test_audio_settings.py b/tests/screens/application/no_output/test_audio_settings.py new file mode 100644 index 000000000..e4d336260 --- /dev/null +++ b/tests/screens/application/no_output/test_audio_settings.py @@ -0,0 +1,39 @@ +import operator + +from automation.screen import Screen +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + + +def open_audio_settings(screen: Screen) -> None: + """Opens Audio settings from its shortcut and waits for it.""" + screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) + screen.expect(screen.audio_settings.is_shown, bool, description="Audio settings") + + +class TestApplyingWithNoDevice: + """With no output device to pick, Apply in Audio settings closes the window and changes nothing. + + Apply closes the window and reports no error. Audio settings then opens again, and Cancel closes + it, which shows the first Apply left the dialog answering as before. + """ + + def test_apply_closes_the_window(self, screen: Screen) -> None: + """The window closes on Apply, and opens and closes again afterwards.""" + settings = screen.audio_settings + + def apply_closes_the_window(screen: Screen) -> None: + open_audio_settings(screen) + + settings.apply() + + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + assert not screen.error_notice.is_shown() + + def the_window_opens_again(screen: Screen) -> None: + open_audio_settings(screen) + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + + screen.scenario(apply_closes_the_window, the_window_opens_again).run() diff --git a/tests/screens/application/no_output/test_playing.py b/tests/screens/application/no_output/test_playing.py new file mode 100644 index 000000000..5ab8e1b89 --- /dev/null +++ b/tests/screens/application/no_output/test_playing.py @@ -0,0 +1,99 @@ +import operator +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.steps.sequencer import on_the_sequencer +from automation.vocabulary.playback import PLAY +from automation.worlds.home import World +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + +NO_OUTPUT_TITLE: Final[str] = "global.dialog.title.no_audio_output" +NO_OUTPUT_MESSAGE: Final[str] = "global.dialog.message.no_audio_output" +MIDDLE_OF_THE_WAVEFORM: Final[float] = 0.5 + + +@pytest.fixture +def world() -> World: + """A home holding reconstructions that play and an arranged project.""" + return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on a reconstruction that plays and on the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +def the_reader_is_told(screen: Screen) -> None: + """Waits for the notice that no sound can play, reads its words, and dismisses it with OK. + + The Playback menu still offers Play, since nothing took the output. + """ + notice = screen.no_output_notice + screen.expect(notice.is_shown, bool, description="the notice that no sound can play") + + assert notice.prompt.title() == screen.words(NO_OUTPUT_TITLE) + assert notice.words() == screen.words(NO_OUTPUT_MESSAGE) + assert not screen.error_notice.is_shown() + assert screen.sequencer.playback.play_entry() == screen.words(PLAY) + + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + +class TestPlayingAReconstruction: + """With no output device, playing the open reconstruction tells the reader that no sound can play. + + Play brings up the notice with its words and leaves the Playback menu offering Play. Once OK + dismisses it, a click on the waveform, which plays from there, brings the same notice up again. + """ + + def test_play_and_a_click_tell_the_reader(self, screen: Screen) -> None: + """Each gesture brings up the plain notice, and no error is reported.""" + + def play_tells_the_reader(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.press_shortcut(ShortcutId.PLAY) + + the_reader_is_told(screen) + + def a_click_on_the_waveform_tells_the_reader_again(screen: Screen) -> None: + screen.reconstructions.waveform.click(MIDDLE_OF_THE_WAVEFORM) + + the_reader_is_told(screen) + + screen.scenario(play_tells_the_reader, a_click_on_the_waveform_tells_the_reader_again).run() + + +class TestPlayingTheSong: + """With no output device, playing the open project's song tells the reader that no sound can play. + + Play on the Sequencer brings up the notice with its words and leaves the Playback menu offering + Play. Once OK dismisses it, Play from this frame brings the same notice up again. + """ + + def test_play_and_play_from_this_frame_tell_the_reader(self, screen: Screen) -> None: + """Each gesture brings up the plain notice, and no error is reported.""" + + def play_tells_the_reader(screen: Screen) -> None: + on_the_sequencer(screen) + + screen.press_shortcut(ShortcutId.PLAY) + + the_reader_is_told(screen) + + def play_from_this_frame_tells_the_reader_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY_FROM_FRAME) + + the_reader_is_told(screen) + + screen.scenario(play_tells_the_reader, play_from_this_frame_tells_the_reader_again).run() diff --git a/tests/screens/application/old_files/__init__.py b/tests/screens/application/old_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/old_files/cases.py b/tests/screens/application/old_files/cases.py new file mode 100644 index 000000000..46babb0de --- /dev/null +++ b/tests/screens/application/old_files/cases.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +from tests.screens.application.old_files.constants import VANISHED +from tests.screens.application.old_files.steps import damaged_name +from tests.suite.screens.seeds.archives import Damage + + +@dataclass(frozen=True) +class StartupCase: + """One row of a startup table: a broken document handed over at start. + + Attributes: damage: The damage done to the file, or None for a file that is gone. + """ + + damage: Optional[Damage] + + def name(self, suffix: str) -> str: + """The file name of the row's document with ``suffix``: the damage's name, or the vanished file's.""" + return damaged_name(self.damage, suffix) if self.damage is not None else f"{VANISHED}{suffix}" + + +STARTUP_CASES: Final[Tuple[StartupCase, ...]] = (*(StartupCase(damage) for damage in Damage), StartupCase(None)) diff --git a/tests/screens/application/old_files/constants.py b/tests/screens/application/old_files/constants.py new file mode 100644 index 000000000..fe880b416 --- /dev/null +++ b/tests/screens/application/old_files/constants.py @@ -0,0 +1,11 @@ +from pathlib import Path +from typing import Final + +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY + +ARCHIVED_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Archived.stn" +ARCHIVED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Archived.stp" +VANISHED: Final[str] = "vanished" +SAMPLES_FIELD: Final[str] = "samples" +NAME_FIELD: Final[str] = "name" +BY_CONFIGURATION: Final[str] = "global.browser.label.by_configuration" diff --git a/tests/screens/application/old_files/steps.py b/tests/screens/application/old_files/steps.py new file mode 100644 index 000000000..cd0fb80ed --- /dev/null +++ b/tests/screens/application/old_files/steps.py @@ -0,0 +1,48 @@ +from typing import List + +from automation.screen import Screen +from automation.views.notices import Notice +from automation.worlds.home import HomeFile, World, screen_filling_state +from sampletones_core.compatibility.kind import ObjectKind +from tests.screens.application.old_files.constants import NAME_FIELD, SAMPLES_FIELD +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document +from tests.suite.screens.seeds.archives import Damage + + +def future_version(current: str) -> str: + """A data version one major step past ``current``.""" + major, _ = current.split(".", 1) + return f"{int(major) + 1}.0" + + +def stored_voice_names() -> List[str]: + """The voice names the archived project stores, read off its document without the application's model.""" + document = stored_document(archived(ObjectKind.PROJECT, ARCHIVED_VERSIONS[ObjectKind.PROJECT])) + return [sample[NAME_FIELD] for sample in document[SAMPLES_FIELD]] + + +def damaged_name(damage: Damage, suffix: str) -> str: + """The file name a document with ``damage`` takes, ending in ``suffix``.""" + return f"{damage.name.lower()}{suffix}" + + +def stated_version(damage: Damage, current: str, older: str) -> str: + """The version the application states when refusing a file with ``damage``: ``older`` or a future one.""" + return older if damage is Damage.OLDER_VERSION else future_version(current) + + +def world_of(*files: HomeFile) -> World: + """A home with a screen-filling session and ``files``.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=files, + ) + + +def shown_notice(screen: Screen) -> Notice: + """Waits for an error or a file-not-found notice and returns the one shown.""" + notices = (screen.error_notice, screen.file_not_found_notice) + screen.expect(lambda: any(notice.is_shown() for notice in notices), bool, description="a notice") + return next(notice for notice in notices if notice.is_shown()) diff --git a/tests/screens/application/old_files/test_broken_projects.py b/tests/screens/application/old_files/test_broken_projects.py new file mode 100644 index 000000000..f04900c96 --- /dev/null +++ b/tests/screens/application/old_files/test_broken_projects.py @@ -0,0 +1,176 @@ +import operator +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.worlds.home import HomeFile, World +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.application.old_files.cases import STARTUP_CASES, StartupCase +from tests.screens.application.old_files.constants import ARCHIVED_PROJECT, VANISHED +from tests.screens.application.old_files.steps import ( + damaged_name, + future_version, + shown_notice, + stated_version, + stored_voice_names, + world_of, +) +from tests.suite.screens.seeds.archives import Damage, archived_document, damaged_document + +SECOND_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Second.stp" +OLDER_PROJECT_VERSION: Final[str] = "0.9" +DOOR_SUFFIX_PROJECT: Final[str] = ".stp" +ARCHIVE_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.TRUNCATED, Damage.FOREIGN_BYTES) +VERSION_DAMAGES: Final[Tuple[Damage, ...]] = (Damage.OLDER_VERSION, Damage.FUTURE_VERSION) + + +def damaged_project(damage: Damage) -> HomeFile: + """A damaged project file in the projects folder, one for each kind of ``damage``.""" + return damaged_document( + ObjectKind.PROJECT, + damage, + destination=PROJECTS_DIRECTORY / damaged_name(damage, ".stp"), + older_version=OLDER_PROJECT_VERSION, + future_version=future_version(SAMPLETONES_PROJECT_DATA_VERSION), + ) + + +def broken_project_world() -> World: + """A home with two sound projects, a damaged project for each damage, and a session.""" + return world_of( + archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT), + archived_document(ObjectKind.PROJECT, SECOND_PROJECT), + *(damaged_project(damage) for damage in Damage), + ) + + +def open_project(screen: Screen, path: Path) -> None: + """Chooses File > Open project with ``path`` and lets the open project be replaced.""" + project = screen.project + screen.answer_next_dialog(DialogKind.OPEN, path) + project.open() + screen.expect(project.replace_prompt.is_shown, bool, description="the question about replacing the project") + project.replace_prompt.confirm() + + +def refused_project(screen: Screen, path: Path) -> str: + """Reads the notice refusing ``path``, dismisses it, checks the project open before stays open, and + returns the words. + """ + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(path.name) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert ARCHIVED_PROJECT.stem in screen.title() + assert screen.sequencer.voices.names() == stored_voice_names() + return words + + +def open_the_second_project_the_same_way(screen: Screen) -> None: + """Opens the second sound project through the same menu and waits for its title.""" + open_project(screen, SECOND_PROJECT) + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + + +class TestRefusingABrokenProject: + """A project that cannot be read is refused with a message naming why, and the one open stays open. + + The archived project is open at start. Each damaged or vanished file is chosen from File > Open + project, and the notice names the file. A final open of a sound project succeeds, which shows the menu + still works. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds sound projects and a damaged project for each kind of damage.""" + return broken_project_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived project at start.""" + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_a_damaged_or_missing_archive_is_refused_naming_the_file(self, screen: Screen) -> None: + """The notice names the file for each damaged archive and for a file removed after it was chosen.""" + vanished = PROJECTS_DIRECTORY / f"{VANISHED}.stp" + + def refuse_each_damage(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in ARCHIVE_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + assert path.name in words + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + open_project(screen, vanished) + + words = refused_project(screen, vanished) + + assert vanished.name in words + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_the_second_project_the_same_way, + ).run() + + def test_a_version_no_step_reaches_is_refused_for_its_version(self, screen: Screen) -> None: + """The notice states the file's version and the current version.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + for damage in VERSION_DAMAGES: + path = PROJECTS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_PROJECT) + open_project(screen, path) + + words = refused_project(screen, path) + + stated = stated_version(damage, SAMPLETONES_PROJECT_DATA_VERSION, OLDER_PROJECT_VERSION) + assert stated in words and SAMPLETONES_PROJECT_DATA_VERSION in words, words + + +class TestABrokenProjectGivenAtStart: + """A project handed over at start that cannot be read is set aside quietly, as the restore promises. + + The application starts on the Main tab with that project absent from the title. File > Open project + then opens a sound project, with no window left shown. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + """One row for each kind of damage, and one for a file that is gone.""" + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + """The home holds sound projects and a damaged project for each kind of damage.""" + return broken_project_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + """The application is handed the row's project at start.""" + return Startup(reconstruction=None, project=PROJECTS_DIRECTORY / case.name(DOOR_SUFFIX_PROJECT)) + + def test_it_starts_with_no_project_open(self, screen: Screen, case: StartupCase) -> None: + """The application starts with no project open and opens a sound one afterwards.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + assert case.name(DOOR_SUFFIX_PROJECT) not in screen.title() + screen.answer_next_dialog(DialogKind.OPEN, SECOND_PROJECT) + + screen.project.open() + + screen.expect(lambda: SECOND_PROJECT.stem in screen.title(), bool, description="the second project open") + assert screen.shown_windows() == () diff --git a/tests/screens/application/old_files/test_broken_reconstructions.py b/tests/screens/application/old_files/test_broken_reconstructions.py new file mode 100644 index 000000000..fda06e59c --- /dev/null +++ b/tests/screens/application/old_files/test_broken_reconstructions.py @@ -0,0 +1,251 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, Optional, Type + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.worlds.home import HomeFile, World +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION +from sampletones_shared.exceptions.reconstruction import ( + IncompatibleReconstructionVersionError, + InvalidReconstructionValuesError, +) +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.old_files.cases import STARTUP_CASES, StartupCase +from tests.screens.application.old_files.constants import ARCHIVED_RECONSTRUCTION, BY_CONFIGURATION, VANISHED +from tests.screens.application.old_files.steps import ( + damaged_name, + future_version, + shown_notice, + stated_version, + world_of, +) +from tests.suite.screens.seeds.archives import Damage, archived_document, damaged_document +from tests.suite.screens.seeds.recordings import stored_recording + +SECOND_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +OLDER_RECONSTRUCTION_VERSION: Final[str] = "2.0" +INVALID_VALUES: Final[str] = "reconstructions.browser.message.invalid_values" +INCOMPATIBLE_VERSION: Final[str] = "reconstructions.browser.template.incompatible_version_template" +RECONSTRUCTION_NOT_FOUND: Final[str] = "reconstructions.browser.message.file_not_found" +DOOR_SUFFIX_RECONSTRUCTION: Final[str] = ".stn" + + +def damaged_reconstruction(damage: Damage) -> HomeFile: + """A damaged reconstruction file in the reconstructions folder for ``damage``.""" + return damaged_document( + ObjectKind.RECONSTRUCTION, + damage, + destination=RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, ".stn"), + older_version=OLDER_RECONSTRUCTION_VERSION, + future_version=future_version(SAMPLETONES_RECONSTRUCTION_DATA_VERSION), + ) + + +def reconstruction_refusal(screen: Screen, damage: Damage) -> str: + """The words the application uses to refuse a reconstruction with ``damage``, as the language file has + them. + """ + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + stated = stated_version(damage, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, OLDER_RECONSTRUCTION_VERSION) + return screen.words(INCOMPATIBLE_VERSION).format(stated, SAMPLETONES_RECONSTRUCTION_DATA_VERSION) + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return screen.words(INVALID_VALUES) + + +def reconstruction_failure(damage: Optional[Damage]) -> Type[BaseException]: + """The failure type the application records for a reconstruction with ``damage``, or for a file that is + gone. + """ + match damage: + case Damage.OLDER_VERSION | Damage.FUTURE_VERSION: + return IncompatibleReconstructionVersionError + case Damage.TRUNCATED | Damage.FOREIGN_BYTES: + return InvalidReconstructionValuesError + case None: + return FileNotFoundError + + +def broken_reconstruction_world() -> World: + """A home with sound reconstructions, a recording, and a damaged reconstruction for each damage.""" + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, SECOND_RECONSTRUCTION), + archived_document(ObjectKind.RECONSTRUCTION, RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn"), + stored_recording(), + *(damaged_reconstruction(damage) for damage in Damage), + ) + + +def refused_reconstruction( + screen: Screen, + damage: Optional[Damage], + expected: str, +) -> None: + """Reads the notice refusing a reconstruction, dismisses it, and checks the reconstruction open before + stays open. + """ + notice = shown_notice(screen) + words = notice.words() + screen.claim_error(reconstruction_failure(damage).__name__) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert words.startswith(expected), words + assert screen.reconstructions.shows_open(ARCHIVED_RECONSTRUCTION) + + +class TestRefusingABrokenReconstruction: + """A reconstruction that cannot be read is refused with a readable message, and the one open stays open. + + Each door that opens a reconstruction is walked with every kind of damage, and then with a file removed + after it was listed or chosen. The last gesture opens a sound reconstruction the same way, which shows + the door still opens what it should. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds sound reconstructions and a damaged one for each kind of damage.""" + return broken_reconstruction_world() + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived reconstruction at start.""" + return Startup(reconstruction=ARCHIVED_RECONSTRUCTION, project=None) + + def test_through_the_browser(self, screen: Screen) -> None: + """The door is a double click on a row of the reconstructions browser.""" + browser = screen.reconstructions.browser + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def open_the_listing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + browser.open_by_click(heading) + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + row = screen.expect_item(partial(browser.file_row, path), description="its row") + + browser.double_click(row) + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_listed(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(vanished), description="its row") + vanished.unlink() + + browser.double_click(row) + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + row = screen.expect_item(lambda: browser.file_row(SECOND_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect( + lambda: screen.reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + open_the_listing, + refuse_each_damage, + refuse_a_file_removed_after_it_was_listed, + open_a_sound_one_the_same_way, + ).run() + + def test_through_reconstruction_open(self, screen: Screen) -> None: + """The door is the Reconstruction > Open menu entry.""" + reconstructions = screen.reconstructions + vanished = RECONSTRUCTIONS_DIRECTORY / f"{VANISHED}.stn" + + def refuse_each_damage(screen: Screen) -> None: + for damage in Damage: + path = RECONSTRUCTIONS_DIRECTORY / damaged_name(damage, DOOR_SUFFIX_RECONSTRUCTION) + screen.answer_next_dialog(DialogKind.OPEN, path) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, damage, reconstruction_refusal(screen, damage)) + + def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: + vanished.unlink() + screen.answer_next_dialog(DialogKind.OPEN, vanished) + + reconstructions.open_from_menu() + + refused_reconstruction(screen, None, screen.words(RECONSTRUCTION_NOT_FOUND)) + + def open_a_sound_one_the_same_way(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + + screen.scenario( + refuse_each_damage, + refuse_a_file_removed_after_it_was_chosen, + open_a_sound_one_the_same_way, + ).run() + + +class TestABrokenReconstructionGivenAtStart: + """A reconstruction handed over at start that cannot be read is set aside quietly, as the restore + promises. + + The application starts quietly with nothing open; Reconstruction > Open then opens a sound + reconstruction, which shows the start left the application as usable as ever. + """ + + @pytest.fixture(params=STARTUP_CASES, ids=lambda case: case.name("")) + def case(self, request: pytest.FixtureRequest) -> StartupCase: + """One row for each kind of damage, and one for a file that is gone.""" + case: StartupCase = request.param + return case + + @pytest.fixture + def world(self) -> World: + """The home holds sound reconstructions and a damaged one for each kind of damage.""" + return broken_reconstruction_world() + + @pytest.fixture + def startup(self, case: StartupCase) -> Startup: + """The application is handed the row's reconstruction at start.""" + return Startup(reconstruction=RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION), project=None) + + def test_it_starts_with_nothing_open(self, screen: Screen, case: StartupCase) -> None: + """No window shows, and a sound reconstruction opens afterwards.""" + if case.damage is None: + (RECONSTRUCTIONS_DIRECTORY / case.name(DOOR_SUFFIX_RECONSTRUCTION)).unlink(missing_ok=True) + reconstructions = screen.reconstructions + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, SECOND_RECONSTRUCTION) + + reconstructions.open_from_menu() + + screen.expect( + lambda: reconstructions.shows_open(SECOND_RECONSTRUCTION), + bool, + description="the sound reconstruction open", + ) + assert screen.shown_windows() == () diff --git a/tests/screens/application/old_files/test_last_release_documents.py b/tests/screens/application/old_files/test_last_release_documents.py new file mode 100644 index 000000000..12bb310e5 --- /dev/null +++ b/tests/screens/application/old_files/test_last_release_documents.py @@ -0,0 +1,130 @@ +from typing import Tuple + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.playback import PlaybackConfig +from sampletones_core.compatibility.kind import ObjectKind +from tests.screens.application.old_files.constants import ARCHIVED_PROJECT, ARCHIVED_RECONSTRUCTION, BY_CONFIGURATION +from tests.screens.application.old_files.steps import stored_voice_names, world_of +from tests.suite.screens.seeds.archives import archived_document +from tests.suite.screens.seeds.recordings import stored_recording + + +class TestTheLastReleasesReconstruction: + """A reconstruction the last release wrote opens in this build, its recording found where it names it.""" + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's reconstruction and its recording.""" + return world_of( + archived_document(ObjectKind.RECONSTRUCTION, ARCHIVED_RECONSTRUCTION), + stored_recording(), + ) + + def test_it_opens_from_the_browser(self, screen: Screen) -> None: + """A double click on the browser's row opens the reconstruction with no notice shown. + + The scenario opens the by-configuration listing on the Reconstructions tab and double-clicks the + row. + """ + reconstructions = screen.reconstructions + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + heading = screen.expect_item( + lambda: reconstructions.browser.heading(screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + reconstructions.browser.open_by_click(heading) + row = screen.expect_item( + lambda: reconstructions.browser.file_row(ARCHIVED_RECONSTRUCTION), description="its row" + ) + + reconstructions.browser.double_click(row) + + screen.expect( + lambda: reconstructions.shows_open(ARCHIVED_RECONSTRUCTION), + bool, + description="the archived reconstruction open", + ) + assert not screen.file_not_found_notice.is_shown() + assert not screen.error_notice.is_shown() + + +class TestTheLastReleasesProject: + """A project the last release wrote opens in this build, lists its voices and plays its song. + + The project opens at start with song looping on. The Sequencer lists the stored voices, Play turns the + entry to Pause and enables Stop, and Stop returns the entry to Play. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's project and a session with song looping on.""" + looping = ApplicationConfig(playback=PlaybackConfig(loop_song=True)) + return World( + state=screen_filling_state(), + application_config=looping, + config=None, + files=(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT),), + ) + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the archived project at start.""" + return Startup(reconstruction=None, project=ARCHIVED_PROJECT) + + def test_it_opens_at_start_lists_its_voices_and_plays_its_song(self, screen: Screen) -> None: + """The voices match the stored ones, and playback starts and stops.""" + sequencer = screen.sequencer + playing_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PAUSE)) + stopped_entry = screen.words(_menu_key(MenuElements.ITEM_PLAYBACK_PLAY)) + + def lists_its_voices(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + def plays(screen: Screen) -> None: + assert sequencer.playback.play_entry() == stopped_entry + + sequencer.playback.play() + + screen.expect(sequencer.playback.play_entry, playing_entry.__eq__, description="Play reading Pause") + assert sequencer.playback.can_stop() + + def stops(screen: Screen) -> None: + sequencer.playback.stop() + + screen.expect(sequencer.playback.play_entry, stopped_entry.__eq__, description="Pause reading Play") + assert not sequencer.playback.can_stop() + + screen.scenario(lists_its_voices, plays, stops).run() + + +class TestOpeningTheLastReleasesProjectFromTheMenu: + """File > Open project opens a project the last release wrote.""" + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's project.""" + return world_of(archived_document(ObjectKind.PROJECT, ARCHIVED_PROJECT)) + + def test_it_lists_the_stored_voices(self, screen: Screen) -> None: + """The Sequencer lists the voices the file stores.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.OPEN, ARCHIVED_PROJECT) + + screen.project.open() + + screen.expect(screen.sequencer.voices.names, stored_voice_names().__eq__, description="the stored voices") + + +def _menu_key(element: MenuElements) -> Tuple[Page, Panel, TextType, MenuElements]: + """The language key of a menu label.""" + return (Page.GLOBAL, Panel.MENU, TextType.LABEL, element) diff --git a/tests/screens/application/old_files/test_last_release_library.py b/tests/screens/application/old_files/test_last_release_library.py new file mode 100644 index 000000000..1655343ab --- /dev/null +++ b/tests/screens/application/old_files/test_last_release_library.py @@ -0,0 +1,95 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from automation.vocabulary.libraries import LIBRARY_LOADED +from automation.worlds.home import World +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.screens.application.old_files.steps import world_of +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document, stored_version +from tests.suite.screens.seeds.archives import archived_document + +CONFIG_FIELD: Final[str] = "config" +OUTDATED_ROW: Final[str] = "instructions.library.template.library_node_outdated_template" +REBUILD_TIMEOUT_SECONDS: Final[float] = 300.0 + + +def archived_library_path() -> Path: + """Where the archived library lies in the library folder, named as its settings name a library file.""" + document = stored_document(archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY])) + config = Config(library=InstructionsLibraryConfig.model_validate(document[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return LIBRARY_DIRECTORY / key.filename + + +class TestTheLastReleasesLibrary: + """A library the last release built reads as out of date, and only a rebuild the user asks for replaces + it. + + The Instructions tab marks the library as outdated. Opening it asks about a rebuild; Cancel keeps the + file as it was, and the next opening asks again. Confirming rebuilds the library in place: the row + loses its mark, the file holds the current data version, and the folder holds that file alone. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the last release's library file.""" + return world_of(archived_document(ObjectKind.LIBRARY, archived_library_path())) + + def test_cancel_leaves_it_and_rebuild_rebuilds_it_in_place(self, screen: Screen) -> None: + """Cancel keeps the file's bytes; the rebuild replaces the file in place.""" + library = screen.instructions.library + path = archived_library_path() + archived_bytes = path.read_bytes() + outdated_mark = screen.words(OUTDATED_ROW).format("") + + def library_row() -> Item: + return screen.expect_item(lambda: library.row(path.name), description="the library's row") + + def reads_out_of_date(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + assert library.tree.label(library_row()).startswith(outdated_mark) + + def cancel_leaves_it(screen: Screen) -> None: + prompt = library.rebuild_prompt + library.tree.open_by_click(library_row()) + screen.expect(prompt.is_shown, bool, description="the rebuild question") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + row = library_row() + if library.tree.is_open(row): + library.tree.open_by_click(row) + screen.expect(lambda: library.tree.is_open(row), operator.not_, description="the row closed") + library.tree.open_by_click(row) + screen.expect(prompt.is_shown, bool, description="the rebuild question asked at the next opening") + assert path.read_bytes() == archived_bytes + + def rebuild_replaces_it_in_place(screen: Screen) -> None: + name = library.tree.label(library_row()).removeprefix(outdated_mark) + + library.rebuild_prompt.confirm() + + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the rebuilt library loaded", + timeout=REBUILD_TIMEOUT_SECONDS, + ) + assert library.tree.label(library_row()) == name + assert stored_version(ObjectKind.LIBRARY, path) == SAMPLETONES_LIBRARY_DATA_VERSION + assert sorted(LIBRARY_DIRECTORY.glob(f"*{path.suffix}")) == [path] + + screen.scenario(reads_out_of_date, cancel_leaves_it, rebuild_replaces_it_in_place).run() diff --git a/tests/screens/application/old_files/test_session_files_from_elsewhere.py b/tests/screens/application/old_files/test_session_files_from_elsewhere.py new file mode 100644 index 000000000..ce3b887e9 --- /dev/null +++ b/tests/screens/application/old_files/test_session_files_from_elsewhere.py @@ -0,0 +1,84 @@ +import json +from pathlib import Path +from typing import Final + +import pytest +import yaml + +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.state.current import Current +from sampletones_application.paths import APPLICATION_STATE_PATH +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH +from tests.suite.screens.seeds.archives import WrittenBytes +from tests.suite.screens.seeds.recordings import Recording + +RECORDING_SECONDS: Final[float] = 0.5 +RECORDING_FREQUENCY: Final[float] = 220.0 +UNKNOWN_CARD: Final[str] = "main.retired.panel" +GONE_FOLDER: Final[str] = "Gone" +LIBRARIES_FOLDER: Final[str] = "Libraries" +VIEWPORT_FIELD: Final[str] = "viewport" +WIDTH_FIELD: Final[str] = "width" +WRONG_KIND_OF_WIDTH: Final[str] = "wide" +GENERAL_FIELD: Final[str] = "general" +MISSING_SETTING: Final[str] = "max_workers" + + +def state_from_elsewhere() -> bytes: + """A session another build left: it names a card and a folder this home lacks, and a width in words.""" + state = screen_filling_state().model_copy( + update={ + "advanced_settings": True, + "current": Current(tab=Tab.SEQUENCER), + "collapsed_cards": {UNKNOWN_CARD: True}, + "expanded_directories": [Path.cwd() / GONE_FOLDER], + } + ) + document = state.model_dump(mode="json") + document[VIEWPORT_FIELD][WIDTH_FIELD] = WRONG_KIND_OF_WIDTH + return yaml.safe_dump(document).encode() + + +def config_from_elsewhere() -> bytes: + """Reconstruction settings another build left: they name a library folder and lack a setting.""" + config = Config() + general = config.general.model_copy(update={"library_directory": str(Path.cwd() / LIBRARIES_FOLDER)}) + document = config.model_copy(update={"general": general}).model_dump(mode="json") + del document[GENERAL_FIELD][MISSING_SETTING] + return json.dumps(document).encode() + + +class TestSessionFilesFromElsewhere: + """A session naming what this home lacks, with a setting of the wrong kind, beside settings missing one. + + The application starts quietly and applies every setting the files give in a form this build reads: the + Sequencer tab is in front, Advanced settings is shown, and the library folder is the one the settings + name. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds the foreign session, the foreign settings and a recording in the named library + folder. + """ + return World( + state=None, + application_config=None, + config=None, + files=( + WrittenBytes(APPLICATION_STATE_PATH, state_from_elsewhere()), + WrittenBytes(CONFIG_PATH, config_from_elsewhere()), + Recording(Path.cwd() / LIBRARIES_FOLDER / "kick.wav", RECORDING_SECONDS, RECORDING_FREQUENCY), + ), + ) + + def test_it_starts_without_a_word_and_applies_what_it_reads(self, screen: Screen) -> None: + """No window shows, and the readable settings apply.""" + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + + assert screen.shown_windows() == () + assert screen.main.advanced.is_shown() + assert screen.main.library_directory() == str(Path.cwd() / LIBRARIES_FOLDER) diff --git a/tests/screens/application/refusing_output/__init__.py b/tests/screens/application/refusing_output/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/refusing_output/conftest.py b/tests/screens/application/refusing_output/conftest.py new file mode 100644 index 000000000..fcfa99754 --- /dev/null +++ b/tests/screens/application/refusing_output/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from automation.boundaries.audio import OutputDevice + + +@pytest.fixture +def output_device() -> OutputDevice: + """The machine offers a device that refuses every stream opened on it.""" + return OutputDevice.REFUSING diff --git a/tests/screens/application/refusing_output/constants.py b/tests/screens/application/refusing_output/constants.py new file mode 100644 index 000000000..8faeeeea6 --- /dev/null +++ b/tests/screens/application/refusing_output/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +PLAYBACK_ERROR_MESSAGE: Final[str] = "global.dialog.message.audio_playback_error" diff --git a/tests/screens/application/refusing_output/test_playing.py b/tests/screens/application/refusing_output/test_playing.py new file mode 100644 index 000000000..321a3ab83 --- /dev/null +++ b/tests/screens/application/refusing_output/test_playing.py @@ -0,0 +1,60 @@ +import operator + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.audio import REFUSED_STREAM +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.vocabulary.playback import PLAY +from automation.worlds.home import World +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.refusing_output.constants import PLAYBACK_ERROR_MESSAGE +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, playing_world + + +@pytest.fixture +def world() -> World: + """A home holding reconstructions that play.""" + return playing_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on a reconstruction that plays.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + +class TestPlayingOnADeviceThatRefusesTheStream: + """A stream the device refuses to open is reported as an error, and the application goes on answering. + + The device refuses on the thread that plays the audio, so the report crosses to the render thread + before the error window opens. Play brings up the error with its words; once OK dismisses it, the + Playback menu offers Play and a second Play reports the refusal again. + """ + + def test_play_reports_the_refusal_each_time(self, screen: Screen) -> None: + notice = screen.error_notice + + def play_reports_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(notice.is_shown, bool, description="the error about the refused stream") + screen.claim_error(REFUSED_STREAM) + assert screen.words(PLAYBACK_ERROR_MESSAGE) in notice.words() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the error dismissed") + screen.expect( + screen.sequencer.playback.play_entry, + screen.words(PLAY).__eq__, + description="the Playback menu offering Play", + ) + + def open_it(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.scenario(open_it, play_reports_it, play_reports_it).run() diff --git a/tests/screens/application/refusing_output/test_playing_a_song.py b/tests/screens/application/refusing_output/test_playing_a_song.py new file mode 100644 index 000000000..c51a2a687 --- /dev/null +++ b/tests/screens/application/refusing_output/test_playing_a_song.py @@ -0,0 +1,58 @@ +import operator + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.audio import REFUSED_STREAM +from automation.screen import Screen +from automation.vocabulary.playback import PLAY +from automation.worlds.home import World +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.refusing_output.constants import PLAYBACK_ERROR_MESSAGE +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + + +@pytest.fixture +def world() -> World: + """A home holding a project with a song to play.""" + return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on the project with the song.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestPlayingASongOnADeviceThatRefusesTheStream: + """A song on a stream the device refuses is reported the way the Reconstructions tab reports it. + + The song opens its stream on a thread of its own, so the report crosses to the render thread before the + error window opens. Play brings up the error with its words, and the refusal is logged once; once OK + dismisses it, the Playback menu offers Play and a second Play reports the refusal again. + """ + + def test_play_reports_the_refusal_each_time(self, screen: Screen) -> None: + notice = screen.error_notice + + def play_reports_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(notice.is_shown, bool, description="the error about the refused stream") + screen.claim_error(REFUSED_STREAM) + assert screen.words(PLAYBACK_ERROR_MESSAGE) in notice.words() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the error dismissed") + screen.expect( + screen.sequencer.playback.play_entry, + screen.words(PLAY).__eq__, + description="the Playback menu offering Play", + ) + + def bring_the_song_forward(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.scenario(bring_the_song_forward, play_reports_it, play_reports_it).run() diff --git a/tests/screens/application/restart/__init__.py b/tests/screens/application/restart/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/restart/constants.py b/tests/screens/application/restart/constants.py new file mode 100644 index 000000000..4afe278bb --- /dev/null +++ b/tests/screens/application/restart/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +RECORDINGS_FOLDER: Final[str] = "Recordings" +RECORDING_NAME: Final[str] = "kick.wav" +RECORDING_SECONDS: Final[float] = 0.25 +RECORDING_FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/application/restart/steps.py b/tests/screens/application/restart/steps.py new file mode 100644 index 000000000..d0d909d24 --- /dev/null +++ b/tests/screens/application/restart/steps.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from automation.screen import Screen +from automation.worlds.home import HomeFile, World, screen_filling_state +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.restart.constants import RECORDING_FREQUENCY, RECORDING_NAME, RECORDING_SECONDS +from tests.suite.screens.seeds.recordings import Recording + + +def home_folder(name: str) -> Path: + """A folder of the home the application was started in.""" + return Path.cwd() / name + + +def recording_in(folder: str) -> HomeFile: + """A short recording placed in ``folder`` of the home.""" + return Recording( + destination=home_folder(folder) / RECORDING_NAME, + seconds=RECORDING_SECONDS, + frequency=RECORDING_FREQUENCY, + ) + + +def leave(screen: Screen) -> None: + """Leaves through Exit, which writes the session, and waits for the application to stop.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +def world_with(state: ApplicationState, *files: HomeFile) -> World: + """A home with ``state`` as its session and ``files``.""" + return World( + state=state, + application_config=None, + config=None, + files=files, + ) + + +def state_with_advanced(shown: bool) -> ApplicationState: + """A screen-filling session with Advanced settings shown or hidden as ``shown`` says.""" + return screen_filling_state().model_copy(update={"advanced_settings": shown}) diff --git a/tests/screens/application/restart/test_cards.py b/tests/screens/application/restart/test_cards.py new file mode 100644 index 000000000..b8defcb8c --- /dev/null +++ b/tests/screens/application/restart/test_cards.py @@ -0,0 +1,69 @@ +import pytest + +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from automation.written import written_state +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.restart.steps import leave, state_with_advanced, world_with + + +class TestTheAdvancedCardAcrossARestart: + """Whether Advanced settings stands is written as the application leaves, and read as it starts.""" + + @pytest.fixture(params=[False, True], ids=["hidden", "shown"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + """The state the session holds: hidden or shown.""" + shown: bool = request.param + return shown + + @pytest.fixture + def world(self, seeded: bool) -> World: + """The home holds a session with Advanced settings as seeded.""" + return world_with(state_with_advanced(seeded)) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + """The card is shown as the session says.""" + screen.expect(screen.main.advanced.is_shown, seeded.__eq__, description="the card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + """After the toggle shortcut and Exit, the session holds the opposite of the seeded state.""" + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, seeded.__ne__, description="the card toggled") + + leave(screen) + + assert written_state().advanced_settings is not seeded + + +class TestACollapsedCardAcrossARestart: + """A card folded into its bar is written as the application leaves, and stands folded at next start.""" + + @pytest.fixture(params=[False, True], ids=["open", "collapsed"]) + def seeded(self, request: pytest.FixtureRequest) -> bool: + """The state the session holds for the Converter card: open or collapsed.""" + collapsed: bool = request.param + return collapsed + + @pytest.fixture + def world(self, seeded: bool) -> World: + """The home holds a session with the Converter card as seeded.""" + state = screen_filling_state().model_copy(update={"collapsed_cards": {TAG_MAIN_CONVERTER_PANEL: seeded}}) + return world_with(state) + + def test_a_home_holding_it_shows_it(self, screen: Screen, seeded: bool) -> None: + """The Converter card is collapsed as the session says.""" + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + def test_leaving_writes_the_toggled_state(self, screen: Screen, seeded: bool) -> None: + """After the toggle and Exit, the session holds the opposite of the seeded state.""" + card = screen.main.card(TAG_MAIN_CONVERTER_PANEL) + screen.expect(card.is_collapsed, seeded.__eq__, description="the Converter card as the session says") + + card.toggle() + + screen.expect(card.is_collapsed, seeded.__ne__, description="the Converter card toggled") + leave(screen) + assert written_state().collapsed_cards.get(TAG_MAIN_CONVERTER_PANEL) is not seeded diff --git a/tests/screens/application/restart/test_converter_and_library.py b/tests/screens/application/restart/test_converter_and_library.py new file mode 100644 index 000000000..94a009717 --- /dev/null +++ b/tests/screens/application/restart/test_converter_and_library.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import Final + +import pytest + +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from automation.written import written_application_config, written_config +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.converter import ConverterConfig +from sampletones_application.constants.output import OutputKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.screens.application.restart.constants import RECORDINGS_FOLDER +from tests.screens.application.restart.steps import home_folder, leave, recording_in, state_with_advanced + +LIBRARIES_FOLDER: Final[str] = "Libraries" +CHOSEN_CHANNELS_AT_ONCE: Final[int] = 2 +SEEDED_CHANNELS_AT_ONCE: Final[int] = 3 + + +def converter_config(output: OutputKind, channels_at_once: int) -> ConverterConfig: + """A converter setting for ``output`` that allows ``channels_at_once`` channels for each step.""" + settings = StemSettings.covering(list(DEFAULT_CHANNELS)).model_copy(update={"channel_cap": channels_at_once}) + return ConverterConfig(output=output, settings=settings) + + +class TestTheConverterSettingsAcrossARestart: + """The converter's output and its channels at once are written as the application leaves, and stand at the + next start. + + The home seeds the mixed output and three channels at once. The scenario checks both, chooses per- + recording output and two channels, and expects Exit to write the chosen values. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a session and settings with the mixed output and three channels at once.""" + return World( + state=screen_filling_state(), + application_config=ApplicationConfig( + converter=converter_config(OutputKind.MIXED, SEEDED_CHANNELS_AT_ONCE), + ), + config=None, + files=(), + ) + + def test_a_home_holding_them_shows_them_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded values show at start and the chosen values are written at exit.""" + main = screen.main + + def shows_the_seeded_ones(screen: Screen) -> None: + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the output the settings name") + assert main.lit_channels_at_once() == SEEDED_CHANNELS_AT_ONCE + + def changes_them(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + main.choose_channels_at_once(CHOSEN_CHANNELS_AT_ONCE) + + screen.expect(main.lit_channels_at_once, CHOSEN_CHANNELS_AT_ONCE.__eq__, description="the step lit") + assert main.output() is OutputKind.PER_RECORDING + + def leaving_writes_them(screen: Screen) -> None: + leave(screen) + + converter = written_application_config().converter + assert converter.output is OutputKind.PER_RECORDING + assert converter.settings.channel_cap == CHOSEN_CHANNELS_AT_ONCE + + screen.scenario(shows_the_seeded_ones, changes_them, leaving_writes_them).run() + + +class TestTheLibraryFolderAcrossARestart: + """The library folder Advanced settings points at is written as the application leaves, and named at the + next start. + + The home seeds one folder as the library folder. The scenario checks it is named, chooses another + folder through the directory dialog, and expects Exit to write the chosen folder. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds Advanced settings shown, settings naming the Libraries folder, and recordings in two + folders. + """ + config = Config() + general = config.general.model_copy(update={"library_directory": str(home_folder(LIBRARIES_FOLDER))}) + return World( + state=state_with_advanced(True), + application_config=None, + config=config.model_copy(update={"general": general}), + files=(recording_in(LIBRARIES_FOLDER), recording_in(RECORDINGS_FOLDER)), + ) + + def test_a_home_holding_it_names_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded folder shows at start and the chosen folder is written at exit.""" + main = screen.main + chosen = home_folder(RECORDINGS_FOLDER) + + def names_the_seeded_one(screen: Screen) -> None: + screen.expect( + main.library_directory, + str(home_folder(LIBRARIES_FOLDER)).__eq__, + description="the library folder the settings name", + ) + + def points_at_another(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.DIRECTORY, chosen) + + main.choose_library_directory() + + screen.expect(main.library_directory, str(chosen).__eq__, description="the chosen folder named") + + def leaving_writes_it(screen: Screen) -> None: + leave(screen) + + assert Path(written_config().general.library_directory) == chosen + + screen.scenario(names_the_seeded_one, points_at_another, leaving_writes_it).run() diff --git a/tests/screens/application/restart/test_deleted_files.py b/tests/screens/application/restart/test_deleted_files.py new file mode 100644 index 000000000..dc3b5e7ea --- /dev/null +++ b/tests/screens/application/restart/test_deleted_files.py @@ -0,0 +1,97 @@ +from pathlib import Path +from typing import Final + +import pytest + +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from automation.written import written_application_config, written_state +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.favorites import Favorites +from sampletones_application.config.session.state.current import Current +from sampletones_application.config.session.state.paths import LastPaths +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.application.restart.steps import home_folder, leave + +DELETED_FOLDER: Final[str] = "Deleted" +DELETED_SUBFOLDER: Final[str] = "Deeper" +DELETED_RECONSTRUCTION: Final[str] = "Deleted.stn" +DELETED_FAVORITE: Final[str] = "deleted.wav" + + +def deleted_folder() -> Path: + """The folder inside the home that the session names and the home lacks.""" + return home_folder(DELETED_FOLDER) / DELETED_SUBFOLDER + + +def deleted_reconstruction() -> Path: + """The reconstruction the session names and the home lacks.""" + return RECONSTRUCTIONS_DIRECTORY / DELETED_RECONSTRUCTION + + +def deleted_favorite() -> Path: + """The starred file the settings name and the home lacks.""" + return home_folder(DELETED_FAVORITE) + + +class TestASessionNamingDeletedFiles: + """A session naming a folder, a reconstruction and a starred file deleted since the last run. + + The application starts quietly with nothing restored, and a dialog opens in the nearest folder + still standing. Leaving lets go of the reconstruction that failed to open and keeps the folder and + the starred file, so a drive plugged back in is found where it was left. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds a session and settings naming a folder, a reconstruction and a starred file that are + gone. + """ + state = screen_filling_state().model_copy( + update={ + "current": Current(reconstruction=deleted_reconstruction()), + "last_paths": LastPaths(reconstruction=deleted_folder()), + } + ) + return World( + state=state, + application_config=ApplicationConfig(favorites=Favorites(paths={deleted_favorite()})), + config=None, + files=(), + ) + + def test_it_restores_nothing_and_asks_in_the_nearest_standing_folder(self, screen: Screen) -> None: + """The Open dialog starts in the current folder, and no file is open.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + screen.answer_next_dialog(DialogKind.OPEN, None) + + screen.reconstructions.open_from_menu() + + request = screen.expect( + screen.dialog_requests, + lambda requests: len(requests) == 1, + description="the open dialog", + )[0] + assert request.initial_directory == Path.cwd() + assert screen.shown_windows() == () + assert screen.reconstructions.open_file() == "" + + def test_the_session_left_lets_the_reconstruction_go_and_keeps_the_folder(self, screen: Screen) -> None: + """The session written at exit holds no reconstruction and still names the deleted dialog folder.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + state = written_state() + assert state.current.reconstruction is None + assert state.last_paths.reconstruction == deleted_folder() + + def test_the_settings_left_still_star_the_deleted_file(self, screen: Screen) -> None: + """The settings written at exit keep the deleted file among the starred paths.""" + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + leave(screen) + + assert deleted_favorite() in written_application_config().favorites.paths diff --git a/tests/screens/application/restart/test_explorer_and_tab.py b/tests/screens/application/restart/test_explorer_and_tab.py new file mode 100644 index 000000000..f188f55a1 --- /dev/null +++ b/tests/screens/application/restart/test_explorer_and_tab.py @@ -0,0 +1,93 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from automation.written import written_state +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.state.current import Current +from tests.screens.application.restart.constants import RECORDINGS_FOLDER +from tests.screens.application.restart.steps import home_folder, leave, recording_in, world_with + +UNTOUCHED_FOLDER: Final[str] = "Untouched" + + +def explorer_world(opened: List[Path]) -> World: + """A home with ``opened`` folders expanded in the session and recordings in two folders.""" + state = screen_filling_state().model_copy(update={"expanded_directories": opened}) + return world_with(state, recording_in(RECORDINGS_FOLDER), recording_in(UNTOUCHED_FOLDER)) + + +def expect_row(screen: Screen, folder: Path) -> Item: + """Waits for the explorer's row of ``folder`` and returns it.""" + return screen.expect_item( + lambda: screen.explorer.file_row(folder), + description=f"the row of {folder.name}", + ) + + +class TestLeavingWritesAFolderOpenedInTheExplorer: + """A folder opened in the explorer is written as the application leaves; a sibling left closed is left + out. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds two folders with a recording each and a session with none opened.""" + return explorer_world([]) + + def test_the_session_names_the_opened_folder_alone(self, screen: Screen) -> None: + """The session at exit names the opened folder and leaves the sibling out.""" + explorer = screen.explorer + opened = home_folder(RECORDINGS_FOLDER) + row = expect_row(screen, opened) + assert not explorer.is_open(row) + + explorer.double_click(row) + + screen.expect(lambda: explorer.is_open(row), bool, description="the folder open") + leave(screen) + written = written_state().expanded_directories + assert opened in written + assert home_folder(UNTOUCHED_FOLDER) not in written + + +class TestAHomeHoldingAnOpenedFolderShowsIt: + """A folder the session names as opened stands open at the start; a sibling left out stands closed.""" + + @pytest.fixture + def world(self) -> World: + """The home holds two folders with a recording each and a session naming one as opened.""" + return explorer_world([home_folder(RECORDINGS_FOLDER)]) + + def test_the_named_folder_stands_open_alone(self, screen: Screen) -> None: + """The named folder is open and its sibling is closed.""" + explorer = screen.explorer + opened = expect_row(screen, home_folder(RECORDINGS_FOLDER)) + closed = expect_row(screen, home_folder(UNTOUCHED_FOLDER)) + + screen.expect(lambda: explorer.is_open(opened), bool, description="the named folder open") + assert not explorer.is_open(closed) + + +class TestTheTabInFrontAcrossARestart: + """The tab in front is written as the application leaves, and brought forward at the next start.""" + + @pytest.fixture + def world(self) -> World: + """The home holds a session with the Instructions tab in front.""" + state = screen_filling_state().model_copy(update={"current": Current(tab=Tab.INSTRUCTIONS)}) + return world_with(state) + + def test_a_home_holding_it_shows_it_and_leaving_writes_the_next(self, screen: Screen) -> None: + """The seeded tab is in front at start, and the tab brought forward is written at exit.""" + screen.expect(screen.tabs.front, Tab.INSTRUCTIONS.__eq__, description="the Instructions tab in front") + + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer tab in front") + leave(screen) + assert written_state().current.tab is Tab.SEQUENCER diff --git a/tests/screens/application/startup/__init__.py b/tests/screens/application/startup/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/startup/test_fresh_home.py b/tests/screens/application/startup/test_fresh_home.py new file mode 100644 index 000000000..05e5825c5 --- /dev/null +++ b/tests/screens/application/startup/test_fresh_home.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest + +from automation.screen import Screen +from automation.worlds.home import World +from automation.written import written_application_config, written_config, written_state +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY + + +class TestAFreshHome: + """The application starts in a home no run has used, and leaves behind what the next start reads. + + The application opens on the folder it was started in with the Main tab in front and no window shown, + and lists the library and reconstructions folders. Exit then writes the Main tab as the session's tab, + the library folder in the settings, and the application's version. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds no session, no settings and no files.""" + return World( + state=None, + application_config=None, + config=None, + files=(), + ) + + def test_it_opens_on_the_folder_it_was_started_in_and_leaves_its_settings(self, screen: Screen) -> None: + """The first start opens on its folder, and Exit writes the session and both settings files.""" + explorer = screen.explorer + + def opens_on_its_folder(screen: Screen) -> None: + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab in front") + + home = screen.expect_item( + lambda: explorer.file_row(Path.cwd()), + description="the folder the application was started in", + ) + + assert explorer.is_open(home) + assert screen.shown_windows() == () + + def lists_the_folders_it_keeps_its_documents_in(screen: Screen) -> None: + for folder in (LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY): + assert explorer.file_row(folder) is not None, folder + + def leaves_its_settings_behind(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_state().current.tab is Tab.MAIN + assert Path(written_config().general.library_directory) == LIBRARY_DIRECTORY + assert written_application_config().metadata.version is not None + + screen.scenario( + opens_on_its_folder, + lists_the_folders_it_keeps_its_documents_in, + leaves_its_settings_behind, + ).run() diff --git a/tests/screens/application/startup/test_tabs.py b/tests/screens/application/startup/test_tabs.py new file mode 100644 index 000000000..e43d62b2b --- /dev/null +++ b/tests/screens/application/startup/test_tabs.py @@ -0,0 +1,19 @@ +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab + + +class TestTabs: + """The tab bar across the window brings a tab forward at a click on its header.""" + + def test_each_header_brings_its_tab_forward(self, screen: Screen) -> None: + """Each header brought forward by a click puts its tab in front.""" + for tab in (Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS, Tab.MAIN): + screen.tabs.bring_to_front(tab) + + front = screen.expect( + screen.tabs.front, + tab.__eq__, + description=f"the {tab} tab in front", + ) + + assert front == tab diff --git a/tests/screens/conftest.py b/tests/screens/conftest.py new file mode 100644 index 000000000..b279989a1 --- /dev/null +++ b/tests/screens/conftest.py @@ -0,0 +1,45 @@ +from automation.plugin.fixtures import ( + output_device_fixture, + screen_application_fixture, + screen_boundaries_fixture, + screen_bridge_fixture, + screen_fixture, + screen_render_thread_fixture, + startup_fixture, + world_fixture, +) +from automation.plugin.hold_fixtures import ( + conversion_hold_fixture, + export_hold_fixture, + regeneration_hold_fixture, + scan_hold_fixture, + screen_holds_fixture, +) +from automation.plugin.hooks import ( + pytest_collection_modifyitems, + pytest_configure, + pytest_pyfunc_call, + pytest_runtest_protocol, + pytest_unconfigure, +) + +__all__ = [ + "conversion_hold_fixture", + "export_hold_fixture", + "output_device_fixture", + "pytest_collection_modifyitems", + "pytest_configure", + "pytest_pyfunc_call", + "pytest_runtest_protocol", + "pytest_unconfigure", + "regeneration_hold_fixture", + "scan_hold_fixture", + "screen_fixture", + "screen_application_fixture", + "screen_boundaries_fixture", + "screen_bridge_fixture", + "screen_holds_fixture", + "screen_render_thread_fixture", + "startup_fixture", + "world_fixture", +] diff --git a/tests/screens/exports/__init__.py b/tests/screens/exports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/conftest.py b/tests/screens/exports/conftest.py new file mode 100644 index 000000000..ccd19d162 --- /dev/null +++ b/tests/screens/exports/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.worlds.home import World +from tests.suite.screens.worlds.songs import exporting_world + + +@pytest.fixture +def world() -> World: + """The home every export scenario starts in: the arranged project, and projects meeting an export's limits.""" + return exporting_world() diff --git a/tests/screens/exports/instruments/__init__.py b/tests/screens/exports/instruments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/conftest.py b/tests/screens/exports/instruments/conftest.py new file mode 100644 index 000000000..da23802d9 --- /dev/null +++ b/tests/screens/exports/instruments/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.application.startup import Startup +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """The home opens with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/exports/instruments/doors/__init__.py b/tests/screens/exports/instruments/doors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/doors/test_doors.py b/tests/screens/exports/instruments/doors/test_doors.py new file mode 100644 index 000000000..368d9dc49 --- /dev/null +++ b/tests/screens/exports/instruments/doors/test_doors.py @@ -0,0 +1,237 @@ +import operator +from pathlib import Path +from typing import Final, List, Optional, Tuple + +from automation.boundaries.dialogs import DialogKind +from automation.dearpygui.items.texts import EntryReading +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, open_voice, open_voice_menu +from automation.vocabulary.exports import EXPORT_INSTRUMENT, NSF_TYPE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.instrument import read_fti +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_NSF +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +EXPORT_TITLE: Final[str] = "reconstructions.instruments.title.export_instrument_dialog" +EXPORTED: Final[str] = "reconstructions.instruments.message.export_instrument_success" +FAMITRACKER_TYPE: Final[str] = "global.dialog.filter.famitracker_instrument" +BITPHASE_PRESET_TYPE: Final[str] = "global.dialog.filter.bitphase_preset" +FTI_MAGIC: Final[bytes] = b"FTI" +JSON_OPENING: Final[bytes] = b"{" +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +EMPTY: Final[str] = "" +RENAMED: Final[str] = "Renamed" +EXPORTS: Final[str] = "exports" + + +def folder(*parts: str) -> Path: + """Returns a folder of the home, created when missing, that a save dialog can answer with as a native + dialog would. + """ + path = Path.cwd().joinpath(EXPORTS, *parts) + path.mkdir(parents=True, exist_ok=True) + return path + + +def export_entry(screen: Screen) -> Optional[EntryReading]: + """The plain Export instrument... entry of the open menu, if it offers one.""" + words = screen.words(EXPORT_INSTRUMENT) + return next((entry for entry in screen.context_menu.entries() if entry.label == words), None) + + +def export_from_the_menu(screen: Screen, voice: str) -> None: + """Chooses Export instrument... on the menu of ``voice``, a voice holding one instrument.""" + open_voice_menu(screen, voice) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +def written(screen: Screen, path: Path) -> None: + """Waits for the notice naming the instrument file ``path`` written, and dismisses it.""" + notice = screen.exports.file_notice + screen.expect(notice.is_shown, bool, description=f"the notice of {path.name}") + words = notice.words() + assert screen.words(EXPORTED) in words + assert str(path) in words + assert path.is_file() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +def leaving_asks_nothing(screen: Screen) -> None: + """Exits a project only exported from, which goes through at once: an export leaves the project as it + was. + """ + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestTheExportEntryFollowsWhatAVoiceHolds: + """Export instrument... is one entry on a voice holding one instrument and a submenu of channels on a + sample holding several; a voice holding none greys it, and greys the Reconstructions tab's button + with it. + + The menus of the instrument, the three-channel sample and the one-channel sample are read in + turn. Then the envelopes of the instrument are emptied, and the button and the menu entry + end greyed. + """ + + def test_one_entry_a_submenu_and_a_greyed_entry(self, screen: Screen) -> None: + """Each voice shows the entry that matches the instruments it holds.""" + menu = screen.context_menu + instruments = screen.reconstructions.instruments + + def an_instrument_offers_one_entry(screen: Screen) -> None: + open_voice_menu(screen, PAD) + + entry = export_entry(screen) + assert entry is not None + assert entry.enabled + assert screen.words(EXPORT_INSTRUMENT) not in menu.submenus() + menu.dismiss() + + def a_sample_of_three_channels_names_each(screen: Screen) -> None: + open_voice_menu(screen, LINE) + + assert export_entry(screen) is None + assert menu.submenu(screen.words(EXPORT_INSTRUMENT)) == [ + (screen.channel_words(channel), True) for channel in LINE_CHANNELS + ] + menu.dismiss() + + def a_sample_of_one_channel_offers_one_entry(screen: Screen) -> None: + open_voice_menu(screen, BASS_VOICE) + + entry = export_entry(screen) + assert entry is not None + assert entry.enabled + assert screen.words(EXPORT_INSTRUMENT) not in menu.submenus() + menu.dismiss() + + def a_voice_left_without_envelopes_greys_both(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + assert instruments.can_export(INSTRUMENT_CHANNEL) + + for feature in (FeatureKey.VOLUME, FeatureKey.ARPEGGIO): + instruments.type_envelope(INSTRUMENT_CHANNEL, feature, EMPTY) + + screen.expect( + lambda: instruments.can_export(INSTRUMENT_CHANNEL), + operator.not_, + description="the button greyed", + ) + open_voice_menu(screen, PAD) + entry = export_entry(screen) + assert entry is not None + assert not entry.enabled + menu.dismiss() + + screen.scenario( + an_instrument_offers_one_entry, + a_sample_of_three_channels_names_each, + a_sample_of_one_channel_offers_one_entry, + a_voice_left_without_envelopes_greys_both, + leave_letting_the_project_go, + ).run() + + +class TestEveryDoorWritesTheSameBytes: + """The Reconstructions tab's button and the voice's menu write the same file for the same voice. + + The instrument is exported through the button, then through the menu, into two folders. The + two files hold the same bytes. + """ + + def test_the_button_and_the_menu_agree(self, screen: Screen) -> None: + """The two files written for the same voice hold the same bytes.""" + instruments = screen.reconstructions.instruments + through_the_button = folder("button") / f"{PAD}{EXT_FILE_INSTRUMENT}" + through_the_menu = folder("menu") / f"{PAD}{EXT_FILE_INSTRUMENT}" + + def export_through_the_button(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + screen.answer_next_dialog(DialogKind.SAVE, through_the_button) + + instruments.export(INSTRUMENT_CHANNEL) + + written(screen, through_the_button) + + def export_through_the_menu(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, through_the_menu) + + export_from_the_menu(screen, PAD) + + written(screen, through_the_menu) + assert through_the_menu.read_bytes() == through_the_button.read_bytes() + + screen.scenario(export_through_the_button, export_through_the_menu, leaving_asks_nothing).run() + + +class TestOneDialogOffersEveryType: + """One save dialog offers the three instrument types: the type picked decides the format, and the name + the instrument inside. + + The voice is exported from its menu with each type picked in turn, then with a new file name. Each + file starts as its format does, and the instrument inside the FamiTracker file takes the name of the + file. + """ + + def test_the_picked_type_decides_the_format(self, screen: Screen) -> None: + """Each type writes its own format, and the dialog lists the three types.""" + types = (FAMITRACKER_TYPE, BITPHASE_PRESET_TYPE, NSF_TYPE) + offered: List[Tuple[str, ...]] = [] + + def export_picking(screen: Screen, type_key: str, extension: str) -> Path: + stem = folder() / PAD + screen.answer_next_save_as(stem, screen.words(type_key)) + + export_from_the_menu(screen, PAD) + + path = stem.with_suffix(extension) + written(screen, path) + request = screen.dialog_requests()[-1] + assert request.title == screen.words(EXPORT_TITLE) + assert request.suggested_name == PAD + offered.append(tuple(file_filter.name for file_filter in request.filters)) + return path + + def the_nsf_type_writes_a_program(screen: Screen) -> None: + path = export_picking(screen, NSF_TYPE, EXT_FILE_NSF) + + assert path.read_bytes().startswith(NSF_MAGIC) + assert offered[0] == tuple(screen.words(key) for key in types) + + def the_bitphase_type_writes_a_preset(screen: Screen) -> None: + path = export_picking(screen, BITPHASE_PRESET_TYPE, EXT_FILE_JSON) + + assert path.read_bytes().lstrip().startswith(JSON_OPENING) + + def the_famitracker_type_writes_an_instrument_named_after_the_file(screen: Screen) -> None: + path = export_picking(screen, FAMITRACKER_TYPE, EXT_FILE_INSTRUMENT) + + assert path.read_bytes().startswith(FTI_MAGIC) + assert read_fti(path).name == PAD + + def a_file_renamed_in_the_dialog_renames_the_instrument(screen: Screen) -> None: + path = folder() / f"{RENAMED}{EXT_FILE_INSTRUMENT}" + screen.answer_next_dialog(DialogKind.SAVE, path) + + export_from_the_menu(screen, PAD) + + written(screen, path) + assert read_fti(path).name == RENAMED + + screen.scenario( + the_nsf_type_writes_a_program, + the_bitphase_type_writes_a_preset, + the_famitracker_type_writes_an_instrument_named_after_the_file, + a_file_renamed_in_the_dialog_renames_the_instrument, + leaving_asks_nothing, + ).run() diff --git a/tests/screens/exports/instruments/from_a_channel/__init__.py b/tests/screens/exports/instruments/from_a_channel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py b/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py new file mode 100644 index 000000000..4c1502458 --- /dev/null +++ b/tests/screens/exports/instruments/from_a_channel/test_taking_a_channel.py @@ -0,0 +1,133 @@ +from typing import Dict, Final, List, Tuple + +from automation.screen import Screen +from automation.steps.exports import leaving_asks_nothing +from automation.steps.reconstructions import voice_title +from automation.steps.sequencer import open_voice, open_voice_menu +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters.naming import instrument_slice_name +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +INSTRUMENT_FROM: Final[str] = "sequencer.voices.label.context_instrument_from" +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] + + +class TestTheSubmenuNamesTheChannelsThatPlay: + """New instrument from names each channel a sample plays, one channel among them; an instrument greys + it. + + The menus of the three-channel sample, the one-channel sample and the instrument are read in + turn. + """ + + def test_each_channel_one_channel_and_none(self, screen: Screen) -> None: + """The submenu lists each playing channel, and the instrument shows a greyed entry.""" + menu = screen.context_menu + + def a_sample_of_three_channels_names_each(screen: Screen) -> None: + open_voice_menu(screen, LINE) + + assert menu.submenu(screen.words(INSTRUMENT_FROM)) == [ + (screen.channel_words(channel), True) for channel in LINE_CHANNELS + ] + menu.dismiss() + + def a_sample_of_one_channel_still_names_it(screen: Screen) -> None: + open_voice_menu(screen, BASS_VOICE) + + assert menu.submenu(screen.words(INSTRUMENT_FROM)) == [(screen.channel_words(ChannelName.TRIANGLE), True)] + assert screen.words(INSTRUMENT_FROM) not in menu.labels() + menu.dismiss() + + def an_instrument_greys_it(screen: Screen) -> None: + open_voice_menu(screen, PAD) + + assert screen.words(INSTRUMENT_FROM) not in menu.submenus() + entry = next(entry for entry in menu.entries() if entry.label == screen.words(INSTRUMENT_FROM)) + assert not entry.enabled + menu.dismiss() + + screen.scenario( + a_sample_of_three_channels_names_each, + a_sample_of_one_channel_still_names_it, + an_instrument_greys_it, + leaving_asks_nothing, + ).run() + + +class TestTakingAChannel: + """Taking a channel adds ` ()` carrying that channel's envelopes and opens it; one + undo takes it away and leaves the sample as it was. + + The envelopes of each channel of the line are read first. Each channel is then taken from the menu, + checked and undone on the History card, since the note keys of the instrument left open take Ctrl+Z + (bugs-and-todos § Bugs). At the end the line plays the same envelopes and the project title shows no + unsaved change. + """ + + def test_each_channel_of_the_line(self, screen: Screen) -> None: + """Each taken channel is listed, opened with the envelopes it played, and gone after one undo.""" + voices = screen.sequencer.voices + instruments = screen.reconstructions.instruments + played: Dict[ChannelName, str] = {} + + def read_what_each_channel_plays(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + lambda: instruments.tab_shown(ChannelName.NOISE), + bool, + description="the line's channels", + ) + + for channel in LINE_CHANNELS: + played[channel] = instruments.envelope(channel, FeatureKey.VOLUME) + + def taking(channel: ChannelName) -> None: + name = instrument_slice_name(LINE, channel) + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in(screen.words(INSTRUMENT_FROM), screen.channel_words(channel)) + + screen.expect(voices.names, (VOICES + [name]).__eq__, description=f"{name} listed") + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + name.__eq__, + description=f"{name} open", + ) + assert screen.tabs.front() == Tab.RECONSTRUCTIONS + assert instruments.offers_audition() + assert instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == played[channel] + + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.history.undo() + + screen.expect(voices.names, VOICES.__eq__, description=f"{name} taken away") + + def take_each_channel_and_undo_it(screen: Screen) -> None: + for channel in LINE_CHANNELS: + taking(channel) + + def the_line_plays_as_it_did(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + lambda: instruments.tab_shown(ChannelName.NOISE), + bool, + description="the line's channels", + ) + + assert {channel: instruments.envelope(channel, FeatureKey.VOLUME) for channel in LINE_CHANNELS} == played + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, 0, LINE, unsaved=False).__eq__, + description="the project as it was", + ) + + screen.scenario( + read_what_each_channel_plays, + take_each_channel_and_undo_it, + the_line_plays_as_it_did, + leaving_asks_nothing, + ).run() diff --git a/tests/screens/exports/instruments/import_files/__init__.py b/tests/screens/exports/instruments/import_files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/instruments/import_files/cases.py b/tests/screens/exports/instruments/import_files/cases.py new file mode 100644 index 000000000..aeac59ce5 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/cases.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +READ_FAILED: Final[str] = "Failed to read an instrument from" +NO_FILE: Final[str] = "No instrument file at" +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and hold no instrument.\n" +HALF: Final[int] = 2 + + +@dataclass(frozen=True) +class BrokenFile: + """A file the import meets broken: how it is laid in the home and what the application logs. + + Attributes: + lay: Writes the broken file at the first path, given the path of a whole exported file. + logged: The start of the error the application logs for this file. + not_found: Whether the application answers with its file-not-found message. + """ + + name: str + lay: Callable[[Path, Path], None] + logged: str + not_found: bool + + +def foreign(destination: Path, _exported: Path) -> None: + """Writes bytes of another program in place of an instrument.""" + destination.write_bytes(FOREIGN_BYTES) + + +def truncated(destination: Path, exported: Path) -> None: + """Writes the first half of the exported file.""" + content = exported.read_bytes() + destination.write_bytes(content[: len(content) // HALF]) + + +def missing(_destination: Path, _exported: Path) -> None: + """Leaves the file absent, so the import finds it gone.""" + + +BROKEN_FILES: Final[Tuple[BrokenFile, ...]] = ( + BrokenFile(name="Foreign", lay=foreign, logged=READ_FAILED, not_found=False), + BrokenFile(name="Truncated", lay=truncated, logged=READ_FAILED, not_found=False), + BrokenFile(name="Missing", lay=missing, logged=NO_FILE, not_found=True), +) diff --git a/tests/screens/exports/instruments/import_files/constants.py b/tests/screens/exports/instruments/import_files/constants.py new file mode 100644 index 000000000..d884c1828 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/constants.py @@ -0,0 +1,8 @@ +from typing import Final, List + +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +IMPORT_INSTRUMENT: Final[str] = "sequencer.voices.label.import_instrument" +KEPT: Final[str] = "Kept" +FOLDER: Final[str] = "kept" +VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] diff --git a/tests/screens/exports/instruments/import_files/steps.py b/tests/screens/exports/instruments/import_files/steps.py new file mode 100644 index 000000000..797c09df1 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/steps.py @@ -0,0 +1,51 @@ +import operator +from pathlib import Path +from typing import List, Tuple + +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.sequencer import open_voice_menu +from automation.views.history import HistoryLine, HistorySegment +from automation.vocabulary.exports import EXPORT_INSTRUMENT +from sampletones_application.categories.hierarchy import Tab +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.screens.exports.instruments.import_files.constants import FOLDER, IMPORT_INSTRUMENT, KEPT +from tests.suite.screens.worlds.songs import PAD + + +def kept_folder() -> Path: + """Returns the folder of the home that keeps the exported instrument files, created when missing.""" + path = Path.cwd() / FOLDER + path.mkdir(parents=True, exist_ok=True) + return path + + +def open_the_list_menu(screen: Screen) -> None: + """Opens the menu the empty foot of the voice list offers.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.voices.right_click_below_the_rows() + screen.expect(screen.context_menu.is_shown, bool, description="the list's menu") + + +def choose_import(screen: Screen) -> None: + """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" + open_the_list_menu(screen) + screen.context_menu.choose(screen.words(IMPORT_INSTRUMENT)) + + +def export_kept(screen: Screen) -> Path: + """Exports the instrument from its menu to the kept folder under the name :data:`KEPT`.""" + path = kept_folder() / f"{KEPT}{EXT_FILE_INSTRUMENT}" + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, path) + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + screen.expect(notice.is_shown, bool, description="the notice of the export") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return path + + +def segments(lines: Tuple[HistoryLine, ...]) -> List[Tuple[HistorySegment, ...]]: + """Returns the segments of each history line, one tuple per line.""" + return [tuple(line.segments) for line in lines] diff --git a/tests/screens/exports/instruments/import_files/test_broken_files.py b/tests/screens/exports/instruments/import_files/test_broken_files.py new file mode 100644 index 000000000..f42fbc1e3 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/test_broken_files.py @@ -0,0 +1,59 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.exports import leaving_asks_nothing +from automation.views.history import HistorySegment +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from tests.screens.exports.instruments.import_files.cases import BROKEN_FILES, BrokenFile +from tests.screens.exports.instruments.import_files.constants import VOICES +from tests.screens.exports.instruments.import_files.steps import choose_import, export_kept, kept_folder, segments + +NOT_FOUND: Final[str] = "sequencer.voices.message.instrument_not_found" + + +class TestABrokenInstrumentFile: + """A file that is no instrument, a truncated one and a missing one each give a message naming the + trouble, and leave the voices and the history as they stood. + + A whole instrument is exported first. Each broken file is then chosen on the import dialog, the + message is read and dismissed, and the voices and the history match their state before the import. + """ + + def test_each_is_reported_and_changes_nothing(self, screen: Screen) -> None: + """Each broken file is reported, and the voices and the history stay as they were.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + + def export_a_whole_file(screen: Screen) -> None: + exported.append(export_kept(screen)) + before.append(segments(history.lines())) + + def importing(broken: BrokenFile) -> None: + path = kept_folder() / f"{broken.name}{EXT_FILE_INSTRUMENT}" + broken.lay(path, exported[0]) + notice = screen.file_not_found_notice if broken.not_found else screen.error_notice + screen.answer_next_dialog(DialogKind.OPEN, path) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description=f"the message about {broken.name}") + screen.claim_error(broken.logged) + if broken.not_found: + assert screen.words(NOT_FOUND) in notice.words() + assert str(path) in notice.words() + + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the message gone") + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def import_each_broken_file(screen: Screen) -> None: + for broken in BROKEN_FILES: + importing(broken) + + screen.scenario(export_a_whole_file, import_each_broken_file, leaving_asks_nothing).run() diff --git a/tests/screens/exports/instruments/import_files/test_import.py b/tests/screens/exports/instruments/import_files/test_import.py new file mode 100644 index 000000000..acb3503e4 --- /dev/null +++ b/tests/screens/exports/instruments/import_files/test_import.py @@ -0,0 +1,208 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.exports import leaving_asks_nothing +from automation.steps.sequencer import open_voice +from automation.views.history import HistorySegment +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.exports.instruments.import_files.constants import IMPORT_INSTRUMENT, KEPT, VOICES +from tests.screens.exports.instruments.import_files.steps import ( + choose_import, + export_kept, + open_the_list_menu, + segments, +) +from tests.suite.screens.worlds.songs import PAD, RELEASING_INSTRUMENT, releasing_instrument + +IMPORT_TITLE: Final[str] = "sequencer.voices.title.import_instrument_dialog" +IMPORTED_TITLE: Final[str] = "sequencer.voices.title.instrument_imported" +IMPORTED_TEMPLATE: Final[str] = "sequencer.voices.template.instrument_omissions" +RELEASE_POINT: Final[str] = "sequencer.voices.label.omission_release_point" +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SETTLING_FRAMES: Final[int] = 20 + + +class TestImportWithNoProjectOpen: + """Import instrument... with no project open stands greyed out and asks for no file; with a project open, it + asks for one. + + The project is closed, and the list's menu offers Import instrument... greyed out: a click on it asks for + no file and raises no notice. A new project is created, and the same choice now asks for a file. + """ + + def test_it_stands_greyed_out_and_asks_for_no_file(self, screen: Screen) -> None: + """The entry is greyed out with no project; with a new project the open dialog appears and the list + stays empty. + """ + voices = screen.sequencer.voices + notice = voices.no_project_notice + menu = screen.context_menu + asked: List[int] = [] + + def close_the_project(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.project.close() + + screen.expect(voices.names, operator.not_, description="no voices listed") + asked.append(len(screen.dialog_requests())) + + def import_stands_greyed_out(screen: Screen) -> None: + open_the_list_menu(screen) + entry = next(entry for entry in menu.entries() if entry.label == screen.words(IMPORT_INSTRUMENT)) + assert not entry.enabled + + menu.choose(screen.words(IMPORT_INSTRUMENT)) + + screen.frames(SETTLING_FRAMES) + assert menu.is_shown() + assert not notice.is_shown() + assert len(screen.dialog_requests()) == asked[0] + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def with_a_new_project_it_asks_for_a_file(screen: Screen) -> None: + screen.project.create() + screen.answer_next_dialog(DialogKind.OPEN, None) + + choose_import(screen) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked[0] + 1).__eq__, + description="the file asked for", + ) + request = screen.dialog_requests()[-1] + assert request.kind == DialogKind.OPEN + assert request.title == screen.words(IMPORT_TITLE) + assert not notice.is_shown() + assert voices.names() == [] + + screen.scenario( + close_the_project, + import_stands_greyed_out, + with_a_new_project_it_asks_for_a_file, + leaving_asks_nothing, + ).run() + + +class TestAnExportedVoiceComesBack: + """An instrument exported and imported again: the dialog opens where it was written, Cancel keeps + everything as it was, and the import adds one voice playing the same envelopes, with no notice, + which one undo takes away. + + The instrument is exported from its menu. Cancel on the open dialog leaves the voices and + the history as they were. Choosing the exported file adds the voice and one history line. The new + voice shows the envelopes of the original, and one undo removes it. + """ + + def test_the_round_trip(self, screen: Screen) -> None: + """The imported voice joins the list quietly, plays the same envelopes and goes with one undo.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + instruments = screen.reconstructions.instruments + exported: List[Path] = [] + before: List[List[Tuple[HistorySegment, ...]]] = [] + envelopes: List[Tuple[str, str]] = [] + + def export_the_instrument(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + envelopes.append( + ( + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME), + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO), + ) + ) + + exported.append(export_kept(screen)) + + before.append(segments(history.lines())) + + def cancel_changes_nothing(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, None) + + choose_import(screen) + + screen.expect(lambda: screen.dialog_requests()[-1].kind == DialogKind.OPEN, bool, description="asked") + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests()[-1].initial_directory == exported[0].parent + assert voices.names() == VOICES + assert segments(history.lines()) == before[0] + + def the_import_adds_the_voice_quietly(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, exported[0]) + + choose_import(screen) + + screen.expect(voices.names, (VOICES + [KEPT]).__eq__, description="the voice imported") + screen.frames(SETTLING_FRAMES) + assert not voices.imported_notice.is_shown() + assert len(history.lines()) == len(before[0]) + 1 + + def it_plays_the_same_envelopes(screen: Screen) -> None: + open_voice(screen, KEPT) + + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + KEPT.__eq__, + description="the imported voice open", + ) + assert ( + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.VOLUME), + instruments.envelope(INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO), + ) == envelopes[0] + + def one_undo_takes_it_away(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(voices.names, VOICES.__eq__, description="the import undone") + + screen.scenario( + export_the_instrument, + cancel_changes_nothing, + the_import_adds_the_voice_quietly, + it_plays_the_same_envelopes, + one_undo_takes_it_away, + leaving_asks_nothing, + ).run() + + +class TestAFileStatingMore: + """A FamiTracker instrument stating a release point imports, and a notice names the release point. + + The file is chosen on the import dialog. The voice joins the list and the notice names the + instrument and the release point. Undo removes the voice. + """ + + def test_the_notice_names_what_it_left(self, screen: Screen) -> None: + """The imported voice is listed and the notice names the release point that the import set aside.""" + voices = screen.sequencer.voices + notice = voices.imported_notice + + def import_it(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, releasing_instrument()) + + choose_import(screen) + + screen.expect(notice.is_shown, bool, description="the notice") + assert voices.names() == VOICES + [RELEASING_INSTRUMENT] + assert notice.prompt.title() == screen.words(IMPORTED_TITLE) + words = notice.words() + assert screen.words(IMPORTED_TEMPLATE).format(name=RELEASING_INSTRUMENT) in words + assert screen.words(RELEASE_POINT) in words + notice.dismiss() + + def undo_and_leave(screen: Screen) -> None: + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, VOICES.__eq__, description="the import undone") + + screen.scenario(import_it, undo_and_leave, leaving_asks_nothing).run() diff --git a/tests/screens/exports/nsf_window/__init__.py b/tests/screens/exports/nsf_window/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/nsf_window/constants.py b/tests/screens/exports/nsf_window/constants.py new file mode 100644 index 000000000..edfcbb1ad --- /dev/null +++ b/tests/screens/exports/nsf_window/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +LEFT_OUT: Final[ChannelName] = ChannelName.NOISE +SETTLING_FRAMES: Final[int] = 20 diff --git a/tests/screens/exports/nsf_window/steps.py b/tests/screens/exports/nsf_window/steps.py new file mode 100644 index 000000000..1868b71a8 --- /dev/null +++ b/tests/screens/exports/nsf_window/steps.py @@ -0,0 +1,8 @@ +from pathlib import Path + + +def folder(name: str) -> Path: + """Creates and returns a folder of the home for the program, as a native dialog's answer would name it.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/tests/screens/exports/nsf_window/test_reaches_the_file.py b/tests/screens/exports/nsf_window/test_reaches_the_file.py new file mode 100644 index 000000000..b87a52499 --- /dev/null +++ b/tests/screens/exports/nsf_window/test_reaches_the_file.py @@ -0,0 +1,156 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.vocabulary.exports import NSF_EXPORTED, NSF_TYPE +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import ProjectExport +from sampletones_core.project import ProjectContainer +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.application import SAMPLETONES_COPYRIGHT +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.screens.exports.nsf_window.constants import LEFT_OUT, SETTLING_FRAMES +from tests.screens.exports.nsf_window.steps import folder +from tests.suite.screens.worlds.songs import LOOPING_PROJECT + +NSF_TITLE: Final[str] = "global.dialog.title.export_nsf_project" +FROM_THE_START: Final[str] = "settings.nsf.label.repeat_from_start" +FROM_A_FRAME: Final[str] = "settings.nsf.label.repeat_from_frame" +HELD_SOUNDS: Final[str] = "settings.nsf.label.scheme_runs" +FULL_SEARCH: Final[str] = "settings.nsf.label.scheme_search" +TYPED_TITLE: Final[str] = "Rainy Day" +SECOND_FRAME: Final[str] = "01" +LOOP_FRAME: Final[int] = 1 + + +def write_chosen_program(destination: Path) -> None: + """Writes the program the window is set to in the scenario: noise left out, a repeat from the second frame, + held sounds stored once and the typed title, the rest as the project states it. + """ + project = ProjectContainer.load(LOOPING_PROJECT) + program = NSFProgram( + information=NSFInformation( + title=TYPED_TITLE, + artist=project.info.author, + copyright=SAMPLETONES_COPYRIGHT, + ), + channels=ALL_CHANNELS - {LEFT_OUT}, + loop_tick=SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(LOOP_FRAME), + scheme=CompressionScheme.RUNS, + ) + NSFBackend.stated().choosing(program).write_project(destination, ProjectExport(project=project)) + + +def write_stated_program(destination: Path) -> None: + """Writes the program the project states for itself, the one the window opens on.""" + project = ProjectContainer.load(LOOPING_PROJECT) + NSFBackend.stated().choosing(NSFProgram.for_project(project)).write_project( + destination, + ProjectExport(project=project), + ) + + +class TestTheWindowReachesTheFile: + """Each choice made in the NSF window reaches the program it writes; with every channel unticked, Export waits. + + The window opens on the project's own setup. The scenario unticks every channel, ticks all but noise, sets + the repeat, the level and the title, picks the file and presses Export. The file equals the program built from + those choices and differs from the program the project states. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LOOPING_PROJECT) + + def test_the_program_written_is_the_one_set_up(self, screen: Screen) -> None: + window = screen.exports.nsf + into = folder("program") + destination = into / f"{LOOPING_PROJECT.stem}{EXT_FILE_NSF}" + asked: List[int] = [] + + def open_the_window(screen: Screen) -> None: + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert all(window.offers(channel) and window.is_ticked(channel) for channel in ChannelName.items()) + assert window.repeat() == screen.words(FROM_THE_START) + assert window.level() == screen.words(FULL_SEARCH) + assert not window.says_a_channel_is_needed() + asked.append(len(screen.dialog_requests())) + + def with_no_channel_export_waits(screen: Screen) -> None: + for channel in ChannelName.items(): + window.tick(channel) + + screen.expect(window.says_a_channel_is_needed, bool, description="the window asking for a channel") + assert not window.can_export() + window.press_export() + screen.frames(SETTLING_FRAMES) + assert window.is_shown() + assert len(screen.dialog_requests()) == asked[0] + assert list(into.iterdir()) == [] + + def a_channel_ticked_lets_it_go(screen: Screen) -> None: + for channel in ChannelName.items(): + if channel != LEFT_OUT: + window.tick(channel) + + screen.expect(window.can_export, bool, description="Export answering") + assert not window.says_a_channel_is_needed() + assert not window.is_ticked(LEFT_OUT) + + def set_the_repeat_the_level_and_the_title(screen: Screen) -> None: + window.choose_repeat(screen.words(FROM_A_FRAME)) + window.type_loop_frame(SECOND_FRAME) + window.choose_level(screen.words(HELD_SOUNDS)) + window.type_title(TYPED_TITLE) + + screen.expect(window.title, TYPED_TITLE.__eq__, description="the title typed") + assert window.repeat() == screen.words(FROM_A_FRAME) + assert window.loop_frame() == SECOND_FRAME + assert window.level() == screen.words(HELD_SOUNDS) + + def choose_the_file(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + window.browse() + + screen.expect(lambda: len(screen.dialog_requests()), (asked[0] + 1).__eq__, description="the file asked") + request = screen.dialog_requests()[-1] + assert request.title == screen.words(NSF_TITLE) + assert request.suggested_name == destination.name + assert [file_filter.name for file_filter in request.filters] == [screen.words(NSF_TYPE)] + + def the_program_written_is_the_one_set_up(screen: Screen) -> None: + notice = screen.exports.project_notice + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(NSF_EXPORTED) in notice.words() + assert not window.is_shown() + notice.dismiss() + chosen = folder("chosen") / destination.name + stated = folder("stated") / destination.name + write_chosen_program(chosen) + write_stated_program(stated) + assert destination.read_bytes() == chosen.read_bytes() + assert destination.read_bytes() != stated.read_bytes() + + screen.scenario( + open_the_window, + with_no_channel_export_waits, + a_channel_ticked_lets_it_go, + set_the_repeat_the_level_and_the_title, + choose_the_file, + the_program_written_is_the_one_set_up, + ).run() diff --git a/tests/screens/exports/nsf_window/test_silent_channels.py b/tests/screens/exports/nsf_window/test_silent_channels.py new file mode 100644 index 000000000..22c3597b1 --- /dev/null +++ b/tests/screens/exports/nsf_window/test_silent_channels.py @@ -0,0 +1,82 @@ +import operator +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.screens.exports.nsf_window.constants import LEFT_OUT, SETTLING_FRAMES +from tests.screens.exports.nsf_window.steps import folder +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +INSTRUMENTS_EXPORTED: Final[str] = "reconstructions.instruments.message.export_instruments_success" +SILENT_IN_THE_RECONSTRUCTION: Final[ChannelName] = ChannelName.PULSE2 + + +class TestAReconstructionsSilentChannels: + """The NSF window of a reconstruction offers only the channels it sounds; a press on a silent channel's greyed + box leaves it unticked. + + The scenario opens the window, presses the silent channel, unticks and ticks a sounding one, and writes the + program, which starts with the NSF signature. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_silent_channel_stays_out(self, screen: Screen) -> None: + window = screen.exports.nsf + destination = folder("reconstruction") / f"{PLAYABLE_RECONSTRUCTION.stem}{EXT_FILE_NSF}" + sounding = [channel for channel in ChannelName.items() if channel != SILENT_IN_THE_RECONSTRUCTION] + + def open_the_window(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.exports.export_reconstruction(MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF) + + screen.expect(window.is_shown, bool, description="the NSF window") + assert not window.offers(SILENT_IN_THE_RECONSTRUCTION) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + assert all(window.offers(channel) and window.is_ticked(channel) for channel in sounding) + + def a_press_on_the_silent_channel_ticks_nothing(screen: Screen) -> None: + window.press_on(SILENT_IN_THE_RECONSTRUCTION) + + screen.frames(SETTLING_FRAMES) + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def a_press_on_a_sounding_channel_lets_it_go_and_back(screen: Screen) -> None: + window.tick(LEFT_OUT) + screen.expect(lambda: window.is_ticked(LEFT_OUT), operator.not_, description="the noise let go") + + window.tick(LEFT_OUT) + + screen.expect(lambda: window.is_ticked(LEFT_OUT), bool, description="the noise ticked again") + assert not window.is_ticked(SILENT_IN_THE_RECONSTRUCTION) + + def the_program_is_written(screen: Screen) -> None: + notice = screen.exports.file_notice + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file") + + window.export() + + screen.expect(notice.is_shown, bool, description="the program written") + assert screen.words(INSTRUMENTS_EXPORTED) in notice.words() + assert destination.read_bytes().startswith(NSF_MAGIC) + notice.dismiss() + + screen.scenario( + open_the_window, + a_press_on_the_silent_channel_ticks_nothing, + a_press_on_a_sounding_channel_lets_it_go_and_back, + the_program_is_written, + ).run() diff --git a/tests/screens/exports/progress/__init__.py b/tests/screens/exports/progress/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/exports/progress/cases.py b/tests/screens/exports/progress/cases.py new file mode 100644 index 000000000..e47fc9020 --- /dev/null +++ b/tests/screens/exports/progress/cases.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +from automation.boundaries.dialogs import DialogKind +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.screen import Screen +from automation.steps.sequencer import open_voice_menu +from automation.vocabulary.exports import EXPORT_INSTRUMENT +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_core.exports.stage import ExportStage +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, + EXT_FILE_NSF, +) +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, PAD + + +def click_cancel(screen: Screen) -> None: + """Clicks Cancel in the export window.""" + screen.exports.progress.cancel() + + +def press_escape(screen: Screen) -> None: + """Presses Escape to cancel the export.""" + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + +@dataclass(frozen=True) +class ExportDoor: + """One door an export starts from: what it starts, what the window lists first, and how the user cancels it. + + Attributes: + name: The folder the door writes into, named after the door. + start: Starts the export into the folder it is given, answering the dialogs it opens. + first_stage: The stage the run reports first, which the window lists. + cancel: The user's gesture that cancels the run. + project: Whether the export writes a project, whose outcome appears in the project notice. + """ + + name: str + start: Callable[[Screen, Path], None] + first_stage: ExportStage + cancel: Callable[[Screen], None] + project: bool + + +def from_the_file_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + """Builds the start of a project export chosen from the File menu, saving as the arranged project.""" + + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{ARRANGED_PROJECT.stem}{extension}") + screen.exports.export_project(item) + + return start + + +def from_the_reconstruction_menu(item: MenuElements, extension: str) -> Callable[[Screen, Path], None]: + """Builds the start of an export chosen from the Reconstruction menu, saving as the playable reconstruction.""" + + def start(screen: Screen, into: Path) -> None: + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PLAYABLE_RECONSTRUCTION.stem}{extension}") + screen.exports.export_reconstruction(item) + + return start + + +def through_the_nsf_window(menu_start: Callable[[Screen], None], stem: str) -> Callable[[Screen, Path], None]: + """Builds the start of an NSF export: opens the window with ``menu_start``, picks the file named ``stem`` + and presses Export. + """ + + def start(screen: Screen, into: Path) -> None: + window = screen.exports.nsf + menu_start(screen) + screen.expect(window.is_shown, bool, description="the NSF window") + screen.answer_next_dialog(DialogKind.SAVE, into / f"{stem}{EXT_FILE_NSF}") + window.browse() + screen.expect( + lambda: screen.dialog_requests()[-1].answer == into / f"{stem}{EXT_FILE_NSF}", + bool, + description="the file chosen", + ) + window.export() + + return start + + +def from_the_voice_menu(screen: Screen, into: Path) -> None: + """Starts the export of one instrument from its right-click menu, saving as the instrument's name.""" + screen.answer_next_dialog(DialogKind.SAVE, into / f"{PAD}{EXT_FILE_INSTRUMENT}") + open_voice_menu(screen, PAD) + screen.context_menu.choose(screen.words(EXPORT_INSTRUMENT)) + + +PROJECT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="module", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER, EXT_FILE_MODULE), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=True, + ), + ExportDoor( + name="bitphase", + start=from_the_file_menu(MenuElements.ITEM_FILE_EXPORT_BITPHASE, EXT_FILE_BITPHASE), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=True, + ), + ExportDoor( + name="program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF), + ARRANGED_PROJECT.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=True, + ), +) + +INSTRUMENT_DOORS: Final[Tuple[ExportDoor, ...]] = ( + ExportDoor( + name="instruments", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_FAMITRACKER, + EXT_FILE_INSTRUMENT, + ), + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="presets", + start=from_the_reconstruction_menu( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_BITPHASE_PRESET, + EXT_FILE_JSON, + ), + first_stage=ExportStage.WRITING, + cancel=press_escape, + project=False, + ), + ExportDoor( + name="sample program", + start=through_the_nsf_window( + lambda screen: screen.exports.export_reconstruction( + MenuElements.ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS_NSF + ), + PLAYABLE_RECONSTRUCTION.stem, + ), + first_stage=ExportStage.WALKING, + cancel=click_cancel, + project=False, + ), + ExportDoor( + name="instrument", + start=from_the_voice_menu, + first_stage=ExportStage.WRITING, + cancel=click_cancel, + project=False, + ), +) diff --git a/tests/screens/exports/progress/constants.py b/tests/screens/exports/progress/constants.py new file mode 100644 index 000000000..bbd376699 --- /dev/null +++ b/tests/screens/exports/progress/constants.py @@ -0,0 +1,7 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName + +FAILED_EXPORT: Final[str] = "Failed to export to" +MODULE_EXPORTED: Final[str] = "global.dialog.message.project_exported_successfully" +INSTRUMENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 diff --git a/tests/screens/exports/progress/steps.py b/tests/screens/exports/progress/steps.py new file mode 100644 index 000000000..3312e38a6 --- /dev/null +++ b/tests/screens/exports/progress/steps.py @@ -0,0 +1,23 @@ +import operator +from pathlib import Path + +from automation.screen import Screen + + +def folder(name: str) -> Path: + """Creates and returns a folder of the home for one door's files, as a native dialog's answer would name it.""" + path = Path.cwd() / "exports" / name + path.mkdir(parents=True, exist_ok=True) + return path + + +def written_project(screen: Screen, exported_key: str, destination: Path) -> str: + """Waits for the notice of the project export ``exported_key`` words, dismisses it, and returns what it said.""" + notice = screen.exports.project_notice + screen.expect(notice.is_shown, bool, description=f"{destination.name} written") + words = notice.words() + assert screen.words(exported_key) in words + assert destination.is_file() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + return words diff --git a/tests/screens/exports/progress/test_cancel.py b/tests/screens/exports/progress/test_cancel.py new file mode 100644 index 000000000..d96244b0e --- /dev/null +++ b/tests/screens/exports/progress/test_cancel.py @@ -0,0 +1,89 @@ +import operator +from typing import Dict, Final + +import pytest + +from automation.application.startup import Startup +from automation.holds.export import FIRST_REPORT, ExportHold +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.views.notices import Notice +from sampletones_core.exports.stage import ExportStage +from tests.screens.exports.progress.cases import INSTRUMENT_DOORS, PROJECT_DOORS, ExportDoor +from tests.screens.exports.progress.steps import folder +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +STAGE_WORDS: Final[Dict[ExportStage, str]] = { + ExportStage.WALKING: "settings.export.label.stage_walking", + ExportStage.COMPRESSING: "settings.export.label.stage_compressing", + ExportStage.WRITING: "settings.export.label.stage_writing", +} + +SETTLING_FRAMES: Final[int] = 20 + + +def outcome_notice(screen: Screen, door: ExportDoor) -> Notice: + """The notice a door's export ends with: the project notice for a project, the file notice otherwise.""" + return screen.exports.project_notice if door.project else screen.exports.file_notice + + +def canceled_and_then_written(screen: Screen, hold: ExportHold, door: ExportDoor) -> None: + """Starts ``door``'s export under the hold, cancels it, and starts it again unheld, which writes. + + After the cancel the window is gone, the folder is empty and no notice appears. + """ + progress = screen.exports.progress + notice = outcome_notice(screen, door) + into = folder(door.name) + hold.hold_at(FIRST_REPORT) + + door.start(screen, into) + + screen.expect(progress.is_shown, bool, description=f"the {door.name} export under way") + screen.expect(hold.waiting, (1).__eq__, description=f"the {door.name} export held") + assert progress.stages() == (screen.words(STAGE_WORDS[door.first_stage]),) + + door.cancel(screen) + + screen.expect(progress.is_shown, operator.not_, description=f"the {door.name} export canceled") + screen.frames(SETTLING_FRAMES) + assert hold.waiting() == 0 + assert not notice.is_shown() + assert list(into.iterdir()) == [] + + hold.release() + door.start(screen, into) + + screen.expect(notice.is_shown, bool, description=f"the {door.name} export written") + assert not progress.is_shown() + assert list(into.iterdir()) != [] + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + +class TestEveryExportAnswersCancel: + """Every export shows its window while it runs, and a cancel stops it with the folder left empty; run to the end, it writes. + + The user cancels by clicking Cancel on some doors and pressing Escape on others. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + def test_the_project_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_project_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in PROJECT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_project_export).run() + + def test_the_instrument_exports(self, screen: Screen, export_hold: ExportHold) -> None: + def each_instrument_export(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for door in INSTRUMENT_DOORS: + canceled_and_then_written(screen, export_hold, door) + + screen.scenario(each_instrument_export).run() diff --git a/tests/screens/exports/progress/test_failure_under_window.py b/tests/screens/exports/progress/test_failure_under_window.py new file mode 100644 index 000000000..c4662f378 --- /dev/null +++ b/tests/screens/exports/progress/test_failure_under_window.py @@ -0,0 +1,69 @@ +import operator +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.holds.export import ExportHold +from automation.screen import Screen +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_shared.paths.extensions import EXT_FILE_MODULE +from tests.screens.exports.progress.constants import FAILED_EXPORT, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +MODULE_FAILED: Final[str] = "global.dialog.message.project_export_failed" +READ_ONLY: Final[int] = 0o555 +WRITABLE: Final[int] = 0o755 + + +class TestAnExportFailingUnderItsWindow: + """An export failing while its window stands closes the window and shows one error saying what failed, with the + folder left empty; once the folder takes files again, the same export writes. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + def test_the_error_follows_the_window(self, screen: Screen, export_hold: ExportHold) -> None: + progress = screen.exports.progress + into = folder("locked") + destination = into / f"{ARRANGED_PROJECT.stem}{EXT_FILE_MODULE}" + + def start_the_module_export(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + screen.expect(progress.is_shown, bool, description="the export under way") + screen.expect(export_hold.waiting, (1).__eq__, description="the export held") + + def the_folder_refuses_the_file(screen: Screen) -> None: + into.chmod(READ_ONLY) + try: + export_hold.release() + + screen.expect(screen.error_notice.is_shown, bool, description="the export failed") + finally: + into.chmod(WRITABLE) + + screen.claim_error(FAILED_EXPORT) + assert not progress.is_shown() + standing = screen.error_notice.prompt.window() + assert standing is not None + assert [window.alias for window in screen.shown_windows()] == [standing.alias] + assert screen.words(MODULE_FAILED) in screen.error_notice.words() + assert list(into.iterdir()) == [] + screen.error_notice.dismiss() + screen.expect(screen.error_notice.is_shown, operator.not_, description="the error gone") + + def the_same_export_then_writes(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(start_the_module_export, the_folder_refuses_the_file, the_same_export_then_writes).run() diff --git a/tests/screens/exports/progress/test_refusals.py b/tests/screens/exports/progress/test_refusals.py new file mode 100644 index 000000000..b7f786b4b --- /dev/null +++ b/tests/screens/exports/progress/test_refusals.py @@ -0,0 +1,129 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.sequencer import open_voice_menu +from automation.vocabulary.exports import EXPORT_INSTRUMENT, NSF_EXPORTED +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_player.specification.nsf import NSF_MAGIC +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_MODULE, EXT_FILE_NSF +from tests.screens.exports.progress.constants import FAILED_EXPORT, INSTRUMENT_CHANNEL, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.screens.worlds.songs import LINE, OVERLONG_PROJECT, TWO_TUNINGS_PROJECT + +NSF_FAILED: Final[str] = "global.dialog.message.nsf_project_export_failed" +BITPHASE_FAILED: Final[str] = "global.dialog.message.bitphase_project_export_failed" +INSTRUMENT_FAILED: Final[str] = "reconstructions.instruments.message.export_instrument_failed" +STORED_TICK_BY_TICK: Final[str] = "settings.nsf.label.scheme_none" +EVERY_REPEAT_ONCE: Final[str] = "settings.nsf.label.scheme_search" + + +def refused(screen: Screen, failed_key: str, destination: Path) -> None: + """Waits for the error naming the export ``failed_key`` words, and checks that ``destination`` is still absent.""" + notice = screen.error_notice + screen.expect(notice.is_shown, bool, description="the export refused") + screen.claim_error(FAILED_EXPORT) + assert screen.words(failed_key) in notice.words() + assert not destination.exists() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the error gone") + + +def export_the_program(screen: Screen, level_key: str, destination: Path) -> None: + """Opens File > Export > NSF program..., picks the level ``level_key`` words and the file, and presses Export.""" + window = screen.exports.nsf + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_NSF) + screen.expect(window.is_shown, bool, description="the NSF window") + window.choose_level(screen.words(level_key)) + screen.answer_next_dialog(DialogKind.SAVE, destination) + window.browse() + screen.expect(lambda: screen.dialog_requests()[-1].answer == destination, bool, description="the file chosen") + assert window.level() == screen.words(level_key) + + window.export() + + +class TestASongTooLargeForTheProgram: + """A song stored tick by tick that outgrows the room of an NSF program is refused with a message and the file + stays absent; stored with each repeat saved once, it is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=OVERLONG_PROJECT) + + def test_it_is_refused_and_then_fits(self, screen: Screen) -> None: + destination = folder("overlong") / f"{OVERLONG_PROJECT.stem}{EXT_FILE_NSF}" + + def stored_tick_by_tick_it_is_refused(screen: Screen) -> None: + export_the_program(screen, STORED_TICK_BY_TICK, destination) + + refused(screen, NSF_FAILED, destination) + + def saving_each_repeat_once_it_is_written(screen: Screen) -> None: + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + written_project(screen, NSF_EXPORTED, destination) + assert destination.read_bytes().startswith(NSF_MAGIC) + + screen.scenario(stored_tick_by_tick_it_is_refused, saving_each_repeat_once_it_is_written).run() + + +class TestTwoTunings: + """A project whose samples were converted at two tunings stops a song that sounds one tuning with a message and + writes no file; a module, which keeps each sample's own pitches, is written. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=TWO_TUNINGS_PROJECT) + + def test_a_song_sounding_one_tuning_stops(self, screen: Screen) -> None: + into = folder("tunings") + + def a_bitphase_project_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + refused(screen, BITPHASE_FAILED, destination) + + def a_program_stops(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_NSF}" + + export_the_program(screen, EVERY_REPEAT_ONCE, destination) + + refused(screen, NSF_FAILED, destination) + + def a_module_is_written(screen: Screen) -> None: + destination = into / f"{TWO_TUNINGS_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + written_project(screen, MODULE_EXPORTED, destination) + + screen.scenario(a_bitphase_project_stops, a_program_stops, a_module_is_written).run() + + def test_an_instrument_export_stops_with_a_message(self, screen: Screen) -> None: + """Export instrument... on a sample says the instrument failed and asks for no file.""" + notice = screen.error_notice + asked = len(screen.dialog_requests()) + open_voice_menu(screen, LINE) + + screen.context_menu.choose_in( + screen.words(EXPORT_INSTRUMENT), + screen.channel_words(INSTRUMENT_CHANNEL), + ) + + screen.expect(notice.is_shown, bool, description="the export refused with a message") + assert screen.words(INSTRUMENT_FAILED) in notice.words() + assert len(screen.dialog_requests()) == asked + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the error gone") diff --git a/tests/screens/exports/progress/test_shortened_envelopes.py b/tests/screens/exports/progress/test_shortened_envelopes.py new file mode 100644 index 000000000..fdf0db091 --- /dev/null +++ b/tests/screens/exports/progress/test_shortened_envelopes.py @@ -0,0 +1,135 @@ +from typing import Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.dearpygui.geometry import Point +from automation.screen import Screen +from automation.steps.sequencer import open_voice +from automation.vocabulary.instruments import TOO_LONG +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_MODULE +from tests.screens.exports.progress.constants import INSTRUMENT_CHANNEL, MODULE_EXPORTED +from tests.screens.exports.progress.steps import folder, written_project +from tests.suite.bitphase import parse_btp +from tests.suite.famitracker import parse_ftm +from tests.suite.screens.worlds.songs import ( + LONG_ENVELOPES_PROJECT, + LONG_ITEMS, + LONG_VOICE, + MIDDLING_ITEMS, + MIDDLING_VOICE, +) + +BITPHASE_EXPORTED: Final[str] = "global.dialog.message.bitphase_project_exported_successfully" +SHORTENED_PROJECT: Final[str] = "global.dialog.template.export_truncated" +KEPT_BY_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +KEPT_BY_BITPHASE: Final[str] = "reconstructions.instruments.template.kept_bitphase" +KEPT_BY_PRESET: Final[str] = "reconstructions.instruments.template.kept_bitphase_preset" +KEPT_SEPARATOR: Final[str] = "reconstructions.instruments.template.kept_separator" +ASIDE: Final[Point] = Point(x=0, y=0) + + +def written_for(names: List[str], voices: Tuple[str, ...]) -> int: + """Counts the instruments listed under ``names`` that were written for one of ``voices``.""" + return sum(1 for name in names if any(name == voice or name.startswith(f"{voice} (") for voice in voices)) + + +class TestShortenedEnvelopes: + """An envelope longer than a format stores shows, in its field's status, each export that cuts it and what each + keeps; a project export says how many of its instruments it shortened. + + The long voice runs past every format's limit, and the middling one past FamiTracker's alone. A format writes a + voice as one instrument or several, so the count in the notice is read against the instruments the file lists. + """ + + @pytest.fixture + def startup(self) -> Startup: + return Startup(reconstruction=None, project=LONG_ENVELOPES_PROJECT) + + def test_the_status_and_the_notices_name_the_cuts(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + into = folder("shortened") + + def status_of(voice: str) -> str: + open_voice(screen, voice) + screen.expect( + lambda: instruments.tab_label(INSTRUMENT_CHANNEL), + voice.__eq__, + description=f"{voice} open", + ) + screen.hand.move_to(ASIDE) + instruments.hover_field(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) + return screen.expect(screen.status, bool, description=f"the status of {voice}'s volume") + + def too_long(items: int, *kept: str) -> str: + return screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=items, + kept=screen.words(KEPT_SEPARATOR).join(kept), + ) + + def the_long_voice_names_every_format(screen: Screen) -> None: + status = status_of(LONG_VOICE) + + assert status == too_long( + LONG_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + screen.words(KEPT_BY_BITPHASE).format(limit=MAX_MACRO_LENGTH), + screen.words(KEPT_BY_PRESET).format(limit=MAX_MACRO_LENGTH), + ) + assert instruments.field_theme(INSTRUMENT_CHANNEL, FeatureKey.VOLUME) == TAG_GLOBAL_THEME_INPUT_WARNING + + def the_middling_voice_names_famitracker_alone(screen: Screen) -> None: + status = status_of(MIDDLING_VOICE) + + assert status == too_long( + MIDDLING_ITEMS, + screen.words(KEPT_BY_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + + def a_bitphase_project_shortens_one(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_BITPHASE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_BITPHASE) + + words = written_project(screen, BITPHASE_EXPORTED, destination) + names = [instrument.name for instrument in parse_btp(destination.read_bytes(), []).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE,)), + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ITEMS, + ) + in words + ) + + def a_module_shortens_both(screen: Screen) -> None: + destination = into / f"{LONG_ENVELOPES_PROJECT.stem}{EXT_FILE_MODULE}" + screen.answer_next_dialog(DialogKind.SAVE, destination) + + screen.exports.export_project(MenuElements.ITEM_FILE_EXPORT_FAMITRACKER) + + words = written_project(screen, MODULE_EXPORTED, destination) + names = [instrument.name for instrument in parse_ftm(destination.read_bytes()).instruments] + assert ( + screen.words(SHORTENED_PROJECT).format( + instruments=written_for(names, (LONG_VOICE, MIDDLING_VOICE)), + frames=MAX_SEQUENCE_ITEMS, + source_frames=LONG_ITEMS, + ) + in words + ) + + screen.scenario( + the_long_voice_names_every_format, + the_middling_voice_names_famitracker_alone, + a_bitphase_project_shortens_one, + a_module_shortens_both, + ).run() diff --git a/tests/screens/history/__init__.py b/tests/screens/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/history/conftest.py b/tests/screens/history/conftest.py new file mode 100644 index 000000000..f3ea9a396 --- /dev/null +++ b/tests/screens/history/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from automation.application.startup import Startup +from automation.worlds.home import World +from tests.suite.screens.worlds.history import HISTORY_PROJECT, history_world + + +@pytest.fixture +def world() -> World: + """The home every history scenario starts in: the project with something in every part a gesture reaches.""" + return history_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on that project with no reconstruction loaded.""" + return Startup(reconstruction=None, project=HISTORY_PROJECT) diff --git a/tests/screens/history/constants.py b/tests/screens/history/constants.py new file mode 100644 index 000000000..e5a3304e8 --- /dev/null +++ b/tests/screens/history/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +LEAD: Final[str] = "Lead" +PAD: Final[str] = "Pad" +EVERY_STEP: Final[int] = 20 diff --git a/tests/screens/history/reconstructions/__init__.py b/tests/screens/history/reconstructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/history/reconstructions/test_held_and_removed.py b/tests/screens/history/reconstructions/test_held_and_removed.py new file mode 100644 index 000000000..04bed4a58 --- /dev/null +++ b/tests/screens/history/reconstructions/test_held_and_removed.py @@ -0,0 +1,177 @@ +import operator +from typing import Dict, Final, List + +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, open_voice +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.history.constants import LEAD +from tests.screens.history.steps import ( + expect_lines, + expect_reading, + history_count, + leading, + press_on_the_sequencer, + typed_values, + volume_fields, +) + +TYPED: Final[Dict[ChannelName, str]] = {ChannelName.PULSE1: "4 2"} +CHANNELS: Final = tuple(TYPED) +TAKEN_OUT: Final[str] = "1" +KEPT: Final[str] = "0" +RETUNED_RATE: Final[int] = 50 + + +def open_lead(screen: Screen) -> None: + open_voice(screen, LEAD) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="Lead open") + + +class TestAnUndoWaitingForARebuild: + """Undo pressed while a typed edit is still being rebuilt waits for it, undoes it, and Redo brings it back. + + Lead opens with its rebuilds held, a volume is typed, and Undo is pressed while the rebuild waits: the + History card stays as it was. Once the rebuild goes, the edit lands and the undo takes it back, so the + field reads as it stood under a line in force at the start; Redo brings the typed values back. + """ + + def test_the_undo_comes_after_the_edit( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + readings: List[Dict[ChannelName, str]] = [] + lines: List[int] = [] + + def type_while_held(screen: Screen) -> None: + open_lead(screen) + readings.append(volume_fields(screen, CHANNELS)) + lines.append(history_count(screen)) + + screen.reconstructions.instruments.type_envelope( + ChannelName.PULSE1, FeatureKey.VOLUME, TYPED[ChannelName.PULSE1] + ) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def undo_while_held(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + + assert history_count(screen) == lines[0] + + def the_edit_lands_and_goes(screen: Screen) -> None: + regeneration_hold.release() + + expect_lines(screen, lines[0] + 1) + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), readings[0], "the volume as it stood") + assert screen.sequencer.history.lines()[-1].current + + def redo_brings_it_back(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.REDO) + + expect_reading( + screen, + lambda: leading(volume_fields(screen, CHANNELS), TYPED), + typed_values(TYPED), + "the volume as typed", + ) + + screen.scenario( + type_while_held, + undo_while_held, + the_edit_lands_and_goes, + redo_brings_it_back, + leave_letting_the_project_go, + ).run() + + +class TestARecordingTakenOut: + """A recording taken out of a sample from the stems card comes back with Undo and leaves again with Redo. + + Lead opens and its second recording is removed at the question. Undo on the Sequencer lists it again + beside the first, and Redo takes it out again while the first stays. + """ + + def test_undo_and_redo_reach_the_stems_card(self, screen: Screen) -> None: + stems = screen.reconstructions.stems + lines: List[int] = [] + + def take_the_second_recording_out(screen: Screen) -> None: + open_lead(screen) + screen.expect(lambda: stems.has_row(TAKEN_OUT), bool, description="the second recording listed") + lines.append(history_count(screen)) + + stems.remove(TAKEN_OUT) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + stems.remove_prompt.confirm() + + screen.expect(lambda: stems.has_row(TAKEN_OUT), operator.not_, description="the second recording gone") + expect_lines(screen, lines[0] + 1) + + def undo_lists_it_again(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + + screen.expect(lambda: stems.has_row(TAKEN_OUT), bool, description="the second recording back") + assert stems.has_row(KEPT) + + def redo_takes_it_out_again(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.REDO) + + screen.expect( + lambda: stems.has_row(TAKEN_OUT), operator.not_, description="the second recording gone again" + ) + assert stems.has_row(KEPT) + + screen.scenario( + take_the_second_recording_out, + undo_lists_it_again, + redo_takes_it_out_again, + leave_letting_the_project_go, + ).run() + + +class TestARateChangeWithASampleOpen: + """A rate change confirmed while a sample stands open is one entry, which Undo and Redo move as a whole. + + Lead opens, and the Sequencer's NES frequency is retyped and confirmed at the question: one line joins + the card. Undo brings the field back to the project's rate and Redo to the new one. + """ + + def test_one_entry_moves_the_rate(self, screen: Screen) -> None: + module = screen.sequencer.module + rates: List[int] = [] + lines: List[int] = [] + + def retune_with_lead_open(screen: Screen) -> None: + open_lead(screen) + screen.tabs.bring_to_front(Tab.SEQUENCER) + rates.append(module.nes_frequency()) + lines.append(history_count(screen)) + + module.retype_nes_frequency(RETUNED_RATE) + screen.expect(module.retune_prompt.is_shown, bool, description="the question about retuning") + module.retune_prompt.confirm() + + screen.expect(module.nes_frequency, RETUNED_RATE.__eq__, description="the new rate") + expect_lines(screen, lines[0] + 1) + + def undo_restores_the_rate(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + + screen.expect(module.nes_frequency, rates[0].__eq__, description="the rate as it stood") + assert history_count(screen) == lines[0] + 1 + + def redo_retunes_again(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.REDO) + + screen.expect(module.nes_frequency, RETUNED_RATE.__eq__, description="the new rate again") + + screen.scenario( + retune_with_lead_open, + undo_restores_the_rate, + redo_retunes_again, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/history/reconstructions/test_tables.py b/tests/screens/history/reconstructions/test_tables.py new file mode 100644 index 000000000..43b4308e3 --- /dev/null +++ b/tests/screens/history/reconstructions/test_tables.py @@ -0,0 +1,86 @@ +from typing import Dict, Final, List, Tuple + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, open_voice +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.history.constants import LEAD +from tests.screens.history.steps import ( + Fields, + expect_lines, + expect_reading, + history_count, + leading, + press_on_the_sequencer, + typed_values, + volume_fields, +) + +TYPED: Final[Dict[ChannelName, str]] = {ChannelName.PULSE1: "9 7", ChannelName.PULSE2: "6"} +CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(TYPED) +NEWEST_LINE: Final[int] = 0 + + +class TestVolumesTypedIntoTwoChannelsOfOneSample: + """Volumes typed into two channels of one sample make one history entry, which every way back and forth reaches. + + Lead opens from its row's menu, and a volume sequence is typed into Pulse 1 and then into Pulse 2: the + History card gains one line. Undo on the Sequencer brings both fields back to what they read, and Redo + brings both typed sequences back. Clicking the oldest line and then the newest does the same. + """ + + def test_one_entry_reached_every_way(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + history = screen.sequencer.history + readings: List[Fields] = [] + lines: List[int] = [] + + def type_into_both_channels(screen: Screen) -> None: + open_voice(screen, LEAD) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="Lead open") + readings.append(volume_fields(screen, CHANNELS)) + lines.append(history_count(screen)) + + for channel, sequence in TYPED.items(): + instruments.type_envelope(channel, FeatureKey.VOLUME, sequence) + screen.expect( + lambda: instruments.envelope(channel, FeatureKey.VOLUME), + lambda text: text != readings[0][channel], + description=f"{channel} typed", + ) + + expect_lines(screen, lines[0] + 1) + + def undo_takes_back_both(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), readings[0], "both channels as they stood") + assert history_count(screen) == lines[0] + 1 + + def redo_brings_back_both(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.REDO) + + expect_reading( + screen, + lambda: leading(volume_fields(screen, CHANNELS), TYPED), + typed_values(TYPED), + "both channels as typed", + ) + readings.append(volume_fields(screen, CHANNELS)) + + def the_oldest_and_the_newest_line(screen: Screen) -> None: + history.jump_to(history_count(screen) - 1) + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), readings[0], "the oldest line's state") + + history.jump_to(NEWEST_LINE) + + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), readings[1], "the newest line's state") + + screen.scenario( + type_into_both_channels, + undo_takes_back_both, + redo_brings_back_both, + the_oldest_and_the_newest_line, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/history/sequencer/__init__.py b/tests/screens/history/sequencer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/history/sequencer/test_lines.py b/tests/screens/history/sequencer/test_lines.py new file mode 100644 index 000000000..5e0bbff1b --- /dev/null +++ b/tests/screens/history/sequencer/test_lines.py @@ -0,0 +1,55 @@ +from typing import Final, List + +import pytest + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go +from sampletones_application.categories.hierarchy import Tab +from tests.screens.history.steps import expect_lines, history_count + +NEWEST_LINE: Final[int] = 0 +TEMPO: Final[int] = 120 +STILL_FRAMES: Final[int] = 20 +LOST_HIGHLIGHT: Final[str] = "A click on the History card's line in force takes its highlight off" + + +class TestAClickOnTheLineInForce: + """A click on the line the project stands at keeps the project and the card as they were. + + The tempo is retyped, which adds a line in force. A click on that line leaves the tempo as typed and the + line marked in force; a click on the oldest line then takes the tempo back, which witnesses the clicks. + """ + + @pytest.mark.xfail(strict=True, reason=LOST_HIGHLIGHT) + def test_the_line_stays_in_force(self, screen: Screen) -> None: + history = screen.sequencer.history + module = screen.sequencer.module + tempos: List[int] = [] + + def retype_the_tempo(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + tempos.append(module.tempo()) + lines = history_count(screen) + + module.retype_tempo(TEMPO) + + expect_lines(screen, lines + 1) + + def click_the_line_in_force(screen: Screen) -> None: + history.jump_to(NEWEST_LINE) + screen.frames(STILL_FRAMES) + + assert module.tempo() == TEMPO + assert history.lines()[NEWEST_LINE].current + + def click_the_oldest_line(screen: Screen) -> None: + history.jump_to(history_count(screen) - 1) + + screen.expect(module.tempo, tempos[0].__eq__, description="the tempo as it stood") + + screen.scenario( + retype_the_tempo, + click_the_line_in_force, + click_the_oldest_line, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/history/sequencer/test_parts.py b/tests/screens/history/sequencer/test_parts.py new file mode 100644 index 000000000..09b0a1a85 --- /dev/null +++ b/tests/screens/history/sequencer/test_parts.py @@ -0,0 +1,244 @@ +import operator +from typing import Callable, Final, List, Tuple, TypeVar + +from automation.screen import Screen +from automation.steps.project import retitle_project +from automation.steps.sequencer import leave_letting_the_project_go +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.history.steps import expect_lines, history_count, press_on_the_sequencer + +ReadingT = TypeVar("ReadingT") + +GRID_ROWS: Final[range] = range(8) +GRID_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +TYPED_VOLUME: Final[str] = "5" +ORDER_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +ORDER_POSITION: Final[int] = 1 +TEMPO: Final[int] = 120 +SPEED: Final[int] = 3 +ROWS: Final[int] = 12 +RETITLED: Final[str] = "Retitled history" +STILL_FRAMES: Final[int] = 20 + + +def walk_back_and_forth( + screen: Screen, + reading: Callable[[], ReadingT], + states: List[ReadingT], +) -> None: + """Undoes every gesture one by one, redoes them all, then jumps to the oldest and the newest line, + reading ``states[index]`` after each step that leaves the project at entry ``index``. + """ + history = screen.sequencer.history + newest = len(states) - 1 + for index in range(newest - 1, -1, -1): + press_on_the_sequencer(screen, ShortcutId.UNDO) + screen.expect(reading, states[index].__eq__, description=f"the state of entry {index}") + + for index in range(1, newest + 1): + press_on_the_sequencer(screen, ShortcutId.REDO) + screen.expect(reading, states[index].__eq__, description=f"the state of entry {index} again") + + history.jump_to(history_count(screen) - 1) + screen.expect(reading, states[0].__eq__, description="the oldest line's state") + history.jump_to(0) + screen.expect(reading, states[newest].__eq__, description="the newest line's state") + + +def gesture_adding_a_line( + screen: Screen, + gesture: Callable[[], None], +) -> None: + """Runs ``gesture`` and waits for the one line it adds to the History card.""" + lines = history_count(screen) + gesture() + expect_lines(screen, lines + 1) + + +class TestTheTrackerCells: + """Each gesture on the tracker's cells is one entry, and every way back and forth shows each grid again. + + A volume is typed, a row cleared, a pitch transposed, a volume lowered, and a cell cut and pasted + elsewhere, each adding one line; then every step is undone, redone and jumped to. + """ + + def test_every_grid_comes_back(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + grids: List[Tuple[Tuple[str, ...], ...]] = [] + + def grid() -> Tuple[Tuple[str, ...], ...]: + return tuple(tracker.labels(row, channel) for row in GRID_ROWS for channel in GRID_CHANNELS) + + def at( + row: int, + channel: ChannelName, + subcolumn: SubColumn, + then: Callable[[], None], + ) -> Callable[[], None]: + def gesture() -> None: + tracker.click(row, channel, subcolumn) + then() + + return gesture + + def edit_the_cells(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + grids.append(grid()) + for gesture in ( + at(1, ChannelName.PULSE1, SubColumn.VOLUME, lambda: screen.hand.type_text(TYPED_VOLUME)), + at(0, ChannelName.PULSE2, SubColumn.VOICE, lambda: screen.press_shortcut(ShortcutId.TRACKER_CLEAR_ROW)), + at( + 2, + ChannelName.PULSE1, + SubColumn.TRANSPOSE, + lambda: screen.press_shortcut(ShortcutId.TRACKER_TRANSPOSE_UP), + ), + at( + 6, + ChannelName.PULSE1, + SubColumn.VOLUME, + lambda: screen.press_shortcut(ShortcutId.TRACKER_VOLUME_DOWN), + ), + at(6, ChannelName.PULSE1, SubColumn.VOICE, lambda: screen.press_shortcut(ShortcutId.TRACKER_CUT_BLOCK)), + at( + 3, + ChannelName.PULSE2, + SubColumn.VOICE, + lambda: screen.press_shortcut(ShortcutId.TRACKER_PASTE_BLOCK), + ), + ): + gesture_adding_a_line(screen, gesture) + grids.append(screen.expect(grid, grids[-1].__ne__, description="the grid changed")) + + def walk(screen: Screen) -> None: + walk_back_and_forth(screen, grid, grids) + + screen.scenario(edit_the_cells, walk, leave_letting_the_project_go).run() + + +class TestTheOrderFrames: + """Each gesture on the song's frames is one entry, and every way back and forth shows each order again. + + A frame is added, duplicated, cloned and cleared from the order table's keys, each adding one line; + then every step is undone, redone and jumped to. + """ + + def test_every_order_comes_back(self, screen: Screen) -> None: + order = screen.sequencer.order + orders: List[Tuple[Tuple[str, ...], ...]] = [] + + def table() -> Tuple[Tuple[str, ...], ...]: + positions = order.positions() + return tuple( + tuple(order.label(channel, position) for position in range(positions)) for channel in CHANNEL_AXIS + ) + + def pressing(shortcut_id: ShortcutId) -> Callable[[], None]: + def gesture() -> None: + order.click(ORDER_CHANNEL, ORDER_POSITION) + screen.press_shortcut(shortcut_id) + + return gesture + + def edit_the_frames(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + orders.append(table()) + for shortcut_id in ( + ShortcutId.ORDER_ADD_FRAME, + ShortcutId.ORDER_DUPLICATE_FRAME, + ShortcutId.ORDER_CLONE_FRAME, + ShortcutId.ORDER_CLEAR_FRAME, + ): + gesture_adding_a_line(screen, pressing(shortcut_id)) + orders.append(screen.expect(table, orders[-1].__ne__, description="the order changed")) + + def walk(screen: Screen) -> None: + walk_back_and_forth(screen, table, orders) + + screen.scenario(edit_the_frames, walk, leave_letting_the_project_go).run() + + +class TestTheModuleValues: + """Tempo, speed and rows per pattern are one entry each, which every way back and forth shows again.""" + + def test_every_value_comes_back(self, screen: Screen) -> None: + module = screen.sequencer.module + values: List[Tuple[int, int, int]] = [] + + def read() -> Tuple[int, int, int]: + return module.tempo(), module.speed(), module.rows() + + def retype_each(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + values.append(read()) + for gesture in ( + lambda: module.retype_tempo(TEMPO), + lambda: module.retype_speed(SPEED), + lambda: module.retype_rows(ROWS), + ): + gesture_adding_a_line(screen, gesture) + values.append(screen.expect(read, values[-1].__ne__, description="the value changed")) + + def walk(screen: Screen) -> None: + walk_back_and_forth(screen, read, values) + + screen.scenario(retype_each, walk, leave_letting_the_project_go).run() + + +class TestTheProjectProperties: + """A title confirmed in Project properties is one entry, Undo brings the old title back, and confirming + the dialog unchanged adds nothing. + + The project is retitled, which adds a line. After an undo the dialog shows the old title, and confirming + it as it stands leaves the card as it was; retitling again then puts a line in force over the one undone, + which witnesses the dialog. + """ + + def test_the_title_comes_back(self, screen: Screen) -> None: + properties = screen.project.properties + titles: List[str] = [] + + def read_the_title(screen: Screen) -> str: + properties.open() + screen.expect(properties.is_shown, bool, description="the Project properties dialog") + return properties.title() + + def retitle(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + titles.append(read_the_title(screen)) + properties.confirm() + screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + + gesture_adding_a_line(screen, lambda: retitle_project(screen, RETITLED)) + + def undo_brings_the_title_back(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + screen.expect(lambda: screen.sequencer.history.lines()[-1].current, bool, description="one step back") + + assert read_the_title(screen) == titles[0] + + def confirming_it_unchanged_adds_nothing(screen: Screen) -> None: + lines = history_count(screen) + + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="the dialog closed") + screen.frames(STILL_FRAMES) + assert history_count(screen) == lines + assert not screen.sequencer.history.lines()[0].current + + retitle_project(screen, RETITLED) + + screen.expect(lambda: screen.sequencer.history.lines()[0].current, bool, description="the retitle in force") + assert history_count(screen) == lines + + screen.scenario( + retitle, + undo_brings_the_title_back, + confirming_it_unchanged_adds_nothing, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/history/sessions/__init__.py b/tests/screens/history/sessions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/history/sessions/test_across_tabs.py b/tests/screens/history/sessions/test_across_tabs.py new file mode 100644 index 000000000..f26d2e027 --- /dev/null +++ b/tests/screens/history/sessions/test_across_tabs.py @@ -0,0 +1,115 @@ +from typing import Dict, Final, List + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, open_voice +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.history.constants import LEAD, PAD +from tests.screens.history.steps import ( + expect_lines, + expect_reading, + history_count, + leading, + press_on_the_sequencer, + typed_values, + volume_fields, +) + +LEAD_TYPED: Final[Dict[ChannelName, str]] = {ChannelName.PULSE1: "8 3"} +PAD_TYPED: Final[Dict[ChannelName, str]] = {ChannelName.PULSE1: "10 5 1"} +CHANNELS: Final = (ChannelName.PULSE1,) +TRACKER_ROW: Final[int] = 1 +TRACKER_VOLUME: Final[str] = "7" + + +class TestEditsAcrossBothTabs: + """A sample's edit, a tracker cell and an instrument's edit undo in turn from the Sequencer and come back. + + Lead's volume is typed, then a tracker volume, then Pad's volume, each adding one line. Undo from the + Sequencer takes Pad's edit back while Pad stands open, then the tracker cell, then Lead's edit, which + reopening Lead shows. Three redos bring all three back, read in the tracker and in each reopened voice. + """ + + def test_each_edit_comes_back_where_it_was_made(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + instruments = screen.reconstructions.instruments + standing: Dict[str, object] = {} + lines: List[int] = [] + + def open_on_the_tab( + screen: Screen, + name: str, + ) -> None: + open_voice(screen, name) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description=f"{name} open") + + def type_into( + screen: Screen, + typed: Dict[ChannelName, str], + ) -> None: + for channel, sequence in typed.items(): + instruments.type_envelope(channel, FeatureKey.VOLUME, sequence) + + expect_reading( + screen, + lambda: leading(volume_fields(screen, CHANNELS), typed), + typed_values(typed), + "the volume as typed", + ) + + def tracker_volume() -> str: + return tracker.label(TRACKER_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + + def edit_on_both_tabs(screen: Screen) -> None: + lines.append(history_count(screen)) + open_on_the_tab(screen, LEAD) + standing[LEAD] = volume_fields(screen, CHANNELS) + type_into(screen, LEAD_TYPED) + expect_lines(screen, lines[0] + 1) + + screen.tabs.bring_to_front(Tab.SEQUENCER) + standing["tracker"] = tracker_volume() + tracker.click(TRACKER_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + screen.hand.type_text(TRACKER_VOLUME) + expect_lines(screen, lines[0] + 2) + + open_on_the_tab(screen, PAD) + standing[PAD] = volume_fields(screen, CHANNELS) + type_into(screen, PAD_TYPED) + expect_lines(screen, lines[0] + 3) + + def undo_in_turn(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), standing[PAD], "Pad as it stood") + + press_on_the_sequencer(screen, ShortcutId.UNDO) + expect_reading(screen, tracker_volume, standing["tracker"], "the tracker cell as it stood") + + press_on_the_sequencer(screen, ShortcutId.UNDO) + open_on_the_tab(screen, LEAD) + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), standing[LEAD], "Lead as it stood") + + def redo_in_turn(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.REDO) + expect_reading( + screen, + lambda: leading(volume_fields(screen, CHANNELS), LEAD_TYPED), + typed_values(LEAD_TYPED), + "Lead as typed", + ) + + press_on_the_sequencer(screen, ShortcutId.REDO) + expect_reading(screen, tracker_volume, TRACKER_VOLUME, "the tracker cell as typed") + + press_on_the_sequencer(screen, ShortcutId.REDO) + open_on_the_tab(screen, PAD) + expect_reading( + screen, + lambda: leading(volume_fields(screen, CHANNELS), PAD_TYPED), + typed_values(PAD_TYPED), + "Pad as typed", + ) + + screen.scenario(edit_on_both_tabs, undo_in_turn, redo_in_turn, leave_letting_the_project_go).run() diff --git a/tests/screens/history/sessions/test_attacks.py b/tests/screens/history/sessions/test_attacks.py new file mode 100644 index 000000000..96e69d2cc --- /dev/null +++ b/tests/screens/history/sessions/test_attacks.py @@ -0,0 +1,220 @@ +import operator +from typing import Final, List + +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, open_voice +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.history.constants import EVERY_STEP, LEAD +from tests.screens.history.steps import ( + expect_lines, + expect_reading, + history_count, + press_on_the_sequencer, + volume_fields, +) + +TEMPO: Final[int] = 111 +LONG_PATTERNS: Final[int] = 64 +TRACKER_ROW: Final[int] = 5 +TRACKER_VOLUME: Final[str] = "3" +SECOND_VOLUME: Final[str] = "4" +LEAD_VOLUME: Final[str] = "2 1" +CHANNELS: Final = (ChannelName.PULSE1,) + + +def tracker_volume(screen: Screen) -> str: + return screen.sequencer.tracker.label(TRACKER_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + + +def type_a_tracker_volume( + screen: Screen, + volume: str, +) -> None: + lines = history_count(screen) + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.tracker.click(TRACKER_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + screen.hand.type_text(volume) + screen.expect(lambda: tracker_volume(screen), volume.__eq__, description=f"the volume {volume}") + screen.expect( + lambda: history_count(screen) != lines or screen.sequencer.history.lines()[0].current, + bool, + description="the line in force", + ) + + +class TestUndoAndRedoPastEitherEnd: + """Undo pressed again and again at the start and Redo again and again at the end change nothing more. + + Twenty undos at the opened project leave the tempo and the card as they were. Retyping the tempo adds a + line, which witnesses the keys; one undo takes it back and twenty redos bring it back once. + """ + + def test_the_ends_hold(self, screen: Screen) -> None: + module = screen.sequencer.module + tempos: List[int] = [] + lines: List[int] = [] + + def twenty_undos_at_the_start(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + tempos.append(module.tempo()) + lines.append(history_count(screen)) + + for _ in range(EVERY_STEP): + screen.press_shortcut(ShortcutId.UNDO) + + assert module.tempo() == tempos[0] + assert history_count(screen) == lines[0] + + def the_keys_act_inside_the_stack(screen: Screen) -> None: + module.retype_tempo(TEMPO) + expect_lines(screen, lines[0] + 1) + press_on_the_sequencer(screen, ShortcutId.UNDO) + screen.expect(module.tempo, tempos[0].__eq__, description="the tempo as it stood") + + for _ in range(EVERY_STEP): + screen.press_shortcut(ShortcutId.REDO) + + screen.expect(module.tempo, TEMPO.__eq__, description="the tempo as typed") + assert history_count(screen) == lines[0] + 1 + + screen.scenario(twenty_undos_at_the_start, the_keys_act_inside_the_stack, leave_letting_the_project_go).run() + + +class TestAnUndoWhileTheSongPlays: + """An edit undone while the song plays takes the cell back and stops the song, as opening a project does. + + The patterns are lengthened so the song outlasts the steps, a tracker volume is typed and the song + started. Undo takes the cell back and the song stops, since a restore rebuilds the Sequencer the way + loading a project does; Play then starts the song again, which witnesses the stop. + """ + + def test_the_cell_comes_back(self, screen: Screen) -> None: + playback = screen.sequencer.playback + standing: List[str] = [] + + def edit_and_play(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.sequencer.module.retype_rows(LONG_PATTERNS) + screen.expect(screen.sequencer.module.rows, LONG_PATTERNS.__eq__, description="the long patterns") + standing.append(tracker_volume(screen)) + type_a_tracker_volume(screen, TRACKER_VOLUME) + + playback.play() + + screen.expect(playback.can_stop, bool, description="the song playing") + + def undo_while_playing(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.UNDO) + + expect_reading(screen, lambda: tracker_volume(screen), standing[0], "the cell as it stood") + screen.expect(playback.can_stop, operator.not_, description="the song stopped") + + def play_again_and_leave(screen: Screen) -> None: + playback.play() + screen.expect(playback.can_stop, bool, description="the song playing again") + playback.stop() + screen.expect(playback.can_stop, operator.not_, description="the song stopped") + + leave_letting_the_project_go(screen) + + screen.scenario(edit_and_play, undo_while_playing, play_again_and_leave).run() + + +class TestTheExitAfterUndos: + """The exit asks nothing once undos return to the opened project, and asks while an edit still stands. + + One tracker edit undone leaves the project clean, so the exit closes at once. Two edits with one undone + leave it changed, so the exit asks first. + """ + + def test_back_at_the_file_the_exit_asks_nothing(self, screen: Screen) -> None: + def edit_and_undo(screen: Screen) -> None: + type_a_tracker_volume(screen, TRACKER_VOLUME) + + press_on_the_sequencer(screen, ShortcutId.UNDO) + + screen.expect( + lambda: screen.sequencer.history.lines()[-1].current, bool, description="the opened line in force" + ) + + def leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + screen.scenario(edit_and_undo, leave).run() + + def test_an_edit_still_standing_is_asked_about(self, screen: Screen) -> None: + def two_edits_and_one_undo(screen: Screen) -> None: + type_a_tracker_volume(screen, TRACKER_VOLUME) + screen.sequencer.module.retype_tempo(TEMPO) + screen.expect(screen.sequencer.module.tempo, TEMPO.__eq__, description="the tempo as typed") + + press_on_the_sequencer(screen, ShortcutId.UNDO) + + screen.expect(lambda: tracker_volume(screen), TRACKER_VOLUME.__eq__, description="the first edit standing") + + screen.scenario(two_edits_and_one_undo, leave_letting_the_project_go).run() + + +class TestAJumpWhileAnEditIsOnItsWay: + """A click on the oldest line while an edit is still being rebuilt waits for it, then lands at the opened project. + + Lead opens with its rebuilds held and a volume is typed. The oldest line is clicked while the rebuild + waits, and the card stays as it was. Once the rebuild goes, the edit lands as a line of its own and the + jump puts the opened project back; Redo brings the typed values back. + """ + + def test_the_jump_comes_after_the_edit( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + history = screen.sequencer.history + standing: List[object] = [] + lines: List[int] = [] + + def type_while_held(screen: Screen) -> None: + open_voice(screen, LEAD) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="Lead open") + standing.append(volume_fields(screen, CHANNELS)) + lines.append(history_count(screen)) + + screen.reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, LEAD_VOLUME) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def click_the_oldest_line(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.jump_to(history_count(screen) - 1) + + assert history_count(screen) == lines[0] + + def the_edit_lands_and_the_jump_follows(screen: Screen) -> None: + regeneration_hold.release() + + expect_lines(screen, lines[0] + 1) + expect_reading(screen, lambda: volume_fields(screen, CHANNELS), standing[0], "the volume as it stood") + assert history.lines()[-1].current + + def redo_brings_the_edit_back(screen: Screen) -> None: + press_on_the_sequencer(screen, ShortcutId.REDO) + + screen.expect( + lambda: volume_fields(screen, CHANNELS)[ChannelName.PULSE1].split()[:2] == LEAD_VOLUME.split(), + bool, + description="the volume as typed", + ) + + screen.scenario( + type_while_held, + click_the_oldest_line, + the_edit_lands_and_the_jump_follows, + redo_brings_the_edit_back, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/history/sessions/test_witness.py b/tests/screens/history/sessions/test_witness.py new file mode 100644 index 000000000..e21603f2d --- /dev/null +++ b/tests/screens/history/sessions/test_witness.py @@ -0,0 +1,87 @@ +import operator +from pathlib import Path +from typing import Final, List + +from automation.screen import Screen +from automation.steps.project import save_project_as +from automation.steps.sequencer import open_voice +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.project import ProjectContainer +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.history.constants import LEAD +from tests.screens.history.steps import expect_lines, history_count, press_on_the_sequencer +from tests.suite.history.audit import fresh_fingerprint + +BEFORE: Final[Path] = PROJECTS_DIRECTORY / "Before.stp" +AFTER: Final[Path] = PROJECTS_DIRECTORY / "After.stp" +TEMPO: Final[int] = 99 +TRACKER_ROW: Final[int] = 3 +TRACKER_VOLUME: Final[str] = "2" +LEAD_VOLUME: Final[str] = "6 6" +TAKEN_OUT: Final[str] = "1" + + +class TestEveryEditUndoneLeavesTheSavedProject: + """A project saved, edited on both tabs and undone back to the save reads clean and saves as it was saved. + + The project is saved as one file, then a tracker volume, the tempo, a sample's volume and a recording + taken out each add a line. Four undos bring the title back to clean, a save to a second file follows, and + both files describe the same project; the exit then asks nothing. + """ + + def test_the_second_file_is_the_first(self, screen: Screen) -> None: + lines: List[int] = [] + + def save_the_project_first(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + save_project_as(screen, BEFORE) + lines.append(history_count(screen)) + + def edit_on_both_tabs(screen: Screen) -> None: + tracker = screen.sequencer.tracker + tracker.click(TRACKER_ROW, ChannelName.PULSE2, SubColumn.VOLUME) + screen.hand.type_text(TRACKER_VOLUME) + expect_lines(screen, lines[0] + 1) + + screen.sequencer.module.retype_tempo(TEMPO) + expect_lines(screen, lines[0] + 2) + + open_voice(screen, LEAD) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="Lead open") + screen.reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, LEAD_VOLUME) + expect_lines(screen, lines[0] + 3) + + stems = screen.reconstructions.stems + stems.remove(TAKEN_OUT) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + stems.remove_prompt.confirm() + expect_lines(screen, lines[0] + 4) + + def undo_everything(screen: Screen) -> None: + history = screen.sequencer.history + for _ in range(4): + press_on_the_sequencer(screen, ShortcutId.UNDO) + + screen.expect(lambda: history.lines()[-1].current, bool, description="the saved line in force") + screen.expect(screen.project.unsaved_prompt.is_shown, operator.not_, description="no question open") + + def save_again_and_compare(screen: Screen) -> None: + save_project_as(screen, AFTER) + + assert fresh_fingerprint(ProjectContainer.load(AFTER)) == fresh_fingerprint(ProjectContainer.load(BEFORE)) + + def leave_asking_nothing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + screen.scenario( + save_the_project_first, + edit_on_both_tabs, + undo_everything, + save_again_and_compare, + leave_asking_nothing, + ).run() diff --git a/tests/screens/history/steps.py b/tests/screens/history/steps.py new file mode 100644 index 000000000..3cd8a55dd --- /dev/null +++ b/tests/screens/history/steps.py @@ -0,0 +1,66 @@ +from typing import Callable, Dict, List, Tuple + +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey + +Fields = Dict[ChannelName, str] + + +def volume_fields( + screen: Screen, + channels: Tuple[ChannelName, ...], +) -> Fields: + """What the volume field of each of ``channels`` reads on the Reconstructions tab.""" + instruments = screen.reconstructions.instruments + return {channel: instruments.envelope(channel, FeatureKey.VOLUME) for channel in channels} + + +def press_on_the_sequencer( + screen: Screen, + shortcut_id: ShortcutId, +) -> None: + """Brings the Sequencer forward and presses the keys of ``shortcut_id``.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.press_shortcut(shortcut_id) + + +def expect_reading( + screen: Screen, + reading: Callable[[], object], + expected: object, + description: str, +) -> None: + """Waits until ``reading`` gives ``expected``.""" + screen.expect(reading, expected.__eq__, description=description) + + +def history_count(screen: Screen) -> int: + """How many lines the History card draws.""" + return len(screen.sequencer.history.lines()) + + +def expect_lines( + screen: Screen, + count: int, +) -> None: + """Waits until the History card draws ``count`` lines.""" + screen.expect(lambda: history_count(screen), count.__eq__, description=f"{count} history lines") + + +def leading( + fields: Fields, + typed: Dict[ChannelName, str], +) -> Dict[ChannelName, List[str]]: + """The leading values of each field, as many as were typed into it. + + A typed volume lands with the release its last frame writes, so the leading values are what + a typed sequence is read back by. + """ + return {channel: fields[channel].split()[: len(sequence.split())] for channel, sequence in typed.items()} + + +def typed_values(typed: Dict[ChannelName, str]) -> Dict[ChannelName, List[str]]: + """The values of each typed sequence, as :func:`leading` reads them back.""" + return {channel: sequence.split() for channel, sequence in typed.items()} diff --git a/tests/screens/instructions/__init__.py b/tests/screens/instructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/instructions/conftest.py b/tests/screens/instructions/conftest.py new file mode 100644 index 000000000..b3264a025 --- /dev/null +++ b/tests/screens/instructions/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.worlds.home import World +from tests.suite.screens.worlds.recordings import converting_world + + +@pytest.fixture +def world() -> World: + """The home every Instructions scenario starts in: a small library built for one-worker settings.""" + return converting_world(()) diff --git a/tests/screens/instructions/test_library.py b/tests/screens/instructions/test_library.py new file mode 100644 index 000000000..e9bf74612 --- /dev/null +++ b/tests/screens/instructions/test_library.py @@ -0,0 +1,71 @@ +from functools import partial +from typing import Dict, Final, Tuple + +from automation.dearpygui.items.colors import read_theme, read_theme_colors +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from automation.steps.instructions import load_library +from automation.worlds.home import one_worker_config +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName, GeneratorName + +GENERATOR_CHANNELS: Final[Dict[GeneratorName, ChannelName]] = { + GeneratorName.PULSE: ChannelName.PULSE1, + GeneratorName.TRIANGLE: ChannelName.TRIANGLE, + GeneratorName.NOISE: ChannelName.NOISE, +} + + +def line_color(screen: Screen, line: str) -> Tuple[float, ...]: + """The color the theme bound to ``line`` draws it in.""" + + def read() -> Tuple[float, ...]: + theme = read_theme(line) + assert theme is not None, f"no theme is bound to '{line}'" + return read_theme_colors(theme)[0] + + return screen.bridge.ask(read) + + +def channel_colors(screen: Screen, channel: ChannelName) -> Tuple[Tuple[float, ...], ...]: + """The colors of the theme that every view naming ``channel`` paints from.""" + return screen.bridge.ask(lambda: read_theme_colors(CHANNEL_THEME_TAGS[channel])) + + +class TestTheGeneratorsOfALibrary: + """A library lists a row per generator, each drawing its fragment's waveform and spectrum in its channel's + color. + + The library loads and shows a row per generator. Each row is clicked in turn, and its waveform draws one + line in a color of its channel's theme. The three generators end with three different colors. + """ + + def test_each_generator_draws_in_its_channels_color(self, screen: Screen) -> None: + """Each generator's row draws a waveform and a spectrum in its own channel's color.""" + instructions = screen.instructions + tree = instructions.library.tree + waveform = instructions.waveform + rows: Dict[GeneratorName, Item] = {} + + def a_row_per_generator(screen: Screen) -> None: + load_library(screen, one_worker_config()) + + for generator in GeneratorName: + rows[generator] = screen.expect_item( + partial(tree.generator_row, generator), description=f"the {generator} row" + ) + + def each_draws_in_its_color(screen: Screen) -> None: + colors: Dict[GeneratorName, Tuple[float, ...]] = {} + for generator, row in rows.items(): + tree.click(row) + + lines = screen.expect(waveform.series, bool, description=f"the {generator} waveform") + assert len(lines) == 1 + screen.expect(instructions.spectrum_bands, bool, description=f"the {generator} spectrum") + colors[generator] = line_color(screen, lines[0]) + assert colors[generator] in channel_colors(screen, GENERATOR_CHANNELS[generator]) + + assert len(set(colors.values())) == len(GeneratorName) + + screen.scenario(a_row_per_generator, each_draws_in_its_color).run() diff --git a/tests/screens/instructions/test_waveform.py b/tests/screens/instructions/test_waveform.py new file mode 100644 index 000000000..14633bd70 --- /dev/null +++ b/tests/screens/instructions/test_waveform.py @@ -0,0 +1,58 @@ +from functools import partial +from typing import Final + +from automation.dearpygui.items.texts import read_label +from automation.screen import Screen +from automation.steps.instructions import load_library +from automation.vocabulary.playback import PAUSE +from automation.worlds.home import one_worker_config +from sampletones_application.tags.general import TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY +from sampletones_core.constants.enums import GeneratorName + +LISTENING_FRAMES: Final[int] = 30 +ZOOM_NOTCHES: Final[int] = 3 + + +def show_the_pulse(screen: Screen) -> None: + """Loads the library, clicks the pulse row and waits until its waveform is drawn.""" + instructions = screen.instructions + tree = instructions.library.tree + load_library(screen, one_worker_config()) + row = screen.expect_item(partial(tree.generator_row, GeneratorName.PULSE), description="the pulse row") + + tree.click(row) + + screen.expect(instructions.waveform.series, bool, description="the pulse drawn") + + +class TestTheFragmentWaveform: + """A click on the fragment's waveform sounds it, and a drag across a narrowed view sounds nothing. + + A fragment lasts a few frames, so the Playback menu's entry is recorded on every frame the gesture spans. + The pulse is shown and zoomed in; the drag leaves the entry away from Pause, and the click brings Pause + up. + """ + + def test_a_click_sounds_it_and_a_drag_does_not(self, screen: Screen) -> None: + """The Playback entry reads Pause during the click and stays away from it during the drag.""" + waveform = screen.instructions.waveform + playing = screen.words(PAUSE) + + def a_drag_sounds_nothing(screen: Screen) -> None: + show_the_pulse(screen) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + with screen.record(partial(read_label, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) as recording: + waveform.drag(0.6, 0.4) + screen.frames(LISTENING_FRAMES) + + assert playing not in recording.values() + + def a_click_sounds_it(screen: Screen) -> None: + with screen.record(partial(read_label, TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY)) as recording: + waveform.click(0.5) + screen.frames(LISTENING_FRAMES) + + assert playing in recording.values() + + screen.scenario(a_drag_sounds_nothing, a_click_sounds_it).run() diff --git a/tests/screens/interface/__init__.py b/tests/screens/interface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/cards/__init__.py b/tests/screens/interface/cards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/cards/test_every_card_folds.py b/tests/screens/interface/cards/test_every_card_folds.py new file mode 100644 index 000000000..456f5b963 --- /dev/null +++ b/tests/screens/interface/cards/test_every_card_folds.py @@ -0,0 +1,114 @@ +import operator +from typing import Dict, Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from automation.application.startup import Startup +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.views.main import Card +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.compose import TAG_SEPARATOR +from sampletones_application.tags.general import SUF_COLLAPSE_STRIP +from sampletones_application.tags.sequencer import TAG_SEQUENCER_HISTORY_PANEL +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +FILL_CARDS: Final[Tuple[str, ...]] = (TAG_SEQUENCER_HISTORY_PANEL,) +TABS: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.RECONSTRUCTIONS, Tab.SEQUENCER, Tab.INSTRUCTIONS) +SETTLING_FRAMES: Final[int] = 8 +STRIP_ENDING: Final[str] = f"{TAG_SEPARATOR}{SUF_COLLAPSE_STRIP}" + + +def cards_on(tab: Tab) -> List[str]: + """The tags of the cards shown on ``tab`` that fold. Runs on the render thread.""" + cards: List[str] = [] + for item in dpg.get_all_items(): + alias = str(dpg.get_item_alias(item)) + if alias.startswith(f"{tab}{TAG_SEPARATOR}") and alias.endswith(STRIP_ENDING): + card = alias[: -len(STRIP_ENDING)] + if read_item(card).shown: + cards.append(card) + + return sorted(cards) + + +def box(screen: Screen, item: str) -> Rect: + """The rectangle ``item`` fills on the screen.""" + rect = screen.bridge.ask(lambda: read_item(item).rect) + assert rect is not None, f"{item} stands nowhere" + return rect + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +class TestEveryCardFoldsOntoItsBar: + """Every card folds onto its bar and unfolds back to where it stood. + + A card folding down keeps its bar and the card's padding around it, at its top edge or, for a card + filling its column, at its bottom. A card folding sideways keeps its rail. The scenario opens a + reconstruction, visits each tab, folds every card on it, unfolds it, and expects the same rectangle. + """ + + def test_each_card_on_each_tab(self, screen: Screen) -> None: + folded: Dict[str, List[str]] = {} + + def folding_down(card: Card) -> None: + before = box(screen, card.tag) + card.toggle() + screen.expect(card.is_collapsed, bool, description=f"{card.tag} folded") + screen.frames(SETTLING_FRAMES) + + collapsed = box(screen, card.tag) + strip = box(screen, card.strip) + padding = strip.y - collapsed.y + assert collapsed.height == strip.height + 2 * padding, f"{card.tag}: {collapsed} around {strip}" + if card.tag in FILL_CARDS: + assert collapsed.y + collapsed.height == before.y + before.height, f"{card.tag} left its bottom" + else: + assert collapsed.y == before.y, f"{card.tag} left its top" + + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description=f"{card.tag} unfolded") + screen.frames(SETTLING_FRAMES) + assert box(screen, card.tag) == before, f"{card.tag} came back elsewhere" + + def folding_sideways(card: Card) -> None: + before = box(screen, card.tag) + card.toggle() + screen.expect(card.is_collapsed, bool, description=f"{card.tag} folded") + screen.frames(SETTLING_FRAMES) + + collapsed = box(screen, card.tag) + rail = box(screen, card.rail) + assert collapsed.height == before.height, f"{card.tag} changed its height folding sideways" + assert collapsed.x <= rail.x and rail.x + rail.width <= collapsed.x + collapsed.width, f"{card.tag}" + assert collapsed.width < before.width + + card.unfold_from_the_rail() + screen.expect(card.is_collapsed, operator.not_, description=f"{card.tag} unfolded") + screen.frames(SETTLING_FRAMES) + assert box(screen, card.tag) == before, f"{card.tag} came back elsewhere" + + def fold_every_card(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in TABS: + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) + folded[tab] = screen.bridge.ask(lambda: cards_on(tab)) + assert folded[tab], f"no card folds on {tab}" + for tag in folded[tab]: + card = screen.main.card(tag) + if card.folds_sideways(): + folding_sideways(card) + else: + folding_down(card) + + screen.scenario(fold_every_card).run() diff --git a/tests/screens/interface/conftest.py b/tests/screens/interface/conftest.py new file mode 100644 index 000000000..cffae2de8 --- /dev/null +++ b/tests/screens/interface/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.worlds.home import World +from tests.suite.screens.worlds.songs import sequencer_world + + +@pytest.fixture +def world() -> World: + """The home every interface scenario starts in: an arranged project and reconstructions that play.""" + return sequencer_world() diff --git a/tests/screens/interface/dialogs/__init__.py b/tests/screens/interface/dialogs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/dialogs/cases.py b/tests/screens/interface/dialogs/cases.py new file mode 100644 index 000000000..4870e7ead --- /dev/null +++ b/tests/screens/interface/dialogs/cases.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_ABOUT +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_NSF_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) + + +@dataclass(frozen=True) +class MenuDialog: + """A dialog a menu entry opens. + + Attributes: + name: The dialog's name in messages. + group: The menu that holds the entry. + item: The entry that opens the dialog. + window: The tag its window stands under. + """ + + name: str + group: MenuElements + item: MenuElements + window: str + + +DIALOGS: Final[Tuple[MenuDialog, ...]] = ( + MenuDialog( + "Display settings", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, + TAG_SETTINGS_DISPLAY_WINDOW, + ), + MenuDialog( + "Keyboard shortcuts", + MenuElements.GROUP_VIEW, + MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + ), + MenuDialog( + "Audio settings", + MenuElements.GROUP_PLAYBACK, + MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, + TAG_SETTINGS_AUDIO_WINDOW, + ), + MenuDialog( + "Project properties", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_PROJECT_PROPERTIES, + TAG_SETTINGS_PROPERTIES_WINDOW, + ), + MenuDialog( + "Render song", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_RENDER_SONG, + TAG_SETTINGS_RENDER_WINDOW, + ), + MenuDialog( + "NSF program", + MenuElements.GROUP_FILE, + MenuElements.ITEM_FILE_EXPORT_NSF, + TAG_SETTINGS_NSF_WINDOW, + ), + MenuDialog( + "About", + MenuElements.GROUP_HELP, + MenuElements.ITEM_HELP_ABOUT, + TAG_GLOBAL_DIALOG_ABOUT, + ), +) diff --git a/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py b/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py new file mode 100644 index 000000000..fb828a3c9 --- /dev/null +++ b/tests/screens/interface/dialogs/test_every_dialog_opens_centered.py @@ -0,0 +1,88 @@ +from functools import partial +from typing import Final, List, Optional + +import pytest + +from automation.application.startup import Startup +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import is_tag_within +from automation.dearpygui.items.regions import read_windows +from automation.dearpygui.items.viewport import read_viewport +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.screen import Screen +from tests.screens.interface.dialogs.cases import DIALOGS, MenuDialog +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +STILL_FRAMES: Final[int] = 20 +CENTER_TOLERANCE: Final[float] = 1.0 +HALF: Final[float] = 2.0 +SETTLING_DRAWN_FRAMES: Final[int] = 2 + + +def dialog_box(window: str) -> Optional[Rect]: + """The box of the shown window standing under ``window``, if one stands. Runs on the render thread.""" + return next( + (reading.rect for reading in read_windows() if reading.shown and is_tag_within(reading.alias, window)), + None, + ) + + +def is_centered(box: Rect, viewport: Rect) -> bool: + """Whether the center of ``box`` lies within a pixel of the center of ``viewport``.""" + return ( + abs(box.x + box.width / HALF - viewport.width / HALF) <= CENTER_TOLERANCE + and abs(box.y + box.height / HALF - viewport.height / HALF) <= CENTER_TOLERANCE + ) + + +def is_inside(box: Rect, viewport: Rect) -> bool: + """Whether ``box`` lies wholly inside ``viewport``.""" + return box.x >= 0 and box.y >= 0 and box.x + box.width <= viewport.width and box.y + box.height <= viewport.height + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestEveryDialogOpensCentered: + """On its first open of the session, every dialog a menu opens stands centered in the window and + inside it, in one place on every frame from its third drawn frame on. + + A dialog whose content settles its height takes two frames to measure itself, as `dialogs.md` + states, so the rule holds from the frame after those. The scenario opens each dialog from its menu + while recording its box, then closes it with Escape. + """ + + def test_each_dialog_on_its_first_open(self, screen: Screen) -> None: + def opening(dialog: MenuDialog) -> None: + with screen.record(partial(dialog_box, dialog.window)) as recording: + screen.menu.choose(dialog.group, dialog.item) + screen.expect( + lambda: screen.bridge.ask(partial(dialog_box, dialog.window)) is not None, + bool, + description=dialog.name, + ) + screen.frames(STILL_FRAMES) + + boxes: List[Rect] = [box for box in recording.values() if box is not None][SETTLING_DRAWN_FRAMES:] + viewport = screen.bridge.ask(read_viewport) + assert boxes, f"{dialog.name} was drawn for {SETTLING_DRAWN_FRAMES} frames or fewer" + assert all(box == boxes[0] for box in boxes), f"{dialog.name} moved: {boxes}" + assert is_inside(boxes[0], viewport), f"{dialog.name} at {boxes[0]} in {viewport}" + assert is_centered(boxes[0], viewport), f"{dialog.name} at {boxes[0]} in {viewport}" + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.expect( + lambda: screen.bridge.ask(partial(dialog_box, dialog.window)) is None, + bool, + description=f"{dialog.name} closed", + ) + + def open_each_dialog_once(screen: Screen) -> None: + for dialog in DIALOGS: + opening(dialog) + + screen.scenario(open_each_dialog_once).run() diff --git a/tests/screens/interface/display/__init__.py b/tests/screens/interface/display/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/display/constants.py b/tests/screens/interface/display/constants.py new file mode 100644 index 000000000..ea4888aed --- /dev/null +++ b/tests/screens/interface/display/constants.py @@ -0,0 +1,8 @@ +from typing import Final + +from sampletones_shared.display import Resolution + +SIZE_SEPARATOR: Final[str] = "x" +HAND_SIZE: Final[Resolution] = Resolution(width=1300, height=820) +HAND_X: Final[int] = 100 +HAND_Y: Final[int] = 60 diff --git a/tests/screens/interface/display/steps.py b/tests/screens/interface/display/steps.py new file mode 100644 index 000000000..f62e91bca --- /dev/null +++ b/tests/screens/interface/display/steps.py @@ -0,0 +1,90 @@ +import operator +from typing import Tuple + +from automation.dearpygui.items.viewport import read_viewport, read_viewport_decorated +from automation.screen import Screen +from automation.worlds.home import World +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.display import Resolution +from tests.screens.interface.display.constants import HAND_SIZE, HAND_X, HAND_Y, SIZE_SEPARATOR + + +def size_named(label: str) -> Resolution: + """The window size a Resolution entry names, written width first.""" + width, height = label.split(SIZE_SEPARATOR) + return Resolution(width=int(width), height=int(height)) + + +def window_size(screen: Screen) -> Tuple[int, int]: + """The width and height of the window in pixels.""" + viewport = screen.bridge.ask(read_viewport) + return round(viewport.width), round(viewport.height) + + +def window_position(screen: Screen) -> Tuple[int, int]: + """Where the window's client area stands on the screen, left then top.""" + viewport = screen.bridge.ask(read_viewport) + return round(viewport.x), round(viewport.y) + + +def framed(screen: Screen) -> bool: + """Whether the window has its frame.""" + return screen.bridge.ask(read_viewport_decorated) + + +def leave(screen: Screen) -> None: + """Leaves the application with the exit shortcut and waits for it to close.""" + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +def open_display_settings(screen: Screen) -> None: + """Opens Display settings and waits for it to show.""" + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + +def another_size(screen: Screen) -> str: + """A size the Resolution list offers other than the one it names.""" + settings = screen.display_settings + current = settings.resolution() + return next(label for label in settings.resolutions() if label != current) + + +def kept(screen: Screen) -> None: + """Waits for the countdown a window change starts, and presses Keep.""" + settings = screen.display_settings + screen.expect(settings.countdown_shown, bool, description="the countdown") + settings.keep() + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + + +def hand_sized_world() -> World: + """A home whose window the user sized by hand, between the sizes Display settings offers.""" + return World( + state=ApplicationState( + viewport=ViewportState( + width=HAND_SIZE.width, + height=HAND_SIZE.height, + x=HAND_X, + y=HAND_Y, + ) + ), + application_config=None, + config=None, + files=(), + ) + + +def at_its_own_size_and_place(screen: Screen) -> None: + """Waits until the window stands at the size it was sized to by hand, and checks its place.""" + screen.expect( + lambda: window_size(screen), + (HAND_SIZE.width, HAND_SIZE.height).__eq__, + description="the window at its own size", + ) + assert window_position(screen) == (HAND_X, HAND_Y) diff --git a/tests/screens/interface/display/test_countdown.py b/tests/screens/interface/display/test_countdown.py new file mode 100644 index 000000000..175b76151 --- /dev/null +++ b/tests/screens/interface/display/test_countdown.py @@ -0,0 +1,132 @@ +import operator +from typing import Final + +import pytest + +from automation.screen import Screen +from automation.worlds.home import World +from tests.screens.interface.display.steps import ( + another_size, + at_its_own_size_and_place, + framed, + hand_sized_world, + kept, + open_display_settings, + size_named, + window_size, +) + +COUNTDOWN_LIMIT_SECONDS: Final[float] = 20.0 +SETTLING_FRAMES: Final[int] = 10 + + +class TestTheWindowModeCountdown: + """A window change counts down: Keep keeps it, while Revert and the count running out bring the window + back. Cancel then puts back the window the dialog opened with. + + The scenario switches to a frameless window and presses Revert, switches again and lets the + countdown run out, then switches once more, presses Keep, and cancels the dialog, expecting the + frame back. + """ + + def test_keep_revert_and_running_out(self, screen: Screen) -> None: + settings = screen.display_settings + + def revert_brings_the_frame_back(screen: Screen) -> None: + open_display_settings(screen) + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not framed(screen) + + settings.revert() + + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def running_out_brings_it_back(screen: Screen) -> None: + settings.toggle_borderless() + screen.expect(settings.countdown_shown, bool, description="the countdown") + + screen.bridge.expect( + settings.countdown_shown, + operator.not_, + description="the countdown run out", + timeout=COUNTDOWN_LIMIT_SECONDS, + ) + + screen.expect(lambda: framed(screen), bool, description="the frame back") + assert not settings.borderless() + + def keep_keeps_it_until_cancel(screen: Screen) -> None: + settings.toggle_borderless() + + kept(screen) + + screen.frames(SETTLING_FRAMES) + assert not framed(screen) + assert settings.borderless() + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.expect(lambda: framed(screen), bool, description="the frame back") + + screen.scenario(revert_brings_the_frame_back, running_out_brings_it_back, keep_keeps_it_until_cancel).run() + + +class TestTheCountdownOnAHandSizedWindow: + """Revert and the count running out put a window the user sized by hand back at its own size and place, + as Cancel does. + + The window opens between the sizes Display settings offers. The scenario picks another size and presses + Revert, then picks it again and lets the countdown run out, expecting the window back where it opened + each time, and Cancel then closes the dialog with the window still there. + """ + + @pytest.fixture + def world(self) -> World: + """A home whose window the user sized by hand, between the sizes Display settings offers.""" + return hand_sized_world() + + def test_revert_and_running_out_put_back_the_window_s_own_size(self, screen: Screen) -> None: + settings = screen.display_settings + + def another_size_on_screen(screen: Screen) -> None: + label = another_size(screen) + settings.choose_resolution(label) + screen.expect(settings.countdown_shown, bool, description="the countdown") + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def revert_puts_it_back(screen: Screen) -> None: + open_display_settings(screen) + another_size_on_screen(screen) + + settings.revert() + + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + at_its_own_size_and_place(screen) + + def running_out_puts_it_back(screen: Screen) -> None: + another_size_on_screen(screen) + + screen.bridge.expect( + settings.countdown_shown, + operator.not_, + description="the countdown run out", + timeout=COUNTDOWN_LIMIT_SECONDS, + ) + + at_its_own_size_and_place(screen) + + def cancel_leaves_it_there(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + at_its_own_size_and_place(screen) + + screen.scenario(revert_puts_it_back, running_out_puts_it_back, cancel_leaves_it_there).run() diff --git a/tests/screens/interface/display/test_discard_prompt.py b/tests/screens/interface/display/test_discard_prompt.py new file mode 100644 index 000000000..b63322778 --- /dev/null +++ b/tests/screens/interface/display/test_discard_prompt.py @@ -0,0 +1,146 @@ +import operator +from typing import Final, List + +import pytest + +from automation.screen import Screen +from automation.worlds.home import World +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.display.constants import HAND_SIZE, HAND_X, HAND_Y +from tests.screens.interface.display.steps import ( + another_size, + at_its_own_size_and_place, + hand_sized_world, + kept, + open_display_settings, + size_named, + window_position, + window_size, +) + + +class TestDiscardingADisplayChange: + """Cancelling Display settings with a change pending asks before the change is thrown away. + + The scenario flips vertical sync and cancels. Keep editing returns to the dialog with the change + in place. Cancel and discard close it, and the dialog reopens with the original value. + """ + + def test_keep_editing_brings_the_dialog_back_and_discard_restores_it(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the vertical sync box flipped") + + def cancel_asks_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(prompt.is_shown, bool, description="the discard prompt") + assert not settings.is_shown() + + def keep_editing(screen: Screen) -> None: + prompt.cancel() + + screen.expect(settings.is_shown, bool, description="the dialog back on the screen") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def discard(screen: Screen) -> None: + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt again") + + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + assert not prompt.is_shown() + + def reopen_as_it_was(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + + assert settings.vsync() == original[0] + + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed at once") + + screen.scenario( + change_vertical_sync, + cancel_asks_first, + keep_editing, + discard, + reopen_as_it_was, + ).run() + + +class TestTheKeyboardOnDisplaySettings: + """The keys alone answer Display settings.""" + + def test_escape_closes_an_unchanged_dialog(self, screen: Screen) -> None: + """Escape closes a dialog that holds no change, with no question.""" + settings = screen.display_settings + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + assert not settings.discard_prompt.is_shown() + + +class TestDiscardingOnAHandSizedWindow: + """Discarding a new size puts a window the user sized by hand back at its own size and place. + + The window opens between the sizes Display settings offers, so the dialog names the offered size + nearest it. The scenario keeps another size and discards it, and expects the window back at the + size and place it opened with. + """ + + @pytest.fixture + def world(self) -> World: + """A home whose window the user sized by hand, between the sizes Display settings offers.""" + return hand_sized_world() + + def test_discarding_puts_back_the_window_s_own_size(self, screen: Screen) -> None: + """The window stands at its own size and place once the new size is discarded.""" + settings = screen.display_settings + prompt = settings.discard_prompt + + def a_size_between_the_offered_ones(screen: Screen) -> None: + assert window_size(screen) == (HAND_SIZE.width, HAND_SIZE.height) + assert window_position(screen) == (HAND_X, HAND_Y) + + open_display_settings(screen) + + assert HAND_SIZE not in [size_named(label) for label in settings.resolutions()] + assert size_named(settings.resolution()) != HAND_SIZE + + def keep_another_size(screen: Screen) -> None: + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def discard_it(screen: Screen) -> None: + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + at_its_own_size_and_place(screen) + + screen.scenario(a_size_between_the_offered_ones, keep_another_size, discard_it).run() diff --git a/tests/screens/interface/display/test_discarded_change_not_written.py b/tests/screens/interface/display/test_discarded_change_not_written.py new file mode 100644 index 000000000..2ab82eba2 --- /dev/null +++ b/tests/screens/interface/display/test_discarded_change_not_written.py @@ -0,0 +1,51 @@ +import operator +from typing import List + +from automation.screen import Screen +from automation.written import written_application_config +from tests.screens.application.restart.steps import leave + + +class TestADiscardedDisplayChange: + """A Display settings change thrown away stays out of the written settings, while one confirmed is + written. + + The scenario flips vertical sync and discards it, flips it again and confirms, leaves the + application, and expects the written settings to hold the confirmed value. + """ + + def test_leaving_writes_only_the_confirmed_change(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def discard_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def confirm_a_change(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog open again") + assert settings.vsync() == original[0] + settings.toggle_vsync() + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + settings.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + + def leaving_writes_the_confirmed_one(screen: Screen) -> None: + leave(screen) + + assert written_application_config().display.vsync is not original[0] + + screen.scenario(discard_a_change, confirm_a_change, leaving_writes_the_confirmed_one).run() diff --git a/tests/screens/interface/display/test_frame_rate_reading.py b/tests/screens/interface/display/test_frame_rate_reading.py new file mode 100644 index 000000000..995d02bec --- /dev/null +++ b/tests/screens/interface/display/test_frame_rate_reading.py @@ -0,0 +1,125 @@ +import operator + +import pytest + +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from automation.written import written_application_config +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.display import DisplayConfig +from tests.screens.interface.display.steps import leave, open_display_settings + + +class TestSwitchingTheFrameRateReadingOff: + """Unticking Show frame rate takes the reading off the menu bar at once, and leaving writes the choice. + + The scenario opens Display settings, unticks the box, sees the reading go, and confirms. The + settings file it leaves behind asks for no reading. + """ + + def test_the_reading_goes_at_once_and_the_choice_is_written(self, screen: Screen) -> None: + settings = screen.display_settings + + def untick_the_box(screen: Screen) -> None: + assert screen.menu.frame_rate_reading_shown() + open_display_settings(screen) + assert settings.frame_rate_shown() + + settings.toggle_frame_rate() + + screen.expect(screen.menu.frame_rate_reading_shown, operator.not_, description="the reading gone") + assert not settings.frame_rate_shown() + + def confirm_and_leave(screen: Screen) -> None: + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + assert not screen.menu.frame_rate_reading_shown() + + leave(screen) + + assert not written_application_config().display.show_frame_rate + + screen.scenario(untick_the_box, confirm_and_leave).run() + + +class TestDiscardingTheSwitchPutsTheReadingBack: + """A switch of Show frame rate that is discarded leaves the reading as it stood, and the file untouched. + + The scenario unticks the box, cancels and discards: the reading comes back. It then unticks the + box once more and confirms, so the file it leaves shows that the first switch wrote nothing and the + second did. + """ + + def test_discard_restores_the_reading_and_a_confirmed_switch_writes(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + + def untick_and_discard(screen: Screen) -> None: + open_display_settings(screen) + settings.toggle_frame_rate() + screen.expect(screen.menu.frame_rate_reading_shown, operator.not_, description="the reading gone") + + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + screen.expect(screen.menu.frame_rate_reading_shown, bool, description="the reading back") + + def untick_and_confirm(screen: Screen) -> None: + open_display_settings(screen) + assert settings.frame_rate_shown() + + settings.toggle_frame_rate() + settings.confirm() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + assert not screen.menu.frame_rate_reading_shown() + + def leave_and_read_the_file(screen: Screen) -> None: + leave(screen) + + assert not written_application_config().display.show_frame_rate + + screen.scenario(untick_and_discard, untick_and_confirm, leave_and_read_the_file).run() + + +class TestAHomeAskingForNoReadingOpensWithoutIt: + """A home whose settings ask for no frame-rate reading opens the application without one, and Display + settings shows the box unticked. + + Ticking the box puts the reading on the bar at once, and discarding that takes it off again. + """ + + @pytest.fixture + def world(self) -> World: + """A home whose settings ask for no frame-rate reading.""" + return World( + state=screen_filling_state(), + application_config=ApplicationConfig(display=DisplayConfig(show_frame_rate=False)), + config=None, + files=(), + ) + + def test_the_reading_stays_off_until_asked_for(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + + def no_reading_at_start(screen: Screen) -> None: + assert not screen.menu.frame_rate_reading_shown() + open_display_settings(screen) + + assert not settings.frame_rate_shown() + + def tick_the_box_and_discard(screen: Screen) -> None: + settings.toggle_frame_rate() + screen.expect(screen.menu.frame_rate_reading_shown, bool, description="the reading on the bar") + + settings.cancel() + screen.expect(prompt.is_shown, bool, description="the discard prompt") + prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="the dialog closed") + screen.expect(screen.menu.frame_rate_reading_shown, operator.not_, description="the reading off again") + + screen.scenario(no_reading_at_start, tick_the_box_and_discard).run() diff --git a/tests/screens/interface/display/test_fullscreen.py b/tests/screens/interface/display/test_fullscreen.py new file mode 100644 index 000000000..146b4ae00 --- /dev/null +++ b/tests/screens/interface/display/test_fullscreen.py @@ -0,0 +1,69 @@ +import os +from typing import Final, List + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.viewport import read_viewport +from automation.environment import screen_size +from automation.screen import Screen +from automation.written import written_state +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + +SCREEN: Final = screen_size(os.environ) +WHOLE_SCREEN: Final[Rect] = Rect(x=0, y=0, width=SCREEN.width, height=SCREEN.height) + + +def window(screen: Screen) -> Rect: + """The rectangle the window fills on the screen.""" + return screen.bridge.ask(read_viewport) + + +def marked_fullscreen(screen: Screen) -> bool: + """Whether View ▸ Fullscreen carries its check mark.""" + return screen.menu.is_checked(MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_FULLSCREEN) + + +class TestFullscreen: + """F11 fills the screen and F11 again gives the window back its size and place. + + The View menu's mark follows the window, and leaving the application from the windowed state writes + a session that is not fullscreen. Two presses in a row end where they began. + """ + + def test_f11_fills_and_restores(self, screen: Screen) -> None: + windowed: List[Rect] = [] + + def f11_fills_the_screen(screen: Screen) -> None: + windowed.append(window(screen)) + assert windowed[0] != WHOLE_SCREEN + + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), WHOLE_SCREEN.__eq__, description="the window filling the screen") + assert marked_fullscreen(screen) + + def f11_again_restores_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), windowed[0].__eq__, description="the window back where it stood") + assert not marked_fullscreen(screen) + + def two_presses_at_once_end_where_they_began(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + screen.press_shortcut(ShortcutId.TOGGLE_FULLSCREEN) + + screen.expect(lambda: window(screen), windowed[0].__eq__, description="the window where it stood") + assert not marked_fullscreen(screen) + + def leaving_writes_a_window(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert not written_state().viewport.fullscreen + + screen.scenario( + f11_fills_the_screen, + f11_again_restores_it, + two_presses_at_once_end_where_they_began, + leaving_writes_a_window, + ).run() diff --git a/tests/screens/interface/display/test_kept_size_at_close.py b/tests/screens/interface/display/test_kept_size_at_close.py new file mode 100644 index 000000000..250f24b9a --- /dev/null +++ b/tests/screens/interface/display/test_kept_size_at_close.py @@ -0,0 +1,52 @@ +from typing import List, Tuple + +from automation.screen import Screen +from automation.written import written_state +from tests.screens.interface.display.steps import ( + another_size, + kept, + open_display_settings, + size_named, + window_position, + window_size, +) + + +class TestClosingWithASizeKeptButNotConfirmed: + """Closing the window while Display settings holds a size kept on the countdown but left unconfirmed + writes the size and the place the dialog opened with. + + The session keeps them, as it does for every setting the dialog has yet to commit. The scenario + keeps another size, closes the window, and expects the opening size and place in the written + session. + """ + + def test_leaving_writes_the_confirmed_size(self, screen: Screen) -> None: + settings = screen.display_settings + opened: List[Tuple[int, int]] = [] + placed: List[Tuple[int, int]] = [] + + def keep_a_size_without_confirming_it(screen: Screen) -> None: + opened.append(window_size(screen)) + placed.append(window_position(screen)) + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + assert screen.wait_for_exit() + viewport = written_state().viewport + assert (viewport.width, viewport.height) == opened[0] + assert (viewport.x, viewport.y) == placed[0] + + screen.scenario(keep_a_size_without_confirming_it, close_the_window).run() diff --git a/tests/screens/interface/display/test_saved_mode.py b/tests/screens/interface/display/test_saved_mode.py new file mode 100644 index 000000000..7dbf3448f --- /dev/null +++ b/tests/screens/interface/display/test_saved_mode.py @@ -0,0 +1,107 @@ +import operator +from typing import Final, List + +import pytest + +from automation.screen import Screen +from automation.worlds.home import World, screen_filling_state +from automation.written import written_application_config, written_state +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.display import DisplayConfig +from sampletones_application.config.session.state.window import ViewportState +from sampletones_shared.display import Resolution +from tests.screens.interface.display.steps import ( + another_size, + framed, + kept, + leave, + open_display_settings, + size_named, + window_size, +) + +SEEDED_SIZE: Final[Resolution] = Resolution(width=1280, height=800) + + +class TestLeavingWritesTheWindowMode: + """A window size and a frameless window, kept on the countdown and confirmed with OK, are what leaving + writes. + """ + + def test_the_size_and_the_frame_are_written(self, screen: Screen) -> None: + settings = screen.display_settings + chosen: List[Resolution] = [] + + def pick_a_size_and_keep_it(screen: Screen) -> None: + open_display_settings(screen) + label = another_size(screen) + + settings.choose_resolution(label) + kept(screen) + + chosen.append(size_named(label)) + screen.expect( + lambda: window_size(screen), + (chosen[0].width, chosen[0].height).__eq__, + description="the window at the size picked", + ) + + def go_frameless_and_keep_it(screen: Screen) -> None: + assert framed(screen) + + settings.toggle_borderless() + kept(screen) + + screen.expect(lambda: framed(screen), operator.not_, description="the window without its frame") + + def confirm_and_leave(screen: Screen) -> None: + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + leave(screen) + + viewport = written_state().viewport + assert (viewport.width, viewport.height) == (chosen[0].width, chosen[0].height) + assert written_application_config().display.borderless + + screen.scenario(pick_a_size_and_keep_it, go_frameless_and_keep_it, confirm_and_leave).run() + + +class TestAHomeHoldingAWindowModeShowsIt: + """A home whose session holds a window size and whose settings ask for a frameless window without + vertical sync opens the application that way, and Display settings shows it. + """ + + @pytest.fixture + def world(self) -> World: + """A home with a 1280 by 800 window at the screen's corner, frameless and without vertical sync.""" + state = screen_filling_state().model_copy( + update={"viewport": ViewportState(width=SEEDED_SIZE.width, height=SEEDED_SIZE.height, x=0, y=0)} + ) + return World( + state=state, + application_config=ApplicationConfig(display=DisplayConfig(borderless=True, vsync=False)), + config=None, + files=(), + ) + + def test_the_window_opens_as_the_session_left_it(self, screen: Screen) -> None: + """The window opens at the written size without its frame, and Display settings names the size, the + frameless mode and the vertical sync setting. + """ + settings = screen.display_settings + + def the_window_stands_as_written(screen: Screen) -> None: + assert window_size(screen) == (SEEDED_SIZE.width, SEEDED_SIZE.height) + assert not framed(screen) + + def display_settings_names_it(screen: Screen) -> None: + open_display_settings(screen) + + assert settings.resolution() == str(SEEDED_SIZE) + assert settings.borderless() + assert not settings.vsync() + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(the_window_stands_as_written, display_settings_names_it).run() diff --git a/tests/screens/interface/keybindings/__init__.py b/tests/screens/interface/keybindings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/keybindings/test_every_key.py b/tests/screens/interface/keybindings/test_every_key.py new file mode 100644 index 000000000..7598edebc --- /dev/null +++ b/tests/screens/interface/keybindings/test_every_key.py @@ -0,0 +1,97 @@ +import operator +from functools import partial +from typing import Final, List + +import pytest + +from automation.application.startup import Startup +from automation.keyboard import press_combination +from automation.screen import Screen +from automation.steps.sequencer import channels_sounding, sounding_but +from automation.written import written_application_config +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 +NEW_MAIN_KEY: Final[KeyCombination] = KeyCombination.parse("Ctrl+Shift+2") +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestEditingARowKeepsEveryKey: + """A channel's row lists its main key and its chord, and a new main key keeps both. + + The Pulse 2 row shows every key the scheme gives it. A pressed key leads the list and the earlier keys + follow it. After OK the chord still mutes Pulse 2, the new key brings it back, and leaving writes every + key down. + """ + + def test_the_row_keeps_its_second_key(self, screen: Screen) -> None: + settings = screen.keyboard_settings + shipped: List[KeyCombination] = [] + + def the_row_shows_every_key(screen: Screen) -> None: + shipped.extend(screen.shortcut(TOGGLE_PULSE_TWO).combinations()) + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + + assert len(shipped) > 1 + assert settings.keys_of(TOGGLE_PULSE_TWO) == display_combinations(shipped) + + def a_pressed_key_leads_the_others(screen: Screen) -> None: + settings.listen_for(TOGGLE_PULSE_TWO) + + press_combination(screen.hand, NEW_MAIN_KEY) + + screen.expect( + partial(settings.keys_of, TOGGLE_PULSE_TWO), + display_combinations([NEW_MAIN_KEY, *shipped]).__eq__, + description="the new key ahead of the others", + ) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + def the_chord_still_mutes_pulse_two(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.frames(SETTLING_FRAMES) + + press_combination(screen.hand, shipped[-1]) + + screen.expect( + partial(channels_sounding, screen), + sounding_but(ChannelName.PULSE2).__eq__, + description="Pulse 2 muted", + ) + + def the_new_key_brings_it_back(screen: Screen) -> None: + press_combination(screen.hand, NEW_MAIN_KEY) + + screen.expect( + partial(channels_sounding, screen), + sounding_but().__eq__, + description="every channel sounding again", + ) + + def leaving_writes_every_key(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_application_config().shortcuts.overrides == { + TOGGLE_PULSE_TWO.value: display_combinations([NEW_MAIN_KEY, *shipped]), + } + + screen.scenario( + the_row_shows_every_key, + a_pressed_key_leads_the_others, + the_chord_still_mutes_pulse_two, + the_new_key_brings_it_back, + leaving_writes_every_key, + ).run() diff --git a/tests/screens/interface/keybindings/test_rebinding_undo.py b/tests/screens/interface/keybindings/test_rebinding_undo.py new file mode 100644 index 000000000..3dee9ff66 --- /dev/null +++ b/tests/screens/interface/keybindings/test_rebinding_undo.py @@ -0,0 +1,150 @@ +import operator +from functools import partial +from typing import Dict, Final + +import pytest + +from automation.application.startup import Startup +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.keys import IMGUI_ENTER, IMGUI_ESCAPE +from automation.keyboard import press_combination +from automation.screen import Screen +from automation.vocabulary.settings import LISTENING +from automation.written import written_application_config +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, + display_combinations, + parse_combinations, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +NEW_UNDO: Final[KeyCombination] = KeyCombination.parse("Ctrl+Alt+U") +RENAMED: Final[str] = "Renamed" +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def rename_the_line(screen: Screen) -> None: + """Renames the line through F2 on its row, which the history records.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.pick(screen.expect_item(partial(voices.row, LINE), description="the line's row")) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) + screen.expect(voices.names, [RENAMED, BASS_VOICE, PAD].__eq__, description="the line renamed") + + +class TestRebindingUndo: + """Undo rebound: the new keys undo and the old ones leave the document as it was, and the menu prints the new keys. + + Escape cancels a capture, a pressed key leads the keys Undo had, keys written in the entry box become + exactly Undo's keys, keys another action holds ask before they are taken, and leaving writes the rebind. + The scenario presses Ctrl+Alt+U on Undo's row, writes it alone in the entry box, renames a voice, tries + the old keys and then the new ones, tries to take Redo's main key and cancels, and leaves the + application. + """ + + def test_the_new_keys_undo(self, screen: Screen) -> None: + settings = screen.keyboard_settings + voices = screen.sequencer.voices + original: Dict[ShortcutId, str] = {} + + def escape_cancels_a_capture(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + original.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.UNDO) + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), screen.words(LISTENING).__eq__, description="listening" + ) + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + original[ShortcutId.UNDO].__eq__, + description="the keys back", + ) + assert settings.is_shown() + + def rebind_undo(screen: Screen) -> None: + settings.listen_for(ShortcutId.UNDO) + + press_combination(screen.hand, NEW_UNDO) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + display_combinations((NEW_UNDO, *parse_combinations(original[ShortcutId.UNDO]))).__eq__, + description="the new keys ahead of the old ones", + ) + settings.write_keys(ShortcutId.UNDO, NEW_UNDO.display()) + + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + NEW_UNDO.display().__eq__, + description="the new keys alone", + ) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + undo = next( + entry + for entry in screen.menu.entries(MenuElements.GROUP_EDIT) + if entry.label == screen.menu.label(MenuElements.ITEM_EDIT_UNDO) + ) + assert undo.keys == NEW_UNDO.display() + + def the_old_keys_do_nothing_and_the_new_undo(screen: Screen) -> None: + rename_the_line(screen) + + press_combination(screen.hand, parse_combinations(original[ShortcutId.UNDO])[0]) + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [RENAMED, BASS_VOICE, PAD] + press_combination(screen.hand, NEW_UNDO) + screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the rename undone") + + def keys_another_action_holds_ask_first(screen: Screen) -> None: + reassign = settings.reassign_prompt + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.UNDO) + + press_combination(screen.hand, parse_combinations(original[ShortcutId.REDO])[0]) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == original[ShortcutId.REDO] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + def leaving_writes_the_rebind(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_application_config().shortcuts.overrides == {ShortcutId.UNDO.value: NEW_UNDO.display()} + + screen.scenario( + escape_cancels_a_capture, + rebind_undo, + the_old_keys_do_nothing_and_the_new_undo, + keys_another_action_holds_ask_first, + leaving_writes_the_rebind, + ).run() diff --git a/tests/screens/interface/menus/__init__.py b/tests/screens/interface/menus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/menus/conftest.py b/tests/screens/interface/menus/conftest.py new file mode 100644 index 000000000..2a4d60c11 --- /dev/null +++ b/tests/screens/interface/menus/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from automation.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/menus/test_popup_width.py b/tests/screens/interface/menus/test_popup_width.py new file mode 100644 index 000000000..c242f116d --- /dev/null +++ b/tests/screens/interface/menus/test_popup_width.py @@ -0,0 +1,71 @@ +from typing import Final, List, Tuple + +import numpy as np + +from automation.dearpygui.geometry import Point +from automation.palettes import Color, shipped_palettes, token_color +from automation.screen import Screen +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME + +POPUP_TOKEN: Final[str] = "popup" +RGB: Final[int] = 3 +COLOR_TOLERANCE: Final[float] = 1.5 / 255.0 +LEFT_INSET: Final[int] = 4 +OPENING_FRAMES: Final[int] = 5 +READINGS: Final[int] = 8 +FRAMES_BETWEEN_READINGS: Final[int] = 3 + +MENUS: Final[Tuple[MenuElements, ...]] = ( + MenuElements.GROUP_FILE, + MenuElements.GROUP_EDIT, + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.GROUP_VOICE, + MenuElements.GROUP_PLAYBACK, + MenuElements.GROUP_VIEW, + MenuElements.GROUP_HELP, +) + + +def popup_right_edge(pixels: np.ndarray, header: Point, background: Color) -> int: + """The last column the popup below ``header`` paints in its background, on the rows it spans. + + The popup's rows are those its background reaches just inside its left edge, under the header. + """ + painted = np.all(np.abs(pixels[:, :, :RGB] - np.array(background[:RGB])) <= COLOR_TOLERANCE, axis=2) + column = header.x + LEFT_INSET + rows = np.nonzero(painted[:, column])[0] + rows = rows[rows > header.y] + assert rows.size, f"no popup stands under the header at {header}" + columns = np.nonzero(painted[rows, column:].any(axis=0))[0] + return int(columns.max()) + column + + +class TestMenuPopupsHoldTheirWidth: + """Every menu popup keeps its width on every frame it stands open. + + The scenario opens each menu, reads the popup's right edge on several frames, and expects one + value. + """ + + def test_each_popup_stands_still(self, screen: Screen) -> None: + background = token_color(shipped_palettes().get(DEFAULT_PALETTE_NAME), POPUP_TOKEN) + + def measuring(group: MenuElements) -> None: + screen.menu.open(group) + screen.frames(OPENING_FRAMES) + header = screen.menu.header(group) + edges: List[int] = [] + for _ in range(READINGS): + edges.append(popup_right_edge(screen.frame_pixels(), header, background)) + screen.frames(FRAMES_BETWEEN_READINGS) + + assert len(set(edges)) == 1, f"the {group} popup widened: {edges}" + screen.menu.close() + screen.frames(OPENING_FRAMES) + + def open_each_menu(screen: Screen) -> None: + for group in MENUS: + measuring(group) + + screen.scenario(open_each_menu).run() diff --git a/tests/screens/interface/menus/test_printed_keys.py b/tests/screens/interface/menus/test_printed_keys.py new file mode 100644 index 000000000..74be9ac1f --- /dev/null +++ b/tests/screens/interface/menus/test_printed_keys.py @@ -0,0 +1,66 @@ +from typing import Final, Tuple + +from automation.screen import Screen +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + +PRINTED: Final[Tuple[Tuple[MenuElements, MenuElements, ShortcutId], ...]] = ( + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_NEW_PROJECT, ShortcutId.NEW_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_OPEN_PROJECT, ShortcutId.OPEN_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT, ShortcutId.SAVE_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_SAVE_PROJECT_AS, ShortcutId.SAVE_PROJECT_AS), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_PROJECT_PROPERTIES, ShortcutId.PROJECT_PROPERTIES), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_RENDER_SONG, ShortcutId.RENDER_SONG), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_CLOSE_PROJECT, ShortcutId.CLOSE_PROJECT), + (MenuElements.GROUP_FILE, MenuElements.ITEM_FILE_EXIT, ShortcutId.EXIT), + (MenuElements.GROUP_EDIT, MenuElements.ITEM_EDIT_UNDO, ShortcutId.UNDO), + (MenuElements.GROUP_EDIT, MenuElements.ITEM_EDIT_REDO, ShortcutId.REDO), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_RECONSTRUCT_FILE, + ShortcutId.RECONSTRUCT_FILE, + ), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_RECONSTRUCT_DIRECTORY, + ShortcutId.RECONSTRUCT_DIRECTORY, + ), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_OPEN, ShortcutId.OPEN_RECONSTRUCTION), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_SAVE, ShortcutId.SAVE_RECONSTRUCTION), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_SAVE_AS, + ShortcutId.SAVE_RECONSTRUCTION_AS, + ), + (MenuElements.GROUP_RECONSTRUCTION, MenuElements.ITEM_RECONSTRUCTION_CLOSE, ShortcutId.CLOSE_RECONSTRUCTION), + ( + MenuElements.GROUP_RECONSTRUCTION, + MenuElements.ITEM_RECONSTRUCTION_EXPORT_WAV, + ShortcutId.EXPORT_RECONSTRUCTION_WAV, + ), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY_FROM_START, ShortcutId.PLAY_FROM_START), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_PLAY_FROM_FRAME, ShortcutId.PLAY_FROM_FRAME), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_STOP, ShortcutId.STOP), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY, ShortcutId.TOGGLE_AUTOPLAY), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG, ShortcutId.TOGGLE_LOOP_SONG), + (MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS, ShortcutId.AUDIO_SETTINGS), + ( + MenuElements.GROUP_PLAYBACK_CHANNELS, + MenuElements.ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, + ShortcutId.UNMUTE_ALL_CHANNELS, + ), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS, ShortcutId.TOGGLE_ADVANCED_SETTINGS), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_FULLSCREEN, ShortcutId.TOGGLE_FULLSCREEN), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_DISPLAY_SETTINGS, ShortcutId.DISPLAY_SETTINGS), + (MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS, ShortcutId.KEYBOARD_SETTINGS), +) + + +class TestPrintedKeysFollowTheScheme: + """Each menu entry prints the keys the scheme in place gives the action it runs.""" + + def test_each_entry_prints_its_keys(self, screen: Screen) -> None: + for group, item, shortcut_id in PRINTED: + entries = {entry.label: entry.keys for entry in screen.menu.entries(group)} + assert screen.menu.label(item) in entries, f"{group} offers no {item}: {list(entries)}" + assert entries[screen.menu.label(item)] == screen.shortcut_words(shortcut_id), f"{item}" diff --git a/tests/screens/interface/palettes/__init__.py b/tests/screens/interface/palettes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/palettes/cases.py b/tests/screens/interface/palettes/cases.py new file mode 100644 index 000000000..d69558f07 --- /dev/null +++ b/tests/screens/interface/palettes/cases.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from typing import Dict + +from automation.boundaries.highlights import HighlightPlace +from automation.palettes import Color + + +@dataclass(frozen=True) +class Painted: + """Every color the interface holds at one moment. + + Attributes: + held: The colors DearPyGui holds for items and themes, by item. + highlights: The highlight colors laid on the tables, by place. + """ + + held: Dict[str, Color] + highlights: Dict[HighlightPlace, Color] diff --git a/tests/screens/interface/palettes/conftest.py b/tests/screens/interface/palettes/conftest.py new file mode 100644 index 000000000..2a4d60c11 --- /dev/null +++ b/tests/screens/interface/palettes/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from automation.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/palettes/constants.py b/tests/screens/interface/palettes/constants.py new file mode 100644 index 000000000..5d68682ba --- /dev/null +++ b/tests/screens/interface/palettes/constants.py @@ -0,0 +1,7 @@ +from typing import Final, Tuple + +from sampletones_application.categories.hierarchy import Tab + +SETTLING_FRAMES: Final[int] = 10 +TAB_FRAMES: Final[int] = 10 +EVERY_TAB: Final[Tuple[Tab, ...]] = (Tab.MAIN, Tab.INSTRUCTIONS, Tab.RECONSTRUCTIONS, Tab.SEQUENCER) diff --git a/tests/screens/interface/palettes/steps.py b/tests/screens/interface/palettes/steps.py new file mode 100644 index 000000000..5130b61c2 --- /dev/null +++ b/tests/screens/interface/palettes/steps.py @@ -0,0 +1,23 @@ +from automation.dearpygui.items.colors import read_held_colors +from automation.palettes import in_fractions +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import EVERY_TAB, TAB_FRAMES +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + + +def painted(screen: Screen) -> Painted: + """Reads every held color and every table highlight on the screen now.""" + return Painted( + held=screen.bridge.ask(read_held_colors), + highlights={place: in_fractions(color) for place, color in screen.table_highlights().items()}, + ) + + +def show_every_tab(screen: Screen) -> None: + """Brings each tab forward once, so every panel stands built and drawn before the colors are read.""" + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for tab in EVERY_TAB: + screen.tabs.bring_to_front(tab) + screen.frames(TAB_FRAMES) diff --git a/tests/screens/interface/palettes/test_every_held_color.py b/tests/screens/interface/palettes/test_every_held_color.py new file mode 100644 index 000000000..602e901c6 --- /dev/null +++ b/tests/screens/interface/palettes/test_every_held_color.py @@ -0,0 +1,101 @@ +import operator +from typing import Dict, List, Tuple + +from automation.palettes import Color, shared_colors, shipped_palettes +from automation.screen import Screen +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import SETTLING_FRAMES +from tests.screens.interface.palettes.steps import painted, show_every_tab + + +def unchanged(readings: List[Painted]) -> Tuple[List[Color], List[Color]]: + """The held colors and the highlight colors that read the same in every reading.""" + held = [readings[0].held[key] for key in readings[0].held if len({reading.held[key] for reading in readings}) == 1] + highlights = [ + readings[0].highlights[place] + for place in readings[0].highlights + if len({reading.highlights[place] for reading in readings}) == 1 + ] + return held, highlights + + +def differing(after: Painted, before: Painted) -> Dict[str, Tuple[Color, Color]]: + """The held colors two readings disagree on, among the items both found drawn. + + A dialog closed between the readings takes its own items off the screen, so those are left out. + """ + common = after.held.keys() & before.held.keys() + assert len(common) > len(before.held) // 2 + return {key: (after.held[key], before.held[key]) for key in common if after.held[key] != before.held[key]} + + +class TestEveryHeldColorFollowsThePalette: + """Picking a palette repaints every color the interface holds. + + A color that no palette changes is one every palette states alike. The table highlights follow the + palette, a round trip comes back to the colors it began with, and Cancel puts back the palette + in force before the preview. + + The scenario reads the starting palette, switches to each other palette and compares readings, + returns to the first palette, then previews another one and cancels. It ends with the starting + colors on the screen. + """ + + def test_no_color_is_left_from_the_palette_before(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + readings: Dict[str, Painted] = {} + + def read_the_palette_the_application_starts_in(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + assert settings.palette() == DEFAULT_PALETTE_NAME + readings[DEFAULT_PALETTE_NAME] = painted(screen) + assert readings[DEFAULT_PALETTE_NAME].highlights + + def every_palette_repaints_what_differs(screen: Screen) -> None: + for name in catalog.names: + if name == DEFAULT_PALETTE_NAME: + continue + + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + readings[name] = painted(screen) + assert readings[name].held.keys() == readings[DEFAULT_PALETTE_NAME].held.keys() + assert readings[name].highlights.keys() == readings[DEFAULT_PALETTE_NAME].highlights.keys() + + held, highlights = unchanged(list(readings.values())) + assert set(held) <= shared_colors(catalog) + assert set(highlights) <= shared_colors(catalog) + + def the_first_palette_again_paints_as_it_began(screen: Screen) -> None: + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + + assert painted(screen) == readings[DEFAULT_PALETTE_NAME] + + def cancel_leaves_nothing_of_the_preview(screen: Screen) -> None: + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + settings.choose_palette(other) + screen.frames(SETTLING_FRAMES) + assert painted(screen) == readings[other] + + settings.cancel() + screen.expect(settings.discard_prompt.is_shown, bool, description="the question about the change") + settings.discard_prompt.confirm() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + screen.frames(SETTLING_FRAMES) + after = painted(screen) + assert after.highlights == readings[DEFAULT_PALETTE_NAME].highlights + assert differing(after, readings[DEFAULT_PALETTE_NAME]) == {} + + screen.scenario( + read_the_palette_the_application_starts_in, + every_palette_repaints_what_differs, + the_first_palette_again_paints_as_it_began, + cancel_leaves_nothing_of_the_preview, + ).run() diff --git a/tests/screens/interface/palettes/test_twenty_swaps.py b/tests/screens/interface/palettes/test_twenty_swaps.py new file mode 100644 index 000000000..50ce47971 --- /dev/null +++ b/tests/screens/interface/palettes/test_twenty_swaps.py @@ -0,0 +1,44 @@ +import operator +from typing import Dict, Final, List + +from automation.palettes import shipped_palettes +from automation.screen import Screen +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from tests.screens.interface.palettes.cases import Painted +from tests.screens.interface.palettes.constants import SETTLING_FRAMES +from tests.screens.interface.palettes.steps import painted, show_every_tab + +SECOND_ROUND: Final[int] = 2 +ROUNDS: Final[int] = 20 + + +class TestTwentySwaps: + """Twenty round trips between two palettes add no items: the interface holds as many items after the + twentieth as after the second, and paints as it did. + """ + + def test_the_item_count_holds(self, screen: Screen) -> None: + settings = screen.display_settings + catalog = shipped_palettes() + other = next(name for name in catalog.names if name != DEFAULT_PALETTE_NAME) + counts: Dict[int, int] = {} + readings: List[Painted] = [] + + def swap_back_and_forth(screen: Screen) -> None: + show_every_tab(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + readings.append(painted(screen)) + + for round_number in range(1, ROUNDS + 1): + settings.choose_palette(other) + settings.choose_palette(DEFAULT_PALETTE_NAME) + screen.frames(SETTLING_FRAMES) + counts[round_number] = screen.item_count() + + assert counts[ROUNDS] == counts[SECOND_ROUND] + assert painted(screen) == readings[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario(swap_back_and_forth).run() diff --git a/tests/screens/interface/shortcuts/__init__.py b/tests/screens/interface/shortcuts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/interface/shortcuts/conftest.py b/tests/screens/interface/shortcuts/conftest.py new file mode 100644 index 000000000..2a4d60c11 --- /dev/null +++ b/tests/screens/interface/shortcuts/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from automation.application.startup import Startup +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with a playable reconstruction and the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) diff --git a/tests/screens/interface/shortcuts/constants.py b/tests/screens/interface/shortcuts/constants.py new file mode 100644 index 000000000..e20e3358d --- /dev/null +++ b/tests/screens/interface/shortcuts/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 diff --git a/tests/screens/interface/shortcuts/steps.py b/tests/screens/interface/shortcuts/steps.py new file mode 100644 index 000000000..e16d54729 --- /dev/null +++ b/tests/screens/interface/shortcuts/steps.py @@ -0,0 +1,9 @@ +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from tests.screens.interface.shortcuts.constants import SETTLING_FRAMES + + +def on_a_tab(screen: Screen, tab: Tab) -> None: + """Brings ``tab`` forward with a click, which leaves every field free of the keyboard.""" + screen.tabs.bring_to_front(tab) + screen.frames(SETTLING_FRAMES) diff --git a/tests/screens/interface/shortcuts/test_acting_on_documents.py b/tests/screens/interface/shortcuts/test_acting_on_documents.py new file mode 100644 index 000000000..a6e3f52cb --- /dev/null +++ b/tests/screens/interface/shortcuts/test_acting_on_documents.py @@ -0,0 +1,40 @@ +import operator + +from automation.screen import Screen +from automation.steps.reconstructions import UNTITLED, expect_open, titled +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + + +class TestShortcutsActingOnDocuments: + """Each shortcut closing or starting a document does what its entry says. + + The scenario closes the reconstruction, closes the project, starts a new project, and leaves the + application. + """ + + def test_each_acts_as_named(self, screen: Screen) -> None: + voices = screen.sequencer.voices + reconstructions = screen.reconstructions + + def documents_close_and_a_new_project_opens(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + on_a_tab(screen, Tab.SEQUENCER) + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(reconstructions.file_line, operator.not_, description="the reconstruction closed") + + screen.press_shortcut(ShortcutId.CLOSE_PROJECT) + screen.expect(voices.names, operator.not_, description="the project closed") + + screen.press_shortcut(ShortcutId.NEW_PROJECT) + + screen.expect(screen.title, titled(screen, screen.words(UNTITLED)).__eq__, description="a new project") + + def exit_leaves(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + screen.scenario(documents_close_and_a_new_project_opens, exit_leaves).run() diff --git a/tests/screens/interface/shortcuts/test_opening_dialogs.py b/tests/screens/interface/shortcuts/test_opening_dialogs.py new file mode 100644 index 000000000..c3084fe69 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_opening_dialogs.py @@ -0,0 +1,110 @@ +import operator +from functools import partial +from typing import Final, Tuple + +from automation.boundaries.dialogs import DialogKind +from automation.dearpygui.items.reading import is_tag_within +from automation.dearpygui.items.regions import read_windows +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_WINDOW, + TAG_SETTINGS_DISPLAY_WINDOW, + TAG_SETTINGS_KEYBINDINGS_WINDOW, + TAG_SETTINGS_PROPERTIES_WINDOW, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.interface.shortcuts.constants import SETTLING_FRAMES +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +DIALOG_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, str], ...]] = ( + (ShortcutId.DISPLAY_SETTINGS, TAG_SETTINGS_DISPLAY_WINDOW), + (ShortcutId.KEYBOARD_SETTINGS, TAG_SETTINGS_KEYBINDINGS_WINDOW), + (ShortcutId.AUDIO_SETTINGS, TAG_SETTINGS_AUDIO_WINDOW), + (ShortcutId.PROJECT_PROPERTIES, TAG_SETTINGS_PROPERTIES_WINDOW), + (ShortcutId.RENDER_SONG, TAG_SETTINGS_RENDER_WINDOW), +) + +FILE_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, DialogKind], ...]] = ( + (ShortcutId.SAVE_PROJECT_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_FAMITRACKER, DialogKind.SAVE), + (ShortcutId.EXPORT_PROJECT_BITPHASE, DialogKind.SAVE), + (ShortcutId.RECONSTRUCT_FILE, DialogKind.OPEN), + (ShortcutId.RECONSTRUCT_DIRECTORY, DialogKind.DIRECTORY), + (ShortcutId.OPEN_RECONSTRUCTION, DialogKind.OPEN), + (ShortcutId.SAVE_RECONSTRUCTION_AS, DialogKind.SAVE), + (ShortcutId.EXPORT_RECONSTRUCTION_WAV, DialogKind.SAVE), + (ShortcutId.EXPORT_INSTRUMENTS_FAMITRACKER, DialogKind.SAVE), +) + + +def window_standing(window: str) -> bool: + """Whether a window standing under ``window`` is shown. Runs on the render thread.""" + return any(reading.shown and is_tag_within(reading.alias, window) for reading in read_windows()) + + +class TestShortcutsOpeningDialogs: + """Each shortcut printed for a dialog opens that dialog, and each one printed for a file asks for that + file. + + Open project asks about the project already open first: Cancel takes the question back with no + file requested, and a second try followed by Confirm requests the file. + """ + + def test_each_opens_what_it_names(self, screen: Screen) -> None: + def each_dialog_shortcut(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, window in DIALOG_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + + screen.press_shortcut(shortcut_id) + + screen.expect(lambda: screen.bridge.ask(partial(window_standing, window)), bool, description=window) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.expect( + lambda: screen.bridge.ask(partial(window_standing, window)), + operator.not_, + description=f"{window} closed", + ) + + def each_file_shortcut(screen: Screen) -> None: + for shortcut_id, kind in FILE_SHORTCUTS: + on_a_tab(screen, Tab.RECONSTRUCTIONS) + asked = len(screen.dialog_requests()) + screen.answer_next_dialog(kind, None) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: len(screen.dialog_requests()), + (asked + 1).__eq__, + description=f"the file {shortcut_id} asks for", + ) + assert screen.dialog_requests()[-1].kind == kind + + def open_project_asks_about_the_open_one_first(screen: Screen) -> None: + prompt = screen.project.replace_prompt + on_a_tab(screen, Tab.SEQUENCER) + asked = len(screen.dialog_requests()) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question about the open project") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert len(screen.dialog_requests()) == asked + screen.answer_next_dialog(DialogKind.OPEN, None) + screen.press_shortcut(ShortcutId.OPEN_PROJECT) + screen.expect(prompt.is_shown, bool, description="the question again") + prompt.confirm() + screen.expect( + lambda: len(screen.dialog_requests()), (asked + 1).__eq__, description="the project asked for" + ) + assert screen.dialog_requests()[-1].kind == DialogKind.OPEN + + screen.scenario(each_dialog_shortcut, each_file_shortcut, open_project_asks_about_the_open_one_first).run() diff --git a/tests/screens/interface/shortcuts/test_switching_views.py b/tests/screens/interface/shortcuts/test_switching_views.py new file mode 100644 index 000000000..a09a1ed26 --- /dev/null +++ b/tests/screens/interface/shortcuts/test_switching_views.py @@ -0,0 +1,116 @@ +from functools import partial +from typing import Final, List, Tuple + +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.steps.sequencer import channels_sounding, checked +from automation.vocabulary.playback import PAUSE, PLAY +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.interface.shortcuts.steps import on_a_tab +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +TAB_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, Tab], ...]] = ( + (ShortcutId.SELECT_TAB_RECONSTRUCTIONS, Tab.RECONSTRUCTIONS), + (ShortcutId.SELECT_TAB_SEQUENCER, Tab.SEQUENCER), + (ShortcutId.SELECT_TAB_INSTRUCTIONS, Tab.INSTRUCTIONS), + (ShortcutId.SELECT_TAB_MAIN, Tab.MAIN), +) + +CHANNEL_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, ChannelName], ...]] = ( + (ShortcutId.TOGGLE_CHANNEL_PULSE_1, ChannelName.PULSE1), + (ShortcutId.TOGGLE_CHANNEL_PULSE_2, ChannelName.PULSE2), + (ShortcutId.TOGGLE_CHANNEL_TRIANGLE, ChannelName.TRIANGLE), + (ShortcutId.TOGGLE_CHANNEL_NOISE, ChannelName.NOISE), +) + +CHECKED_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements, MenuElements], ...]] = ( + (ShortcutId.TOGGLE_AUTOPLAY, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_AUTOPLAY), + (ShortcutId.TOGGLE_LOOP_SONG, MenuElements.GROUP_PLAYBACK, MenuElements.ITEM_PLAYBACK_LOOP_SONG), + (ShortcutId.TOGGLE_ADVANCED_SETTINGS, MenuElements.GROUP_VIEW, MenuElements.ITEM_VIEW_SHOW_ADVANCED_SETTINGS), +) + +FOLLOW_SHORTCUTS: Final[Tuple[Tuple[ShortcutId, MenuElements], ...]] = ( + (ShortcutId.FOLLOW_PATTERNS, MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS), + (ShortcutId.FOLLOW_OFF, MenuElements.ITEM_PLAYBACK_FOLLOW_OFF), + (ShortcutId.FOLLOW_ROWS, MenuElements.ITEM_PLAYBACK_FOLLOW_ROWS), +) + +FOLLOW_ITEMS: Final[Tuple[MenuElements, ...]] = tuple(item for _, item in FOLLOW_SHORTCUTS) + + +def followed(screen: Screen) -> List[bool]: + """Which follow modes Playback ▸ Follow marks as checked, in menu order.""" + return [checked(screen, MenuElements.GROUP_PLAYBACK_FOLLOW, screen.menu.label(item)) for item in FOLLOW_ITEMS] + + +class TestShortcutsSwitchingWhatIsShown: + """Each tab, channel, mode and playback shortcut changes what its menu entry or the screen says it + changes. + + The scenario goes through the tab shortcuts, mutes and restores each channel, flips each checked + entry twice, selects each follow mode, and starts and stops playback. + """ + + def test_each_switches_its_state(self, screen: Screen) -> None: + playback = screen.sequencer.playback + + def tabs(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + for shortcut_id, tab in TAB_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(screen.tabs.front, tab.__eq__, description=f"{tab} in front") + + screen.press_shortcut(ShortcutId.NEXT_TAB) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the next tab in front") + screen.press_shortcut(ShortcutId.PREVIOUS_TAB) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the previous tab in front") + + def channels(screen: Screen) -> None: + on_a_tab(screen, Tab.SEQUENCER) + for index, (shortcut_id, channel) in enumerate(CHANNEL_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position > index for position in range(len(CHANNEL_SHORTCUTS))] + screen.expect(partial(channels_sounding, screen), expected.__eq__, description=f"{channel} muted") + + for shortcut_id, channel in CHANNEL_SHORTCUTS: + screen.press_shortcut(shortcut_id) + + screen.expect(partial(channels_sounding, screen), all, description="every channel sounding again") + + def switches(screen: Screen) -> None: + for shortcut_id, group, item in CHECKED_SHORTCUTS: + on_a_tab(screen, Tab.SEQUENCER) + before = screen.menu.is_checked(group, item) + + screen.press_shortcut(shortcut_id) + + screen.expect( + lambda: screen.menu.is_checked(group, item), + (not before).__eq__, + description=f"{item} switched", + ) + screen.press_shortcut(shortcut_id) + screen.expect(lambda: screen.menu.is_checked(group, item), before.__eq__, description=f"{item} back") + + def follow_modes(screen: Screen) -> None: + for index, (shortcut_id, item) in enumerate(FOLLOW_SHORTCUTS): + screen.press_shortcut(shortcut_id) + + expected = [position == index for position in range(len(FOLLOW_ITEMS))] + screen.expect(partial(followed, screen), expected.__eq__, description=f"{item} alone checked") + + def play_and_stop(screen: Screen) -> None: + for start in (ShortcutId.PLAY, ShortcutId.PLAY_FROM_START): + screen.press_shortcut(start) + screen.expect(playback.play_entry, screen.words(PAUSE).__eq__, description=f"playing after {start}") + + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.play_entry, screen.words(PLAY).__eq__, description="stopped") + + screen.scenario(tabs, channels, switches, follow_modes, play_and_stop).run() diff --git a/tests/screens/main/__init__.py b/tests/screens/main/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/cards/__init__.py b/tests/screens/main/cards/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/cards/test_card_layout.py b/tests/screens/main/cards/test_card_layout.py new file mode 100644 index 000000000..1e48af449 --- /dev/null +++ b/tests/screens/main/cards/test_card_layout.py @@ -0,0 +1,176 @@ +import operator +from typing import Final, List + +import pytest + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.regions import read_scroll +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.worlds.home import World, lived_in_world +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import ( + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_CONFIG_PANEL, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_SOURCE_PANEL, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.seeds.recordings import Recording + +PAST_THE_DIALOG: Final[int] = 30 +SECONDS: Final[float] = 0.2 +FREQUENCY: Final[float] = 220.0 +SAME_LINE_PIXELS: Final[float] = 1.0 + + +def box(screen: Screen, tag: str) -> Rect: + """Returns the rectangle of the item ``tag``.""" + rect = screen.bridge.ask(lambda: read_item(tag).rect) + assert rect is not None, tag + return rect + + +def bottom(rect: Rect) -> float: + """Returns the lower edge of ``rect``.""" + return rect.y + rect.height + + +def right(rect: Rect) -> float: + """Returns the right edge of ``rect``.""" + return rect.x + rect.width + + +@pytest.fixture +def world() -> World: + """The home holds thirty short voices, and the saved state shows the Advanced settings.""" + state = lived_in_world().state + assert state is not None + files = tuple( + Recording(destination=home_path(f"voice{index:02d}.wav"), seconds=SECONDS, frequency=FREQUENCY) + for index in range(PAST_THE_DIALOG) + ) + return World( + state=state.model_copy(update={"advanced_settings": True}), + application_config=None, + config=None, + files=files, + ) + + +class TestTheCardsOfTheMainTab: + """The Main tab stacks General settings, the Converter and Source settings from top to bottom, and the + Advanced card keeps General's line. + """ + + def test_the_order_and_the_shared_line(self, screen: Screen) -> None: + """The cards stand in order, General and Advanced end on one line, and Advanced lies right of + General. + """ + general = box(screen, TAG_MAIN_CONFIG_PANEL) + advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) + converter = box(screen, TAG_MAIN_CONVERTER_PANEL) + source = box(screen, TAG_MAIN_SOURCE_PANEL) + + assert general.y < converter.y < source.y + assert abs(bottom(general) - bottom(advanced)) <= SAME_LINE_PIXELS + assert right(general) < advanced.x + + def test_advanced_stands_beside_general_ending_where_the_converter_does(self, screen: Screen) -> None: + """After Advanced is put away and shown again, it stands beside General and ends at the Converter's + right edge. + """ + + def advanced_off_and_on_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + + screen.expect(screen.main.advanced.is_shown, bool, description="Advanced back") + + def the_two_side_by_side(screen: Screen) -> None: + screen.expect( + lambda: right(box(screen, TAG_MAIN_CONFIG_PANEL)) < box(screen, TAG_MAIN_ADVANCED_PANEL).x, + bool, + description="the two side by side", + ) + advanced = box(screen, TAG_MAIN_ADVANCED_PANEL) + assert abs(right(advanced) - right(box(screen, TAG_MAIN_CONVERTER_PANEL))) <= SAME_LINE_PIXELS + + screen.scenario(advanced_off_and_on_again, the_two_side_by_side).run() + + def test_general_fills_the_row_with_advanced_put_away(self, screen: Screen) -> None: + """With Advanced put away, General ends at the Converter's right edge.""" + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + + screen.expect( + lambda: abs(right(box(screen, TAG_MAIN_CONFIG_PANEL)) - right(box(screen, TAG_MAIN_CONVERTER_PANEL))), + lambda gap: gap <= SAME_LINE_PIXELS, + description="General ending where the Converter does", + ) + + def test_collapsing_general_alone_gives_up_the_rows_height(self, screen: Screen) -> None: + """Collapsing General with Advanced put away shrinks the card and lifts the Converter to meet it.""" + general = screen.main.card(TAG_MAIN_CONFIG_PANEL) + heights: List[float] = [] + + def put_advanced_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) + screen.expect(screen.main.advanced.is_shown, operator.not_, description="Advanced put away") + heights.append(box(screen, TAG_MAIN_CONFIG_PANEL).height) + + def collapse_general(screen: Screen) -> None: + general.toggle() + + screen.expect(general.is_collapsed, bool, description="General collapsed") + screen.expect( + lambda: box(screen, TAG_MAIN_CONVERTER_PANEL).y - bottom(box(screen, TAG_MAIN_CONFIG_PANEL)), + lambda gap: gap < heights[0], + description="the Converter meeting the collapsed card", + ) + collapsed = box(screen, TAG_MAIN_CONFIG_PANEL) + assert collapsed.height < heights[0] + + screen.scenario(put_advanced_away, collapse_general).run() + + +class TestAddingAfterScrollingThePick: + """A mix picked from a scrolled list closes on Add, and the card answers the next gesture. + + The checklist asks it of twelve recordings. The question fits twelve without scrolling on this + screen, so the scenario gathers as many as make it scroll. It scrolls the pick list to the end and + presses Add. A click on the first row then shows that recording on the Source card. + """ + + def test_the_question_closes_and_the_card_answers(self, screen: Screen) -> None: + """Add closes the question and the next row pick reaches the Source card.""" + main = screen.main + question = main.converter.mix_question + paths = [home_path(f"voice{index:02d}.wav") for index in range(PAST_THE_DIALOG)] + + def open_the_question_and_scroll_it(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + screen.expect( + lambda: screen.bridge.ask(lambda: read_scroll(question.tags.well)).maximum, + lambda maximum: maximum > 0, + description="the pick list scrolling", + ) + + screen.hand.scroll_to_end(question.tags.well) + + def add(screen: Screen) -> None: + question.add() + + screen.expect(question.is_shown, operator.not_, description="the question closed") + + def the_card_answers(screen: Screen) -> None: + main.converter.list.pick(paths[0]) + + screen.expect(main.source.subject, paths[0].stem.__eq__, description="the card on the row picked") + + screen.scenario(open_the_question_and_scroll_it, add, the_card_answers).run() diff --git a/tests/screens/main/gathering/__init__.py b/tests/screens/main/gathering/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/gathering/conftest.py b/tests/screens/main/gathering/conftest.py new file mode 100644 index 000000000..538a2265f --- /dev/null +++ b/tests/screens/main/gathering/conftest.py @@ -0,0 +1,58 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.steps.main import home_path +from automation.worlds.home import HomeFile, World, lived_in_world +from tests.screens.main.gathering.constants import ( + COLLIDING, + INNER, + LOOPS, + LOOPS_HELD, + NOTES, + TAKES, + TAKES_AT_THE_TOP, + TAKES_INSIDE, +) +from tests.suite.screens.seeds.archives import WrittenBytes +from tests.suite.screens.seeds.constants import KICK, SNARE +from tests.suite.screens.seeds.recordings import Recording + +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.2 +TONE_FREQUENCY: Final[float] = 220.0 +NOTE_TEXT: Final[bytes] = b"A folder holding words, not sounds.\n" + + +def recording(path: Path, seconds: float) -> HomeFile: + """Returns a seeded tone recording of ``seconds`` to be written at ``path``.""" + return Recording(destination=path, seconds=seconds, frequency=TONE_FREQUENCY) + + +def gathering_world() -> World: + """Returns the lived-in world plus two long recordings, the Takes folder with a subfolder, the Loops + folder, three recordings with colliding names and a Notes folder holding only a text file. + """ + files: List[HomeFile] = [ + recording(home_path(KICK), PLAYED_SECONDS), + recording(home_path(SNARE), PLAYED_SECONDS), + *(recording(home_path(TAKES) / name, SHORT_SECONDS) for name in TAKES_AT_THE_TOP), + *(recording(home_path(TAKES) / INNER / name, SHORT_SECONDS) for name in TAKES_INSIDE), + *(recording(home_path(LOOPS) / name, SHORT_SECONDS) for name in LOOPS_HELD), + *(recording(home_path(name), SHORT_SECONDS) for name in COLLIDING), + WrittenBytes(home_path(NOTES) / INNER / "readme.txt", NOTE_TEXT), + ] + world = lived_in_world() + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(files), + ) + + +@pytest.fixture +def world() -> World: + """The home holds the recordings and folders the gathering scenarios pick from.""" + return gathering_world() diff --git a/tests/screens/main/gathering/constants.py b/tests/screens/main/gathering/constants.py new file mode 100644 index 000000000..2733d40aa --- /dev/null +++ b/tests/screens/main/gathering/constants.py @@ -0,0 +1,10 @@ +from typing import Final, Tuple + +TAKES: Final[str] = "Takes" +INNER: Final[str] = "Inner" +LOOPS: Final[str] = "Loops" +NOTES: Final[str] = "Notes" +TAKES_AT_THE_TOP: Final[Tuple[str, ...]] = ("one.wav", "two.wav", "three.wav") +TAKES_INSIDE: Final[Tuple[str, ...]] = ("four.wav", "five.wav") +LOOPS_HELD: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +COLLIDING: Final[Tuple[str, ...]] = ("Kick Loop.wav", "kick loop.wav", "żółw.wav") diff --git a/tests/screens/main/gathering/steps.py b/tests/screens/main/gathering/steps.py new file mode 100644 index 000000000..71cc2d099 --- /dev/null +++ b/tests/screens/main/gathering/steps.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from automation.screen import Screen + + +def gathered(screen: Screen, *paths: Path) -> None: + """Waits until the list holds a row for each of ``paths`` and no other row.""" + converter = screen.main.converter + expected = sorted(converter.list.row(path) for path in paths) + screen.expect( + lambda: sorted(converter.list.rows()), + expected.__eq__, + description=f"the list holding {[path.name for path in paths]} alone", + ) diff --git a/tests/screens/main/gathering/test_after_a_refresh.py b/tests/screens/main/gathering/test_after_a_refresh.py new file mode 100644 index 000000000..53352b20e --- /dev/null +++ b/tests/screens/main/gathering/test_after_a_refresh.py @@ -0,0 +1,34 @@ +from automation.screen import Screen +from automation.steps.main import explorer_row, gather, home_path +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.main import TAG_MAIN_EXPLORER_BUTTON_REFRESH +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.seeds.constants import KICK + + +class TestGatheringARowRefreshBuiltAnew: + """A recording Ctrl-clicked the moment Refresh brings its row back joins the list. + + Refresh takes the browser's rows down and builds them anew over a few frames, so the row of the + recording comes back as a new row far below the top of a long browser. The scenario presses + Refresh, waits for the row to come back and Ctrl-clicks it at once, expecting the recording in the + list. + """ + + def test_the_rebuilt_row_gathers(self, screen: Screen) -> None: + explorer = screen.explorer + kick = home_path(KICK) + + def refresh(screen: Screen) -> None: + before = explorer_row(screen, kick) + + screen.hand.click(compose_tag(TAG_MAIN_EXPLORER_BUTTON_REFRESH)) + + screen.expect(lambda: explorer.file_row(kick), before.__ne__, description="the row taken down") + + def ctrl_click_the_row_it_brought_back(screen: Screen) -> None: + gather(screen, kick) + + gathered(screen, kick) + + screen.scenario(refresh, ctrl_click_the_row_it_brought_back).run() diff --git a/tests/screens/main/gathering/test_card_header.py b/tests/screens/main/gathering/test_card_header.py new file mode 100644 index 000000000..acf6d9b09 --- /dev/null +++ b/tests/screens/main/gathering/test_card_header.py @@ -0,0 +1,37 @@ +from automation.screen import Screen +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_COLLAPSE_STRIP, + TAG_GLOBAL_THEME_COLLAPSE_HEADER, + TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED, +) +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_SOURCE_PANEL + + +class TestACardHeaderUnderThePointer: + """A card's header bar lights while the pointer rests on it and settles back once the pointer leaves. + + The pointer rests on the Converter's header bar, which lights. Then it moves to the Source settings + header, and the first bar returns to its resting theme. + """ + + def test_the_bar_lights_and_settles(self, screen: Screen) -> None: + strip = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_COLLAPSE_STRIP) + elsewhere = compose_tag(TAG_MAIN_SOURCE_PANEL, SUF_COLLAPSE_STRIP) + + def rest_on_the_bar(screen: Screen) -> None: + screen.hand.hover(strip) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED.__eq__, description="lit" + ) + + def move_off(screen: Screen) -> None: + screen.hand.scroll_into_view(elsewhere) + screen.hand.hover(elsewhere) + + screen.expect( + lambda: screen.theme_of(strip), TAG_GLOBAL_THEME_COLLAPSE_HEADER.__eq__, description="settled" + ) + + screen.scenario(rest_on_the_bar, move_off).run() diff --git a/tests/screens/main/gathering/test_folders_and_edges.py b/tests/screens/main/gathering/test_folders_and_edges.py new file mode 100644 index 000000000..392a25ffe --- /dev/null +++ b/tests/screens/main/gathering/test_folders_and_edges.py @@ -0,0 +1,145 @@ +import operator +from pathlib import Path +from typing import Final + +from automation.screen import Screen +from automation.steps.main import explorer_row, home_path +from automation.vocabulary.converter import CANCEL_RUN, FOLDER_ROW +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.gathering.constants import ( + COLLIDING, + LOOPS, + LOOPS_HELD, + NOTES, + TAKES, + TAKES_AT_THE_TOP, + TAKES_INSIDE, +) +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.seeds.constants import KICK + +ADD_FOLDER: Final[str] = "main.explorer.label.context_add_folder_stems" +NOTHING_BELOW: Final[str] = "main.converter.message.scan_nothing_below" + + +def folder_label(screen: Screen, path: Path, count: int) -> str: + """Returns the text a folder row shows for ``path`` holding ``count`` recordings.""" + return screen.words(FOLDER_ROW).format(name=path.name, count=count) + + +def wait_for_the_scan_to_end(screen: Screen) -> None: + """Waits until the Converter has finished reading the folders it was given.""" + screen.expect(lambda: not screen.main.converter.scan_shown(), bool, description="the folder read") + + +class TestGatheringAFolder: + """A folder lands as one row counting its recordings, subfolders included, and starts no run. + + A recording is gathered first. Ctrl-click on the Takes folder and Add folder from the menu of the + Loops folder each add one counted row. The scenario ends with no run on screen and the recording + still on top. + """ + + def test_ctrl_click_and_add_folder_each_give_one_counted_row(self, screen: Screen) -> None: + """Each gesture gives its folder one row with the count of the recordings inside.""" + converter = screen.main.converter + + def gather_a_recording_first(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + + def ctrl_click_a_folder(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(TAKES))) + + gathered(screen, home_path(KICK), home_path(TAKES)) + wait_for_the_scan_to_end(screen) + count = len(TAKES_AT_THE_TOP) + len(TAKES_INSIDE) + assert converter.list.label(home_path(TAKES)) == folder_label(screen, home_path(TAKES), count) + + def add_folder_from_its_menu(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(LOOPS))) + screen.expect(screen.context_menu.is_shown, bool, description="the folder's menu") + + screen.context_menu.choose(screen.words(ADD_FOLDER)) + + gathered(screen, home_path(KICK), home_path(TAKES), home_path(LOOPS)) + wait_for_the_scan_to_end(screen) + assert converter.list.label(home_path(LOOPS)) == folder_label(screen, home_path(LOOPS), len(LOOPS_HELD)) + + def no_run_started_and_the_recording_stands_first(screen: Screen) -> None: + assert not converter.run_shown() + assert converter.action() != screen.words(CANCEL_RUN) + assert converter.list.rows()[0] == converter.list.row(home_path(KICK)) + + screen.scenario( + gather_a_recording_first, + ctrl_click_a_folder, + add_folder_from_its_menu, + no_run_started_and_the_recording_stands_first, + ).run() + + +class TestGatheringAtTheEdges: + """Gathering copes with its edges: a folder gathered twice, a folder with no recordings and names that + collide. + """ + + def test_a_folder_gathered_twice_stands_once(self, screen: Screen) -> None: + """Ctrl-clicking the same folder twice leaves one row for it.""" + for _ in range(2): + screen.explorer.ctrl_click(explorer_row(screen, home_path(TAKES))) + wait_for_the_scan_to_end(screen) + + gathered(screen, home_path(TAKES)) + + def test_a_folder_of_no_recordings_says_so_and_gathers_nothing(self, screen: Screen) -> None: + """A folder holding only words shows a notice and adds no row; the next folder of sounds gathers + once the notice is dismissed. + """ + converter = screen.main.converter + notice = converter.nothing_below_notice + + def ctrl_click_a_folder_of_words(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(NOTES))) + + screen.expect(notice.is_shown, bool, description="the notice") + assert notice.words() == screen.words(NOTHING_BELOW) + + def dismiss_and_gather_a_folder_of_sounds(screen: Screen) -> None: + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice gone") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(LOOPS))) + + gathered(screen, home_path(LOOPS)) + + screen.scenario(ctrl_click_a_folder_of_words, dismiss_and_gather_a_folder_of_sounds).run() + + def test_names_colliding_in_case_and_spacing_stand_apart(self, screen: Screen) -> None: + """Recordings whose names differ only in case or spacing get separate rows, and ticking a box on + one leaves the others as they were. + """ + converter = screen.main.converter + paths = [home_path(name) for name in COLLIDING] + + def gather_each(screen: Screen) -> None: + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + + gathered(screen, *paths) + + def a_box_on_one_leaves_the_others(screen: Screen) -> None: + first, *others = paths + before = [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] + + converter.list.tick(first, ChannelName.PULSE1) + + screen.expect( + lambda: converter.list.channel_ticked(first, ChannelName.PULSE1), + (not before[0]).__eq__, + description="the box flipped on the first", + ) + assert [converter.list.channel_ticked(other, ChannelName.PULSE1) for other in others] == before + + screen.scenario(gather_each, a_box_on_one_leaves_the_others).run() diff --git a/tests/screens/main/gathering/test_navigating_and_clicking.py b/tests/screens/main/gathering/test_navigating_and_clicking.py new file mode 100644 index 000000000..960f35265 --- /dev/null +++ b/tests/screens/main/gathering/test_navigating_and_clicking.py @@ -0,0 +1,81 @@ +from functools import partial +from typing import Final + +from automation.screen import Screen +from automation.steps.main import explorer_row, home_path +from automation.vocabulary.converter import CONVERT_NOTHING, CONVERT_ONE +from tests.screens.main.gathering.constants import INNER, LOOPS, TAKES +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.seeds.constants import KICK, SNARE + +ADD_AS_STEM: Final[str] = "main.explorer.label.context_add_stem" + + +class TestNavigatingGathersNothing: + """Opening folders in the explorer leaves the list empty; a double-click on a recording gathers that + one. + + The scenario opens three folders and expects the hint, an empty list and the idle Convert button. A + double-click on a recording then gives the list one row and names that recording on the button. + """ + + def test_the_list_stays_empty_until_a_recording_is_double_clicked(self, screen: Screen) -> None: + """The list holds no row until a recording is double-clicked.""" + converter = screen.main.converter + + def open_folders(screen: Screen) -> None: + for folder in (home_path(TAKES), home_path(TAKES) / INNER, home_path(LOOPS)): + row = explorer_row(screen, folder) + + screen.explorer.open_by_click(row) + + screen.expect(partial(screen.explorer.is_open, row), bool, description=f"{folder.name} open") + + def the_list_stays_empty(screen: Screen) -> None: + assert converter.list.hint_shown() + assert not converter.list.list_shown() + assert converter.action() == screen.words(CONVERT_NOTHING) + + def a_double_click_gathers_that_recording(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(KICK).stem) + assert not converter.list.hint_shown() + + screen.scenario(open_folders, the_list_stays_empty, a_double_click_gathers_that_recording).run() + + +class TestAPlainClickOnARecording: + """A plain click plays a recording; Ctrl-click and Add as stem gather it. + + A click starts playback and the hint stays. Ctrl-click on the same recording gathers it. Add as stem + from another recording's menu gathers that one too, and the menu closes. + """ + + def test_it_plays_and_the_gathering_gestures_gather(self, screen: Screen) -> None: + """A click only plays, while Ctrl-click and the Add as stem entry add rows.""" + converter = screen.main.converter + + def a_click_plays(screen: Screen) -> None: + heard = screen.sound_heard() + screen.explorer.click(explorer_row(screen, home_path(KICK))) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + assert converter.list.hint_shown() + + def ctrl_click_gathers(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(KICK))) + + gathered(screen, home_path(KICK)) + + def add_as_stem_gathers(screen: Screen) -> None: + screen.explorer.right_click(explorer_row(screen, home_path(SNARE))) + screen.expect(screen.context_menu.is_shown, bool, description="the recording's menu") + + screen.context_menu.choose(screen.words(ADD_AS_STEM)) + + gathered(screen, home_path(KICK), home_path(SNARE)) + assert not screen.context_menu.is_shown() + + screen.scenario(a_click_plays, ctrl_click_gathers, add_as_stem_gathers).run() diff --git a/tests/screens/main/gathering/test_right_clicking.py b/tests/screens/main/gathering/test_right_clicking.py new file mode 100644 index 000000000..1e2abd11b --- /dev/null +++ b/tests/screens/main/gathering/test_right_clicking.py @@ -0,0 +1,68 @@ +import operator +from typing import Final, List + +from automation.dearpygui.geometry import Rect +from automation.screen import Screen +from automation.steps.main import explorer_row, home_path +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.seeds.constants import KICK, SNARE + +ROUNDS: Final[int] = 20 +FIRST_COUNTED_ROUND: Final[int] = 2 +POINTER_REACH: Final[int] = 4 + + +class TestRightClickingOverAndOver: + """Twenty right-clicks, alternating between the explorer and the list, each open a menu at the pointer + and leave the interface as large as it was. + + A recording is gathered first. Each round opens the menu, reads its box and entries and dismisses + it. The scenario ends when the item count of the last round equals that of the second round. + """ + + def test_each_menu_stands_at_the_pointer_and_the_interface_does_not_grow(self, screen: Screen) -> None: + """Each menu overlaps the pointer, has labeled entries and the item count stays level.""" + converter = screen.main.converter + menu = screen.context_menu + counts: List[int] = [] + + def gather(screen: Screen) -> None: + screen.explorer.double_click(explorer_row(screen, home_path(KICK))) + gathered(screen, home_path(KICK)) + + def right_click_over_and_over(screen: Screen) -> None: + for round_number in range(1, ROUNDS + 1): + in_the_list = round_number % 2 == 0 + row = converter.list.row(home_path(KICK)) if in_the_list else explorer_row(screen, home_path(SNARE)) + if in_the_list: + screen.hand.scroll_into_view(row) + screen.hand.right_click(row) + else: + screen.explorer.right_click(row) + screen.expect(menu.is_shown, bool, description=f"the menu of round {round_number}") + pointer = screen.pointer() + + box = menu.box() + entries = menu.entries() + menu.dismiss() + + screen.expect(menu.is_shown, operator.not_, description=f"the menu of round {round_number} put away") + assert ( + box.overlap( + Rect( + x=pointer.x - POINTER_REACH, + y=pointer.y - POINTER_REACH, + width=2 * POINTER_REACH, + height=2 * POINTER_REACH, + ) + ) + is not None + ), (box, pointer) + assert entries and all(entry.enabled or entry.label for entry in entries) + if round_number >= FIRST_COUNTED_ROUND: + counts.append(screen.item_count()) + + def nothing_grew(screen: Screen) -> None: + assert counts[-1] == counts[0], counts + + screen.scenario(gather, right_click_over_and_over, nothing_grew).run() diff --git a/tests/screens/main/library/__init__.py b/tests/screens/main/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/library/steps.py b/tests/screens/main/library/steps.py new file mode 100644 index 000000000..52d0a522d --- /dev/null +++ b/tests/screens/main/library/steps.py @@ -0,0 +1,16 @@ +from automation.worlds.home import World, screen_filling_state +from sampletones_core.configs import Config +from tests.suite.screens.seeds.libraries import MiniLibrary + + +def library_world(config: Config, *, built: bool) -> World: + """A world with Advanced settings on and ``config`` in force; with ``built`` set, the home also holds + the library that ``config`` describes. + """ + state = screen_filling_state().model_copy(update={"advanced_settings": True}) + return World( + state=state, + application_config=None, + config=config, + files=(MiniLibrary(config),) if built else (), + ) diff --git a/tests/screens/main/library/test_folders_and_generators.py b/tests/screens/main/library/test_folders_and_generators.py new file mode 100644 index 000000000..cd8b86ce3 --- /dev/null +++ b/tests/screens/main/library/test_folders_and_generators.py @@ -0,0 +1,174 @@ +import operator +from typing import Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from automation.boundaries.dialogs import DialogKind +from automation.dearpygui.items.reading import find_item +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from automation.steps.instructions import library_path, library_row, open_library_row +from automation.steps.main import home_path +from automation.vocabulary.libraries import LIBRARY_LOADED +from automation.worlds.home import World, one_worker_config +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.structures.tree.node import GeneratorNode +from sampletones_shared.paths.user import LIBRARY_DIRECTORY +from tests.screens.main.library.steps import library_world +from tests.suite.screens.seeds.archives import archived_library + +OTHER_LIBRARIES: Final[str] = "Other libraries" + + +def generator_rows(screen: Screen, row: Item) -> List[Item]: + """The generator rows below the library ``row``, read on the render thread.""" + + def read() -> List[Item]: + found: List[Item] = [] + find_item(row, lambda item: _collect_generator(item, found)) + return found + + return screen.bridge.ask(read) + + +def _collect_generator(item: Item, found: List[Item]) -> bool: + user_data = dpg.get_item_user_data(item) + if isinstance(user_data, tuple) and user_data and isinstance(user_data[0], GeneratorNode): + found.append(item) + + return False + + +class TestAnotherLibraryFolder: + """Advanced settings pointed at another folder list that folder's libraries; pointed back, the first + folder's library returns loaded. + + The first library is opened and reads loaded. The library folder is pointed at a folder holding an + older library, where the first library is gone and the older one is flagged outdated. Pointing back + lists the first library again and clears the older one. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library and a folder of other libraries holding an older one.""" + config = one_worker_config() + world = library_world(config, built=True) + return World( + state=world.state, + application_config=None, + config=config, + files=(*world.files, archived_library(home_path(OTHER_LIBRARIES))), + ) + + def test_each_folder_lists_its_own(self, screen: Screen) -> None: + """Each library folder lists its own libraries and none of the other's.""" + library = screen.instructions.library + main = screen.main + ours = library_path(one_worker_config()) + names: List[str] = [] + + def ours_listed_and_loaded(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + row = library_row(screen, ours) + names.append(library.tree.label(row)) + + open_library_row(screen, row) + + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(names[0]).__eq__, description="loaded") + + def point_at_the_other_folder(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + screen.answer_next_dialog(DialogKind.DIRECTORY, home_path(OTHER_LIBRARIES)) + main.choose_library_directory() + screen.expect( + main.library_directory, str(home_path(OTHER_LIBRARIES)).__eq__, description="the other folder" + ) + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(lambda: library.row(ours.name) is None, bool, description="ours no longer listed") + assert library.tree.library_row(lambda node: node.outdated) is not None + + def point_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + screen.answer_next_dialog(DialogKind.DIRECTORY, LIBRARY_DIRECTORY) + main.choose_library_directory() + screen.expect(main.library_directory, str(LIBRARY_DIRECTORY).__eq__, description="our folder") + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + library_row(screen, ours) + assert library.tree.library_row(lambda node: node.outdated) is None + + screen.scenario(ours_listed_and_loaded, point_at_the_other_folder, point_back).run() + + def test_pointing_back_finds_the_library_loaded(self, screen: Screen) -> None: + """After pointing away and back, the first library's status reads loaded without a second opening.""" + library = screen.instructions.library + main = screen.main + ours = library_path(one_worker_config()) + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + name = library.tree.label(library_row(screen, ours)) + open_library_row(screen, library_row(screen, ours)) + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(name).__eq__, description="loaded") + screen.tabs.bring_to_front(Tab.MAIN) + for folder in (home_path(OTHER_LIBRARIES), LIBRARY_DIRECTORY): + screen.answer_next_dialog(DialogKind.DIRECTORY, folder) + main.choose_library_directory() + screen.expect(main.library_directory, str(folder).__eq__, description=f"the folder {folder.name}") + + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + library_row(screen, ours) + screen.expect(library.status, screen.words(LIBRARY_LOADED).format(name).__eq__, description="loaded again") + + +class TestGeneratorsUnderALibrary: + """A library this build reads opens onto its generators; one another version built opens onto none. + + The old library's row asks to rebuild when opened, and Cancel keeps it closed. The current + library's row opens onto generator rows while the old one still lists none. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library and an older library in the library folder.""" + config = one_worker_config() + world = library_world(config, built=True) + return World( + state=world.state, + application_config=None, + config=config, + files=(*world.files, archived_library(LIBRARY_DIRECTORY)), + ) + + def test_the_old_one_lists_none_and_ours_lists_them(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.rebuild_prompt + ours = library_path(one_worker_config()) + + def the_old_one_opens_onto_nothing(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + old = screen.expect_item( + lambda: library.tree.library_row(lambda node: node.outdated), + description="the old library's row", + ) + + library.tree.open_by_click(old) + + screen.expect(prompt.is_shown, bool, description="the rebuild question") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + def ours_opens_onto_its_generators(screen: Screen) -> None: + open_library_row(screen, library_row(screen, ours)) + + screen.expect( + lambda: len(generator_rows(screen, library_row(screen, ours))), bool, description="generator rows" + ) + old = library.tree.library_row(lambda node: node.outdated) + assert old is not None + assert generator_rows(screen, old) == [] + + screen.scenario(the_old_one_opens_onto_nothing, ours_opens_onto_its_generators).run() diff --git a/tests/screens/main/library/test_generating.py b/tests/screens/main/library/test_generating.py new file mode 100644 index 000000000..bea83f233 --- /dev/null +++ b/tests/screens/main/library/test_generating.py @@ -0,0 +1,140 @@ +import operator +from functools import partial +from typing import Final, List + +import pytest + +from automation.screen import Screen +from automation.steps.instructions import library_path, library_row +from automation.vocabulary.libraries import LIBRARY_LOADED +from automation.worlds.home import World, one_worker_config +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from tests.screens.main.library.steps import library_world + +QUICK_SAMPLE_RATE: Final[int] = 11025 +GENERATION_TIMEOUT_SECONDS: Final[float] = 300.0 +LIBRARY_MISSING: Final[str] = "instructions.library.template.library_not_exists_template" +GENERATE: Final[str] = "instructions.library.label.generate_library_button" +REGENERATE: Final[str] = "instructions.library.label.regenerate_library_button" +GENERATION_CANCELED: Final[str] = "instructions.library.message.status_generation_canceled" + + +def quick_config() -> Config: + """Settings whose library generates in seconds: a low sample rate, measured by the plain spectrum.""" + config = one_worker_config() + library = config.library.model_copy( + update={"sample_rate": QUICK_SAMPLE_RATE, "spectrum_method": SpectrumMethod.FFT} + ) + return config.model_copy(update={"library": library}) + + +class TestGeneratingALibrary: + """A configuration with no library generates one, which the tree lists and the status reads as loaded.""" + + @pytest.fixture + def world(self) -> World: + """A home holding no library for the quick settings.""" + return library_world(quick_config(), built=False) + + def test_generate_lists_it_loaded_and_offers_regenerate(self, screen: Screen) -> None: + """Generate lists the library, the status reads it loaded, and the button then offers Regenerate.""" + library = screen.instructions.library + path = library_path(quick_config()) + + def nothing_to_list(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered") + assert library.row(path.name) is None + + def generate(screen: Screen) -> None: + library.generate() + + row = screen.bridge.expect( + partial(library.row, path.name), + lambda found: found is not None, + description="the library listed", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert row is not None + name = library.tree.label(row) + screen.bridge.expect( + library.status, + screen.words(LIBRARY_LOADED).format(name).__eq__, + description="the library loaded", + timeout=GENERATION_TIMEOUT_SECONDS, + ) + assert library.generate_label() == screen.words(REGENERATE) + assert path.exists() + + screen.scenario(nothing_to_list, generate).run() + + def test_cancelling_a_generation_leaves_no_library(self, screen: Screen) -> None: + """Canceling a generation reports it; Generate is offered again and the library has no file or row.""" + library = screen.instructions.library + path = library_path(quick_config()) + + def start_and_cancel(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library.generate() + + library.cancel_generation() + + screen.bridge.expect( + library.notice.is_shown, bool, description="the cancel reported", timeout=GENERATION_TIMEOUT_SECONDS + ) + assert library.notice.words() == screen.words(GENERATION_CANCELED) + library.notice.dismiss() + screen.expect(library.notice.is_shown, operator.not_, description="the report dismissed") + + def nothing_was_written(screen: Screen) -> None: + screen.expect(library.generate_label, screen.words(GENERATE).__eq__, description="Generate offered again") + + assert not path.exists() + assert library.row(path.name) is None + assert library.status().endswith(screen.words(LIBRARY_MISSING).format("")) + + screen.scenario(start_and_cancel, nothing_was_written).run() + + +class TestRegeneratingALibrary: + """Regenerate asks first, and Cancel keeps the library as it stands. + + The built library is listed and Regenerate pressed. Cancel on the question keeps the file's bytes, + and the next press asks again. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding a built library.""" + return library_world(one_worker_config(), built=True) + + def test_cancel_keeps_it_and_the_next_press_asks_again(self, screen: Screen) -> None: + library = screen.instructions.library + prompt = library.regenerate_prompt + path = library_path(one_worker_config()) + kept: List[bytes] = [] + + def regenerate_asks(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.INSTRUCTIONS) + library_row(screen, path) + kept.append(path.read_bytes()) + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question") + + def cancel_keeps_it(screen: Screen) -> None: + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + + library.generate() + + screen.expect(prompt.is_shown, bool, description="the question asked again") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question gone again") + assert path.read_bytes() == kept[0] + + screen.scenario(regenerate_asks, cancel_keeps_it).run() diff --git a/tests/screens/main/list/__init__.py b/tests/screens/main/list/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/list/conftest.py b/tests/screens/main/list/conftest.py new file mode 100644 index 000000000..0032f7de5 --- /dev/null +++ b/tests/screens/main/list/conftest.py @@ -0,0 +1,23 @@ +from typing import Final, List + +import pytest + +from automation.steps.main import home_path +from automation.worlds.home import HomeFile, World, lived_in_world +from tests.screens.main.list.constants import FORTY, FORTY_COUNT +from tests.screens.main.list.steps import recording, take +from tests.suite.screens.seeds.constants import KICK, SNARE + +PLAYED_SECONDS: Final[float] = 2.0 +SHORT_SECONDS: Final[float] = 0.02 + + +@pytest.fixture +def world() -> World: + """The home holds the Kick and Snare recordings and a Forty folder of short takes.""" + files: List[HomeFile] = [ + recording(home_path(KICK), PLAYED_SECONDS), + recording(home_path(SNARE), PLAYED_SECONDS), + *(recording(home_path(FORTY) / take(index), SHORT_SECONDS) for index in range(FORTY_COUNT)), + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) diff --git a/tests/screens/main/list/constants.py b/tests/screens/main/list/constants.py new file mode 100644 index 000000000..12d164b6c --- /dev/null +++ b/tests/screens/main/list/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +FORTY: Final[str] = "Forty" +FORTY_COUNT: Final[int] = 40 +FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/main/list/steps.py b/tests/screens/main/list/steps.py new file mode 100644 index 000000000..827646970 --- /dev/null +++ b/tests/screens/main/list/steps.py @@ -0,0 +1,27 @@ +from functools import partial +from pathlib import Path + +from automation.screen import Screen +from automation.steps.main import explorer_row +from automation.worlds.home import HomeFile +from tests.screens.main.list.constants import FREQUENCY +from tests.suite.screens.seeds.recordings import Recording + + +def take(index: int) -> str: + """Returns the file name of the numbered take, such as ``take07.wav``.""" + return f"take{index:02d}.wav" + + +def recording(path: Path, seconds: float) -> HomeFile: + """Returns a seeded tone recording of ``seconds`` to be written at ``path``.""" + return Recording(destination=path, seconds=seconds, frequency=FREQUENCY) + + +def gather(screen: Screen, *paths: Path) -> None: + """Ctrl-clicks each of ``paths`` in the explorer and waits for its row and for the folder read to end.""" + converter = screen.main.converter + for path in paths: + screen.explorer.ctrl_click(explorer_row(screen, path)) + screen.expect(partial(converter.list.has_row, path), bool, description=f"{path.name} gathered") + screen.expect(lambda: not converter.scan_shown(), bool, description="the read done") diff --git a/tests/screens/main/list/test_folders.py b/tests/screens/main/list/test_folders.py new file mode 100644 index 000000000..573a9ade6 --- /dev/null +++ b/tests/screens/main/list/test_folders.py @@ -0,0 +1,158 @@ +import operator +from functools import partial +from typing import Final + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.items.regions import read_scroll +from automation.screen import Screen +from automation.steps.main import choose_from_the_row_menu, home_path +from automation.vocabulary.converter import FOLDER_ROW +from tests.screens.main.list.constants import FORTY, FORTY_COUNT +from tests.screens.main.list.steps import gather, recording, take +from tests.suite.screens.seeds.constants import KICK + +STILL_FRAMES: Final[int] = 30 +SHOW_RECORDINGS: Final[str] = "main.converter.label.context_open_folder" +HIDE_RECORDINGS: Final[str] = "main.converter.label.context_close_folder" + + +class TestAFolderInTheList: + """A gathered folder arrives closed; its marker, a double-click on its name and its menu each open and + close it. + """ + + def test_each_gesture_opens_and_closes_it(self, screen: Screen) -> None: + """Each gesture opens the folder and the same gesture closes it again.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def arrives_closed(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + + def the_marker(screen: Screen) -> None: + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="open by its marker") + + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by its marker") + + def a_double_click_on_its_name(screen: Screen) -> None: + screen.hand.double_click(listing.row(folder)) + screen.expect(partial(listing.is_open, folder), bool, description="open by a double-click") + + screen.hand.double_click(listing.row(folder)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed by a double-click") + + def its_menu(screen: Screen) -> None: + choose_from_the_row_menu(screen, folder, screen.words(SHOW_RECORDINGS)) + screen.expect(partial(listing.is_open, folder), bool, description="open from its menu") + + choose_from_the_row_menu(screen, folder, screen.words(HIDE_RECORDINGS)) + + screen.expect(partial(listing.is_open, folder), operator.not_, description="closed from its menu") + + screen.scenario(arrives_closed, the_marker, a_double_click_on_its_name, its_menu).run() + + +class TestAFolderThatScrolls: + """An open folder of forty scrolls in its own region, end to end, and its rows hold still under the + pointer. + + The folder opens beside a recording. The wheel takes it to the last row while the outer list keeps + its scroll. The pointer then rests on that row for thirty frames and its rectangle stays the same. + """ + + def test_it_scrolls_on_its_own_to_its_last_row(self, screen: Screen) -> None: + """The folder scrolls to its last row in its own region.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + region = listing.tags.region(str(folder)) + last = folder / take(FORTY_COUNT - 1) + + def open_it(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert screen.bridge.ask(lambda: read_scroll(region)).maximum > 0 + + def the_wheel_takes_it_to_its_last_row(screen: Screen) -> None: + well_before = screen.bridge.ask(lambda: read_scroll(listing.tags.well)) + + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + assert screen.bridge.ask(lambda: read_scroll(listing.tags.well)) == well_before + + def rows_hold_still_under_the_pointer(screen: Screen) -> None: + row = listing.row(last) + screen.hand.hover(row) + + with screen.record(lambda: read_item(row).rect) as recording: + screen.frames(STILL_FRAMES) + + assert len(set(recording.values())) == 1, recording.values() + + screen.scenario(open_it, the_wheel_takes_it_to_its_last_row, rows_hold_still_under_the_pointer).run() + + +class TestRemovingFolders: + """Removing a recording inside an open folder, or the folder itself, takes exactly what it names.""" + + def test_a_recording_inside_goes_alone_and_the_folder_counts_one_fewer(self, screen: Screen) -> None: + """Only the chosen recording goes; the folder stays open, counts one fewer and the other rows are + kept. + """ + listing = screen.main.converter.list + folder = home_path(FORTY) + removed = folder / take(1) + + def open_the_folder_beside_a_recording(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + def remove_one_inside(screen: Screen) -> None: + outside = screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home_path(KICK)))) + + listing.remove(removed) + + screen.expect(partial(listing.has_row, removed), operator.not_, description="the recording gone") + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=FORTY_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="the folder counting one fewer") + assert listing.has_row(folder / take(0)) and listing.has_row(folder / take(2)) + assert listing.is_open(folder) + assert screen.bridge.ask(lambda: dpg.get_alias_id(listing.row(home_path(KICK)))) == outside + + screen.scenario(open_the_folder_beside_a_recording, remove_one_inside).run() + + def test_an_open_folder_goes_with_its_recordings_and_comes_back_closed(self, screen: Screen) -> None: + """The folder leaves with its recordings, and gathering it again brings it back closed.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def remove_it_open(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.remove(folder) + + screen.expect(partial(listing.has_row, folder), operator.not_, description="the folder gone") + assert listing.rows() == [listing.row(home_path(KICK))] + + def gather_it_again(screen: Screen) -> None: + gather(screen, folder) + + assert not listing.is_open(folder) + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(folder)] + + screen.scenario(remove_it_open, gather_it_again).run() diff --git a/tests/screens/main/list/test_look.py b/tests/screens/main/list/test_look.py new file mode 100644 index 000000000..a21360d6c --- /dev/null +++ b/tests/screens/main/list/test_look.py @@ -0,0 +1,75 @@ +from functools import partial +from typing import Dict, Final + +import dearpygui.dearpygui as dpg + +from automation.dearpygui.items.reading import read_item +from automation.screen import Screen +from automation.steps.main import home_path +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_TEXT, TAG_GLOBAL_THEME_STEMS_GROUP_ROW +from sampletones_application.tags.main import PRE_MAIN_CONVERTER_STEMS +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.list.constants import FORTY +from tests.screens.main.list.steps import gather +from tests.suite.screens.seeds.constants import KICK + +SAME_LINE_PIXELS: Final[float] = 1.0 +HEADING: Final[str] = "heading" + +CHANNEL_NAMES: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: "global.context.label.pulse_1", + ChannelName.PULSE2: "global.context.label.pulse_2", + ChannelName.TRIANGLE: "global.context.label.triangle", + ChannelName.NOISE: "global.context.label.noise", +} + + +class TestTheListsLook: + """The channel names above the list wear their channel's color; a folder row carries the band and has a + recording's height. + + A recording and a folder are gathered. The scenario reads the heading texts and themes, the band + theme of each row, the row heights and the line shared by a recording's name and its boxes. + """ + + def test_names_bands_and_lines(self, screen: Screen) -> None: + """Names, bands, heights and lines match what the list should look like.""" + listing = screen.main.converter.list + folder = home_path(FORTY) + + def gather_a_recording_and_a_folder(screen: Screen) -> None: + gather(screen, home_path(KICK), folder) + + def the_channel_names_wear_their_colours(screen: Screen) -> None: + for channel, key in CHANNEL_NAMES.items(): + name = compose_tag(PRE_MAIN_CONVERTER_STEMS, HEADING, channel, SUF_TEXT) + + assert screen.bridge.ask(partial(dpg.get_value, name)) == screen.words(key) + assert screen.theme_of(name) == CHANNEL_THEME_TAGS[channel] + + def the_folder_alone_carries_the_band(screen: Screen) -> None: + assert listing.band_theme(folder) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW + assert listing.band_theme(home_path(KICK)) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def rows_share_a_height_and_names_share_a_line_with_their_boxes(screen: Screen) -> None: + folder_box = screen.bridge.ask(lambda: read_item(listing.row(folder)).rect) + recording_box = screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK))).rect) + check_box = screen.bridge.ask( + lambda: read_item(listing.channel_box(home_path(KICK), ChannelName.PULSE1)).rect + ) + assert folder_box is not None and recording_box is not None and check_box is not None + + assert folder_box.height == recording_box.height + assert ( + abs((recording_box.y + recording_box.height / 2) - (check_box.y + check_box.height / 2)) + <= SAME_LINE_PIXELS + ) + + screen.scenario( + gather_a_recording_and_a_folder, + the_channel_names_wear_their_colours, + the_folder_alone_carries_the_band, + rows_share_a_height_and_names_share_a_line_with_their_boxes, + ).run() diff --git a/tests/screens/main/list/test_rows.py b/tests/screens/main/list/test_rows.py new file mode 100644 index 000000000..bb234f4e6 --- /dev/null +++ b/tests/screens/main/list/test_rows.py @@ -0,0 +1,132 @@ +import operator +from functools import partial +from typing import Final + +from automation.screen import Screen +from automation.steps.main import home_path +from automation.vocabulary.converter import CONVERT_ONE, CONVERT_SEVERAL +from sampletones_application.tags.general import TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.list.steps import gather +from tests.suite.screens.seeds.constants import KICK, SNARE + +PLAY: Final[str] = "global.context.label.play" + + +class TestTheListComingAndGoing: + """With nothing gathered the hint shows alone; the first recording brings the list, and removing the + last one brings the hint back. + """ + + def test_the_hint_and_the_list_take_turns(self, screen: Screen) -> None: + """The hint and the list swap places as recordings arrive and leave.""" + listing = screen.main.converter.list + + def the_hint_alone(screen: Screen) -> None: + assert listing.hint_shown() + assert not listing.list_shown() + + def the_first_recording_brings_the_list(screen: Screen) -> None: + gather(screen, home_path(KICK)) + + assert listing.list_shown() + assert not listing.hint_shown() + + def removing_the_last_takes_it_away(screen: Screen) -> None: + listing.remove(home_path(KICK)) + + screen.expect(listing.hint_shown, bool, description="the hint back") + assert not listing.list_shown() + assert listing.rows() == [] + + screen.scenario(the_hint_alone, the_first_recording_brings_the_list, removing_the_last_takes_it_away).run() + + +class TestUntickingEveryChannel: + """A recording with every channel unticked reads as out of play and leaves the run count, and stays + listed. + """ + + def test_the_row_dims_and_the_run_counts_without_it(self, screen: Screen) -> None: + """The row takes the inert theme and the button counts one recording; ticking one channel restores + both. + """ + listing = screen.main.converter.list + converter = screen.main.converter + + def gather_two(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def untick_every_channel_of_one(screen: Screen) -> None: + for channel in ChannelName: + if listing.channel_ticked(home_path(KICK), channel): + listing.tick(home_path(KICK), channel) + screen.expect( + partial(listing.channel_ticked, home_path(KICK), channel), + operator.not_, + description=f"{channel} let go", + ) + + screen.expect( + partial(listing.row_theme, home_path(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW_INERT.__eq__, + description="the row out of play", + ) + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(SNARE).stem) + assert listing.has_row(home_path(KICK)) + + def one_channel_brings_it_back(screen: Screen) -> None: + listing.tick(home_path(KICK), ChannelName.PULSE1) + + screen.expect( + partial(listing.row_theme, home_path(KICK)), + TAG_GLOBAL_THEME_STEMS_ROW.__eq__, + description="the row in play", + ) + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + screen.scenario(gather_two, untick_every_channel_of_one, one_channel_brings_it_back).run() + + +class TestPlayingFromTheList: + """A recording in the list plays on a double-click, and its menu leads with Play while the file exists.""" + + def test_a_double_click_plays_and_play_greys_once_the_file_is_gone(self, screen: Screen) -> None: + """Play is the first menu entry and turns disabled after the file is deleted from the disk.""" + listing = screen.main.converter.list + menu = screen.context_menu + + def a_double_click_plays(screen: Screen) -> None: + gather(screen, home_path(KICK)) + heard = screen.sound_heard() + + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + assert listing.rows() == [listing.row(home_path(KICK))] + + def its_menu_leads_with_play(screen: Screen) -> None: + screen.hand.right_click(listing.row(home_path(KICK))) + screen.expect(menu.is_shown, bool, description="the menu") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), True) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def play_greys_once_the_file_is_gone(screen: Screen) -> None: + home_path(KICK).unlink() + + screen.hand.right_click(listing.row(home_path(KICK))) + screen.expect(menu.is_shown, bool, description="the menu again") + + first = menu.entries()[0] + + assert (first.label, first.enabled) == (screen.words(PLAY), False) + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away again") + + screen.scenario(a_double_click_plays, its_menu_leads_with_play, play_greys_once_the_file_is_gone).run() diff --git a/tests/screens/main/list/test_thousands.py b/tests/screens/main/list/test_thousands.py new file mode 100644 index 000000000..894ba6584 --- /dev/null +++ b/tests/screens/main/list/test_thousands.py @@ -0,0 +1,123 @@ +from functools import partial +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.dearpygui.items.reading import read_item +from automation.screen import Screen +from automation.steps.main import home_path +from automation.vocabulary.converter import FOLDER_ROW, REMOVE_RECORDING +from automation.worlds.home import World, lived_in_world +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.paths import LAYOUT_DIRECTORY +from sampletones_shared.utils.serialization import load_yaml_model +from tests.screens.main.list.constants import FREQUENCY +from tests.screens.main.list.steps import gather +from tests.suite.screens.seeds.recordings import Recording + +THOUSANDS: Final[str] = "Thousands" +THOUSANDS_COUNT: Final[int] = 1500 +TINY_SECONDS: Final[float] = 0.01 +ONE_ROW: Final[int] = 1 +STEMS_LAYOUT: Final[Path] = LAYOUT_DIRECTORY / "general" / "stems.yaml" +MENU_ROW: Final[int] = 3 +WHEEL_PAST: Final[int] = 5 + + +def thousand(index: int) -> str: + """Returns the file name of the numbered recording in the folder of thousands.""" + return f"take{index:04d}.wav" + + +def rows_inside(screen: Screen, folder: Path) -> List[str]: + """Returns the rows of ``folder`` that the list draws at this moment, in list order.""" + listing = screen.main.converter.list + inside = {listing.row(folder / thousand(index)) for index in range(THOUSANDS_COUNT)} + return [row for row in listing.rows() if row in inside] + + +class TestAFolderOfThousands: + """A folder of thousands opens drawing only the rows in view; its last row and any row menu still + answer. + + The folder is gathered and opened. One scenario scrolls to the last row. The other opens a row's + menu, turns the wheel, chooses Remove and expects that row alone to be gone. + """ + + @pytest.fixture + def world(self) -> World: + """The home holds one folder of 1500 tiny recordings.""" + files = [ + Recording(destination=home_path(THOUSANDS) / thousand(index), seconds=TINY_SECONDS, frequency=FREQUENCY) + for index in range(THOUSANDS_COUNT) + ] + return World(state=lived_in_world().state, application_config=None, config=None, files=tuple(files)) + + def open_it(self, screen: Screen) -> None: + """Gathers the folder of thousands and opens it in the list.""" + listing = screen.main.converter.list + gather(screen, home_path(THOUSANDS)) + listing.toggle_folder(home_path(THOUSANDS)) + screen.expect(partial(listing.is_open, home_path(THOUSANDS)), bool, description="the folder open") + + def test_it_draws_the_rows_in_view_and_reaches_its_last(self, screen: Screen) -> None: + """The drawn rows fill the region plus the overscan, and scrolling to the end draws the last row.""" + listing = screen.main.converter.list + folder = home_path(THOUSANDS) + region = listing.tags.region(str(folder)) + last = folder / thousand(THOUSANDS_COUNT - 1) + + def opens_drawing_what_is_in_view(screen: Screen) -> None: + self.open_it(screen) + drawn = rows_inside(screen, folder) + region_box = screen.bridge.ask(lambda: read_item(region).rect) + first = screen.bridge.ask(lambda: read_item(drawn[0]).rect) + second = screen.bridge.ask(lambda: read_item(drawn[1]).rect) + assert region_box is not None and first is not None and second is not None + pitch = second.y - first.y + overscan = load_yaml_model(STEMS_LAYOUT, StemsListLayout).window_overscan + + assert len(drawn) <= int(region_box.height / pitch) + ONE_ROW + 2 * overscan + + def reaches_the_last_row(screen: Screen) -> None: + screen.hand.scroll_to_end(region) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(opens_drawing_what_is_in_view, reaches_the_last_row).run() + + def test_remove_chosen_after_the_wheel_turned_removes_the_row_the_menu_was_opened_on(self, screen: Screen) -> None: + """The menu stays open while the wheel turns, and Remove deletes the row it was opened on.""" + listing = screen.main.converter.list + menu = screen.context_menu + folder = home_path(THOUSANDS) + region = listing.tags.region(str(folder)) + target = folder / thousand(MENU_ROW) + + def open_the_menu_and_turn_the_wheel(screen: Screen) -> None: + self.open_it(screen) + screen.hand.scroll_into_view(listing.row(target)) + screen.hand.right_click(listing.row(target)) + screen.expect(menu.is_shown, bool, description="the row's menu") + + screen.hand.turn_wheel_over(region, WHEEL_PAST) + + assert menu.is_shown() + + def remove_from_the_menu(screen: Screen) -> None: + menu.choose(screen.words(REMOVE_RECORDING)) + + expected = screen.words(FOLDER_ROW).format(name=folder.name, count=THOUSANDS_COUNT - 1) + screen.expect(partial(listing.label, folder), expected.__eq__, description="one row fewer") + + def that_row_alone_went(screen: Screen) -> None: + screen.hand.turn_wheel_over(region, -2 * WHEEL_PAST) + screen.expect(partial(listing.has_row, folder / thousand(0)), bool, description="the top in view") + + assert not listing.has_row(target) + assert listing.has_row(folder / thousand(MENU_ROW - 1)) and listing.has_row(folder / thousand(MENU_ROW + 1)) + + screen.scenario(open_the_menu_and_turn_the_wheel, remove_from_the_menu, that_row_alone_went).run() diff --git a/tests/screens/main/mix/__init__.py b/tests/screens/main/mix/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/mix/test_turning_to_a_mix.py b/tests/screens/main/mix/test_turning_to_a_mix.py new file mode 100644 index 000000000..e51c20d7d --- /dev/null +++ b/tests/screens/main/mix/test_turning_to_a_mix.py @@ -0,0 +1,223 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.vocabulary.converter import REMOVE_RECORDING +from automation.worlds.home import World, lived_in_world +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from tests.suite.screens.seeds.recordings import Recording + +GATHERED: Final[int] = MAX_STEM_SOURCES + 2 +SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 +NAMES_CLICKED: Final[int] = 4 +COUNT_LINE: Final[str] = "main.converter.template.stem_selection_limit" + +MOVES: Final[List[str]] = [ + "main.converter.label.context_move_up", + "main.converter.label.context_move_down", + "main.converter.label.context_join_above", + "main.converter.label.context_join_below", + "main.converter.label.context_isolate", +] + + +def recording(index: int) -> Path: + """The home path of the gathered recording numbered ``index``.""" + return home_path(f"voice{index:02d}.wav") + + +def recordings(count: int) -> List[Path]: + """The home paths of the first ``count`` gathered recordings.""" + return [recording(index) for index in range(count)] + + +@pytest.fixture +def world() -> World: + """A lived-in home holding two more recordings than a mix has room for.""" + files = tuple(Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) for path in recordings(GATHERED)) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + +def count_line(screen: Screen, picked: int, total: int) -> str: + """The line the mix question shows for ``picked`` recordings ticked out of ``total``.""" + return screen.words(COUNT_LINE).format(picked=picked, total=total, room=MAX_STEM_SOURCES) + + +class TestTurningToAMixPastItsRoom: + """Turning to a mix with more recordings gathered than a mix holds asks which to take, with a full + pick ticked. + """ + + def test_the_question_ticks_a_full_pick_and_cancel_leaves_everything(self, screen: Screen) -> None: + """The question ticks the first recordings up to the room of a mix; Cancel keeps the list and the + per-recording switch, and turning to a mix again asks again. + """ + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + + def turn_to_a_mix(screen: Screen) -> None: + gather(screen, *paths) + + main.choose_output(OutputKind.MIXED) + + screen.expect(question.is_shown, bool, description="the question") + picked, past = paths[:MAX_STEM_SOURCES], paths[MAX_STEM_SOURCES:] + assert all(question.is_ticked(path) for path in picked) + assert not any(question.is_ticked(path) or question.is_live(path) for path in past) + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES, GATHERED) + + def cancel_leaves_the_list_and_the_switch(screen: Screen) -> None: + question.cancel() + + screen.expect(question.is_shown, operator.not_, description="the question gone") + assert main.output() is OutputKind.PER_RECORDING + assert main.converter.list.rows() == [main.converter.list.row(path) for path in paths] + + def asking_again_asks_again(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(question.is_shown, bool, description="the question again") + question.cancel() + screen.expect(question.is_shown, operator.not_, description="the question gone again") + + screen.scenario(turn_to_a_mix, cancel_leaves_the_list_and_the_switch, asking_again_asks_again).run() + + def test_a_full_pick_swaps_one_for_one_and_add_waits_for_a_pick(self, screen: Screen) -> None: + """With a full pick, letting one go makes the next recording live for ticking; Add answers once at + least one recording is picked and then makes the mix. + """ + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + ninth = paths[MAX_STEM_SOURCES] + + def open_the_question(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + + def letting_one_go_makes_the_ninth_live(screen: Screen) -> None: + assert not question.is_live(ninth) + + question.tick(paths[0]) + + screen.expect(partial(question.is_live, ninth), bool, description="the ninth live") + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES - 1, GATHERED) + question.tick(ninth) + screen.expect(partial(question.is_ticked, ninth), bool, description="the ninth picked") + assert question.count_line() == count_line(screen, MAX_STEM_SOURCES, GATHERED) + assert not question.is_live(paths[0]) + + def add_waits_until_something_is_picked(screen: Screen) -> None: + for path in paths[1 : MAX_STEM_SOURCES + 1]: + question.tick(path) + screen.expect(question.can_add, operator.not_, description="Add waiting") + assert question.count_line() == count_line(screen, 0, GATHERED) + + question.tick(paths[0]) + + screen.expect(question.can_add, bool, description="Add answering") + + def add_makes_the_mix(screen: Screen) -> None: + question.add() + + screen.expect(question.is_shown, operator.not_, description="the question gone") + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the switch on a mix") + + screen.scenario( + open_the_question, + letting_one_go_makes_the_ninth_live, + add_waits_until_something_is_picked, + add_makes_the_mix, + ).run() + + def test_a_double_click_sounds_a_row_and_names_leave_no_highlight(self, screen: Screen) -> None: + """A double-click on a name plays that recording, and clicking names leaves the ticks and highlights + as they were. + """ + main = screen.main + question = main.converter.mix_question + paths = recordings(GATHERED) + + def open_the_question(screen: Screen) -> None: + gather(screen, *paths) + main.choose_output(OutputKind.MIXED) + screen.expect(question.is_shown, bool, description="the question") + + def a_double_click_sounds_it(screen: Screen) -> None: + heard = screen.sound_heard() + screen.hand.double_click(question.name(paths[0])) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + + def names_clicked_leave_no_highlight(screen: Screen) -> None: + before = [question.is_ticked(path) for path in paths] + for path in paths[1 : 1 + NAMES_CLICKED]: + screen.hand.scroll_into_view(question.name(path)) + screen.hand.click(question.name(path)) + + assert not any(question.is_highlighted(path) for path in paths) + assert [question.is_ticked(path) for path in paths] == before + question.cancel() + screen.expect(question.is_shown, operator.not_, description="the question gone") + + screen.scenario(open_the_question, a_double_click_sounds_it, names_clicked_leave_no_highlight).run() + + +class TestAMixOfOneAndOfTwo: + """A mix of one draws no level band and offers no moves; a second recording brings the bands, and + one per recording takes them away. + + One recording is gathered and the switch turned to a mix. Its right-click menu lists Remove and no + moves. A second recording brings the level bands; turning back to one per recording removes them. + """ + + def test_bands_come_with_the_second_recording_and_go_with_one_per_recording(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + first, second = recordings(2) + + def a_mix_of_one(screen: Screen) -> None: + gather(screen, first) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="the switch on a mix") + + assert not listing.has_levels() + + def its_menu_offers_removal_and_no_moves(screen: Screen) -> None: + screen.hand.right_click(listing.row(first)) + screen.expect(menu.is_shown, bool, description="the menu") + + labels = menu.labels() + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert screen.words(REMOVE_RECORDING) in labels + assert not set(labels) & {screen.words(move) for move in MOVES} + + def the_second_brings_the_bands(screen: Screen) -> None: + gather(screen, second) + + screen.expect(listing.has_levels, bool, description="the level bands") + + def one_per_recording_takes_them_away(screen: Screen) -> None: + main.choose_output(OutputKind.PER_RECORDING) + + screen.expect(listing.has_levels, operator.not_, description="the bands gone") + assert listing.rows() == [listing.row(first), listing.row(second)] + + screen.scenario( + a_mix_of_one, + its_menu_offers_removal_and_no_moves, + the_second_brings_the_bands, + one_per_recording_takes_them_away, + ).run() diff --git a/tests/screens/main/row_settings/__init__.py b/tests/screens/main/row_settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/row_settings/conftest.py b/tests/screens/main/row_settings/conftest.py new file mode 100644 index 000000000..ddc8c59fa --- /dev/null +++ b/tests/screens/main/row_settings/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.worlds.home import World, lived_in_world +from tests.screens.main.row_settings.steps import recordings + + +@pytest.fixture +def world() -> World: + """A lived-in home holding the kick and snare recordings and a folder of two takes.""" + return World(state=lived_in_world().state, application_config=None, config=None, files=recordings()) diff --git a/tests/screens/main/row_settings/constants.py b/tests/screens/main/row_settings/constants.py new file mode 100644 index 000000000..ea4422b59 --- /dev/null +++ b/tests/screens/main/row_settings/constants.py @@ -0,0 +1,6 @@ +from typing import Final, Tuple + +PAIR: Final[str] = "Pair" +PAIR_TAKES: Final[Tuple[str, ...]] = ("left.wav", "right.wav") +PLAYED_SECONDS: Final[float] = 2.0 +FREQUENCY: Final[float] = 220.0 diff --git a/tests/screens/main/row_settings/steps.py b/tests/screens/main/row_settings/steps.py new file mode 100644 index 000000000..83f53d6cf --- /dev/null +++ b/tests/screens/main/row_settings/steps.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import Dict, Tuple + +from automation.screen import Screen +from automation.steps.main import home_path +from automation.worlds.home import HomeFile +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import FREQUENCY, PAIR, PAIR_TAKES, PLAYED_SECONDS +from tests.suite.screens.seeds.constants import KICK, SNARE +from tests.suite.screens.seeds.recordings import Recording + + +def recordings() -> Tuple[HomeFile, ...]: + """The recordings the home holds: kick, snare and the two takes in the Pair folder.""" + return ( + Recording(destination=home_path(KICK), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + Recording(destination=home_path(SNARE), seconds=PLAYED_SECONDS, frequency=FREQUENCY), + *( + Recording(destination=home_path(PAIR) / name, seconds=PLAYED_SECONDS, frequency=FREQUENCY) + for name in PAIR_TAKES + ), + ) + + +def ticked(screen: Screen, path: Path) -> Dict[ChannelName, bool]: + """Reads which channel boxes the list shows ticked on the row at ``path``.""" + return {channel: screen.main.converter.list.channel_ticked(path, channel) for channel in ChannelName} diff --git a/tests/screens/main/row_settings/test_boxes.py b/tests/screens/main/row_settings/test_boxes.py new file mode 100644 index 000000000..30d8346a1 --- /dev/null +++ b/tests/screens/main/row_settings/test_boxes.py @@ -0,0 +1,86 @@ +import operator +from functools import partial + +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.vocabulary.converter import FOLDER_ROW +from sampletones_application.ui.themes.channels import PARTIAL_CHANNEL_THEME_TAGS +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import PAIR, PAIR_TAKES + + +class TestAFoldersBoxes: + """A folder's box reads partial where its recordings disagree; one click on it settles them all and the + next click lets them go. + + The folder of two takes is gathered and opened. The scenario clicks Pulse 1 on one take, then on the + folder, then on the folder again, and expects the folder's box to turn partial, then every take to + hold the box, then every take to let it go. + """ + + def test_a_box_inside_turns_the_folders_partial_and_the_folders_settles_them(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first, second = (folder / name for name in PAIR_TAKES) + channel = ChannelName.PULSE1 + + def open_the_folder(screen: Screen) -> None: + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def a_box_inside_changes_that_row_alone(screen: Screen) -> None: + listing.tick(first, channel) + + screen.expect(partial(listing.channel_ticked, first, channel), operator.not_, description="the box let go") + assert listing.channel_ticked(second, channel) + screen.expect( + partial(listing.channel_theme, folder, channel), + PARTIAL_CHANNEL_THEME_TAGS[channel].__eq__, + description="the folder's box partial", + ) + assert not listing.channel_ticked(folder, channel) + + def the_folders_box_settles_them_all(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), bool, description="the folder's box held") + assert listing.channel_ticked(first, channel) and listing.channel_ticked(second, channel) + + def the_next_click_lets_them_go(screen: Screen) -> None: + listing.tick(folder, channel) + + screen.expect(partial(listing.channel_ticked, folder, channel), operator.not_, description="let go") + assert not listing.channel_ticked(first, channel) and not listing.channel_ticked(second, channel) + + screen.scenario( + open_the_folder, + a_box_inside_changes_that_row_alone, + the_folders_box_settles_them_all, + the_next_click_lets_them_go, + ).run() + + +class TestTheCardFollowsABoxClicked: + """Ticking a box inside an open folder brings the card to that row, and the folder's own box brings it + to the folder. + """ + + def test_the_card_names_the_row_whose_box_was_clicked(self, screen: Screen) -> None: + """Source settings names the take whose box was clicked, then the folder once its box is clicked.""" + main = screen.main + listing = main.converter.list + folder = home_path(PAIR) + first = folder / PAIR_TAKES[0] + gather(screen, folder) + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + listing.tick(first, ChannelName.PULSE1) + + screen.expect(main.source.subject, first.stem.__eq__, description="the card on the row clicked") + listing.tick(folder, ChannelName.PULSE1) + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the card on the folder") diff --git a/tests/screens/main/row_settings/test_during_a_run.py b/tests/screens/main/row_settings/test_during_a_run.py new file mode 100644 index 000000000..b39276224 --- /dev/null +++ b/tests/screens/main/row_settings/test_during_a_run.py @@ -0,0 +1,93 @@ +import operator +from functools import partial +from typing import Dict, Final, List + +import pytest + +from automation.dearpygui.items.reading import read_item +from automation.holds.conversion import ConversionHold +from automation.screen import Screen +from automation.steps.main import choose_from_the_row_menu, gather, home_path +from automation.vocabulary.converter import CANCEL_RUN, REMOVE_RECORDING +from automation.worlds.home import World +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.steps import recordings, ticked +from tests.suite.screens.seeds.constants import KICK, SNARE +from tests.suite.screens.worlds.recordings import converting_world + +HELD_TIMEOUT_SECONDS: Final[float] = 60.0 +PROGRESS: Final[str] = "main.converter.template.progress_template" + + +class TestTheListDuringARun: + """While a run converts the list, Del, the menu's Remove and the channel keys leave the list as it is; + after the run they reach the rows again. + + A kick and a snare recording are gathered and a held run starts. Del and the Pulse 2 key then act on + the picked kick, whose row is disabled and whose boxes stay as they were. Once the run ends and its + question is closed, the menu's Remove takes the snare away and Del takes the kick away. + """ + + @pytest.fixture + def world(self) -> World: + """A home holding the recordings, set up to convert them.""" + return converting_world(recordings()) + + def test_the_list_stands_as_it_was_through_the_run(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """The rows and their boxes stay the same through the run, and removal works again after it.""" + converter = screen.main.converter + listing = converter.list + before: List[Dict[ChannelName, bool]] = [] + + def start_a_held_run(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + + converter.press_action() + + started = screen.words(PROGRESS).format(0, 2) + screen.bridge.expect( + converter.status, + lambda status: status.startswith(started), + description="the run under way on its first recording", + timeout=HELD_TIMEOUT_SECONDS, + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def the_keys_and_the_menu_reach_nothing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + assert not screen.bridge.ask(lambda: read_item(listing.row(home_path(KICK)))).enabled + assert not screen.context_menu.is_shown() + assert listing.rows() == [listing.row(home_path(KICK)), listing.row(home_path(SNARE))] + assert ticked(screen, home_path(KICK)) == before[0] + + def after_the_run_the_menu_and_del_reach_the_row(screen: Screen) -> None: + conversion_hold.release() + screen.bridge.expect( + converter.end_prompt.is_shown, + bool, + description="the run's end", + timeout=HELD_TIMEOUT_SECONDS, + ) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end prompt closed") + assert ticked(screen, home_path(KICK)) == before[0] + + choose_from_the_row_menu(screen, home_path(SNARE), screen.words(REMOVE_RECORDING)) + screen.expect( + partial(listing.has_row, home_path(SNARE)), operator.not_, description="removed from the menu" + ) + listing.pick(home_path(KICK)) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(KICK)), operator.not_, description="removed by Del") + + screen.scenario( + start_a_held_run, + the_keys_and_the_menu_reach_nothing, + after_the_run_the_menu_and_del_reach_the_row, + ).run() diff --git a/tests/screens/main/row_settings/test_hundreds.py b/tests/screens/main/row_settings/test_hundreds.py new file mode 100644 index 000000000..1e0e7d5ad --- /dev/null +++ b/tests/screens/main/row_settings/test_hundreds.py @@ -0,0 +1,71 @@ +import operator +from functools import partial +from typing import Final + +import pytest + +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.worlds.home import World, lived_in_world +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from tests.screens.main.row_settings.constants import FREQUENCY +from tests.suite.screens.seeds.recordings import Recording + +HUNDREDS: Final[str] = "Hundreds" +HUNDREDS_COUNT: Final[int] = 300 +TINY_SECONDS: Final[float] = 0.01 + + +class TestHundredsGathered: + """Some three hundred recordings gathered: the card collapses and expands, the interface answers + meanwhile, and the list scrolls end to end. + + The folder is gathered and the card collapsed. The Sequencer and the Main tab are brought to the + front in turn. The card is then expanded, the folder opened and the list scrolled to its end, where + the last row is drawn and hovered. + """ + + @pytest.fixture + def world(self) -> World: + """A lived-in home holding a folder of three hundred very short recordings.""" + files = tuple( + Recording( + destination=home_path(HUNDREDS) / f"take{index:03d}.wav", seconds=TINY_SECONDS, frequency=FREQUENCY + ) + for index in range(HUNDREDS_COUNT) + ) + return World(state=lived_in_world().state, application_config=None, config=None, files=files) + + def test_collapse_expand_and_scroll_end_to_end(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + folder = home_path(HUNDREDS) + last = folder / f"take{HUNDREDS_COUNT - 1:03d}.wav" + + def gather_and_collapse(screen: Screen) -> None: + gather(screen, folder) + card.toggle() + + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + def the_interface_answers_meanwhile(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + + def expand_and_scroll_end_to_end(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.toggle_folder(folder) + screen.expect(partial(listing.is_open, folder), bool, description="the folder open") + + screen.hand.scroll_to_end(listing.tags.region(str(folder))) + + screen.expect(partial(listing.has_row, last), bool, description="the last row drawn") + screen.hand.scroll_into_view(listing.row(last)) + screen.hand.hover(listing.row(last)) + + screen.scenario(gather_and_collapse, the_interface_answers_meanwhile, expand_and_scroll_end_to_end).run() diff --git a/tests/screens/main/row_settings/test_keys.py b/tests/screens/main/row_settings/test_keys.py new file mode 100644 index 000000000..ad3b81384 --- /dev/null +++ b/tests/screens/main/row_settings/test_keys.py @@ -0,0 +1,106 @@ +import operator +from functools import partial +from typing import Dict, Final, List + +from automation.screen import Screen +from automation.steps.main import gather, home_path +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_INPUT_SEARCH +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_PANEL +from sampletones_application.utils.gui.shortcuts.ids import CHANNEL_SHORTCUT_IDS, ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.steps import ticked +from tests.suite.screens.seeds.constants import KICK, SNARE + +FILTER_TEXT: Final[str] = "zz" + + +class TestAChannelKey: + """A channel's key settles that channel on the picked row alone; with nothing picked it changes nothing. + + Two recordings are gathered and Pulse 2 is pressed with nothing picked. The kick is then picked and + the key pressed again; the kick's Pulse 2 flips once and the snare keeps its boxes. + """ + + def test_it_reaches_the_picked_row_alone(self, screen: Screen) -> None: + listing = screen.main.converter.list + before: List[Dict[ChannelName, bool]] = [] + + def press_with_nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + before.extend((ticked(screen, home_path(KICK)), ticked(screen, home_path(SNARE)))) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + def press_with_a_row_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped once") + assert ticked(screen, home_path(SNARE)) == before[1] + + screen.scenario(press_with_nothing_picked, press_with_a_row_picked).run() + + +class TestTheKeysOfTheList: + """Del and the channel keys reach the picked row while the Converter card is open and no field holds + the keys. + """ + + def test_a_collapsed_card_keeps_del_from_the_list(self, screen: Screen) -> None: + """Del leaves both rows in place while the card is collapsed and removes the picked row once open.""" + main = screen.main + listing = main.converter.list + card = main.card(TAG_MAIN_CONVERTER_PANEL) + + def del_with_the_card_collapsed(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + card.toggle() + screen.expect(card.is_collapsed, bool, description="the card collapsed") + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def del_with_the_card_open(screen: Screen) -> None: + card.toggle() + screen.expect(card.is_collapsed, operator.not_, description="the card open") + listing.pick(home_path(SNARE)) + + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(del_with_the_card_collapsed, del_with_the_card_open).run() + + def test_a_focused_field_takes_del_and_the_channel_keys(self, screen: Screen) -> None: + """Keys typed into the explorer's filter field stay in the field; once the field lets go, the same key + reaches the picked row. + """ + main = screen.main + listing = main.converter.list + field = compose_tag(TAG_MAIN_EXPLORER_PANEL, SUF_INPUT_SEARCH) + before: List[Dict[ChannelName, bool]] = [] + + def keys_into_the_field(screen: Screen) -> None: + gather(screen, home_path(KICK)) + listing.pick(home_path(KICK)) + before.append(ticked(screen, home_path(KICK))) + screen.hand.click(field) + screen.hand.type_text(FILTER_TEXT) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + def the_same_key_reaches_the_row_once_the_field_lets_go(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.press_shortcut(CHANNEL_SHORTCUT_IDS[ChannelName.PULSE2]) + + expected = {**before[0], ChannelName.PULSE2: not before[0][ChannelName.PULSE2]} + screen.expect(lambda: ticked(screen, home_path(KICK)), expected.__eq__, description="Pulse 2 flipped") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(keys_into_the_field, the_same_key_reaches_the_row_once_the_field_lets_go).run() diff --git a/tests/screens/main/row_settings/test_pick.py b/tests/screens/main/row_settings/test_pick.py new file mode 100644 index 000000000..360916141 --- /dev/null +++ b/tests/screens/main/row_settings/test_pick.py @@ -0,0 +1,118 @@ +import operator +from functools import partial +from typing import Final + +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.vocabulary.converter import FOLDER_ROW +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.main.row_settings.constants import PAIR, PAIR_TAKES +from tests.screens.main.row_settings.steps import ticked +from tests.suite.screens.seeds.constants import KICK, SNARE + +NEW_RECORDINGS: Final[str] = "main.source.label.new_recordings" + + +class TestTheCardFollowsThePick: + """Source settings names the picked row, recording or folder, and reads New recordings while no row is + picked. + """ + + def test_it_names_each_pick_in_turn(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + + def nothing_picked(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(PAIR)) + + assert main.source.subject() == screen.words(NEW_RECORDINGS) + + def a_recording_picked(screen: Screen) -> None: + listing.pick(home_path(KICK)) + + screen.expect(main.source.subject, home_path(KICK).stem.__eq__, description="the recording named") + assert listing.is_picked(home_path(KICK)) + assert {channel: main.source.channel_ticked(channel) for channel in ChannelName} == ticked( + screen, home_path(KICK) + ) + + def a_folder_picked(screen: Screen) -> None: + listing.pick(home_path(PAIR)) + + expected = screen.words(FOLDER_ROW).format(name=PAIR, count=len(PAIR_TAKES)) + screen.expect(main.source.subject, expected.__eq__, description="the folder named") + assert not listing.is_picked(home_path(KICK)) + + screen.scenario(nothing_picked, a_recording_picked, a_folder_picked).run() + + +class TestThePickHoldsThrough: + """The pick stands through a second click, a double-click that plays the row, and Esc stopping it. + + The kick is picked twice, then double-clicked so that it plays, then Esc stops playback. After each + gesture the kick is still the picked row and the card still names it. + """ + + def test_clicks_double_clicks_and_esc_keep_the_pick(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + playback = screen.sequencer.playback + + def pick_twice(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + screen.expect(partial(listing.is_picked, home_path(KICK)), bool, description="picked") + + listing.pick(home_path(KICK)) + + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + def a_double_click_plays_and_keeps_it(screen: Screen) -> None: + heard = screen.sound_heard() + screen.hand.double_click(listing.row(home_path(KICK))) + + screen.expect(screen.sound_heard, heard.__lt__, description="the recording heard") + assert listing.is_picked(home_path(KICK)) + + def esc_stops_and_keeps_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + screen.expect(playback.can_stop, operator.not_, description="playback stopped") + assert listing.is_picked(home_path(KICK)) + assert main.source.subject() == home_path(KICK).stem + + screen.scenario(pick_twice, a_double_click_plays_and_keeps_it, esc_stops_and_keeps_it).run() + + +class TestARightClickMovesThePick: + """A right-click on a row picks it, and Del then removes that row. + + The kick is picked and the snare right-clicked. The card names the snare and the menu is put away; + Del then removes the snare and leaves the kick. + """ + + def test_del_after_a_right_click_removes_the_row_clicked(self, screen: Screen) -> None: + main = screen.main + listing = main.converter.list + menu = screen.context_menu + + def right_click_another_row(screen: Screen) -> None: + gather(screen, home_path(KICK), home_path(SNARE)) + listing.pick(home_path(KICK)) + + screen.hand.right_click(listing.row(home_path(SNARE))) + + screen.expect(menu.is_shown, bool, description="the menu") + screen.expect(main.source.subject, home_path(SNARE).stem.__eq__, description="the card on the row clicked") + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + def del_removes_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.SOURCES_REMOVE_SOURCE) + + screen.expect(partial(listing.has_row, home_path(SNARE)), operator.not_, description="the row removed") + assert listing.rows() == [listing.row(home_path(KICK))] + + screen.scenario(right_click_another_row, del_removes_it).run() diff --git a/tests/screens/main/run/__init__.py b/tests/screens/main/run/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/run/conftest.py b/tests/screens/main/run/conftest.py new file mode 100644 index 000000000..b39a40822 --- /dev/null +++ b/tests/screens/main/run/conftest.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Final + +import pytest + +from automation.steps.main import home_path +from automation.worlds.home import HomeFile, World +from tests.screens.main.run.constants import ALBUM, ALBUM_TAKES, BASS, DISC, DISC_TAKES, DRUMS, LEAD +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.recordings import converting_world + +SECONDS: Final[float] = 0.3 +FREQUENCY: Final[float] = 220.0 + + +def recording(path: Path) -> HomeFile: + """A short recording to be written at ``path``.""" + return Recording(destination=path, seconds=SECONDS, frequency=FREQUENCY) + + +def run_world() -> World: + """A home holding three recordings and an Album folder with a nested Disc2 folder, set up to convert.""" + paths = [ + home_path(BASS), + home_path(LEAD), + home_path(DRUMS), + *(home_path(ALBUM) / name for name in ALBUM_TAKES), + *(home_path(ALBUM) / DISC / name for name in DISC_TAKES), + ] + return converting_world(tuple(recording(path) for path in paths)) + + +@pytest.fixture +def world() -> World: + """The world of ``run_world``.""" + return run_world() diff --git a/tests/screens/main/run/constants.py b/tests/screens/main/run/constants.py new file mode 100644 index 000000000..a550b5c33 --- /dev/null +++ b/tests/screens/main/run/constants.py @@ -0,0 +1,11 @@ +from typing import Final, Tuple + +RUN_TIMEOUT_SECONDS: Final[float] = 120.0 +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DRUMS: Final[str] = "drums.wav" +ALBUM: Final[str] = "Album" +DISC: Final[str] = "Disc2" +ALBUM_TAKES: Final[Tuple[str, ...]] = ("one.wav", "two.wav") +DISC_TAKES: Final[Tuple[str, ...]] = ("three.wav",) +RECONSTRUCTION_SUFFIX: Final[str] = ".stn" diff --git a/tests/screens/main/run/steps.py b/tests/screens/main/run/steps.py new file mode 100644 index 000000000..fb0b271ba --- /dev/null +++ b/tests/screens/main/run/steps.py @@ -0,0 +1,31 @@ +import operator +from pathlib import Path +from typing import Dict, List + +from automation.screen import Screen +from tests.screens.main.run.constants import RECONSTRUCTION_SUFFIX, RUN_TIMEOUT_SECONDS + + +def run_to_its_end(screen: Screen) -> None: + """Presses the button naming the run and waits for the question its end asks, which it closes.""" + converter = screen.main.converter + converter.press_action() + wait_for_the_end(screen) + + +def wait_for_the_end(screen: Screen) -> None: + """Waits for the question the run's end asks and closes it.""" + converter = screen.main.converter + screen.bridge.expect(converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end closed") + + +def written(destination: Path) -> List[Path]: + """The reconstruction files below ``destination``, sorted.""" + return sorted(destination.rglob(f"*{RECONSTRUCTION_SUFFIX}")) + + +def modified(paths: List[Path]) -> Dict[Path, int]: + """The modification time of each of ``paths``, in nanoseconds.""" + return {path: path.stat().st_mtime_ns for path in paths} diff --git a/tests/screens/main/run/test_button_and_lines.py b/tests/screens/main/run/test_button_and_lines.py new file mode 100644 index 000000000..a1f94be84 --- /dev/null +++ b/tests/screens/main/run/test_button_and_lines.py @@ -0,0 +1,106 @@ +from typing import Final + +from automation.dearpygui.items.reading import read_item +from automation.holds.conversion import ConversionHold +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.vocabulary.converter import CONVERT_NOTHING, CONVERT_ONE, CONVERT_SEVERAL +from sampletones_application.constants.output import OutputKind +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_GROUP_INPUT, TAG_MAIN_CONVERTER_GROUP_ORDER +from tests.screens.main.run.constants import BASS, LEAD, RUN_TIMEOUT_SECONDS +from tests.screens.main.run.steps import wait_for_the_end + +MIX_SEVERAL: Final[str] = "main.converter.template.mix_recordings" + + +class TestTheButtonNamesTheRun: + """The button under the list names the run each time the list or the switch changes. + + One recording is gathered, then a second, then the switch turned to a mix, then both removed. The + button reads, in turn, Convert one, Convert several, the mix, and Convert nothing. + """ + + def test_one_several_a_mix_and_none(self, screen: Screen) -> None: + main = screen.main + converter = main.converter + + def one(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert converter.action() == screen.words(CONVERT_ONE).format(name=home_path(BASS).stem) + + def several(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + assert converter.action() == screen.words(CONVERT_SEVERAL).format(count=2) + + def a_mix(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + + screen.expect(converter.action, screen.words(MIX_SEVERAL).format(count=2).__eq__, description="a mix named") + + def none(screen: Screen) -> None: + converter.list.remove(home_path(BASS)) + converter.list.remove(home_path(LEAD)) + + screen.expect(converter.action, screen.words(CONVERT_NOTHING).__eq__, description="nothing to name") + + screen.scenario(one, several, a_mix, none).run() + + +class TestTheLinesOfTheCard: + """Order joins from a mix's second recording, Destination always stands, and Input shows only while + a run reads. + """ + + def test_order_and_destination(self, screen: Screen) -> None: + """A mix of one shows no Order line, the second recording brings it; Destination always shows.""" + main = screen.main + converter = main.converter + + def order_waits_for_a_second_recording_in_a_mix(screen: Screen) -> None: + assert converter.destination() + gather(screen, home_path(BASS)) + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown + + def the_second_brings_it(screen: Screen) -> None: + gather(screen, home_path(LEAD)) + + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_ORDER)).shown, + bool, + description="Order joining", + ) + assert converter.destination() + + screen.scenario(order_waits_for_a_second_recording_in_a_mix, the_second_brings_it).run() + + def test_input_stands_while_a_run_reads(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """The Input line is hidden before a held run, shown while it reads and hidden again after its end.""" + converter = screen.main.converter + + def no_input_before_the_run(screen: Screen) -> None: + gather(screen, home_path(BASS)) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + def input_while_it_reads(screen: Screen) -> None: + converter.press_action() + + screen.bridge.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown, + bool, + description="the Input line", + timeout=RUN_TIMEOUT_SECONDS, + ) + + def no_input_after_it(screen: Screen) -> None: + conversion_hold.release() + wait_for_the_end(screen) + + assert not screen.bridge.ask(lambda: read_item(TAG_MAIN_CONVERTER_GROUP_INPUT)).shown + + screen.scenario(no_input_before_the_run, input_while_it_reads, no_input_after_it).run() diff --git a/tests/screens/main/run/test_destination.py b/tests/screens/main/run/test_destination.py new file mode 100644 index 000000000..1aeac1536 --- /dev/null +++ b/tests/screens/main/run/test_destination.py @@ -0,0 +1,96 @@ +from pathlib import Path +from typing import Dict, FrozenSet, List + +from automation.screen import Screen +from automation.steps.main import gather, give_each_its_own_channel, home_path +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.main.run.constants import ALBUM, ALBUM_TAKES, BASS, DISC, DISC_TAKES, LEAD, RECONSTRUCTION_SUFFIX +from tests.screens.main.run.steps import modified, run_to_its_end, written + + +def converted_channels(path: Path) -> FrozenSet[ChannelName]: + """The channels a written reconstruction approximates, read off the file.""" + return frozenset(rendered_channels(Reconstruction.load(path))) + + +def written_as(path: Path) -> Path: + """The one reconstruction a run wrote for the recording at ``path``, wherever below the folder it went.""" + found = [file for file in written(RECONSTRUCTIONS_DIRECTORY) if file.stem == path.stem] + assert len(found) == 1, found + return found[0] + + +class TestWhereARunWrites: + """A run writes each recording with the channels its row gave, where the Destination line says.""" + + def channels(self) -> Dict[Path, ChannelName]: + """The channel each gathered recording is given: the bass Triangle, the lead Pulse 1.""" + return {home_path(BASS): ChannelName.TRIANGLE, home_path(LEAD): ChannelName.PULSE1} + + def test_each_recording_carries_the_channels_its_row_gave(self, screen: Screen) -> None: + """Each written reconstruction approximates only the channel its recording was given.""" + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + + run_to_its_end(screen) + + for path, channel in self.channels().items(): + assert converted_channels(written_as(path)) == {channel} + + def test_the_destination_line_names_the_folder_holding_what_the_run_writes(self, screen: Screen) -> None: + """Each reconstruction lands in a folder of its own channels, and the Destination line names the + folder holding both.""" + converter = screen.main.converter + gather(screen, home_path(BASS), home_path(LEAD)) + give_each_its_own_channel(screen, self.channels()) + destination = Path(converter.destination()) + + run_to_its_end(screen) + + folders = {written_as(path).parent for path in self.channels()} + assert len(folders) == len(self.channels()) + assert all(folder.parent == destination for folder in folders) + + +class TestAFolderConverts: + """A listed folder converts into a tree mirroring it; a rerun replaces nothing already written and + makes the rest. + + The Album folder, with its nested Disc2 folder, is gathered and converted. One written file is then + deleted and the run repeated; the deleted file returns and the others keep their modification times. + """ + + def test_a_mirrored_tree_then_only_what_is_missing(self, screen: Screen) -> None: + converter = screen.main.converter + folder = home_path(ALBUM) + expected = [Path(name).with_suffix(RECONSTRUCTION_SUFFIX) for name in ALBUM_TAKES] + [ + (Path(DISC) / name).with_suffix(RECONSTRUCTION_SUFFIX) for name in DISC_TAKES + ] + kept: List[Dict[Path, int]] = [] + destinations: List[Path] = [] + + def convert_the_folder(screen: Screen) -> None: + gather(screen, folder) + destinations.append(Path(converter.destination())) + + run_to_its_end(screen) + + files = written(destinations[0]) + assert len(files) == len(expected) + assert all(any(file.as_posix().endswith(part.as_posix()) for file in files) for part in expected) + + def rerun_makes_only_what_is_missing(screen: Screen) -> None: + files = written(destinations[0]) + missing, *standing = files + missing.unlink() + kept.append(modified(standing)) + + run_to_its_end(screen) + + assert written(destinations[0]) == files + assert modified(standing) == kept[0] + + screen.scenario(convert_the_folder, rerun_makes_only_what_is_missing).run() diff --git a/tests/screens/main/run/test_progress_and_failure.py b/tests/screens/main/run/test_progress_and_failure.py new file mode 100644 index 000000000..dca2f8493 --- /dev/null +++ b/tests/screens/main/run/test_progress_and_failure.py @@ -0,0 +1,96 @@ +import multiprocessing +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.screen import Screen +from automation.steps.main import gather, home_path +from automation.vocabulary.converter import CANCEL_RUN, CONVERT_ONE +from tests.screens.main.run.constants import BASS, RUN_TIMEOUT_SECONDS +from tests.screens.main.run.steps import written + +RUN_FAILED: Final[str] = "main.converter.message.status_error" + + +class TestAHeldRun: + """A run shows its progress, Continue lets it run on, and Stop ends it with nothing written and every + worker gone. + + A held run starts on one recording and the button turns to its cancel label. Pressing it asks about + stopping; Cancel on that question keeps the run going. Pressing again and confirming ends the run, + restores the Convert label and leaves no worker process and no written file. + """ + + @pytest.mark.usefixtures("conversion_hold") + def test_progress_continue_and_stop(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.cancel_prompt + destinations: List[Path] = [] + + def start_it(screen: Screen) -> None: + gather(screen, home_path(BASS)) + destinations.append(Path(converter.destination())) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + assert converter.action() == screen.words(CANCEL_RUN) + + def continue_lets_it_run(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about stopping") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert converter.action() == screen.words(CANCEL_RUN) + + def stop_ends_it(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.bridge.expect( + lambda: len(multiprocessing.active_children()), + operator.not_, + description="no worker left", + timeout=RUN_TIMEOUT_SECONDS, + ) + assert written(destinations[0]) == [] + + screen.scenario(start_it, continue_lets_it_run, stop_ends_it).run() + + +class TestARecordingGoneBeforeTheRun: + """A recording deleted after it was gathered is reported in words naming it when the run reaches it.""" + + def test_the_run_says_what_went_wrong(self, screen: Screen) -> None: + """An error notice opens with the run-failed words and the file's name, and the status line reads the + run-failed words. + """ + converter = screen.main.converter + notice = screen.error_notice + gather(screen, home_path(BASS)) + home_path(BASS).unlink() + + converter.press_action() + + screen.bridge.expect(notice.is_shown, bool, description="the failure reported", timeout=RUN_TIMEOUT_SECONDS) + words = notice.words() + screen.claim_error(home_path(BASS).name) + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the report dismissed") + assert words.startswith(screen.words(RUN_FAILED)) + assert home_path(BASS).name in words + assert converter.status() == screen.words(RUN_FAILED) diff --git a/tests/screens/main/run/test_replacing.py b/tests/screens/main/run/test_replacing.py new file mode 100644 index 000000000..d60098eba --- /dev/null +++ b/tests/screens/main/run/test_replacing.py @@ -0,0 +1,134 @@ +import operator +import shutil +from pathlib import Path +from typing import Dict, Final, List + +from automation.screen import Screen +from automation.steps.main import explorer_row, gather, home_path +from sampletones_application.constants.output import OutputKind +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.main.run.constants import ALBUM, BASS, DRUMS, LEAD, RECONSTRUCTION_SUFFIX +from tests.screens.main.run.steps import modified, run_to_its_end, wait_for_the_end, written + +OTHER_RECONSTRUCTION: Final[str] = "other.stn" +RECONSTRUCT_FILE: Final[str] = "main.explorer.label.context_reconstruct_file" +RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" + + +class TestReplacingWhatARunWrote: + """Recordings gathered by name and converted before ask first, and Convert anyway replaces exactly + them. + + Three recordings are converted and a fourth reconstruction is copied beside them. Running again asks + about replacing; Cancel keeps every file as it was, and Convert anyway rewrites the three while the + fourth keeps its modification time. + """ + + def test_cancel_writes_nothing_and_convert_anyway_replaces_those_alone(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = converter.overwrite_prompt + paths = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] + state: Dict[str, Dict[Path, int]] = {} + destinations: List[Path] = [] + + def convert_three_and_set_another_beside_them(screen: Screen) -> None: + gather(screen, *paths) + destinations.append(Path(converter.destination())) + run_to_its_end(screen) + first = written(destinations[0])[0] + shutil.copyfile(first, destinations[0] / OTHER_RECONSTRUCTION) + state["before"] = modified(written(destinations[0])) + + def cancel_writes_nothing(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question about replacing") + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not converter.run_shown() + assert modified(written(destinations[0])) == state["before"] + + def convert_anyway_replaces_those_alone(screen: Screen) -> None: + converter.press_action() + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + wait_for_the_end(screen) + after = modified(written(destinations[0])) + other = destinations[0] / OTHER_RECONSTRUCTION + assert after[other] == state["before"][other] + assert all( + after[file] != state["before"][file] + for file in (destinations[0] / f"{path.stem}{RECONSTRUCTION_SUFFIX}" for path in paths) + ) + + screen.scenario( + convert_three_and_set_another_beside_them, + cancel_writes_nothing, + convert_anyway_replaces_those_alone, + ).run() + + +class TestReconstructingLists: + """Reconstruct lists what it names to convert one apiece and leaves the run to the reader; over a mix it + asks first. + + Two recordings are gathered and Reconstruct file chosen on a third: it joins the list and nothing is + written. The list then turns into a mix, and Reconstruct directory on the Album asks about it: Keep the + mix leaves it standing, and Replace it lists the Album alone, one reconstruction per recording, with + nothing written. + """ + + def test_a_list_takes_it_and_a_mix_is_asked_about(self, screen: Screen) -> None: + main = screen.main + converter = main.converter + prompt = converter.replace_prompt + listed = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] + standing: List[List[Path]] = [] + + def choose_from_the_browser(screen: Screen, path: Path, entry: str) -> None: + screen.explorer.right_click(explorer_row(screen, path)) + screen.expect(screen.context_menu.is_shown, bool, description="the browser's menu") + screen.context_menu.choose(screen.words(entry)) + + def a_list_takes_the_file(screen: Screen) -> None: + standing.append(written(RECONSTRUCTIONS_DIRECTORY)) + gather(screen, home_path(BASS), home_path(LEAD)) + + choose_from_the_browser(screen, home_path(DRUMS), RECONSTRUCT_FILE) + + screen.expect( + lambda: converter.list.has_row(home_path(DRUMS)), bool, description="the third recording listed" + ) + assert converter.list.rows() == [converter.list.row(path) for path in listed] + assert not prompt.is_shown() + assert not converter.run_shown() + assert written(RECONSTRUCTIONS_DIRECTORY) == standing[0] + + def keep_the_mix(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") + + choose_from_the_browser(screen, home_path(ALBUM), RECONSTRUCT_DIRECTORY) + screen.expect(prompt.is_shown, bool, description="the question about the mix") + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert main.output() is OutputKind.MIXED + assert all(converter.list.has_row(path) for path in listed) + + def replace_it(screen: Screen) -> None: + choose_from_the_browser(screen, home_path(ALBUM), RECONSTRUCT_DIRECTORY) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(lambda: converter.list.has_row(home_path(ALBUM)), bool, description="the Album listed") + assert main.output() is OutputKind.PER_RECORDING + assert converter.list.rows() == [converter.list.row(home_path(ALBUM))] + assert not converter.run_shown() + assert written(RECONSTRUCTIONS_DIRECTORY) == standing[0] + + screen.scenario(a_list_takes_the_file, keep_the_mix, replace_it).run() diff --git a/tests/screens/main/scan/__init__.py b/tests/screens/main/scan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/main/scan/test_reading_a_folder.py b/tests/screens/main/scan/test_reading_a_folder.py new file mode 100644 index 000000000..1a9febb33 --- /dev/null +++ b/tests/screens/main/scan/test_reading_a_folder.py @@ -0,0 +1,202 @@ +import operator +from pathlib import Path +from typing import Final, List + +import pytest + +from automation.holds.base import Holds +from automation.holds.scan import ScanHold, WindingDownScanHold +from automation.holds.signal import ReleaseSignal +from automation.screen import Screen +from automation.steps.main import explorer_row, home_path +from automation.vocabulary.converter import FOLDER_ROW, SCAN_PROGRESS +from automation.worlds.home import HomeFile, World, lived_in_world +from sampletones_application.categories.hierarchy import Tab +from tests.suite.screens.seeds.recordings import Recording + +MANY: Final[str] = "Many" +FEW: Final[str] = "Few" +MANY_RECORDINGS: Final[int] = 130 +FEW_RECORDINGS: Final[int] = 2 +REPORTED_COUNT: Final[int] = 128 +SECONDS: Final[float] = 0.05 +FREQUENCY: Final[float] = 330.0 +WIND_DOWN_RELEASE_FILE: Final[str] = "release-winding-down-scan" +RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" + + +def folder_of(name: str, count: int) -> List[HomeFile]: + """The recordings of a folder named ``name`` holding ``count`` short takes.""" + return [ + Recording(destination=home_path(name) / f"take{index:03d}.wav", seconds=SECONDS, frequency=FREQUENCY) + for index in range(count) + ] + + +@pytest.fixture +def world() -> World: + """A lived-in home holding a folder of many recordings and a folder of two.""" + lived_in = lived_in_world() + return World( + state=lived_in.state, + application_config=None, + config=None, + files=(*folder_of(MANY, MANY_RECORDINGS), *folder_of(FEW, FEW_RECORDINGS)), + ) + + +def folder_label(screen: Screen, name: str, count: int) -> str: + """The text of a folder's row in the list, naming the folder and its recordings.""" + return screen.words(FOLDER_ROW).format(name=name, count=count) + + +class TestReadingALargeFolder: + """A folder being read shows a window naming it and counting what it found, while the rest of the + interface answers. + + The large folder is Ctrl-clicked and a held read begins. The window reports the count so far, the + Sequencer and Main tabs come to the front in turn, and releasing the read closes the window and + gathers the folder as one row. + """ + + def test_the_window_counts_and_the_interface_answers_meanwhile(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def start_reading(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + + expected = screen.words(SCAN_PROGRESS).format(count=REPORTED_COUNT, name=MANY) + screen.expect(converter.scan_words, expected.__eq__, description="the count of the folder read so far") + assert converter.scan_shown() + + def the_interface_answers(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.expect(screen.tabs.front, Tab.SEQUENCER.__eq__, description="the Sequencer in front") + screen.tabs.bring_to_front(Tab.MAIN) + screen.expect(screen.tabs.front, Tab.MAIN.__eq__, description="the Main tab back") + assert converter.scan_shown() + assert converter.list.hint_shown() + + def the_read_lands(screen: Screen) -> None: + scan_hold.release() + + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + screen.expect( + lambda: converter.list.has_row(home_path(MANY)), + bool, + description="the folder gathered", + ) + assert converter.list.label(home_path(MANY)) == folder_label(screen, MANY, MANY_RECORDINGS) + + screen.scenario(start_reading, the_interface_answers, the_read_lands).run() + + +class TestStoppingARead: + """Stop gives the read up and gathers nothing; the next Ctrl-click reads afresh. + + The large folder is read and stopped, then the small folder is Ctrl-clicked and released. The list + ends with the small folder's row alone. + """ + + def test_stop_gathers_nothing_and_the_next_read_runs(self, screen: Screen, scan_hold: ScanHold) -> None: + converter = screen.main.converter + + def stop_a_read(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + + converter.stop_scan() + + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + def the_next_read_runs(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) + + screen.expect(converter.scan_shown, bool, description="a new read under way") + scan_hold.release() + screen.expect( + lambda: converter.list.has_row(home_path(FEW)), bool, description="the second folder gathered" + ) + assert converter.list.rows() == [converter.list.row(home_path(FEW))] + + screen.scenario(stop_a_read, the_next_read_runs).run() + + +class TestAskingForAFolderDuringARead: + """A folder asked for while another is being read waits; asking again once the window closes reads it. + + The large folder is read and the small folder's Reconstruct menu entry chosen meanwhile. The read + under way goes on and the list gains only the large folder. Ctrl-clicking the small folder after + the window closes gathers it. + """ + + def test_the_read_under_way_runs_on_and_the_folder_asked_for_reads_afterwards( + self, + screen: Screen, + scan_hold: ScanHold, + ) -> None: + converter = screen.main.converter + + def ask_for_a_second_folder_during_a_read(screen: Screen) -> None: + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + + screen.explorer.right_click(explorer_row(screen, home_path(FEW))) + screen.expect(screen.context_menu.is_shown, bool, description="the second folder's menu") + screen.context_menu.choose(screen.words(RECONSTRUCT_DIRECTORY)) + + screen.expect(screen.context_menu.is_shown, operator.not_, description="the menu put away") + assert converter.scan_shown() + assert MANY in converter.scan_words() + + def the_first_read_lands(screen: Screen) -> None: + scan_hold.release() + + screen.expect( + lambda: converter.list.has_row(home_path(MANY)), bool, description="the first folder gathered" + ) + assert not converter.run_shown() + + def asking_again_reads_it(screen: Screen) -> None: + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) + + screen.expect( + lambda: converter.list.has_row(home_path(FEW)), bool, description="the second folder gathered" + ) + + screen.scenario( + ask_for_a_second_folder_during_a_read, + the_first_read_lands, + asking_again_reads_it, + ).run() + + +class TestAskingForAFolderAsAStoppedReadWindsDown: + """A folder asked for once Stop closed the window is read, as the scan promises once its window closes. + + The read of the large folder is held past its last entry, so Stop closes the window while the read still + winds down. The small folder is Ctrl-clicked then, and the hold lets the stopped read end. + """ + + @pytest.fixture + def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> WindingDownScanHold: + """A scan hold that keeps a stopped read winding down until the scenario releases it.""" + hold = WindingDownScanHold(ReleaseSignal(Path.cwd().parent / WIND_DOWN_RELEASE_FILE)) + hold.install(monkeypatch) + screen_holds.add(hold) + return hold + + def test_the_folder_asked_for_is_read(self, screen: Screen, scan_hold: WindingDownScanHold) -> None: + """The small folder, Ctrl-clicked just after Stop, is gathered once the hold is released.""" + converter = screen.main.converter + screen.explorer.ctrl_click(explorer_row(screen, home_path(MANY))) + screen.expect(converter.scan_shown, bool, description="the read under way") + converter.stop_scan() + screen.expect(converter.scan_shown, operator.not_, description="the window closed") + + screen.explorer.ctrl_click(explorer_row(screen, home_path(FEW))) + + scan_hold.release() + screen.expect(lambda: converter.list.has_row(home_path(FEW)), bool, description="the folder asked for gathered") diff --git a/tests/screens/prompts/__init__.py b/tests/screens/prompts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/closing/__init__.py b/tests/screens/prompts/closing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/closing/constants.py b/tests/screens/prompts/closing/constants.py new file mode 100644 index 000000000..256e347f9 --- /dev/null +++ b/tests/screens/prompts/closing/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 30 diff --git a/tests/screens/prompts/closing/test_during_an_edit.py b/tests/screens/prompts/closing/test_during_an_edit.py new file mode 100644 index 000000000..1e50146a6 --- /dev/null +++ b/tests/screens/prompts/closing/test_during_an_edit.py @@ -0,0 +1,126 @@ +import operator +from typing import List + +import pytest + +from automation.application.startup import Startup +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.reconstructions import ( + expect_open, + first_raised, + marked, + raise_the_first_level_while_held, + titled, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.closing.constants import SETTLING_FRAMES +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + + +class TestClosingTheWindowWhileAnEditIsOnItsWay: + """A close asked for during an edit asks about the edited reconstruction once the edit lands.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_two_closes_ask_once(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """Two closes during a held rebuild bring one question, Cancel leaves the screen quiet, and a + further close asks again.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + + def edit_and_close_twice_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + screen.close_window() + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_edit_lands_and_one_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def close_once_more_and_leave(screen: Screen) -> None: + screen.close_window() + screen.expect(prompt.is_shown, bool, description="the question about leaving again") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_and_close_twice_while_the_rebuild_is_held, + the_edit_lands_and_one_question_comes, + cancel_leaves_no_question, + close_once_more_and_leave, + ).run() + + def test_an_edit_made_after_the_close_is_drawn_away( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """An edit typed after the close is dropped, and the field shows the edit that landed.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_and_close_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def edit_again_after_the_close(screen: Screen) -> None: + reconstructions.instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, first_raised(typed[0])) + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_first_edit_lands_and_the_second_is_drawn_away(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about leaving") + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + typed[0].__eq__, + description="the field drawing the edit that landed", + ) + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving again") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_and_close_while_the_rebuild_is_held, + edit_again_after_the_close, + the_first_edit_lands_and_the_second_is_drawn_away, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/closing/test_over_a_question.py b/tests/screens/prompts/closing/test_over_a_question.py new file mode 100644 index 000000000..32563edb6 --- /dev/null +++ b/tests/screens/prompts/closing/test_over_a_question.py @@ -0,0 +1,210 @@ +import operator +from typing import Final + +from automation.screen import Screen +from automation.steps.main import convert_alone, home_path +from automation.steps.project import retitle_project, save_project_as, saved_project_title +from automation.vocabulary.dialogs import CLOSE_PROJECT_MESSAGE, EXIT_PROJECT_MESSAGE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.prompts.closing.constants import SETTLING_FRAMES +from tests.suite.screens.worlds.recordings import BASS, LEAD + +PROJECT: Final[str] = "Closing.stp" +SAVED_TITLE: Final[str] = "Before closing" +NEW_TITLE: Final[str] = "After closing" + + +def change_a_saved_project(screen: Screen) -> None: + """Saves a new titled project, then retitles it, which leaves the saved file with the first title.""" + screen.project.create() + retitle_project(screen, SAVED_TITLE) + save_project_as(screen, PROJECTS_DIRECTORY / PROJECT) + + retitle_project(screen, NEW_TITLE) + + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + +def leave_letting_the_project_go(screen: Screen) -> None: + """Answers Exit to the question about the project and expects the saved file to keep its first title.""" + prompt = screen.project.unsaved_prompt + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + +class TestClosingTheWindowTwiceAtOnce: + """Two closes before the first is answered ask about the unsaved project once. + + The saved project is retitled and the window closed twice. One question comes; Cancel keeps the + application, and a further close asks once more and leaves on Exit. + """ + + def test_one_question_and_cancel_keeps_the_application(self, screen: Screen) -> None: + """Two closes bring one question, and Cancel keeps the application running.""" + prompt = screen.project.unsaved_prompt + + def close_twice(screen: Screen) -> None: + screen.close_window() + screen.close_window() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + screen.frames(SETTLING_FRAMES) + assert len(screen.shown_windows()) == 1 + + def cancel_leaves_no_question(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.is_running() + + def close_once_more(screen: Screen) -> None: + screen.close_window() + + leave_letting_the_project_go(screen) + + screen.scenario(change_a_saved_project, close_twice, cancel_leaves_no_question, close_once_more).run() + + +class TestClosingTheWindowOverTheCloseProjectQuestion: + """Closing the window while Close project asks about the unsaved project leaves once Discard closes it. + + The saved project is retitled and Close project asks. The window close waits behind that question, and + Discard closes the project, which leaves nothing for the exit to ask about: the application stops with + no second question, and the file keeps its saved title. + """ + + def test_discard_leaves_without_a_second_question(self, screen: Screen) -> None: + """The exit reads the project once Discard has closed it, so it asks nothing and leaves.""" + prompt = screen.project.unsaved_prompt + + def close_the_project(screen: Screen) -> None: + screen.project.close() + + screen.expect(prompt.is_shown, bool, description="the question about closing the project") + assert prompt.words() == screen.words(CLOSE_PROJECT_MESSAGE) + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert prompt.words() == screen.words(CLOSE_PROJECT_MESSAGE) + assert len(screen.shown_windows()) == 1 + assert screen.is_running() + + def discard_and_leave(screen: Screen) -> None: + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + screen.scenario( + change_a_saved_project, + close_the_project, + close_the_window, + discard_and_leave, + ).run() + + +class TestClosingTheWindowOverTheReassignQuestion: + """Closing the window while Keyboard settings asks to reassign keys asks about the project after the + dialog. + + The saved project is retitled, Redo listens and Undo's keys ask to reassign. The window close waits; + Cancel brings the dialog back, and closing the dialog brings the question about the project. + """ + + def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + """The question about the project comes after the reassign question and its dialog.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + prompt = screen.project.unsaved_prompt + + def ask_to_reassign(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert reassign.is_shown() + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def answer_the_dialog_and_its_question(screen: Screen) -> None: + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + + settings.cancel() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert len(screen.shown_windows()) == 1 + + screen.scenario( + change_a_saved_project, + ask_to_reassign, + close_the_window, + answer_the_dialog_and_its_question, + leave_letting_the_project_go, + ).run() + + +class TestClosingTheWindowOverTheOverwriteQuestion: + """Closing the window while the Converter asks to replace a run's files asks about the project after it. + + The saved project is retitled and a run is converted again, which asks about replacing. The window + close waits; Cancel brings the question about the project. + """ + + def test_the_exit_question_waits_its_turn(self, screen: Screen) -> None: + """The question about the project comes after the overwrite question.""" + converter = screen.main.converter + overwrite = converter.overwrite_prompt + prompt = screen.project.unsaved_prompt + + def convert_and_ask_to_convert_again(screen: Screen) -> None: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + converter.end_prompt.cancel() + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the run's end answered") + + converter.press_action() + + screen.expect(overwrite.is_shown, bool, description="the question about replacing") + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert overwrite.is_shown() + assert not prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + overwrite.cancel() + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert not converter.run_shown() + assert len(screen.shown_windows()) == 1 + + screen.scenario( + change_a_saved_project, + convert_and_ask_to_convert_again, + close_the_window, + cancel_hands_the_screen_over, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/conftest.py b/tests/screens/prompts/conftest.py new file mode 100644 index 000000000..0dfb39d5e --- /dev/null +++ b/tests/screens/prompts/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.worlds.home import World +from tests.suite.screens.worlds.recordings import documents_world + + +@pytest.fixture +def world() -> World: + """The home every prompt scenario starts in: documents to put away and replace, and recordings to convert.""" + return documents_world() diff --git a/tests/screens/prompts/every_door/__init__.py b/tests/screens/prompts/every_door/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/every_door/cases.py b/tests/screens/prompts/every_door/cases.py new file mode 100644 index 000000000..fd2ed72aa --- /dev/null +++ b/tests/screens/prompts/every_door/cases.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Final, Tuple + +from automation.vocabulary.dialogs import CLOSE +from tests.screens.prompts.every_door.constants import EXIT + + +class Door(StrEnum): + """A gesture that puts the open document away, to replace it or to leave. + + A voice opens from a double-click on its row and from Edit on its row's menu. + """ + + BROWSER = "browser" + MENU_OPEN = "menu_open" + CLOSE = "close" + VOICE = "voice" + VOICE_MENU = "voice_menu" + CONVERSION_LOAD = "conversion_load" + EXIT = "exit" + WINDOW_CLOSE = "window_close" + + +ASKING_DOORS: Final[Tuple[Door, ...]] = tuple(Door) + + +@dataclass(frozen=True) +class Question: + """A question as the reader meets it, with its title, its words and the answers it offers.""" + + title: str + words: str + answers: Tuple[str, ...] + + +@dataclass(frozen=True) +class Standing: + """What the screen shows of the open document, which a question taken back keeps as it was.""" + + title: str + file_line: str + volume: str + windows: Tuple[str, ...] + dialogs: int diff --git a/tests/screens/prompts/every_door/constants.py b/tests/screens/prompts/every_door/constants.py new file mode 100644 index 000000000..cdb4c3fba --- /dev/null +++ b/tests/screens/prompts/every_door/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 +SAMPLE_ORDINAL: Final[int] = 0 +EXIT: Final[str] = "global.dialog.label.exit" diff --git a/tests/screens/prompts/every_door/steps.py b/tests/screens/prompts/every_door/steps.py new file mode 100644 index 000000000..30de0c5e3 --- /dev/null +++ b/tests/screens/prompts/every_door/steps.py @@ -0,0 +1,48 @@ +import operator + +from automation.screen import Screen +from automation.steps.main import convert_alone, home_path +from automation.steps.reconstructions import load_from_the_browser +from automation.steps.sequencer import double_click_voice, open_voice +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SETTLING_FRAMES +from tests.suite.screens.worlds.recordings import BASS, LEAD, OTHER_RECONSTRUCTION + + +def knock(screen: Screen, door: Door, *, voice: str) -> None: + """Makes the gesture ``door`` stands for. + + A door that loads opens Other.stn, the run's bass, or ``voice``. + """ + match door: + case Door.BROWSER: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + case Door.MENU_OPEN: + screen.reconstructions.open_from_menu() + case Door.CLOSE: + screen.reconstructions.close_from_menu() + case Door.VOICE: + double_click_voice(screen, voice) + case Door.VOICE_MENU: + open_voice(screen, voice) + case Door.CONVERSION_LOAD: + convert_alone(screen, home_path(BASS), channel=ChannelName.PULSE1, replacing=[home_path(LEAD)]) + screen.main.converter.end_prompt.confirm() + screen.expect(screen.main.converter.end_prompt.is_shown, operator.not_, description="the end answered") + case Door.EXIT: + screen.press_shortcut(ShortcutId.EXIT) + case Door.WINDOW_CLOSE: + screen.close_window() + + +def nothing_asked(screen: Screen) -> None: + """Waits a few frames and expects every window to stay closed.""" + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + +def expect_title(screen: Screen, title: str) -> None: + """Waits until the window title reads ``title``.""" + screen.expect(screen.title, title.__eq__, description=f"the title reading '{title}'") diff --git a/tests/screens/prompts/every_door/test_edited_reconstruction.py b/tests/screens/prompts/every_door/test_edited_reconstruction.py new file mode 100644 index 000000000..adfa36ff0 --- /dev/null +++ b/tests/screens/prompts/every_door/test_edited_reconstruction.py @@ -0,0 +1,180 @@ +import operator +from typing import Callable, Final + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import ( + expect_open, + marked, + raise_the_first_level, + remove_from_the_browser, + titled, +) +from automation.vocabulary.dialogs import ( + CANCEL, + CLOSE, + CLOSE_MESSAGE, + CLOSE_TITLE, + DISCARD, + EDIT_MESSAGE, + EDIT_TITLE, + EXIT_RECONSTRUCTION_MESSAGE, + LOAD_MESSAGE, + LOAD_TITLE, + SAVE, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import ASKING_DOORS, Door, Question, Standing +from tests.screens.prompts.every_door.constants import EXIT, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_SAMPLE + +EXIT_TITLE: Final[str] = "global.dialog.title.exit_confirmation" + + +def question_at(screen: Screen, door: Door) -> Question: + """The question ``door`` asks about a standalone reconstruction with unsaved changes, in the words of the + language file. + """ + match door: + case Door.BROWSER | Door.MENU_OPEN | Door.CONVERSION_LOAD: + return Question( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.CLOSE: + return Question( + screen.words(CLOSE_TITLE), + screen.words(CLOSE_MESSAGE), + (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)), + ) + case Door.VOICE | Door.VOICE_MENU: + return Question( + screen.words(EDIT_TITLE), + screen.words(EDIT_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + case Door.EXIT | Door.WINDOW_CLOSE: + return Question( + screen.words(EXIT_TITLE), + screen.words(EXIT_RECONSTRUCTION_MESSAGE), + (screen.words(SAVE), screen.words(EXIT), screen.words(CANCEL)), + ) + + +def standing(screen: Screen) -> Standing: + """What the screen shows of the open reconstruction right now.""" + reconstructions = screen.reconstructions + return Standing( + title=screen.title(), + file_line=reconstructions.file_line(), + volume=reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + windows=tuple(window.alias for window in screen.shown_windows()), + dialogs=len(screen.dialog_requests()), + ) + + +def asked(screen: Screen, door: Door) -> Question: + """Waits for the question about the reconstruction and returns it as the screen shows it.""" + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description=f"the question {door} asks") + return Question(prompt.title(), prompt.words(), prompt.answers()) + + +def asks_and_cancel_keeps_it(door: Door, *, voice: str) -> Callable[[Screen], None]: + """The step that knocks at ``door``, expects its question, and takes it back with Cancel, which keeps + everything as it stood. + """ + + def step(screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + before = standing(screen) + + knock(screen, door, voice=voice) + + assert asked(screen, door) == question_at(screen, door) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert standing(screen) == before + + step.__name__ = f"{door.value}_asks_and_cancel_keeps_it" + return step + + +def leave_letting_it_go(screen: Screen) -> None: + """Exits and answers Exit to the question about the reconstruction.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the reconstruction") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestAnEditedReconstructionAtEveryDoor: + """Every door asks about an edited reconstruction in its own words, and Cancel keeps everything as it + stood. + + The reconstruction is open beside a project and its first level is raised. Each door is knocked at in + turn; its question is compared with the expected title, words and answers, and Cancel leaves the title, + file line, volume and windows as before. The scenario ends by exiting. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + """Each door asks its own question and Cancel keeps the screen as it was.""" + + def edit_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + screen.scenario( + edit_it, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() + + +class TestAReconstructionWhoseFileWasRemovedAtEveryDoor: + """Every door asks about a reconstruction whose file was removed, as it asks about an edited one. + + The open reconstruction loses its file in the browser and shows as unsaved. Each door is knocked at in + turn, asks its question, and Cancel keeps everything as it stood. The scenario ends by exiting. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_each_door_asks_its_question(self, screen: Screen) -> None: + """Each door asks its own question and Cancel keeps the screen as it was.""" + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + expect_title(screen, titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True))) + + screen.scenario( + remove_its_file, + *(asks_and_cancel_keeps_it(door, voice=SONG_SAMPLE) for door in ASKING_DOORS), + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_edited_sample.py b/tests/screens/prompts/every_door/test_edited_sample.py new file mode 100644 index 000000000..4f85c3d7f --- /dev/null +++ b/tests/screens/prompts/every_door/test_edited_sample.py @@ -0,0 +1,119 @@ +import operator +from functools import partial +from typing import Callable, List + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.main import home_path +from automation.steps.project import leave_letting_the_project_go +from automation.steps.reconstructions import converted, marked, raise_the_first_level, titled, voice_title +from automation.steps.sequencer import open_voice +from automation.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + + +class TestAnEditedSampleAtEveryDoor: + """An edited sample of the project is put away at every door with no question, and its edits stay in the + project. + + The sample is opened and edited. The browser, the Open menu, another voice, Close and a conversion each + go through at once, and the edited sample is still there when it is opened again. Only the exit and the + window close ask, and they ask about the project. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_the_exit_asks_about_the_project(self, screen: Screen) -> None: + """The doors go through at once and the exits ask about the project.""" + reconstructions = screen.reconstructions + edit: List[str] = [] + sample_open = partial(voice_title, screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True) + + def open_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, sample_open()) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == edit[0] + + def edit_the_sample(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + + edit.append(raise_the_first_level(screen, ChannelName.PULSE1, title=sample_open())) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_sample(screen) + + def another_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_INSTRUMENT) + + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + assert screen.title() == titled(screen, marked(SONG.stem, unsaved=True)) + nothing_asked(screen) + open_the_sample(screen) + + def close_closes_at_once(screen: Screen) -> None: + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True))) + assert reconstructions.file_line() == "" + nothing_asked(screen) + open_the_sample(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_sample(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + assert not reconstructions.unsaved_prompt.is_shown() + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert screen.title() == sample_open() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_sample, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + another_voice_opens_at_once, + close_closes_at_once, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_instrument.py b/tests/screens/prompts/every_door/test_instrument.py new file mode 100644 index 000000000..bd6366272 --- /dev/null +++ b/tests/screens/prompts/every_door/test_instrument.py @@ -0,0 +1,126 @@ +import operator +from typing import Callable, Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.main import home_path +from automation.steps.project import leave_letting_the_project_go +from automation.steps.reconstructions import converted, edit_envelope, marked, titled, voice_title +from automation.steps.sequencer import open_voice +from automation.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL, SETTLING_FRAMES +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +FADING: Final[str] = "12 8 4" + + +class TestAnInstrumentAtEveryDoor: + """An instrument is put away at every door with no question, Close is unavailable, and the exit asks about + the project. + + The instrument is opened and its volume envelope edited. The browser, the Open menu, a sample and a + conversion each go through at once, and the edited instrument is still there when it is opened again. + Close does nothing, and the exit and the window close ask about the project. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_door_asks_and_close_stands_aside(self, screen: Screen) -> None: + """The doors go through at once, Close is unavailable and the exits ask about the project.""" + reconstructions = screen.reconstructions + edited = titled(screen, marked(SONG.stem, unsaved=True)) + + def open_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="its edit drawn", + ) + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(reconstructions.instruments.offers_audition, bool, description="the instrument open") + + edit_envelope(screen, channel=ChannelName.PULSE1, feature=FeatureKey.VOLUME, sequence=FADING, title=edited) + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + assert not reconstructions.instruments.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + open_the_instrument(screen) + + def a_sample_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True)) + assert not reconstructions.instruments.offers_audition() + nothing_asked(screen) + open_the_instrument(screen) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert reconstructions.instruments.offers_audition() + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == FADING + assert screen.title() == edited + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, marked(SONG.stem, unsaved=True), converted(home_path(BASS)).name)) + nothing_asked(screen) + open_the_instrument(screen) + + def leaving_asks_about_the_project(door: Door) -> Callable[[Screen], None]: + def step(screen: Screen) -> None: + prompt = screen.project.unsaved_prompt + + knock(screen, door, voice=SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about the project") + assert prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + prompt.cancel() + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(SETTLING_FRAMES) + assert reconstructions.instruments.offers_audition() + + step.__name__ = f"{door.value}_asks_about_the_project" + return step + + screen.scenario( + edit_the_instrument, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_sample_opens_at_once, + close_has_nothing_to_close, + the_conversion_loads_at_once, + leaving_asks_about_the_project(Door.EXIT), + leaving_asks_about_the_project(Door.WINDOW_CLOSE), + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/prompts/every_door/test_nothing_open.py b/tests/screens/prompts/every_door/test_nothing_open.py new file mode 100644 index 000000000..e1e59f983 --- /dev/null +++ b/tests/screens/prompts/every_door/test_nothing_open.py @@ -0,0 +1,83 @@ +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.main import home_path +from automation.steps.reconstructions import converted, titled, voice_title +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.worlds.recordings import BASS, OTHER_RECONSTRUCTION, SONG, SONG_SAMPLE + + +class TestNothingOpenAtEveryDoor: + """With nothing open, every door that loads loads at once, and Close is unavailable.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a project with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_door_loads_at_once(self, screen: Screen) -> None: + """The browser, the Open menu, a voice and a conversion each load at once and the title follows.""" + reconstructions = screen.reconstructions + empty = titled(screen, SONG.stem) + + def close_it_again(screen: Screen) -> None: + reconstructions.close_from_menu() + expect_title(screen, empty) + + def close_has_nothing_to_close(screen: Screen) -> None: + assert not reconstructions.can_close() + + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + + nothing_asked(screen) + assert screen.title() == empty + + def the_browser_loads_at_once(screen: Screen) -> None: + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OTHER_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + close_it_again(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + close_it_again(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + screen.scenario( + close_has_nothing_to_close, + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + a_voice_opens_at_once, + the_conversion_loads_at_once, + ).run() + + def test_the_window_closes_at_once(self, screen: Screen) -> None: + """Closing the window leaves the application at once.""" + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the song open") + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/every_door/test_saved_reconstruction.py b/tests/screens/prompts/every_door/test_saved_reconstruction.py new file mode 100644 index 000000000..c2ab72552 --- /dev/null +++ b/tests/screens/prompts/every_door/test_saved_reconstruction.py @@ -0,0 +1,85 @@ +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.main import home_path +from automation.steps.reconstructions import converted, expect_open, load_from_the_browser, titled, voice_title +from tests.screens.prompts.every_door.cases import Door +from tests.screens.prompts.every_door.constants import SAMPLE_ORDINAL +from tests.screens.prompts.every_door.steps import expect_title, knock, nothing_asked +from tests.suite.screens.worlds.recordings import BASS, OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION, SONG, SONG_SAMPLE + + +class TestASavedReconstructionAtEveryDoor: + """A reconstruction with nothing unsaved is put away at every door at once, and leaving leaves.""" + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction beside a project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_no_door_asks(self, screen: Screen) -> None: + """Every door replaces, closes or leaves at once.""" + + def the_browser_loads_at_once(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.BROWSER, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OTHER_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_menu_asks_for_a_file_at_once(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.OPEN, OPEN_RECONSTRUCTION) + + knock(screen, Door.MENU_OPEN, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, OPEN_RECONSTRUCTION.name)) + nothing_asked(screen) + + def the_conversion_loads_at_once(screen: Screen) -> None: + knock(screen, Door.CONVERSION_LOAD, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem, converted(home_path(BASS)).name)) + nothing_asked(screen) + + def a_voice_opens_at_once(screen: Screen) -> None: + knock(screen, Door.VOICE_MENU, voice=SONG_SAMPLE) + + expect_title(screen, voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False)) + nothing_asked(screen) + + def close_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.CLOSE, voice=SONG_SAMPLE) + + expect_title(screen, titled(screen, SONG.stem)) + nothing_asked(screen) + + def the_window_closes_at_once(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.WINDOW_CLOSE, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() + + screen.scenario( + the_browser_loads_at_once, + the_menu_asks_for_a_file_at_once, + the_conversion_loads_at_once, + a_voice_opens_at_once, + close_closes_at_once, + the_window_closes_at_once, + ).run() + + def test_exit_leaves_at_once(self, screen: Screen) -> None: + """The exit shortcut leaves the application at once.""" + expect_open(screen, OPEN_RECONSTRUCTION) + + knock(screen, Door.EXIT, voice=SONG_SAMPLE) + + assert screen.wait_for_exit() diff --git a/tests/screens/prompts/keyboard/__init__.py b/tests/screens/prompts/keyboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/keyboard/constants.py b/tests/screens/prompts/keyboard/constants.py new file mode 100644 index 000000000..e20e3358d --- /dev/null +++ b/tests/screens/prompts/keyboard/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 diff --git a/tests/screens/prompts/keyboard/steps.py b/tests/screens/prompts/keyboard/steps.py new file mode 100644 index 000000000..7ec31d916 --- /dev/null +++ b/tests/screens/prompts/keyboard/steps.py @@ -0,0 +1,18 @@ +from automation.screen import Screen +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + + +def tab(screen: Screen, times: int) -> None: + """Presses the shortcut for the next control in a dialog ``times`` times.""" + for _ in range(times): + screen.press_shortcut(ShortcutId.DIALOG_NEXT_CONTROL) + + +def enter(screen: Screen) -> None: + """Presses the shortcut that activates the focused control of a dialog.""" + screen.press_shortcut(ShortcutId.DIALOG_ACTIVATE) + + +def escape(screen: Screen) -> None: + """Presses the shortcut that cancels a dialog.""" + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) diff --git a/tests/screens/prompts/keyboard/test_display_question.py b/tests/screens/prompts/keyboard/test_display_question.py new file mode 100644 index 000000000..6d9b2c637 --- /dev/null +++ b/tests/screens/prompts/keyboard/test_display_question.py @@ -0,0 +1,56 @@ +import operator +from typing import List + +from automation.screen import Screen +from tests.screens.prompts.keyboard.steps import enter, escape, tab + + +class TestTheDisplayQuestionFromTheKeyboard: + """The question Escape asks of changed Display settings answers to the keys, as does the dialog after it. + + The user flips Vertical sync and presses Escape. Enter keeps the dialog open with the change. Escape + again, then Tab and Enter, discards the change; the dialog reopens with the original value. + """ + + def test_enter_keeps_editing_and_tab_enter_discards(self, screen: Screen) -> None: + settings = screen.display_settings + prompt = settings.discard_prompt + original: List[bool] = [] + + def change_vertical_sync(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="the Display settings dialog") + original.append(settings.vsync()) + + settings.toggle_vsync() + + screen.expect(settings.vsync, original[0].__ne__, description="the box flipped") + + def escape_asks(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, bool, description="the discard question") + + def enter_keeps_editing(screen: Screen) -> None: + enter(screen) + + screen.expect(settings.is_shown, bool, description="the dialog back") + assert not prompt.is_shown() + assert settings.vsync() != original[0] + + def escape_then_tab_and_enter_discard(screen: Screen) -> None: + escape(screen) + screen.expect(prompt.is_shown, bool, description="the discard question again") + + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert not settings.is_shown() + settings.open() + screen.expect(settings.is_shown, bool, description="the dialog opened again") + assert settings.vsync() == original[0] + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="the unchanged dialog closed") + + screen.scenario(change_vertical_sync, escape_asks, enter_keeps_editing, escape_then_tab_and_enter_discard).run() diff --git a/tests/screens/prompts/keyboard/test_exit_chain.py b/tests/screens/prompts/keyboard/test_exit_chain.py new file mode 100644 index 000000000..d1605e729 --- /dev/null +++ b/tests/screens/prompts/keyboard/test_exit_chain.py @@ -0,0 +1,103 @@ +import operator +from typing import Final, List + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.project import retitle_project, saved_project_title +from automation.steps.reconstructions import expect_open, marked, raise_the_first_level, titled +from automation.vocabulary.dialogs import EXIT_PROJECT_MESSAGE, EXIT_RECONSTRUCTION_MESSAGE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.keyboard.constants import SETTLING_FRAMES +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +RETITLED: Final[str] = "Retitled from the keyboard" + + +class TestTheExitChainFromTheKeyboard: + """Tab, Enter and Escape answer the question about the reconstruction that the project's answer opens. + + Each question starts on Cancel, so one Tab reaches Save and two reach Exit. The scenario opens a + project beside an edited reconstruction and retitles the project. Exit asks about the project, and Exit + on that question asks about the reconstruction. Escape there keeps the application and both changes. + Then the keys save the project, leave the reconstruction's file as it was, and close the application. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_second_keeps_everything_and_the_keys_then_save_and_leave(self, screen: Screen) -> None: + project_prompt = screen.project.unsaved_prompt + reconstruction_prompt = screen.reconstructions.unsaved_prompt + stored: List[bytes] = [] + + def open_the_project_and_change_both(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + screen.answer_next_dialog(DialogKind.OPEN, SONG) + screen.project.open() + screen.expect( + screen.sequencer.voices.names, [SONG_SAMPLE, SONG_INSTRUMENT].__eq__, description="the song's voices" + ) + retitle_project(screen, RETITLED) + + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True)), + ) + + def exit_asks_about_the_project(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + screen.expect(project_prompt.is_shown, bool, description="the question about the project") + assert project_prompt.words() == screen.words(EXIT_PROJECT_MESSAGE) + + def exit_on_the_project_asks_about_the_reconstruction(screen: Screen) -> None: + tab(screen, 2) + enter(screen) + + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + assert reconstruction_prompt.words() == screen.words(EXIT_RECONSTRUCTION_MESSAGE) + assert not project_prompt.is_shown() + + def escape_keeps_the_application_and_both_changes(screen: Screen) -> None: + escape(screen) + + screen.expect(reconstruction_prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.is_running() + assert screen.shown_windows() == () + assert screen.title() == titled( + screen, marked(SONG.stem, unsaved=True), marked(OPEN_RECONSTRUCTION.name, unsaved=True) + ) + assert saved_project_title(SONG) != RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_keys_save_the_project_and_leave(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(project_prompt.is_shown, bool, description="the question about the project again") + tab(screen, 1) + enter(screen) + screen.expect(reconstruction_prompt.is_shown, bool, description="the question about the reconstruction") + + tab(screen, 2) + enter(screen) + + assert screen.wait_for_exit() + assert saved_project_title(SONG) == RETITLED + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + open_the_project_and_change_both, + exit_asks_about_the_project, + exit_on_the_project_asks_about_the_reconstruction, + escape_keeps_the_application_and_both_changes, + the_keys_save_the_project_and_leave, + ).run() diff --git a/tests/screens/prompts/keyboard/test_load_chain.py b/tests/screens/prompts/keyboard/test_load_chain.py new file mode 100644 index 000000000..8bf5e5e09 --- /dev/null +++ b/tests/screens/prompts/keyboard/test_load_chain.py @@ -0,0 +1,83 @@ +import operator +from typing import List + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.main import convert_alone, home_path +from automation.steps.reconstructions import ( + converted, + edited_title, + expect_open, + first_level_raised, + leading, + raise_the_first_level, + stored_levels, +) +from sampletones_core.constants.enums import ChannelName +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.worlds.recordings import BASS, LEAD, OPEN_RECONSTRUCTION + + +class TestTheLoadChainFromTheKeyboard: + """The keys answer Load at a run's end, and the question about unsaved changes that Load opens. + + The scenario edits the open reconstruction and converts a recording. Tab and Enter choose Load, which + asks about the changes. Escape keeps the edited reconstruction open. After a second conversion, Tab and + Enter choose Save, which writes the edit and opens the conversion. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_keeps_the_edits_and_the_keys_then_save_and_load(self, screen: Screen) -> None: + converter = screen.main.converter + prompt = screen.reconstructions.unsaved_prompt + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen, OPEN_RECONSTRUCTION)) + + def load_from_the_keyboard(screen: Screen, recording: str) -> None: + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + tab(screen, 1) + enter(screen) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert not converter.end_prompt.is_shown() + + def escape_keeps_the_edited_one(screen: Screen) -> None: + load_from_the_keyboard(screen, BASS) + + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen, OPEN_RECONSTRUCTION) + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def save_and_load_from_the_keyboard(screen: Screen) -> None: + load_from_the_keyboard(screen, LEAD) + + tab(screen, 1) + enter(screen) + + expect_open(screen, converted(home_path(LEAD))) + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + escape_keeps_the_edited_one, + save_and_load_from_the_keyboard, + ).run() diff --git a/tests/screens/prompts/keyboard/test_save_that_asks_where.py b/tests/screens/prompts/keyboard/test_save_that_asks_where.py new file mode 100644 index 000000000..af44c9e5b --- /dev/null +++ b/tests/screens/prompts/keyboard/test_save_that_asks_where.py @@ -0,0 +1,87 @@ +import operator +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.reconstructions import edited_title, expect_open, remove_from_the_browser, titled +from automation.vocabulary.dialogs import CLOSE_MESSAGE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.prompts.keyboard.constants import SETTLING_FRAMES +from tests.screens.prompts.keyboard.steps import enter, escape, tab +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" + + +class TestASaveThatAsksWhereFromTheKeyboard: + """The question a dismissed save dialog brings back answers to the keys like the first one. + + The open reconstruction loses its file in the browser, so closing it asks and Save asks where to write. + The save dialog is dismissed and the question returns. Escape keeps the reconstruction open. Closing + again, the keys choose Save, the save dialog names a file, and the reconstruction is written there and + closed. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_escape_on_the_returned_question_then_the_keys_save_where_asked(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def take_its_file_away(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + screen.title, + edited_title(screen, OPEN_RECONSTRUCTION).__eq__, + description="the reconstruction left unsaved", + ) + + def save_asks_where_and_a_dismissed_dialog_asks_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.words() == screen.words(CLOSE_MESSAGE) + screen.answer_next_dialog(DialogKind.SAVE, None) + + tab(screen, 1) + enter(screen) + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + assert len(prompt.shown_windows()) == 1 + + def escape_keeps_it_open(screen: Screen) -> None: + escape(screen) + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.title() == edited_title(screen, OPEN_RECONSTRUCTION) + assert screen.shown_windows() == () + + def the_keys_save_it_where_asked_and_close_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.CLOSE_RECONSTRUCTION) + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + tab(screen, 1) + enter(screen) + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + assert not prompt.is_shown() + + screen.scenario( + take_its_file_away, + save_asks_where_and_a_dismissed_dialog_asks_again, + escape_keeps_it_open, + the_keys_save_it_where_asked_and_close_it, + ).run() diff --git a/tests/screens/prompts/load/__init__.py b/tests/screens/prompts/load/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/load/test_conversion_over_edits.py b/tests/screens/prompts/load/test_conversion_over_edits.py new file mode 100644 index 000000000..6de804da8 --- /dev/null +++ b/tests/screens/prompts/load/test_conversion_over_edits.py @@ -0,0 +1,284 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.main import RUN_TIMEOUT_SECONDS, convert_alone, home_path +from automation.steps.reconstructions import ( + converted, + edited_title, + expect_open, + first_level_raised, + leading, + load_from_the_browser, + marked, + raise_the_first_level, + stored_levels, + titled, +) +from automation.vocabulary.dialogs import CANCEL, DISCARD, LOAD_MESSAGE, LOAD_TITLE, SAVE +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.seeds.reconstructions import StoredReconstruction +from tests.suite.screens.worlds.recordings import BASS, LEAD, OPEN_RECONSTRUCTION + +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +REPLACED_MESSAGE: Final[str] = "global.dialog.message.load_replaced_reconstruction" + + +def convert_and_ask_to_load(screen: Screen, recording: str) -> None: + """Converts the home's ``recording`` alone on Pulse 1, leaving a volume to edit. + + Answers Load at the run's end. + """ + converter = screen.main.converter + convert_alone( + screen, + home_path(recording), + channel=ChannelName.PULSE1, + replacing=[home_path(BASS), home_path(LEAD)], + ) + + converter.end_prompt.confirm() + + screen.expect(converter.end_prompt.is_shown, operator.not_, description="the end answered") + + +def load_question(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + """The question about unsaved changes a load asks: its title, its words and its answers.""" + return ( + screen.words(LOAD_TITLE), + screen.words(LOAD_MESSAGE), + (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)), + ) + + +def asked(screen: Screen) -> Tuple[str, str, Tuple[str, ...]]: + """Waits for the question about unsaved changes and returns its title, words and answers.""" + prompt = screen.reconstructions.unsaved_prompt + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + return prompt.title(), prompt.words(), prompt.answers() + + +class TestLoadingAConversionOverAnEditedReconstruction: + """Load at a run's end, with an edited reconstruction open, asks about its changes first. + + Save writes them and loads the conversion, Discard loads it and leaves the file as it was, and + Cancel keeps the edited reconstruction open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_cancel_keeps_the_edits_and_save_writes_them_before_loading(self, screen: Screen) -> None: + """Cancel keeps the edits and the file as they were. + + A second Load then saves the edit and opens the conversion. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + stored: List[bytes] = [] + standing: List[int] = [] + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1)) + + typed.append( + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen, OPEN_RECONSTRUCTION)) + ) + + def load_asks_about_the_changes(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + + assert asked(screen) == load_question(screen) + + def cancel_keeps_the_edited_one_open(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited_title(screen, OPEN_RECONSTRUCTION) + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def save_writes_the_changes_and_loads_the_next(screen: Screen) -> None: + convert_and_ask_to_load(screen, LEAD) + assert asked(screen) == load_question(screen) + + prompt.save() + + expect_open(screen, converted(home_path(LEAD))) + assert screen.title() == titled(screen, converted(home_path(LEAD)).name) + assert not prompt.is_shown() + saved = stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) + assert leading(saved, standing) == first_level_raised(standing) + + screen.scenario( + edit_the_open_reconstruction, + load_asks_about_the_changes, + cancel_keeps_the_edited_one_open, + save_writes_the_changes_and_loads_the_next, + ).run() + + def test_discard_loads_the_conversion_and_leaves_the_file(self, screen: Screen) -> None: + """Discard opens the conversion and keeps the file as it was. + + A later save of the reloaded file writes only the new edit. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + stored: List[bytes] = [] + standing: Dict[ChannelName, List[int]] = {} + + def edit_the_open_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + standing.update({channel: stored_levels(OPEN_RECONSTRUCTION, channel) for channel in PULSES}) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen, OPEN_RECONSTRUCTION)) + + def discard_loads_the_conversion(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + assert asked(screen) == load_question(screen) + + prompt.confirm() + + expect_open(screen, converted(home_path(BASS))) + assert screen.title() == titled(screen, converted(home_path(BASS)).name) + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + def the_file_takes_a_save_made_later(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + expect_open(screen, OPEN_RECONSTRUCTION) + raise_the_first_level(screen, ChannelName.PULSE2, title=edited_title(screen, OPEN_RECONSTRUCTION)) + + reconstructions.save_from_menu() + + screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") + second = standing[ChannelName.PULSE2] + assert leading(stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE2), second) == first_level_raised(second) + assert stored_levels(OPEN_RECONSTRUCTION, ChannelName.PULSE1) == standing[ChannelName.PULSE1] + + screen.scenario( + edit_the_open_reconstruction, + discard_loads_the_conversion, + the_file_takes_a_save_made_later, + ).run() + + +class TestAConversionWrittenOverTheOpenFile: + """A run writing over the edited reconstruction open offers Discard and Cancel only. + + The scenario converts a recording and edits the loaded result. A second run replaces that file and + its end asks about it. Cancel keeps the edits, and Discard loads what the run wrote. + """ + + def test_only_discard_and_cancel_and_each_does_what_it_says(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + converter = screen.main.converter + prompt = reconstructions.replaced_prompt + drawn: Dict[str, str] = {} + + def convert_and_load(screen: Screen) -> None: + convert_and_ask_to_load(screen, BASS) + + expect_open(screen, converted(home_path(BASS))) + drawn["converted"] = screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + bool, + description="Pulse 1's volume drawn", + ) + + def edit_it(screen: Screen) -> None: + drawn["edit"] = raise_the_first_level( + screen, + ChannelName.PULSE1, + title=titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)), + ) + + def convert_over_it(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.MAIN) + converter.press_action() + screen.expect(converter.overwrite_prompt.is_shown, bool, description="the question about replacing") + converter.overwrite_prompt.confirm() + screen.bridge.expect( + converter.end_prompt.is_shown, bool, description="the run's end", timeout=RUN_TIMEOUT_SECONDS + ) + + converter.end_prompt.confirm() + + screen.expect(prompt.is_shown, bool, description="the question about the file written over") + assert prompt.title() == screen.words(LOAD_TITLE) + assert prompt.words() == screen.words(REPLACED_MESSAGE) + assert prompt.answers() == (screen.words(DISCARD), screen.words(CANCEL)) + assert not reconstructions.unsaved_prompt.is_shown() + + def cancel_keeps_the_edits(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == titled(screen, marked(converted(home_path(BASS)).name, unsaved=True)) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["edit"] + + def discard_loads_what_the_run_wrote(screen: Screen) -> None: + convert_over_it(screen) + + prompt.confirm() + + screen.expect( + screen.title, + titled(screen, converted(home_path(BASS)).name).__eq__, + description="the conversion loaded", + ) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == drawn["converted"] + + screen.scenario( + convert_and_load, + edit_it, + convert_over_it, + cancel_keeps_the_edits, + discard_loads_what_the_run_wrote, + ).run() + + +class TestReloadingACleanReconstruction: + """A reconstruction with every change saved loads its own file again at once, as the file now stands.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_file_changed_on_disk_loads_as_it_stands(self, screen: Screen) -> None: + """After the file is rewritten on disk, loading it from the browser shows the rewritten content.""" + reconstructions = screen.reconstructions + standing: List[str] = [] + + def change_the_file_behind_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + raise_the_first_level(screen, ChannelName.PULSE1, title=edited_title(screen, OPEN_RECONSTRUCTION)) + reconstructions.save_from_menu() + screen.expect(screen.title, titled(screen, OPEN_RECONSTRUCTION.name).__eq__, description="the save landed") + + StoredReconstruction(OPEN_RECONSTRUCTION).write() + + def reloading_asks_nothing(screen: Screen) -> None: + load_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + standing[0].__eq__, + description="the file as it now stands", + ) + assert not reconstructions.unsaved_prompt.is_shown() + assert screen.title() == titled(screen, OPEN_RECONSTRUCTION.name) + + screen.scenario(change_the_file_behind_it, reloading_asks_nothing).run() diff --git a/tests/screens/prompts/modals/__init__.py b/tests/screens/prompts/modals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/modals/constants.py b/tests/screens/prompts/modals/constants.py new file mode 100644 index 000000000..256e347f9 --- /dev/null +++ b/tests/screens/prompts/modals/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 30 diff --git a/tests/screens/prompts/modals/test_held_rebuild.py b/tests/screens/prompts/modals/test_held_rebuild.py new file mode 100644 index 000000000..fd091020f --- /dev/null +++ b/tests/screens/prompts/modals/test_held_rebuild.py @@ -0,0 +1,187 @@ +import operator +from typing import Final, List + +import pytest + +from automation.application.startup import Startup +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.reconstructions import ( + expect_open, + load_from_the_browser, + marked, + raise_the_first_level_while_held, + remove_from_the_browser, + titled, +) +from automation.vocabulary.dialogs import CLOSE_TITLE, LOAD_TITLE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, OTHER_RECONSTRUCTION + +REBUILD_FAILURE: Final[str] = "The rebuild broke on its worker" + + +class RebuildBrokeError(RuntimeError): + """The failure a held rebuild is let go with, standing for a rebuild that raised.""" + + +class TestTwoGesturesWaitingOnAHeldRebuild: + """A load and a close asked for while an edit is on its way each ask their question once it lands, in + order. + + A first level is raised while the rebuild is held, then a load and a close are asked for. Both wait; + when the rebuild is released the load asks first, Cancel brings the close's question, and Cancel again + leaves the edited reconstruction open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_their_questions_come_one_after_the_other( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """The load asks first, the close asks next, and then the screen is quiet.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + typed: List[str] = [] + + def edit_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + typed.append(raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1)) + + def load_and_close_while_it_is_held(screen: Screen) -> None: + load_from_the_browser(screen, OTHER_RECONSTRUCTION) + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def the_load_asks_first(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the first question") + assert prompt.title() == screen.words(LOAD_TITLE) + assert len(screen.shown_windows()) == 1 + + def the_close_asks_next(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.title, screen.words(CLOSE_TITLE).__eq__, description="the second question") + assert len(screen.shown_windows()) == 1 + + def nothing_asks_after_them(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the questions answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == typed[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_while_the_rebuild_is_held, + load_and_close_while_it_is_held, + the_load_asks_first, + the_close_asks_next, + nothing_asks_after_them, + leave_letting_it_go, + ).run() + + +class TestAFailedRebuildBeforeAClose: + """A rebuild that fails while a close waits on it reports the failure first, and the close asks after. + + The open reconstruction loses its file, its first level is raised while the rebuild is held, and a + close is asked for. The rebuild fails; the failure shows alone, and once it is dismissed the close + asks. Cancel keeps the reconstruction as it stood. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_first_and_the_question_after( + self, + screen: Screen, + regeneration_hold: RegenerationHold, + ) -> None: + """The failure shows first and the close asks once the failure is dismissed.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + standing: List[str] = [] + + def edit_a_removed_reconstruction_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + standing.append(reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME)) + + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def close_while_it_is_held(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + + def the_failure_comes_first(screen: Screen) -> None: + regeneration_hold.fail(RebuildBrokeError(REBUILD_FAILURE)) + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(RebuildBrokeError.__name__) + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def the_question_comes_once_the_failure_is_read(screen: Screen) -> None: + notice.dismiss() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + + def cancel_keeps_the_reconstruction_as_it_stood(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == standing[0] + + def leave_letting_it_go(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario( + edit_a_removed_reconstruction_while_the_rebuild_is_held, + close_while_it_is_held, + the_failure_comes_first, + the_question_comes_once_the_failure_is_read, + cancel_keeps_the_reconstruction_as_it_stood, + leave_letting_it_go, + ).run() diff --git a/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py new file mode 100644 index 000000000..36a972717 --- /dev/null +++ b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py @@ -0,0 +1,302 @@ +import operator +from typing import Dict, List + +from automation.holds.conversion import ConversionHold +from automation.screen import Screen +from automation.steps.main import RUN_TIMEOUT_SECONDS, gather, home_path +from automation.vocabulary.converter import CONVERT_ONE +from automation.vocabulary.settings import LISTENING +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.worlds.recordings import BASS + + +def start_a_held_run(screen: Screen) -> None: + """Converts the home's bass on the Main tab; the hold keeps the run halfway and the function returns once + progress shows. + """ + converter = screen.main.converter + screen.tabs.bring_to_front(Tab.MAIN) + gather(screen, home_path(BASS)) + + converter.press_action() + + screen.bridge.expect( + converter.progress, lambda progress: progress > 0, description="progress", timeout=RUN_TIMEOUT_SECONDS + ) + + +def let_the_run_end(screen: Screen, hold: ConversionHold) -> None: + """Releases the held run and waits until it is over, while its question waits for its turn.""" + converter = screen.main.converter + hold.release() + + screen.bridge.expect( + converter.action, + screen.words(CONVERT_ONE).format(name=home_path(BASS).stem).__eq__, + description="the run over", + timeout=RUN_TIMEOUT_SECONDS, + ) + screen.frames(SETTLING_FRAMES) + + +def the_end_comes_and_closes(screen: Screen) -> None: + """Expects the run's question to come once the screen is free, and Cancel puts it away.""" + end = screen.main.converter.end_prompt + screen.expect(end.is_shown, bool, description="the run's question") + assert len(screen.shown_windows()) == 1 + + end.cancel() + + screen.expect(end.is_shown, operator.not_, description="the run's question answered") + assert screen.shown_windows() == () + + +class TestARunEndingBehindDisplaySettings: + """A run that ends while Display settings is open asks its question once the dialog is answered.""" + + def test_the_question_waits_for_an_unchanged_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """With Display settings left unchanged, the run's question comes after Cancel closes the dialog.""" + settings = screen.display_settings + + def open_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + + settings.open() + + screen.expect(settings.is_shown, bool, description="Display settings") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + open_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_hands_the_screen_over, + ).run() + + def test_the_discard_question_comes_before_the_runs(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """With a changed setting, Cancel asks about discarding the change first, and the run's question comes + after Discard. + """ + settings = screen.display_settings + discard = settings.discard_prompt + + def change_display_settings_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original = settings.vsync() + + settings.toggle_vsync() + + screen.expect(settings.vsync, original.__ne__, description="the box flipped") + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_asks_about_the_change_first(screen: Screen) -> None: + settings.cancel() + + screen.expect(discard.is_shown, bool, description="the discard question") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def discard_hands_the_screen_over(screen: Screen) -> None: + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + assert not settings.is_shown() + the_end_comes_and_closes(screen) + + screen.scenario( + change_display_settings_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_asks_about_the_change_first, + discard_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheWindowModeCountdown: + """A run that ends while the window-mode countdown shows waits for the countdown and for the dialog behind + it. + """ + + def test_the_question_waits_for_keep_and_the_dialog(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Keep brings the dialog back, and the run's question comes after the change is discarded.""" + settings = screen.display_settings + discard = settings.discard_prompt + original: List[bool] = [] + + def change_the_window_mode_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + original.append(settings.borderless()) + + settings.toggle_borderless() + + screen.expect(settings.countdown_shown, bool, description="the countdown") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def keep_brings_the_dialog_back_first(screen: Screen) -> None: + settings.keep() + + screen.expect(settings.is_shown, bool, description="Display settings back") + screen.frames(SETTLING_FRAMES) + assert not settings.countdown_shown() + assert not screen.main.converter.end_prompt.is_shown() + assert settings.borderless() != original[0] + + def discarding_the_change_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + screen.expect(discard.is_shown, bool, description="the discard question") + + discard.confirm() + + screen.expect(discard.is_shown, operator.not_, description="the discard question answered") + the_end_comes_and_closes(screen) + + screen.scenario( + change_the_window_mode_over_a_held_run, + the_run_ends_and_its_question_waits, + keep_brings_the_dialog_back_first, + discarding_the_change_hands_the_screen_over, + ).run() + + +class TestARunEndingBehindTheReassignQuestion: + """A run that ends while Keyboard settings asks to reassign keys waits for the question and for the dialog + behind it. + """ + + def test_the_question_waits_for_both(self, screen: Screen, conversion_hold: ConversionHold) -> None: + """Cancel brings the dialog back, and the run's question comes after the dialog closes.""" + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + keys: Dict[ShortcutId, str] = {} + + def ask_to_reassign_over_a_held_run(screen: Screen) -> None: + start_a_held_run(screen) + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + keys.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.REDO) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + assert not settings.is_shown() + + def the_run_ends_and_its_question_waits(screen: Screen) -> None: + let_the_run_end(screen, conversion_hold) + + assert reassign.is_shown() + assert not screen.main.converter.end_prompt.is_shown() + + def cancel_brings_the_dialog_back_first(screen: Screen) -> None: + reassign.cancel() + + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + screen.frames(SETTLING_FRAMES) + assert not screen.main.converter.end_prompt.is_shown() + assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] + + def closing_the_dialog_hands_the_screen_over(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + the_end_comes_and_closes(screen) + + screen.scenario( + ask_to_reassign_over_a_held_run, + the_run_ends_and_its_question_waits, + cancel_brings_the_dialog_back_first, + closing_the_dialog_hands_the_screen_over, + ).run() + + +class TestTheRowListeningAfterTheReassignQuestion: + """A row that reads as listening after Cancel answers the reassign question takes the next keys pressed. + + Redo listens, Undo's keys ask to reassign, and Cancel brings the dialog back with the row still + listening. The same keys then ask again, and Escape ends the listening with the dialog still open. + """ + + def test_the_same_keys_ask_again(self, screen: Screen) -> None: + """The same keys pressed again ask the reassign question once more. + + Cancel brings the row back listening a second time, and Escape then gives it back Redo's own keys. + """ + settings = screen.keyboard_settings + reassign = settings.reassign_prompt + keys: Dict[ShortcutId, str] = {} + + def cancel_the_question(screen: Screen) -> None: + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + keys.update( + {shortcut_id: settings.keys_of(shortcut_id) for shortcut_id in (ShortcutId.UNDO, ShortcutId.REDO)} + ) + settings.listen_for(ShortcutId.REDO) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(reassign.is_shown, bool, description="the question about reassigning") + + reassign.cancel() + + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + def the_same_keys_ask_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning again") + + def escape_ends_the_listening_alone(screen: Screen) -> None: + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back again") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect( + lambda: settings.keys_of(ShortcutId.REDO), + keys[ShortcutId.REDO].__eq__, + description="Redo's own keys", + ) + assert settings.is_shown() + assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] + + def cancel_closes_the_dialog(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + screen.scenario( + cancel_the_question, + the_same_keys_ask_again, + escape_ends_the_listening_alone, + cancel_closes_the_dialog, + ).run() diff --git a/tests/screens/prompts/modals/test_save_into_a_closed_folder.py b/tests/screens/prompts/modals/test_save_into_a_closed_folder.py new file mode 100644 index 000000000..2ec968765 --- /dev/null +++ b/tests/screens/prompts/modals/test_save_into_a_closed_folder.py @@ -0,0 +1,93 @@ +import operator +import stat +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.reconstructions import expect_open, marked, remove_from_the_browser, titled +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.prompts.modals.constants import SETTLING_FRAMES +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" +LOCKED_FOLDER: Final[str] = "Locked" +READ_AND_ENTER: Final[int] = stat.S_IRUSR | stat.S_IXUSR +SAVE_FAILED: Final[str] = "global.dialog.message.reconstruction_save_failed" + + +class TestASaveIntoAFolderThatTakesNothing: + """A save into a folder that accepts no file shows its failure alone, and the reconstruction stays open. + + The open reconstruction loses its file and a read-only folder is made. Close asks, Save into the folder + fails, and the failure shows alone. Dismissing it keeps the reconstruction open, and a save into a + writable folder closes it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Opens a reconstruction with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_failure_stands_alone_and_a_save_elsewhere_closes_it(self, screen: Screen) -> None: + """The failure shows alone, and a save elsewhere writes the file and closes the reconstruction.""" + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + notice = screen.error_notice + locked = RECONSTRUCTIONS_DIRECTORY / LOCKED_FOLDER + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + + def remove_its_file(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + locked.mkdir() + locked.chmod(READ_AND_ENTER) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + screen.title, + titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)).__eq__, + description="the reconstruction left unsaved", + ) + + def save_into_the_locked_folder(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing") + screen.answer_next_dialog(DialogKind.SAVE, locked / KEPT) + + prompt.save() + + screen.expect(notice.is_shown, bool, description="the failure reported") + screen.claim_error(PermissionError.__name__) + assert screen.words(SAVE_FAILED) in notice.words() + screen.frames(SETTLING_FRAMES) + assert not prompt.is_shown() + assert len(screen.shown_windows()) == 1 + + def dismissing_it_leaves_the_reconstruction_open(screen: Screen) -> None: + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the failure dismissed") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + assert not (locked / KEPT).exists() + + def a_save_elsewhere_closes_it(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert kept.exists() + + screen.scenario( + remove_its_file, + save_into_the_locked_folder, + dismissing_it_leaves_the_reconstruction_open, + a_save_elsewhere_closes_it, + ).run() diff --git a/tests/screens/prompts/open_voice/__init__.py b/tests/screens/prompts/open_voice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/open_voice/test_open_voice.py b/tests/screens/prompts/open_voice/test_open_voice.py new file mode 100644 index 000000000..b5832c56f --- /dev/null +++ b/tests/screens/prompts/open_voice/test_open_voice.py @@ -0,0 +1,278 @@ +import operator +from typing import Dict, Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import ( + edit_envelope, + expect_open, + marked, + raise_the_first_level, + titled, + voice_title, +) +from automation.steps.sequencer import double_click_voice, open_voice, voice_row +from automation.vocabulary.dialogs import CANCEL, DISCARD, EDIT_MESSAGE, EDIT_TITLE, SAVE +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG, SONG_INSTRUMENT, SONG_SAMPLE + +SAMPLE_ORDINAL: Final[int] = 0 +PERSONS_HOLD_FRAMES: Final[int] = 10 +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +REMOVE_VOICE: Final[str] = "sequencer.voices.label.context_remove" +FADING: Final[str] = "12 8 4" + + +def sample_title(screen: Screen, *, unsaved: bool) -> str: + """The window title while the project's sample is open, with or without unsaved changes.""" + return voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=unsaved) + + +def leave_without_saving(screen: Screen) -> None: + """Exits and discards the project's changes at the question the exit asks.""" + prompt = screen.project.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about the project") + + prompt.confirm() + + assert screen.wait_for_exit() + + +def open_the_sample(screen: Screen) -> None: + """Opens the project's sample from the Sequencer and waits for it to show in front.""" + open_voice(screen, SONG_SAMPLE) + + screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + + +class TestAVoiceDoubleClickedOverAnEditedReconstruction: + """A voice double-clicked over an edited reconstruction of its own asks about the edits first. + + The scenario edits the open reconstruction and double-clicks a sample. Cancel keeps the + reconstruction and the Sequencer in front. After a second double-click, Discard opens the sample + and keeps the reconstruction's file as it was. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open beside the song.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=SONG) + + def test_the_question_comes_first_and_cancel_keeps_the_reconstruction(self, screen: Screen) -> None: + prompt = screen.reconstructions.unsaved_prompt + edited = titled(screen, SONG.stem, marked(OPEN_RECONSTRUCTION.name, unsaved=True)) + stored: List[bytes] = [] + + def edit_the_reconstruction(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + stored.append(OPEN_RECONSTRUCTION.read_bytes()) + + raise_the_first_level(screen, ChannelName.PULSE1, title=edited) + + def the_double_click_asks_first(screen: Screen) -> None: + double_click_voice(screen, SONG_SAMPLE) + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert prompt.title() == screen.words(EDIT_TITLE) + assert prompt.words() == screen.words(EDIT_MESSAGE) + assert prompt.answers() == (screen.words(SAVE), screen.words(DISCARD), screen.words(CANCEL)) + assert screen.title() == edited + + def cancel_keeps_the_reconstruction(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + assert screen.title() == edited + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + assert screen.tabs.front() is Tab.SEQUENCER + + def discard_opens_the_voice(screen: Screen) -> None: + double_click_voice(screen, SONG_SAMPLE) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(screen.title, sample_title(screen, unsaved=False).__eq__, description="the sample open") + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + assert OPEN_RECONSTRUCTION.read_bytes() == stored[0] + + screen.scenario( + edit_the_reconstruction, + the_double_click_asks_first, + cancel_keeps_the_reconstruction, + discard_opens_the_voice, + ).run() + + +class TestTheOpenVoiceGoesWithItsProject: + """The Reconstructions tab empties once the voice open on it leaves the project, or the project closes.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_removing_the_open_voice_empties_the_tab(self, screen: Screen) -> None: + """Removing the open sample from the voices list leaves the project alone in the title. + + The file line of the Reconstructions tab ends empty. + """ + voices = screen.sequencer.voices + menu = screen.context_menu + + def remove_it(screen: Screen) -> None: + open_the_sample(screen) + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(voice_row(screen, SONG_SAMPLE)) + screen.expect(menu.is_shown, bool, description="the voice's menu") + + menu.choose(screen.words(REMOVE_VOICE)) + + screen.expect(voices.names, [SONG_INSTRUMENT].__eq__, description="the sample gone") + assert not voices.remove_prompt.is_shown() + + def the_tab_empties(screen: Screen) -> None: + screen.expect( + screen.title, + titled(screen, marked(SONG.stem, unsaved=True)).__eq__, + description="nothing open beside the project", + ) + assert screen.reconstructions.file_line() == "" + + screen.scenario(remove_it, the_tab_empties, leave_without_saving).run() + + def test_closing_the_project_empties_the_tab(self, screen: Screen) -> None: + """Closing the project leaves nothing open, no voices and an empty file line.""" + + def close_the_project(screen: Screen) -> None: + open_the_sample(screen) + + screen.project.close() + + screen.expect(screen.title, titled(screen).__eq__, description="nothing open") + assert screen.reconstructions.file_line() == "" + assert screen.sequencer.voices.names() == [] + + screen.scenario(close_the_project).run() + + +class TestTheTabFollowsTheProjectsHistory: + """Undo and redo pressed on the Sequencer tab redraw the voice open on the Reconstructions tab.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_undo_and_redo_of_a_sample_edit(self, screen: Screen) -> None: + """Undo restores the sample's volume as it stood. + + Redo brings the edit back with its unsaved title. + """ + reconstructions = screen.reconstructions + drawn: Dict[str, str] = {} + + def edit_the_sample(screen: Screen) -> None: + open_the_sample(screen) + drawn["standing"] = reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + drawn["edit"] = raise_the_first_level(screen, ChannelName.PULSE1, title=sample_title(screen, unsaved=True)) + + def undo_from_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["standing"].__eq__, + description="the edit undone", + ) + + def redo_from_the_sequencer(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.REDO) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["edit"].__eq__, + description="the edit redone", + ) + assert screen.title() == sample_title(screen, unsaved=True) + + screen.scenario(edit_the_sample, undo_from_the_sequencer, redo_from_the_sequencer, leave_without_saving).run() + + def test_undo_of_an_instrument_edit_redraws_its_envelopes(self, screen: Screen) -> None: + """Undo restores the instrument's volume as it stood and the project's title shows no unsaved mark.""" + reconstructions = screen.reconstructions + drawn: Dict[str, str] = {} + + def edit_the_instrument(screen: Screen) -> None: + open_voice(screen, SONG_INSTRUMENT) + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the instrument open") + drawn["standing"] = reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=titled(screen, marked(SONG.stem, unsaved=True)), + ) + + def undo_from_the_sequencer(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: reconstructions.instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + drawn["standing"].__eq__, + description="the edit undone", + ) + assert screen.title() == titled(screen, SONG.stem) + + screen.scenario(edit_the_instrument, undo_from_the_sequencer).run() + + +class TestAVoiceOpenedByADoubleClickHeldAsAPersonHoldsIt: + """A voice double-clicked opens as it stands, however long the second press is held.""" + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the song open and no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_voice_opens_unchanged(self, screen: Screen) -> None: + """The volume drawn after a held double-click equals the volume drawn after a plain open. + + The title shows the sample without an unsaved mark. + """ + reconstructions = screen.reconstructions + drawn: List[Tuple[str, ...]] = [] + + def read_it_and_put_it_away(screen: Screen) -> None: + open_the_sample(screen) + drawn.append(tuple(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME) for channel in PULSES)) + + reconstructions.close_from_menu() + + screen.expect(screen.title, titled(screen, SONG.stem).__eq__, description="the sample put away") + + def double_click_it_holding_the_second_press(screen: Screen) -> None: + screen.hand.double_click_held(voice_row(screen, SONG_SAMPLE), frames=PERSONS_HOLD_FRAMES) + + screen.expect(screen.tabs.front, Tab.RECONSTRUCTIONS.__eq__, description="the sample in front") + screen.frames(PERSONS_HOLD_FRAMES) + assert screen.title() == sample_title(screen, unsaved=False) + assert ( + tuple(reconstructions.instruments.envelope(channel, FeatureKey.VOLUME) for channel in PULSES) + == drawn[0] + ) + + screen.scenario(read_it_and_put_it_away, double_click_it_holding_the_second_press).run() diff --git a/tests/screens/prompts/vanished/__init__.py b/tests/screens/prompts/vanished/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/prompts/vanished/test_reconstruction_removed.py b/tests/screens/prompts/vanished/test_reconstruction_removed.py new file mode 100644 index 000000000..9878b5f00 --- /dev/null +++ b/tests/screens/prompts/vanished/test_reconstruction_removed.py @@ -0,0 +1,140 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.boundaries.dialogs import DialogKind +from automation.screen import Screen +from automation.steps.reconstructions import ( + edited_title, + expect_open, + remove_from_the_browser, + stored_levels, + titled, +) +from automation.vocabulary.dialogs import CANCEL, CLOSE, CLOSE_MESSAGE, CLOSE_TITLE, SAVE +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION + +KEPT: Final[str] = "Kept.stn" +PULSES: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.PULSE2) +SETTLING_FRAMES: Final[int] = 10 +NOT_APPLICABLE: Final[str] = "reconstructions.reconstruction.label.path_not_applicable" +RATE_SET_BY_THE_PROJECT: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_locked" +RATE_KEPT_WITHOUT_A_FILE: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_no_file" + + +def take_its_file_away(screen: Screen) -> None: + """Removes the open reconstruction's file in the browser and waits for it to show as unsaved.""" + expect_open(screen, OPEN_RECONSTRUCTION) + + remove_from_the_browser(screen, OPEN_RECONSTRUCTION) + + screen.expect( + screen.title, edited_title(screen, OPEN_RECONSTRUCTION).__eq__, description="the reconstruction left unsaved" + ) + + +class TestAReconstructionWhoseFileWasRemoved: + """The open reconstruction whose file the browser removed stays open with nowhere to save, and asks where. + + Closing it asks first. Save asks where to write it, a dismissed dialog brings the question back + once, and Cancel keeps it open. + """ + + @pytest.fixture + def startup(self) -> Startup: + """Starts with the reconstruction open and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_close_asks_where_a_dismissed_dialog_asks_once_and_cancel_keeps_it(self, screen: Screen) -> None: + """The last Save names a file, which receives the reconstruction's levels. + + The removed file stays gone. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + kept = RECONSTRUCTIONS_DIRECTORY / KEPT + standing: List[List[int]] = [] + + def remove_it(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + standing.extend(stored_levels(OPEN_RECONSTRUCTION, channel) for channel in PULSES) + + take_its_file_away(screen) + + assert reconstructions.file_line() == screen.words(NOT_APPLICABLE) + assert not reconstructions.can_save() + + def close_asks_first(screen: Screen) -> None: + reconstructions.close_from_menu() + + screen.expect(prompt.is_shown, bool, description="the question about closing") + assert prompt.title() == screen.words(CLOSE_TITLE) + assert prompt.words() == screen.words(CLOSE_MESSAGE) + assert prompt.answers() == (screen.words(SAVE), screen.words(CLOSE), screen.words(CANCEL)) + + def save_asks_where_and_a_dismissed_dialog_asks_once_more(screen: Screen) -> None: + screen.answer_next_dialog(DialogKind.SAVE, None) + + prompt.save() + + screen.expect(lambda: len(screen.dialog_requests()), bool, description="the save dialog asked") + screen.expect(prompt.is_shown, bool, description="the question back") + screen.frames(SETTLING_FRAMES) + assert len(prompt.shown_windows()) == 1 + (request,) = screen.dialog_requests() + assert request.kind is DialogKind.SAVE + assert request.initial_directory == RECONSTRUCTIONS_DIRECTORY + assert request.suggested_name == OPEN_RECONSTRUCTION.name + + def cancel_keeps_it_open(screen: Screen) -> None: + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question gone") + screen.frames(SETTLING_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == edited_title(screen, OPEN_RECONSTRUCTION) + assert len(screen.dialog_requests()) == 1 + + def save_where_asked_writes_it_and_closes(screen: Screen) -> None: + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing again") + screen.answer_next_dialog(DialogKind.SAVE, kept) + + prompt.save() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction closed") + assert [stored_levels(kept, channel) for channel in PULSES] == standing + assert not OPEN_RECONSTRUCTION.exists() + + screen.scenario( + remove_it, + close_asks_first, + save_asks_where_and_a_dismissed_dialog_asks_once_more, + cancel_keeps_it_open, + save_where_asked_writes_it_and_closes, + ).run() + + def test_the_locked_rate_says_nothing_of_a_project(self, screen: Screen) -> None: + """The rate stays locked and its explanation words speak of the missing file, not of a project. + + Closing the reconstruction afterwards lets its changes go, so the application leaves with + nothing to ask. + """ + reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt + take_its_file_away(screen) + + assert not reconstructions.can_retune() + assert reconstructions.retune_lock_explained() + assert reconstructions.retune_lock_words() != screen.words(RATE_SET_BY_THE_PROJECT) + assert reconstructions.retune_lock_words() == screen.words(RATE_KEPT_WITHOUT_A_FILE) + + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing") + prompt.confirm() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction let go") diff --git a/tests/screens/reconstructions/__init__.py b/tests/screens/reconstructions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/conftest.py b/tests/screens/reconstructions/conftest.py new file mode 100644 index 000000000..4183e13aa --- /dev/null +++ b/tests/screens/reconstructions/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from automation.worlds.home import World +from tests.suite.screens.worlds.recordings import playing_world + + +@pytest.fixture +def world() -> World: + """The home every Reconstructions scenario starts in: reconstructions that play, and a project.""" + return playing_world() diff --git a/tests/screens/reconstructions/edits/__init__.py b/tests/screens/reconstructions/edits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/edits/test_gestures_at_once.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py new file mode 100644 index 000000000..601f04ec5 --- /dev/null +++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py @@ -0,0 +1,368 @@ +import operator +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.dearpygui.geometry import Point +from automation.holds.regeneration import RegenerationHold +from automation.screen import Screen +from automation.steps.project import save_project_as +from automation.steps.reconstructions import ( + expect_open, + load_from_the_browser, + marked, + raise_the_first_level, + raise_the_first_level_while_held, + stored_levels, + titled, + voice_title, +) +from automation.steps.sequencer import open_voice +from automation.views.bar_graph import BarGraph +from automation.views.instruments import Instruments +from automation.vocabulary.dialogs import LOAD_TITLE +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.worlds.recordings import ( + OPEN_RECONSTRUCTION, + PLAYABLE_RECONSTRUCTION, + SECOND_PLAYABLE, + SHORT_RECONSTRUCTION, + SONG, + SONG_SAMPLE, + STEMS_RECONSTRUCTION, +) + +DRAGGED_ITEM: Final[int] = 5 +FIRST_ITEM: Final[int] = 1 +QUIET: Final[float] = 4.0 +LOUDEST: Final[int] = 15 +ARPEGGIO_STEP: Final[float] = 12.0 +ARPEGGIO_FLOOR: Final[int] = -128 +ARPEGGIO_CEILING: Final[int] = 127 +SAMPLE_ORDINAL: Final[int] = 0 +LAST_RECORDING: Final[str] = "2" +SETTLE_FRAMES: Final[int] = 20 +NEAR_THE_START: Final[float] = 0.05 +ZOOM_NOTCHES: Final[int] = 5 +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" + + +def items(instruments: Instruments, channel: ChannelName, feature: FeatureKey) -> List[int]: + """The items of the envelope that ``channel`` draws for ``feature``, read from the instruments panel.""" + return [int(item) for item in instruments.envelope(channel, feature).split()] + + +def dragged( + graph: BarGraph, + standing: List[int], + *, + end: float, + bounds: Tuple[int, int], +) -> Tuple[int, List[int]]: + """Drags the bar of the item :data:`DRAGGED_ITEM` to ``end`` and returns the item it moved and the items + expected after. + + The item the plot puts under the release takes the value there, brought within ``bounds``, and every other + item stands as it did. + """ + release: Point = graph.drag_item(DRAGGED_ITEM, start=standing[DRAGGED_ITEM], end=end) + index, value = graph.item_under(release) + low, high = bounds + expected = [*standing] + expected[index] = min(max(round(value), low), high) + return index, expected + + +def expect_items( + screen: Screen, instruments: Instruments, channel: ChannelName, feature: FeatureKey, expected: List[int] +) -> None: + """Waits until the envelope of ``channel`` and ``feature`` reads ``expected``.""" + screen.expect( + lambda: items(instruments, channel, feature), + expected.__eq__, + description=f"{channel} {feature} as the edits left it", + ) + + +def leave_letting_it_go(screen: Screen) -> None: + """Presses Exit, confirms the question about unsaved changes and waits for the application to close.""" + prompt = screen.reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + +class TestADragAndARemovalAtOnce: + """A volume dragged and a recording removed while the drag is on its way both land. + + The rebuild is held while a volume item is dragged; a recording is removed and the removal confirmed + meanwhile. After the rebuild is released, the recording is gone and the dragged value stands. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_both_land(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The removed recording leaves the card and the dragged volume item keeps its new value.""" + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + stems = reconstructions.stems + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] + + def drag_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + graph.zoom_in(NEAR_THE_START, ZOOM_NOTCHES) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + + moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def remove_a_recording_meanwhile(screen: Screen) -> None: + stems.remove(LAST_RECORDING) + screen.expect(stems.remove_prompt.is_shown, bool, description="the question about removing") + + stems.remove_prompt.confirm() + + screen.expect(stems.remove_prompt.is_shown, operator.not_, description="the question answered") + assert stems.has_row(LAST_RECORDING) + + def both_land(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(lambda: stems.has_row(LAST_RECORDING), operator.not_, description="the recording gone") + index, expected = moved[0] + screen.expect( + lambda: items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[: index + 1], + expected[: index + 1].__eq__, + description="the drag landed", + ) + + screen.scenario( + drag_while_the_rebuild_is_held, remove_a_recording_meanwhile, both_land, leave_letting_it_go + ).run() + + +class TestADragAndAnUndoAtOnce: + """Undo pressed while a drag of a sample is on its way undoes the drag, and the edit saved before it stays. + + Consecutive edits of one sample run together into one entry of the history, and a save closes the run, so + the edit before the drag stands in an entry of its own. + + A sample opens from the song, its first level is raised and the project is saved. With the rebuild held, + an item is dragged and Undo pressed. Once released, the envelope reads as saved and holds there. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A project is open at start, with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_drag_is_undone(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The envelope returns to the values of the saved edit and stays there.""" + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + edited: List[List[int]] = [] + + def edit_the_sample_and_save(screen: Screen) -> None: + open_voice(screen, SONG_SAMPLE) + screen.expect( + screen.title, + voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=False).__eq__, + description="the sample open", + ) + regeneration_hold.release() + raise_the_first_level( + screen, + ChannelName.PULSE1, + title=voice_title(screen, SONG.stem, SAMPLE_ORDINAL, SONG_SAMPLE, unsaved=True), + ) + edited.append(items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)) + + save_project_as(screen, SAVED_SONG) + + def drag_and_undo_while_the_rebuild_is_held(screen: Screen) -> None: + regeneration_hold.hold_again() + screen.hand.scroll_into_view(graph.plot) + release = graph.drag_item(FIRST_ITEM, start=edited[0][FIRST_ITEM], end=QUIET) + index, _ = graph.item_under(release) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME)[index] != edited[0][index] + + screen.press_shortcut(ShortcutId.UNDO) + + def the_drag_alone_is_undone(screen: Screen) -> None: + regeneration_hold.release() + + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, edited[0]) + screen.frames(SETTLE_FRAMES) + assert items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) == edited[0] + + screen.scenario( + edit_the_sample_and_save, drag_and_undo_while_the_rebuild_is_held, the_drag_alone_is_undone + ).run() + + +class TestADragAndASaveAtOnce: + """A save asked for while a drag is on its way writes the drag. + + With the rebuild held, an item is dragged and Save pressed; the file stays as it was until the rebuild is + released, then holds the dragged levels and the title shows a saved file. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_the_file_has_the_drag(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The saved file holds the dragged levels.""" + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + moved: List[Tuple[int, List[int]]] = [] + stored: List[bytes] = [] + + def drag_and_save_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + stored.append(SHORT_RECONSTRUCTION.read_bytes()) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + moved.append(dragged(graph, standing, end=QUIET, bounds=(0, LOUDEST))) + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + screen.press_shortcut(ShortcutId.SAVE_RECONSTRUCTION) + + screen.frames(SETTLE_FRAMES) + assert SHORT_RECONSTRUCTION.read_bytes() == stored[0] + + def the_save_writes_the_drag(screen: Screen) -> None: + regeneration_hold.release() + + index, expected = moved[0] + screen.expect( + lambda: stored_levels(SHORT_RECONSTRUCTION, ChannelName.PULSE1)[: index + 1], + expected[: index + 1].__eq__, + description="the file holding the drag", + ) + screen.frames(SETTLE_FRAMES) + assert screen.title() == titled(screen, SHORT_RECONSTRUCTION.name) + + screen.scenario(drag_and_save_while_the_rebuild_is_held, the_save_writes_the_drag).run() + + +class TestTwoDimensionsBeforeTheFadeEnds: + """A volume and an arpeggio dragged one after the other while the first is on its way both stay. + + With the rebuild held, a volume item is dragged and then an arpeggio item. Once released, both envelopes + hold their dragged values and the title shows unsaved changes. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_both_edits_stay(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """The volume and the arpeggio envelopes both read their dragged values.""" + instruments = screen.reconstructions.instruments + volume = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + arpeggio = instruments.graph(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + moved: List[Tuple[int, List[int]]] = [] + + def drag_the_volume_while_held(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(volume.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.VOLUME) + + moved.append(dragged(volume, standing, end=QUIET, bounds=(0, LOUDEST))) + + screen.expect(regeneration_hold.waiting, bool, description="the rebuild held") + + def drag_the_arpeggio_meanwhile(screen: Screen) -> None: + screen.hand.scroll_into_view(arpeggio.plot) + standing = items(instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + moved.append(dragged(arpeggio, standing, end=ARPEGGIO_STEP, bounds=(ARPEGGIO_FLOOR, ARPEGGIO_CEILING))) + + def both_stay(screen: Screen) -> None: + regeneration_hold.release() + + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.VOLUME, moved[0][1]) + expect_items(screen, instruments, ChannelName.PULSE1, FeatureKey.ARPEGGIO, moved[1][1]) + assert screen.title() == titled(screen, marked(SHORT_RECONSTRUCTION.name, unsaved=True)) + + screen.scenario( + drag_the_volume_while_held, + drag_the_arpeggio_meanwhile, + both_stay, + leave_letting_it_go, + ).run() + + +class TestTwoOpensWhileAnEditIsOnItsWay: + """Two reconstructions double-clicked while an edit is on its way ask once, and the answer opens the second. + + The first level is raised while the rebuild is held, and two other reconstructions are double-clicked in + the browser. Once released, one question about the unsaved changes shows. Discard opens the second + reconstruction, and nothing asks after it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_second_opens(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """One question shows, and Discard opens the reconstruction double-clicked last.""" + prompt = screen.reconstructions.unsaved_prompt + + def edit_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def open_two_while_it_is_held(screen: Screen) -> None: + load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) + load_from_the_browser(screen, SECOND_PLAYABLE) + + screen.frames(SETTLE_FRAMES) + assert screen.shown_windows() == () + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def the_edit_lands_and_one_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert prompt.title() == screen.words(LOAD_TITLE) + screen.frames(SETTLE_FRAMES) + assert len(screen.shown_windows()) == 1 + + def discard_opens_the_second(screen: Screen) -> None: + prompt.confirm() + + expect_open(screen, SECOND_PLAYABLE) + screen.frames(SETTLE_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, SECOND_PLAYABLE.name) + + screen.scenario( + edit_while_the_rebuild_is_held, + open_two_while_it_is_held, + the_edit_lands_and_one_question_comes, + discard_opens_the_second, + ).run() diff --git a/tests/screens/reconstructions/instruments/__init__.py b/tests/screens/reconstructions/instruments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/instruments/constants.py b/tests/screens/reconstructions/instruments/constants.py new file mode 100644 index 000000000..32291ed98 --- /dev/null +++ b/tests/screens/reconstructions/instruments/constants.py @@ -0,0 +1,9 @@ +from typing import Final + +from automation.dearpygui.keys import IMGUI_LETTER_A + +STILL_FRAMES: Final[int] = 20 +NOTE_FRAMES: Final[int] = 30 +FADING: Final[str] = "12 8 4" +NOTHING: Final[str] = "" +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") diff --git a/tests/screens/reconstructions/instruments/steps.py b/tests/screens/reconstructions/instruments/steps.py new file mode 100644 index 000000000..80e92a360 --- /dev/null +++ b/tests/screens/reconstructions/instruments/steps.py @@ -0,0 +1,33 @@ +from automation.screen import Screen +from automation.steps.reconstructions import edit_envelope, marked, titled +from automation.steps.sequencer import open_voice +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import FADING +from tests.suite.screens.worlds.recordings import SONG, SONG_INSTRUMENT + + +def open_the_instrument(screen: Screen) -> None: + """Opens the song's instrument from the Sequencer and waits for the Audition switch to appear.""" + instruments = screen.reconstructions.instruments + open_voice(screen, SONG_INSTRUMENT) + + screen.expect(instruments.offers_audition, bool, description="the instrument open") + + +def song_title(screen: Screen, *, unsaved: bool) -> str: + """The window title of the song, marked as unsaved or saved.""" + return titled(screen, marked(SONG.stem, unsaved=unsaved)) + + +def give_it_a_volume(screen: Screen) -> None: + """Opens the song's instrument and types the fading sequence into its Pulse 1 Volume field, leaving the + project unsaved. + """ + open_the_instrument(screen) + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) diff --git a/tests/screens/reconstructions/instruments/test_audition.py b/tests/screens/reconstructions/instruments/test_audition.py new file mode 100644 index 000000000..84d4d0d41 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_audition.py @@ -0,0 +1,93 @@ +from functools import partial +from typing import Callable, Dict, List + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.project import leave_letting_the_project_go +from automation.steps.reconstructions import expect_open +from automation.vocabulary.playback import PLAY +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import GeneratorName +from tests.screens.reconstructions.instruments.constants import NOTE_FRAMES, PIANO_C +from tests.screens.reconstructions.instruments.steps import give_it_a_volume +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SONG, SONG_INSTRUMENT + + +class TestTheAuditionSwitch: + """The Audition switch draws the instrument on the generator it names, and back. + + The instrument gets a volume and shows on the pulse. Choosing the triangle redraws the waveform, + choosing the pulse again restores the first drawing, and the application exits letting the project + go. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_generator_redraws_the_waveform(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + waveform = screen.reconstructions.waveform + drawn: Dict[GeneratorName, List[float]] = {} + + def drawn_now() -> List[float]: + return waveform.drawn(SONG_INSTRUMENT) + + def on_the_pulse(screen: Screen) -> None: + give_it_a_volume(screen) + + assert instruments.audition() == screen.generator_words(GeneratorName.PULSE) + drawn[GeneratorName.PULSE] = drawn_now() + + def on_the_triangle(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.TRIANGLE)) + + drawn[GeneratorName.TRIANGLE] = screen.expect( + drawn_now, drawn[GeneratorName.PULSE].__ne__, description="the waveform redrawn" + ) + + def back_on_the_pulse(screen: Screen) -> None: + instruments.choose_audition(screen.generator_words(GeneratorName.PULSE)) + + screen.expect(drawn_now, drawn[GeneratorName.PULSE].__eq__, description="the pulse drawn again") + + screen.scenario(on_the_pulse, on_the_triangle, back_on_the_pulse, leave_letting_the_project_go).run() + + +class TestAnInstrumentsWaveform: + """A click on an open instrument's waveform keeps playback silent, while a note key sounds it. + + A reconstruction that plays stands open before the instrument, so a click that played would play it. + The click plays nothing and leaves the Play button as it was; the note key plays sound. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song and a playable reconstruction.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=SONG) + + def test_a_click_plays_nothing_and_a_key_does(self, screen: Screen) -> None: + waveform = screen.reconstructions.waveform + + def heard_over(gesture: Callable[[], None]) -> bool: + heard = screen.sound_heard() + gesture() + screen.frames(NOTE_FRAMES) + + return screen.sound_heard() > heard + + def a_click_plays_nothing(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + assert not heard_over(partial(waveform.click, 0.5)) + assert screen.sequencer.playback.play_entry() == screen.words(PLAY) + + def a_note_key_sounds_it(screen: Screen) -> None: + assert heard_over(partial(screen.hand.press_key, PIANO_C, modifiers=[])) + + screen.scenario(a_click_plays_nothing, a_note_key_sounds_it, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_note_keys.py b/tests/screens/reconstructions/instruments/test_note_keys.py new file mode 100644 index 000000000..51138feb1 --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_note_keys.py @@ -0,0 +1,171 @@ +import operator +from typing import Final, List + +import pytest + +from automation.application.startup import Startup +from automation.dearpygui.keys import IMGUI_DIGIT_ZERO, IMGUI_ESCAPE, IMGUI_LETTER_A +from automation.screen import Screen +from automation.steps.project import leave_letting_the_project_go +from automation.steps.reconstructions import expect_open +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import NOTE_FRAMES, NOTHING, PIANO_C +from tests.screens.reconstructions.instruments.steps import give_it_a_volume +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG + +PIANO_D_UP: Final[int] = IMGUI_LETTER_A + ord("w") - ord("a") +DIGIT_TWO: Final[int] = IMGUI_DIGIT_ZERO + 2 +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 + + +class TestTheNoteKeys: + """With an instrument open the note keys sound it, a digit key sounds nothing, and a field being typed + in takes the note keys as characters. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_keys_sound_the_instrument_and_a_field_keeps_them(self, screen: Screen) -> None: + """Piano keys play sound and a digit, which types a step in the tracker, plays none; with the + Arpeggio field focused, a note key writes its letter and plays nothing. + """ + instruments = screen.reconstructions.instruments + + def sounded(key: int) -> bool: + heard = screen.sound_heard() + screen.hand.press_key(key, modifiers=[]) + screen.frames(NOTE_FRAMES) + + return screen.sound_heard() > heard + + def the_keys_sound_it(screen: Screen) -> None: + give_it_a_volume(screen) + + assert sounded(PIANO_C) + assert sounded(PIANO_D_UP) + + def a_digit_sounds_nothing(screen: Screen) -> None: + assert not sounded(DIGIT_TWO) + assert sounded(PIANO_C) + + def a_field_takes_them_as_characters(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + screen.hand.scroll_into_view(field) + screen.hand.click(field) + + assert not sounded(PIANO_C) + assert "z" in instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) + + screen.scenario( + the_keys_sound_it, + a_digit_sounds_nothing, + a_field_takes_them_as_characters, + leave_letting_the_project_go, + ).run() + + def test_undo_undoes_with_an_instrument_open(self, screen: Screen) -> None: + """Undo removes the typed volume while the Reconstructions tab shows an instrument.""" + instruments = screen.reconstructions.instruments + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + NOTHING.__eq__, + description="the volume undone", + ) + + +class TestAChannelKeyOnAReconstruction: + """With a reconstruction open, a channel's number key ticks its box above the waveform.""" + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_two_lets_pulse_two_go_and_brings_it_back(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + expect_open(screen, OPEN_RECONSTRUCTION) + assert reconstructions.channel_ticked(ChannelName.PULSE2) + + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + + screen.expect( + lambda: reconstructions.channel_ticked(ChannelName.PULSE2), operator.not_, description="Pulse 2 let go" + ) + screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) + screen.expect(lambda: reconstructions.channel_ticked(ChannelName.PULSE2), bool, description="Pulse 2 back") + + +class TestAChannelChordBesideAField: + """While a field of a reconstruction's instrument is typed in, Pulse 2's key types its digit, and its chord + switches Pulse 2 above the waveform all the same. + + The key types a digit into Pulse 1's Volume field and leaves Pulse 2 ticked. The chord then lets Pulse 2 + go and leaves the field as it read, the chord again brings Pulse 2 back, and the key types once more into + the field, which is where a chord that typed would have written. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction sounding every channel and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_key_types_and_the_chord_switches_pulse_two(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + typed: List[str] = [] + + def volume() -> str: + return instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + def pulse_two_ticked() -> bool: + return reconstructions.channel_ticked(ChannelName.PULSE2) + + def the_key_types_its_digit(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + instruments.bring_forward(ChannelName.PULSE1) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + screen.hand.scroll_into_view(field) + screen.hand.click(field) + before = volume() + + screen.press_shortcut(TOGGLE_PULSE_TWO) + + typed.append(screen.expect(volume, before.__ne__, description="a digit typed into the field")) + assert pulse_two_ticked() + + def the_chord_lets_pulse_two_go_and_types_nothing(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect(pulse_two_ticked, operator.not_, description="Pulse 2 let go") + screen.frames(NOTE_FRAMES) + assert volume() == typed[0] + + def the_chord_brings_it_back(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect(pulse_two_ticked, bool, description="Pulse 2 back") + + def the_key_types_into_the_field_again(screen: Screen) -> None: + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.expect(volume, typed[0].__ne__, description="a second digit typed into the field") + assert pulse_two_ticked() + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.scenario( + the_key_types_its_digit, + the_chord_lets_pulse_two_go_and_types_nothing, + the_chord_brings_it_back, + the_key_types_into_the_field_again, + ).run() diff --git a/tests/screens/reconstructions/instruments/test_pitch_rows.py b/tests/screens/reconstructions/instruments/test_pitch_rows.py new file mode 100644 index 000000000..674ad4c4d --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_pitch_rows.py @@ -0,0 +1,190 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.project import leave_letting_the_project_go +from automation.steps.reconstructions import edit_envelope, expect_open +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.reconstructions.instruments.constants import FADING, NOTHING, STILL_FRAMES +from tests.screens.reconstructions.instruments.steps import give_it_a_volume, open_the_instrument, song_title +from tests.suite.screens.worlds.recordings import OPEN_RECONSTRUCTION, SONG + +BENT_ITEM: Final[int] = 2 +FLAT_PITCH: Final[str] = "0 0 0 0 0" +BEND: Final[float] = 40.0 +PITCH_FLOOR: Final[int] = -128 +PITCH_CEILING: Final[int] = 127 + +PULSE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, + FeatureKey.DUTY_CYCLE, +) + +TRIANGLE_ROWS: Final[Tuple[FeatureKey, ...]] = ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.PITCH, + FeatureKey.HI_PITCH, +) + +NOISE_ROWS: Final[Tuple[FeatureKey, ...]] = (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE) +PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.pitch_bend" +HI_PITCH_BEND: Final[str] = "reconstructions.instruments.tooltip.hi_pitch_bend" + + +class TestTheRowsEachChannelDraws: + """Pulse and triangle tabs draw Pitch and Hi-pitch rows and the noise tab omits them; a bend field + explains a step. + + Each channel tab shows its rows in order and a pitch stepper, and the Audition switch stays hidden. + Hovering the Pitch and Hi-pitch fields shows their bend explanations; hovering Volume shows none. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_rows_per_channel_and_the_bend_explanation(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + expected = { + ChannelName.PULSE1: PULSE_ROWS, + ChannelName.PULSE2: PULSE_ROWS, + ChannelName.TRIANGLE: TRIANGLE_ROWS, + ChannelName.NOISE: NOISE_ROWS, + } + + def each_tab_draws_its_rows(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + for channel, rows in expected.items(): + instruments.bring_forward(channel) + screen.expect(partial(instruments.rows, channel), rows.__eq__, description=f"the rows of {channel}") + assert instruments.offers_pitch_stepper(channel) + + assert not instruments.offers_audition() + + def a_bend_field_explains_a_step(screen: Screen) -> None: + instruments.bring_forward(ChannelName.PULSE1) + for feature, key in ((FeatureKey.PITCH, PITCH_BEND), (FeatureKey.HI_PITCH, HI_PITCH_BEND)): + field = instruments.field(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + + screen.expect(partial(screen.shows_text, screen.words(key)), bool, description=f"{key} shown") + + def the_volume_field_explains_no_bend(screen: Screen) -> None: + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + screen.hand.scroll_into_view(field) + screen.hand.hover(field) + screen.frames(STILL_FRAMES) + + assert not screen.shows_text(screen.words(PITCH_BEND)) + assert not screen.shows_text(screen.words(HI_PITCH_BEND)) + + screen.scenario(each_tab_draws_its_rows, a_bend_field_explains_a_step, the_volume_field_explains_no_bend).run() + + +class TestAnInstrument: + """An instrument offers the Audition switch and only its own tab, and Export follows its + envelopes. + + The opened instrument shows the Pulse 1 tab alone, with Export unavailable. A typed volume makes + Export available, and clearing the volume makes it unavailable again. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_no_stepper_and_export_answers_while_it_sounds(self, screen: Screen) -> None: + """The pitch stepper stays hidden, and Export turns on or off as the volume is typed or cleared.""" + instruments = screen.reconstructions.instruments + + def its_tab_alone(screen: Screen) -> None: + open_the_instrument(screen) + + assert not instruments.offers_pitch_stepper(ChannelName.PULSE1) + assert [channel for channel in ChannelName if instruments.tab_shown(channel)] == [ChannelName.PULSE1] + assert not instruments.can_export(ChannelName.PULSE1) + + def a_volume_brings_export(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=FADING, + title=song_title(screen, unsaved=True), + ) + + screen.expect(lambda: instruments.can_export(ChannelName.PULSE1), bool, description="Export answering") + + def clearing_it_greys_export_out(screen: Screen) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, NOTHING) + + screen.expect( + lambda: instruments.can_export(ChannelName.PULSE1), operator.not_, description="Export greyed out" + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + screen.scenario( + its_tab_alone, + a_volume_brings_export, + clearing_it_greys_export_out, + leave_letting_the_project_go, + ).run() + + +class TestDraggingAPitchBar: + """Dragging a bar of the Pitch row sets that item to the value of the release and keeps the other items + as they were. + + A flat pitch is typed, then one bar is dragged. The field shows the released value, clamped to the + pitch range, for that item alone. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_item_takes_the_value_under_the_release(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.PITCH) + standing: List[int] = [] + + def give_it_a_flat_pitch(screen: Screen) -> None: + give_it_a_volume(screen) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.PITCH, FLAT_PITCH) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH), + FLAT_PITCH.__eq__, + description="the flat pitch typed", + ) + + screen.hand.scroll_into_view(graph.plot) + + standing.extend(int(item) for item in FLAT_PITCH.split()) + + def drag_one_bar(screen: Screen) -> None: + release = graph.drag_item(BENT_ITEM, start=standing[BENT_ITEM], end=BEND) + + _, under = graph.value_under(release) + expected = [*standing] + expected[BENT_ITEM] = min(max(round(under), PITCH_FLOOR), PITCH_CEILING) + screen.expect( + lambda: [int(item) for item in instruments.envelope(ChannelName.PULSE1, FeatureKey.PITCH).split()], + expected.__eq__, + description="one item bent", + ) + assert expected[BENT_ITEM] != standing[BENT_ITEM] + + screen.scenario(give_it_a_flat_pitch, drag_one_bar, leave_letting_the_project_go).run() diff --git a/tests/screens/reconstructions/instruments/test_typed_sequences.py b/tests/screens/reconstructions/instruments/test_typed_sequences.py new file mode 100644 index 000000000..11776e74e --- /dev/null +++ b/tests/screens/reconstructions/instruments/test_typed_sequences.py @@ -0,0 +1,196 @@ +from functools import partial +from pathlib import Path +from typing import Final, List, Optional + +import pytest + +from automation.application.startup import Startup +from automation.dearpygui.geometry import Point +from automation.screen import Screen +from automation.steps.project import save_project_as +from automation.steps.reconstructions import edit_envelope +from automation.vocabulary.instruments import TOO_LONG +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.project import ProjectContainer +from sampletones_core.project.voices.instrument import Instrument +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.screens.reconstructions.instruments.constants import FADING, NOTHING, STILL_FRAMES +from tests.screens.reconstructions.instruments.steps import open_the_instrument, song_title +from tests.suite.screens.worlds.recordings import SONG, SONG_INSTRUMENT + +SAVED_SONG: Final[Path] = PROJECTS_DIRECTORY / "Saved.stp" +ASIDE: Final[Point] = Point(x=0, y=0) +LOOPING_VOLUME: Final[str] = "15 14 | 12 10" +LOOPING_DUTY: Final[str] = "0 1 | 2" +LOOP_FROM_THE_START: Final[str] = "| 15" +TWO_LOOPS: Final[str] = "15 | | 14" +NOT_A_NUMBER: Final[str] = "x" +TOO_LOUD: Final[str] = "99" +LOUDEST: Final[str] = "15" +TYPING_HINT: Final[str] = "reconstructions.instruments.message.status_sequence" +KEPT_FAMITRACKER: Final[str] = "reconstructions.instruments.template.kept_famitracker" +INVALID_INPUT: Final[str] = "Invalid PULSE1 data input for VOLUME" + + +def status_over(screen: Screen, item: str) -> str: + """Moves the pointer onto ``item`` from the window's corner, which sets the status line, and reads it.""" + screen.hand.move_to(ASIDE) + screen.hand.scroll_into_view(item) + screen.hand.hover(item) + return screen.status() + + +def saved_instrument(path: Path, name: str) -> Instrument: + """The instrument named ``name`` in the project stored at ``path``.""" + return next( + voice for voice in ProjectContainer.load(path).voices if isinstance(voice, Instrument) and voice.name == name + ) + + +class TestALoopTyped: + """A sequence typed with "|" repeats from the item after it, and a sibling of another length from its + own point. + + The status line explains the "|" before anything is typed. Volume and Duty cycle get one loop each; + after Save as, the stored instrument holds each loop point. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_each_envelope_keeps_its_own_loop(self, screen: Screen) -> None: + instruments = screen.reconstructions.instruments + + def the_status_line_explains_the_bar(screen: Screen) -> None: + open_the_instrument(screen) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + + hint = screen.words(TYPING_HINT).format(instrument_feature=FeatureKey.VOLUME.capitalized) + screen.expect(partial(status_over, screen, field), hint.__eq__, description="the hint about the bar") + + def type_two_loops(screen: Screen) -> None: + edit_envelope( + screen, + channel=ChannelName.PULSE1, + feature=FeatureKey.VOLUME, + sequence=LOOPING_VOLUME, + title=song_title(screen, unsaved=True), + ) + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE, LOOPING_DUTY) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.DUTY_CYCLE), + LOOPING_DUTY.__eq__, + description="the duty cycle typed", + ) + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOPING_VOLUME + + def the_project_keeps_each_loop(screen: Screen) -> None: + save_project_as(screen, SAVED_SONG) + + envelopes = saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes + assert (envelopes.volume.items, envelopes.volume.loop_point) == ((15, 14, 12, 10), 2) + assert (envelopes.duty_cycle.items, envelopes.duty_cycle.loop_point) == ((0, 1, 2), 2) + + screen.scenario(the_status_line_explains_the_bar, type_two_loops, the_project_keeps_each_loop).run() + + +class TestSequencesTheFieldAnswers: + """An unreadable sequence is refused and flagged, and a readable one is applied as written. + + A value past the range is brought into it, an empty field clears the dimension, and a sequence + longer than an export keeps is applied with a warning naming what the export keeps. After Save as, + the project holds the last sequence applied. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_refused_clamped_cleared_and_warned(self, screen: Screen) -> None: + """Each kind of typed sequence gets its own answer: an error, a clamp, a clear or a warning.""" + instruments = screen.reconstructions.instruments + long_sequence = " ".join(["8"] * (MAX_SEQUENCE_ITEMS + 1)) + plain: List[Optional[str]] = [] + + def typed(sequence: str) -> None: + instruments.type_envelope(ChannelName.PULSE1, FeatureKey.VOLUME, sequence) + screen.frames(STILL_FRAMES) + + def theme() -> Optional[str]: + return instruments.field_theme(ChannelName.PULSE1, FeatureKey.VOLUME) + + def unreadable_sequences_are_refused(screen: Screen) -> None: + open_the_instrument(screen) + plain.append(theme()) + + for sequence in (TWO_LOOPS, NOT_A_NUMBER): + typed(sequence) + + screen.claim_error(INVALID_INPUT) + assert theme() == TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == sequence + assert screen.title() == song_title(screen, unsaved=False) + + def a_loop_from_the_start_is_applied(screen: Screen) -> None: + typed(LOOP_FROM_THE_START) + + screen.expect(screen.title, song_title(screen, unsaved=True).__eq__, description="the edit applied") + assert theme() != TAG_GLOBAL_THEME_INPUT_INVALID + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == LOOP_FROM_THE_START + + def a_value_past_the_range_is_brought_into_it(screen: Screen) -> None: + typed(TOO_LOUD) + + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + LOUDEST.__eq__, + description="the loudest level written", + ) + + def a_sequence_past_every_export_is_applied_with_a_warning(screen: Screen) -> None: + typed(long_sequence) + + screen.expect(theme, TAG_GLOBAL_THEME_INPUT_WARNING.__eq__, description="the warning") + instruments.bring_forward(ChannelName.PULSE1) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + warning = screen.words(TOO_LONG).format( + instrument_feature=FeatureKey.VOLUME.capitalized, + items=MAX_SEQUENCE_ITEMS + 1, + kept=screen.words(KEPT_FAMITRACKER).format(limit=MAX_SEQUENCE_ITEMS), + ) + screen.expect( + partial(status_over, screen, field), warning.__eq__, description="the status naming what each keeps" + ) + + def an_empty_field_clears_it(screen: Screen) -> None: + typed(NOTHING) + + screen.expect(theme, plain[0].__eq__, description="the warning gone") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) == NOTHING + + def the_project_keeps_what_was_applied_last(screen: Screen) -> None: + typed(FADING) + screen.expect( + lambda: instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME), + FADING.__eq__, + description="the last sequence applied", + ) + + save_project_as(screen, SAVED_SONG) + + assert saved_instrument(SAVED_SONG, SONG_INSTRUMENT).envelopes.volume.items == (12, 8, 4) + + screen.scenario( + unreadable_sequences_are_refused, + a_loop_from_the_start_is_applied, + a_value_past_the_range_is_brought_into_it, + a_sequence_past_every_export_is_applied_with_a_warning, + an_empty_field_clears_it, + the_project_keeps_what_was_applied_last, + ).run() diff --git a/tests/screens/reconstructions/player/__init__.py b/tests/screens/reconstructions/player/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/player/constants.py b/tests/screens/reconstructions/player/constants.py new file mode 100644 index 000000000..836315f30 --- /dev/null +++ b/tests/screens/reconstructions/player/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +RECONSTRUCTION_LINE: Final[str] = "global.graph.label.waveform_reconstruction" diff --git a/tests/screens/reconstructions/player/steps.py b/tests/screens/reconstructions/player/steps.py new file mode 100644 index 000000000..3089934c8 --- /dev/null +++ b/tests/screens/reconstructions/player/steps.py @@ -0,0 +1,21 @@ +from automation.screen import Screen +from automation.views.waveform import Waveform + + +def entry(screen: Screen) -> str: + """What the Playback menu's first entry reads: Play while stopped, Pause while playing, Resume while paused.""" + return screen.sequencer.playback.play_entry() + + +def expect_entry(screen: Screen, key: str) -> None: + """Waits until the Playback menu's first entry reads the words of ``key``.""" + screen.expect(lambda: entry(screen), screen.words(key).__eq__, description=f"the Playback menu reading {key}") + + +def advancing(screen: Screen, waveform: Waveform, after: float) -> float: + """Waits until the cursor stands past sample ``after`` and returns where it stands.""" + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: reading > after, + description=f"the cursor moving past sample {after:.0f}", + ) diff --git a/tests/screens/reconstructions/player/test_browser_and_channels.py b/tests/screens/reconstructions/player/test_browser_and_channels.py new file mode 100644 index 000000000..9879e958d --- /dev/null +++ b/tests/screens/reconstructions/player/test_browser_and_channels.py @@ -0,0 +1,140 @@ +import operator +from typing import Dict, Final, List + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import expect_open, load_from_the_browser +from automation.vocabulary.playback import PAUSE, PLAY +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.reconstructions.player.constants import RECONSTRUCTION_LINE +from tests.screens.reconstructions.player.steps import advancing, entry, expect_entry +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, SECOND_PLAYABLE + +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +SILENCE: Final[float] = 1e-6 +SILENT_SHARE_TOLERANCE: Final[float] = 0.05 + + +def silent_share(drawn: List[float]) -> float: + """The share of drawn heights that sit at silence.""" + return sum(1 for height in drawn if abs(height) < SILENCE) / len(drawn) + + +class TestPlayingFromTheBrowser: + """A reconstruction opened from the browser plays on Space, its cursor moving along the waveform.""" + + def test_space_plays_and_the_cursor_moves_on(self, screen: Screen) -> None: + """The reconstruction opens stopped, and Space starts it with the cursor moving along the waveform.""" + waveform = screen.reconstructions.waveform + + def open_it(screen: Screen) -> None: + load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) + + expect_open(screen, PLAYABLE_RECONSTRUCTION) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def space_plays_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + + screen.scenario(open_it, space_plays_it).run() + + +class TestAnotherReconstructionAfterClosing: + """A reconstruction opened after another was closed draws its cursor as it plays. + + The first reconstruction plays, stops and closes from the menu; the second opens from the browser, plays, + and its cursor moves on. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_its_cursor_moves_on(self, screen: Screen) -> None: + """The second reconstruction plays with its cursor advancing.""" + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + + def play_and_close_the_first(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + reconstructions.close_from_menu() + + screen.expect(reconstructions.file_line, operator.not_, description="nothing open") + + def open_the_second_and_play(screen: Screen) -> None: + load_from_the_browser(screen, SECOND_PLAYABLE) + expect_open(screen, SECOND_PLAYABLE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + advancing(screen, waveform, first) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(play_and_close_the_first, open_the_second_and_play).run() + + +class TestChoosingChannels: + """The waveform draws the channels ticked above it, and ticking one back draws what it drew before. + + The scenario unticks Pulse 1 and Triangle, which leaves the beat alone, and ticks them again. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_beat_alone_falls_silent_between_its_strokes(self, screen: Screen) -> None: + """The beat alone is silent between its strokes; ticking the channels back restores the drawing.""" + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + line = screen.words(RECONSTRUCTION_LINE) + drawn: Dict[str, List[float]] = {} + + def every_channel_sounds_throughout(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + drawn["all"] = waveform.drawn(line) + + assert silent_share(drawn["all"]) == 0.0 + + def the_beat_alone(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + beat = screen.expect( + lambda: waveform.drawn(line), + drawn["all"].__ne__, + description="the waveform redrawn", + ) + assert abs(silent_share(beat) - (BEAT_FRAMES - BEAT_LENGTH) / BEAT_FRAMES) <= SILENT_SHARE_TOLERANCE + + def every_channel_back(screen: Screen) -> None: + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + reconstructions.tick_channel(channel) + + screen.expect(lambda: waveform.drawn(line), drawn["all"].__eq__, description="the waveform as before") + + screen.scenario(every_channel_sounds_throughout, the_beat_alone, every_channel_back).run() diff --git a/tests/screens/reconstructions/player/test_source_switch.py b/tests/screens/reconstructions/player/test_source_switch.py new file mode 100644 index 000000000..66a17d3f9 --- /dev/null +++ b/tests/screens/reconstructions/player/test_source_switch.py @@ -0,0 +1,108 @@ +import operator +from pathlib import Path +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.worlds.home import World +from tests.screens.reconstructions.player.constants import RECONSTRUCTION_LINE +from tests.suite.screens.seeds.recordings import Recording +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, TAKES, playing_world + +ORIGINAL_SWITCH: Final[str] = "reconstructions.reconstruction.label.original_audio_radio" +RECONSTRUCTION_SWITCH: Final[str] = "reconstructions.reconstruction.label.reconstruction_radio" +ORIGINAL_LINE: Final[str] = "global.graph.label.waveform_original" + + +class TestTheSourceSwitch: + """The switch above the waveform puts the source it names on top of the waveform, and back.""" + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_source_chosen_is_drawn_on_top(self, screen: Screen) -> None: + """The reconstruction starts on top, the original takes the top when chosen, and the reconstruction + again. + """ + reconstructions = screen.reconstructions + waveform = reconstructions.waveform + original = waveform.series_tag(screen.words(ORIGINAL_LINE)) + reconstruction = waveform.series_tag(screen.words(RECONSTRUCTION_LINE)) + + def the_reconstruction_on_top(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + assert waveform.series() == (original, reconstruction) + + def the_original_chosen(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(ORIGINAL_SWITCH)) + + screen.expect(waveform.series, (reconstruction, original).__eq__, description="the original on top") + + def the_reconstruction_chosen_again(screen: Screen) -> None: + reconstructions.choose_audio_source(screen.words(RECONSTRUCTION_SWITCH)) + + screen.expect(waveform.series, (original, reconstruction).__eq__, description="the reconstruction on top") + + screen.scenario(the_reconstruction_on_top, the_original_chosen, the_reconstruction_chosen_again).run() + + +class TestTheSourceSwitchWithItsRecordingGone: + """A reconstruction whose recording is gone offers the reconstruction alone. + + The home lacks the recording. Opening the reconstruction shows a notice naming the missing file; once + dismissed, the switch offers no other source. + """ + + @pytest.fixture + def world(self) -> World: + """The Reconstructions home with the first recording's file removed.""" + world = playing_world() + gone = Path.cwd() / TAKES[0] + return World( + state=world.state, + application_config=world.application_config, + config=world.config, + files=tuple(file for file in world.files if not (isinstance(file, Recording) and file.destination == gone)), + ) + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_the_switch_stays_on_the_reconstruction(self, screen: Screen) -> None: + """The notice names the missing recording, and the switch offers the reconstruction only.""" + reconstructions = screen.reconstructions + notice = screen.file_not_found_notice + + def the_missing_recording_is_named(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + + assert str(Path.cwd() / TAKES[0]) in notice.words() + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + def the_switch_offers_nothing_else(screen: Screen) -> None: + assert not reconstructions.can_choose_audio_source() + assert reconstructions.audio_source() == screen.words(RECONSTRUCTION_SWITCH) + + screen.scenario(the_missing_recording_is_named, the_switch_offers_nothing_else).run() + + def test_the_waveform_draws_the_reconstruction_alone(self, screen: Screen) -> None: + """The waveform shows one line, the reconstruction's.""" + waveform = screen.reconstructions.waveform + notice = screen.file_not_found_notice + expect_open(screen, PLAYABLE_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the notice about the recording") + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + assert waveform.series() == (waveform.series_tag(screen.words(RECONSTRUCTION_LINE)),) diff --git a/tests/screens/reconstructions/player/test_waveform_gestures.py b/tests/screens/reconstructions/player/test_waveform_gestures.py new file mode 100644 index 000000000..b89c781ea --- /dev/null +++ b/tests/screens/reconstructions/player/test_waveform_gestures.py @@ -0,0 +1,271 @@ +import operator +from typing import Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import expect_open +from automation.views.waveform import Waveform +from automation.vocabulary.playback import PAUSE, PLAY +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.reconstructions.player.steps import advancing, entry, expect_entry +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION + +SAMPLE_RATE: Final[int] = 44100 +PLAYING_WINDOW_SECONDS: Final[float] = 3.0 +STILL_FRAMES: Final[int] = 20 +ZOOM_NOTCHES: Final[int] = 3 +WIDTH_TOLERANCE: Final[float] = 0.01 +SPACE_BURST: Final[int] = 5 +PAST_THE_EDGE: Final[float] = 1.4 +RESUME: Final[str] = "global.menu.label.item_playback_resume" + + +def cursor(waveform: Waveform) -> float: + """The cursor's sample on the waveform, which must be drawn.""" + found = waveform.cursor() + assert found is not None, "no cursor stands on the waveform" + return found + + +def playing_from(screen: Screen, waveform: Waveform, sample: float) -> float: + """Waits for the cursor to stand at ``sample`` or a little past it, where playing from ``sample`` puts it.""" + window = PLAYING_WINDOW_SECONDS * SAMPLE_RATE + return screen.expect( + lambda: waveform.cursor() or -1.0, + lambda reading: sample <= reading < sample + window, + description=f"the cursor playing on from sample {sample:.0f}", + ) + + +def held_still(screen: Screen, waveform: Waveform) -> float: + """The cursor's sample, read twice some frames apart and found the same.""" + standing = cursor(waveform) + screen.frames(STILL_FRAMES) + assert waveform.cursor() == standing + return standing + + +def width(limits: Tuple[float, float]) -> float: + """The span a pair of axis limits covers.""" + low, high = limits + return high - low + + +class TestTheWaveformGestures: + """A click plays from a point, moves the cursor while paused and jumps while playing; Space pauses and + resumes. + + A drag pans the view and a double-click fits it, and both leave the cursor and what plays as they were. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_clicks_and_space_steer_playback(self, screen: Screen) -> None: + """A click plays from there, Space pauses and resumes, and Stop takes the cursor away.""" + waveform = screen.reconstructions.waveform + stood: List[float] = [] + + def a_click_while_stopped_plays_from_there(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + + def space_pauses_and_the_cursor_holds(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + held_still(screen, waveform) + + def a_click_while_paused_moves_the_cursor_and_stays_paused(screen: Screen) -> None: + under = waveform.sample_under(0.25) + + waveform.click(0.25) + + screen.expect(waveform.cursor, float(round(under)).__eq__, description="the cursor where clicked") + stood.append(held_still(screen, waveform)) + assert entry(screen) == screen.words(RESUME) + + def space_resumes_from_the_cursor(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, stood[0]) + + def a_click_while_playing_jumps_there(screen: Screen) -> None: + under = waveform.sample_under(0.75) + + waveform.click(0.75) + + playing_from(screen, waveform, under) + assert entry(screen) == screen.words(PAUSE) + + def stop_takes_the_cursor_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + expect_entry(screen, PLAY) + screen.expect(waveform.cursor, operator.not_, description="no cursor") + + screen.scenario( + a_click_while_stopped_plays_from_there, + space_pauses_and_the_cursor_holds, + a_click_while_paused_moves_the_cursor_and_stays_paused, + space_resumes_from_the_cursor, + a_click_while_playing_jumps_there, + stop_takes_the_cursor_away, + ).run() + + def test_a_drag_pans_and_a_double_click_fits_without_touching_playback(self, screen: Screen) -> None: + """A drag pans the zoomed view and a double-click fits it again, while the pause, the playing and the + stop go on as they were. + """ + waveform = screen.reconstructions.waveform + fitted: List[Tuple[float, float]] = [] + stood: List[float] = [] + + def pause_partway(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + fitted.append( + screen.expect(waveform.limits, lambda limits: limits[1] > SAMPLE_RATE, description="the view fitted") + ) + waveform.click(0.5) + expect_entry(screen, PAUSE) + + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, RESUME) + screen.frames(STILL_FRAMES) + stood.append(held_still(screen, waveform)) + + def zoom_in_and_drag(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + zoomed = screen.expect( + waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed" + ) + + waveform.drag(0.6, 0.4) + + panned = screen.expect(waveform.limits, lambda limits: limits[0] > zoomed[0], description="panned") + assert abs(width(panned) - width(zoomed)) <= WIDTH_TOLERANCE * width(zoomed) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_fits_and_leaves_the_pause(screen: Screen) -> None: + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() == stood[0] + assert entry(screen) == screen.words(RESUME) + + def a_double_click_leaves_playing_playing(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + expect_entry(screen, PAUSE) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + before = cursor(waveform) + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + assert entry(screen) == screen.words(PAUSE) + advancing(screen, waveform, before) + + def a_double_click_leaves_stopped_stopped(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + waveform.zoom_in(0.5, ZOOM_NOTCHES) + screen.expect(waveform.limits, lambda limits: width(limits) < width(fitted[0]), description="zoomed") + + waveform.double_click(0.5) + + screen.expect(waveform.limits, fitted[0].__eq__, description="the view fitted") + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + screen.scenario( + pause_partway, + zoom_in_and_drag, + a_double_click_fits_and_leaves_the_pause, + a_double_click_leaves_playing_playing, + a_double_click_leaves_stopped_stopped, + ).run() + + +class TestPressesThatAreNoClick: + """A triple click and a drag that leaves the plot play nothing, and the next plain click plays. + + The scenario triple-clicks, then drags past the plot's edge on a zoomed view, and expects the cursor + absent and the menu reading Play both times. A plain click then plays, and Stop ends it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_a_triple_click_and_a_drag_off_the_plot_play_nothing(self, screen: Screen) -> None: + """The triple click and the drag off the plot leave the cursor absent and the menu reading Play.""" + waveform = screen.reconstructions.waveform + + def a_triple_click(screen: Screen) -> None: + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + waveform.triple_click(0.5) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_drag_off_the_plot(screen: Screen) -> None: + waveform.zoom_in(0.5, ZOOM_NOTCHES) + + waveform.drag(0.5, PAST_THE_EDGE) + + screen.frames(STILL_FRAMES) + assert waveform.cursor() is None + assert entry(screen) == screen.words(PLAY) + + def a_plain_click_plays(screen: Screen) -> None: + under = waveform.sample_under(0.5) + + waveform.click(0.5) + + expect_entry(screen, PAUSE) + playing_from(screen, waveform, under) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) + + screen.scenario(a_triple_click, a_drag_off_the_plot, a_plain_click_plays).run() + + +class TestABurstOfSpace: + """Five presses of Space from a stop play, pause, resume, pause and resume: the burst ends playing.""" + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + def test_an_odd_burst_ends_playing(self, screen: Screen) -> None: + """After the burst the menu reads Pause and the cursor moves on.""" + waveform = screen.reconstructions.waveform + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + for _ in range(SPACE_BURST): + screen.press_shortcut(ShortcutId.PLAY) + + expect_entry(screen, PAUSE) + advancing(screen, waveform, cursor(waveform)) + screen.press_shortcut(ShortcutId.STOP) + expect_entry(screen, PLAY) diff --git a/tests/screens/reconstructions/stems/__init__.py b/tests/screens/reconstructions/stems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/reconstructions/stems/test_stems_card.py b/tests/screens/reconstructions/stems/test_stems_card.py new file mode 100644 index 000000000..c70a8ce10 --- /dev/null +++ b/tests/screens/reconstructions/stems/test_stems_card.py @@ -0,0 +1,286 @@ +import operator +from functools import partial +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import expect_open, load_from_the_browser, marked, titled +from automation.views.stems import StemsCard +from automation.vocabulary.playback import PAUSE +from sampletones_application.tags.general import TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION, STEM_TAKES, STEMS_RECONSTRUCTION + +KEYS: Final[Tuple[str, ...]] = ("0", "1", "2") +FIRST: Final[str] = "0" +MIDDLE: Final[str] = "1" +LAST: Final[str] = "2" +STILL_FRAMES: Final[int] = 20 +SILENT: Final[float] = 0.4 +PLAYING_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) +REMOVE_TITLE: Final[str] = "reconstructions.reconstruction.title.remove_stem_dialog" +REMOVE_MESSAGE: Final[str] = "reconstructions.reconstruction.message.remove_stem_message" +NOTHING_EXPORTED: Final[str] = "global.context.template.size_bytes" + + +def take(index: int) -> Path: + """The path of the stem recording at ``index``, under the working directory.""" + return Path.cwd() / STEM_TAKES[index] + + +def swatches(stems: StemsCard, keys: Tuple[str, ...]) -> Dict[str, Tuple[float, ...]]: + """The color of each row of ``keys`` on the stems card.""" + return {key: stems.swatch(key) for key in keys} + + +def stems_title(screen: Screen, *, unsaved: bool) -> str: + """The window title of the stems reconstruction, marked unsaved or saved.""" + return titled(screen, marked(STEMS_RECONSTRUCTION.name, unsaved=unsaved)) + + +class TestRevealingARecording: + """A double-click on a row shows its recording in the file manager; a click shows nothing; no row stays lit. + + The scenario clicks a row, double-clicks the first row and then the last, and expects the recordings shown + in that order and no row highlighted. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_a_double_click_shows_the_recording_and_leaves_the_row_plain(self, screen: Screen) -> None: + """Each double-click shows its own recording, and the rows keep their plain look.""" + stems = screen.reconstructions.stems + + def a_click_shows_nothing(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + + stems.click(FIRST) + + screen.frames(STILL_FRAMES) + assert screen.revealed() == () + assert not any(stems.is_highlighted(key) for key in KEYS) + + def a_double_click_shows_that_recording(screen: Screen) -> None: + stems.reveal(FIRST) + + screen.expect(screen.revealed, (take(0),).__eq__, description="the first recording shown") + assert not any(stems.is_highlighted(key) for key in KEYS) + + def another_row_shows_its_own(screen: Screen) -> None: + stems.reveal(LAST) + + screen.expect(screen.revealed, (take(0), take(2)).__eq__, description="the last recording shown") + assert not any(stems.is_highlighted(key) for key in KEYS) + + screen.scenario(a_click_shows_nothing, a_double_click_shows_that_recording, another_row_shows_its_own).run() + + +class TestRemovingARecording: + """Removing a recording asks first; the rest keep their colors and play, and the last one cannot go. + + The scenario cancels the question, then removes the middle recording and confirms. The rest play, the + first goes too, and the last recording stays without a remove gesture. Saved and opened again, the card + lists only the last recording in its old color. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_cancel_keeps_it_and_remove_leaves_the_rest_as_they_were(self, screen: Screen) -> None: + """Cancel keeps every row; Remove drops one, and the others keep their colors and play.""" + reconstructions = screen.reconstructions + stems = reconstructions.stems + prompt = stems.remove_prompt + colors: List[Dict[str, Tuple[float, ...]]] = [] + + def the_file_named(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + + assert screen.title() == stems_title(screen, unsaved=False) + assert all(stems.has_row(key) for key in KEYS) + colors.append(swatches(stems, KEYS)) + + def cancel_keeps_it(screen: Screen) -> None: + stems.remove(MIDDLE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + assert prompt.title() == screen.words(REMOVE_TITLE) + assert screen.words(REMOVE_MESSAGE) in prompt.words() + + prompt.cancel() + + screen.expect(prompt.is_shown, operator.not_, description="the question taken back") + screen.frames(STILL_FRAMES) + assert all(stems.has_row(key) for key in KEYS) + assert screen.title() == stems_title(screen, unsaved=False) + + def remove_the_middle(screen: Screen) -> None: + stems.remove(MIDDLE) + screen.expect(prompt.is_shown, bool, description="the question again") + + prompt.confirm() + + screen.expect(lambda: stems.has_row(MIDDLE), operator.not_, description="the middle recording gone") + assert screen.title() == stems_title(screen, unsaved=True) + assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + + def the_rest_plays(screen: Screen) -> None: + waveform = reconstructions.waveform + + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + first = screen.expect(waveform.cursor, bool, description="the cursor") + assert first is not None + screen.expect(lambda: waveform.cursor() or 0.0, lambda reading: reading > first, description="moving on") + screen.press_shortcut(ShortcutId.STOP) + + def the_last_cannot_go(screen: Screen) -> None: + stems.remove(FIRST) + screen.expect(prompt.is_shown, bool, description="the question about the first") + prompt.confirm() + screen.expect(lambda: stems.has_row(FIRST), operator.not_, description="the first recording gone") + + assert not stems.can_remove(LAST) + assert stems.swatch(LAST) == colors[0][LAST] + + def saved_and_opened_again_it_keeps_its_color(screen: Screen) -> None: + reconstructions.save_from_menu() + screen.expect(screen.title, stems_title(screen, unsaved=False).__eq__, description="saved") + reconstructions.close_from_menu() + screen.expect(reconstructions.file_line, operator.not_, description="closed") + + load_from_the_browser(screen, STEMS_RECONSTRUCTION) + + expect_open(screen, STEMS_RECONSTRUCTION) + screen.expect(lambda: stems.has_row(LAST), bool, description="the last recording listed") + assert not stems.has_row(FIRST) + assert not stems.has_row(MIDDLE) + assert stems.swatch(LAST) == colors[0][LAST] + + screen.scenario( + the_file_named, + cancel_keeps_it, + remove_the_middle, + the_rest_plays, + the_last_cannot_go, + saved_and_opened_again_it_keeps_its_color, + ).run() + + +class TestSilencingARecording: + """A recording whose every frame falls silent leaves the card, and the others keep their colors. + + With the middle recording heard alone, each channel's volume is dragged to silence across every bar, which + leaves the channel standing by for that recording. + + The scenario ends on the middle row gone and the other rows in their old colors, then leaves the + application. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction with several recordings is open at start, with no project.""" + return Startup(reconstruction=STEMS_RECONSTRUCTION, project=None) + + def test_its_row_leaves_and_the_others_keep_their_colors(self, screen: Screen) -> None: + """The silenced recording's row leaves the card and the other rows keep their colors.""" + reconstructions = screen.reconstructions + stems = reconstructions.stems + instruments = reconstructions.instruments + colors: List[Dict[str, Tuple[float, ...]]] = [] + + def hear_the_middle_alone(screen: Screen) -> None: + expect_open(screen, STEMS_RECONSTRUCTION) + colors.append(swatches(stems, KEYS)) + + for key in (FIRST, LAST): + stems.tick(key) + screen.expect(partial(stems.hears, key), operator.not_, description=f"{key} left unheard") + + def silence_every_channel_of_it(screen: Screen) -> None: + for channel in PLAYING_CHANNELS: + instruments.bring_forward(channel) + graph = instruments.graph(channel, FeatureKey.VOLUME) + screen.hand.scroll_into_view(graph.plot) + + bars = len(instruments.envelope(channel, FeatureKey.VOLUME).split()) + + graph.drag_across(0, bars - 1, value=SILENT) + + screen.expect( + partial(instruments.envelope, channel, FeatureKey.VOLUME), + operator.not_, + description=f"{channel} standing by", + ) + + def its_row_leaves(screen: Screen) -> None: + screen.expect(lambda: stems.has_row(MIDDLE), operator.not_, description="the middle recording gone") + + assert swatches(stems, (FIRST, LAST)) == {key: colors[0][key] for key in (FIRST, LAST)} + + def leave_letting_it_go(screen: Screen) -> None: + prompt = reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(hear_the_middle_alone, silence_every_channel_of_it, its_row_leaves, leave_letting_it_go).run() + + +class TestLettingAChannelGo: + """A channel whose volume is dragged to silence on every frame mutes its tab and exports nothing. + + The silence is dragged at a height that rounds to it, above the strip under the bars that a press ignores. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A short reconstruction is open at start, with no project.""" + return Startup(reconstruction=SHORT_RECONSTRUCTION, project=None) + + def test_the_tab_mutes_and_its_size_reads_nothing(self, screen: Screen) -> None: + """The channel's tab takes the muted look, its size reads zero bytes and its export is unavailable.""" + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + graph = instruments.graph(ChannelName.PULSE1, FeatureKey.VOLUME) + + def drag_pulse_one_to_silence(screen: Screen) -> None: + expect_open(screen, SHORT_RECONSTRUCTION) + instruments.bring_forward(ChannelName.PULSE1) + screen.hand.scroll_into_view(graph.plot) + assert instruments.tab_theme(ChannelName.PULSE1) != TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED + + bars = len(instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME).split()) + + graph.drag_across(0, bars - 1, value=SILENT) + + screen.expect( + lambda: instruments.tab_theme(ChannelName.PULSE1), + TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED.__eq__, + description="the tab muted", + ) + assert instruments.size(ChannelName.PULSE1) == screen.words(NOTHING_EXPORTED).format(bytes=0) + assert not instruments.can_export(ChannelName.PULSE1) + + def leave_letting_it_go(screen: Screen) -> None: + prompt = reconstructions.unsaved_prompt + screen.press_shortcut(ShortcutId.EXIT) + screen.expect(prompt.is_shown, bool, description="the question about leaving") + + prompt.confirm() + + assert screen.wait_for_exit() + + screen.scenario(drag_pulse_one_to_silence, leave_letting_it_go).run() diff --git a/tests/screens/sequencer/__init__.py b/tests/screens/sequencer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/colors/__init__.py b/tests/screens/sequencer/colors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/colors/test_kind_colors.py b/tests/screens/sequencer/colors/test_kind_colors.py new file mode 100644 index 000000000..d2cf492e7 --- /dev/null +++ b/tests/screens/sequencer/colors/test_kind_colors.py @@ -0,0 +1,202 @@ +import operator +from functools import partial +from typing import Final, List, Optional, Tuple + +from automation.dearpygui.items.colors import rounded_color +from automation.palettes import Color, shipped_palettes, token_color +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go +from automation.views.history import HistoryLine +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_OFF +from tests.suite.screens.worlds.songs import BASS_ROW, BASS_VOICE, LINE, PAD, PAD_ROW + +SAMPLE_TOKEN: Final[str] = "voice_sample" +INSTRUMENT_TOKEN: Final[str] = "voice_instrument" +NEUTRAL_TOKEN: Final[str] = "text_disabled" +CUT_KEY: Final[str] = "-" +FIRST_ROW: Final[int] = 0 +EMPTY_ROW: Final[int] = 1 +CUT_ROW: Final[int] = 2 +PLACED_ROW: Final[int] = 3 +TYPING_FRAMES: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 10 +RGB: Final[int] = 3 +ALPHA: Final[int] = 3 +LINE_NUMBER: Final[str] = "00" +FIRST_NUMBER: Final[str] = "01" +PAD_NUMBER: Final[str] = "02" +POSITION_MARK: Final[str] = ":" + + +def type_into(screen: Screen, row: int, channel: ChannelName, text: str) -> None: + """Clicks the voice slot of a channel at a row, types the text and lets the typing settle.""" + screen.sequencer.tracker.click(row, channel, SubColumn.VOICE) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + +def kind_mark(screen: Screen, voice: str) -> Color: + """The rounded color of the kind mark beside the voice called ``voice`` in the list.""" + return rounded_color(screen.sequencer.voices.kind_color(voice)) + + +def voice_color(screen: Screen, row: int, channel: ChannelName) -> Optional[Color]: + """The text color of a channel's voice cell at a row, or None when the cell shows none.""" + return screen.sequencer.tracker.text_color(row, channel, SubColumn.VOICE) + + +def position_color(line: HistoryLine, position: str) -> Color: + """The color of the piece of ``line`` naming a voice by its ``position``.""" + return rounded_color( + next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + ) + + +def is_dimmed(color: Optional[Color], full: Color) -> bool: + """Whether ``color`` is ``full`` dimmed: the same hue, drawn fainter.""" + return color is not None and color[:RGB] == full[:RGB] and color[ALPHA] < full[ALPHA] + + +class TestKindColorsInEveryPalette: + """A sample reads in the sample color and an instrument in the instrument color, as kind marks and as tracker cells, in every palette and live on a swap. + + An empty cell and a cut read neutral, and a muted channel's cells keep their hue, fainter. The scenario cuts a cell and mutes the triangle, then checks the default palette and every shipped palette in Display settings. + """ + + def test_marks_and_cells_take_every_palette(self, screen: Screen) -> None: + """Marks and cells take the colors of each palette as it is chosen.""" + tracker = screen.sequencer.tracker + settings = screen.display_settings + catalog = shipped_palettes() + + def cut_a_cell_and_mute_the_triangle(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + sample = token_color(catalog.get(DEFAULT_PALETTE_NAME), SAMPLE_TOKEN) + assert voice_color(screen, BASS_ROW, ChannelName.TRIANGLE) == sample + + type_into(screen, CUT_ROW, ChannelName.PULSE1, CUT_KEY) + tracker.click_header(ChannelName.TRIANGLE) + + screen.expect( + partial(tracker.label, CUT_ROW, ChannelName.PULSE1, SubColumn.VOICE), + NOTE_OFF.__eq__, + description="the cut", + ) + screen.expect( + partial(voice_color, screen, BASS_ROW, ChannelName.TRIANGLE), + partial(is_dimmed, full=sample), + description="the muted triangle's cell dimmed", + ) + + def painted_in(screen: Screen, palette: Palette) -> None: + sample = token_color(palette, SAMPLE_TOKEN) + instrument = token_color(palette, INSTRUMENT_TOKEN) + neutral = token_color(palette, NEUTRAL_TOKEN) + + assert kind_mark(screen, LINE) == sample + assert kind_mark(screen, BASS_VOICE) == sample + assert kind_mark(screen, PAD) == instrument + assert voice_color(screen, FIRST_ROW, ChannelName.PULSE1) == sample + assert voice_color(screen, PAD_ROW, ChannelName.PULSE2) == instrument + assert voice_color(screen, EMPTY_ROW, ChannelName.PULSE1) == neutral + assert voice_color(screen, CUT_ROW, ChannelName.PULSE1) == neutral + assert is_dimmed(voice_color(screen, BASS_ROW, ChannelName.TRIANGLE), sample) + + def every_palette_paints_the_kinds(screen: Screen) -> None: + painted_in(screen, catalog.get(DEFAULT_PALETTE_NAME)) + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + + for name in catalog.names: + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + + painted_in(screen, catalog.get(name)) + + settings.choose_palette(DEFAULT_PALETTE_NAME) + settings.cancel() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + screen.scenario( + cut_a_cell_and_mute_the_triangle, + every_palette_paints_the_kinds, + leave_letting_the_project_go, + ).run() + + +class TestColorsFollowTheVoice: + """A voice's color travels with it: a placement's history line names it in its color, a move re-numbers and re-colors its cells and leaves the others alone, and the lines naming it keep its color once it is removed. + + An instrument is placed, moved up one place and removed after a confirmation. + """ + + def test_placed_moved_and_removed(self, screen: Screen) -> None: + """Each step shows the instrument's number and color in the cells and in the history.""" + tracker = screen.sequencer.tracker + voices = screen.sequencer.voices + history = screen.sequencer.history + palette = shipped_palettes().get(DEFAULT_PALETTE_NAME) + sample = token_color(palette, SAMPLE_TOKEN) + instrument = token_color(palette, INSTRUMENT_TOKEN) + placed: List[Tuple[HistoryLine, int]] = [] + + def place_the_instrument(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + before = len(history.lines()) + + type_into(screen, PLACED_ROW, ChannelName.PULSE1, PAD_NUMBER) + + lines = screen.expect(history.lines, lambda found: len(found) == before + 1, description="its entry") + assert position_color(lines[0], PAD_NUMBER) == instrument + assert voice_color(screen, PLACED_ROW, ChannelName.PULSE1) == instrument + placed.append((lines[0], before + 1)) + + def moving_it_up_renumbers_its_cells_alone(screen: Screen) -> None: + row = screen.expect_item(partial(voices.row, PAD), description="the instrument's row") + voices.pick(row) + + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP) + + screen.expect(voices.names, [LINE, PAD, BASS_VOICE].__eq__, description="the instrument moved up") + screen.expect( + partial(tracker.label, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE), + FIRST_NUMBER.__eq__, + description="its cell renumbered", + ) + for row_index, channel, number, color in ( + (PAD_ROW, ChannelName.PULSE2, FIRST_NUMBER, instrument), + (PLACED_ROW, ChannelName.PULSE1, FIRST_NUMBER, instrument), + (BASS_ROW, ChannelName.TRIANGLE, PAD_NUMBER, sample), + (FIRST_ROW, ChannelName.PULSE1, LINE_NUMBER, sample), + ): + assert tracker.label(row_index, channel, SubColumn.VOICE) == number + assert voice_color(screen, row_index, channel) == color + + def removed_its_older_lines_keep_its_color(screen: Screen) -> None: + prompt = voices.remove_prompt + row = screen.expect_item(partial(voices.row, PAD), description="the instrument's row") + voices.pick(row) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + + prompt.confirm() + + screen.expect(voices.names, [LINE, BASS_VOICE].__eq__, description="the instrument removed") + line, count = placed[0] + lines = history.lines() + older = lines[len(lines) - count] + assert older.segments == line.segments + assert position_color(older, PAD_NUMBER) == instrument + + screen.scenario( + place_the_instrument, + moving_it_up_renumbers_its_cells_alone, + removed_its_older_lines_keep_its_color, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/conftest.py b/tests/screens/sequencer/conftest.py new file mode 100644 index 000000000..3fb95755f --- /dev/null +++ b/tests/screens/sequencer/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from automation.application.startup import Startup +from automation.worlds.home import World +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + + +@pytest.fixture +def world() -> World: + """The home every Sequencer scenario starts in: an arranged project, and reconstructions to add to it.""" + return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on the arranged project with no reconstruction loaded.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) diff --git a/tests/screens/sequencer/history/__init__.py b/tests/screens/sequencer/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/history/cases.py b/tests/screens/sequencer/history/cases.py new file mode 100644 index 000000000..e58bc5e5c --- /dev/null +++ b/tests/screens/sequencer/history/cases.py @@ -0,0 +1,46 @@ +import operator +from functools import partial +from typing import Callable, Final, Tuple + +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.steps import pick, rename + +DUPLICATE: Final[str] = "sequencer.voices.label.context_duplicate" +Gesture = Callable[[Screen, str], None] + + +def duplicate(screen: Screen, name: str) -> None: + """Duplicates the voice ``name`` from the context menu of its row.""" + voices = screen.sequencer.voices + menu = screen.context_menu + screen.tabs.bring_to_front(Tab.SEQUENCER) + voices.right_click(screen.expect_item(partial(voices.row, name), description=f"the row of {name}")) + screen.expect(menu.is_shown, bool, description="the row's menu") + menu.choose(screen.words(DUPLICATE)) + + +def move(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place, down where a place lies below it and up otherwise.""" + last = screen.sequencer.voices.names()[-1] == name + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_UP if last else ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def remove(screen: Screen, name: str) -> None: + """Removes the voice ``name`` by shortcut and confirms the question about removing.""" + prompt = screen.sequencer.voices.remove_prompt + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + screen.expect(prompt.is_shown, bool, description="the question about removing") + prompt.confirm() + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + + +GESTURES: Final[Tuple[Tuple[str, Gesture], ...]] = ( + ("rename", rename), + ("duplicate", duplicate), + ("move", move), + ("remove", remove), +) diff --git a/tests/screens/sequencer/history/constants.py b/tests/screens/sequencer/history/constants.py new file mode 100644 index 000000000..36ae797f6 --- /dev/null +++ b/tests/screens/sequencer/history/constants.py @@ -0,0 +1,6 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 10 +RENAMED: Final[str] = "Renamed" +PAD_POSITION: Final[str] = "02" +POSITION_MARK: Final[str] = ":" diff --git a/tests/screens/sequencer/history/steps.py b/tests/screens/sequencer/history/steps.py new file mode 100644 index 000000000..4b51474aa --- /dev/null +++ b/tests/screens/sequencer/history/steps.py @@ -0,0 +1,32 @@ +from functools import partial + +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.keys import IMGUI_ENTER +from automation.screen import Screen +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_INPUT_RENAME +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.constants import RENAMED, SETTLING_FRAMES + + +def pick(screen: Screen, name: str) -> None: + """Opens the Sequencer tab, picks the row of the voice ``name`` and lets the screen settle.""" + voices = screen.sequencer.voices + screen.tabs.bring_to_front(Tab.SEQUENCER) + row = screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + voices.pick(row) + screen.frames(SETTLING_FRAMES) + + +def rename(screen: Screen, name: str) -> None: + """Renames the voice ``name`` to the fixed new name: picks it, edits it and confirms.""" + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_RENAME_VOICE) + screen.expect( + lambda: screen.bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_INPUT_RENAME)).shown, + bool, + description="the name being edited", + ) + + screen.hand.replace_text(TAG_SEQUENCER_VOICES_INPUT_RENAME, RENAMED) + screen.hand.press_key(IMGUI_ENTER, modifiers=[]) diff --git a/tests/screens/sequencer/history/test_envelope_drag.py b/tests/screens/sequencer/history/test_envelope_drag.py new file mode 100644 index 000000000..43e2ea46a --- /dev/null +++ b/tests/screens/sequencer/history/test_envelope_drag.py @@ -0,0 +1,70 @@ +from typing import Final, List, Tuple + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, open_voice +from automation.views.history import HistoryLine +from sampletones_application.categories.hierarchy import Tab +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.sequencer.history.constants import PAD_POSITION, POSITION_MARK +from tests.suite.screens.worlds.songs import PAD + +VOLUME_LETTER: Final[str] = "v" +ARPEGGIO_LETTER: Final[str] = "a" +QUIET: Final[float] = 4.0 +ARPEGGIO_STEP: Final[float] = 5.0 +DRAGGED_ITEM: Final[int] = 1 + + +def position_color(line: HistoryLine, position: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice by its ``position``.""" + return next(segment.color for segment in line.segments if segment.words.rstrip(POSITION_MARK) == position) + + +class TestAnEnvelopeDragIsOneEntry: + """A drag across an instrument's envelope makes one history entry naming the voice and the dimension. + + The pad instrument is opened and its volume envelope is dragged: one entry appears, naming the + instrument by its number in the color of its kind. A drag across the arpeggio envelope adds a second + entry. One undo takes back the arpeggio drag only, and the project is left. + """ + + def test_one_entry_per_dimension(self, screen: Screen) -> None: + """Each drag makes exactly one entry, and one undo reverts one drag.""" + instruments = screen.reconstructions.instruments + history = screen.sequencer.history + lines: List[int] = [] + standing: List[str] = [] + + def drag(feature: FeatureKey, end: float) -> None: + graph = instruments.graph(ChannelName.PULSE1, feature) + screen.hand.scroll_into_view(graph.plot) + graph.drag_item(DRAGGED_ITEM, start=0.0, end=end) + + def drag_the_volume(screen: Screen) -> None: + open_voice(screen, PAD) + screen.expect(instruments.offers_audition, bool, description="the instrument open") + lines.append(len(history.lines())) + + drag(FeatureKey.VOLUME, QUIET) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 1, description="one entry") + assert position_color(after[0], PAD_POSITION) == screen.sequencer.voices.kind_color(PAD) + assert VOLUME_LETTER in after[0].words.split() + + def drag_the_arpeggio(screen: Screen) -> None: + standing.append(instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO)) + + drag(FeatureKey.ARPEGGIO, ARPEGGIO_STEP) + + after = screen.expect(history.lines, lambda found: len(found) == lines[0] + 2, description="a second entry") + assert ARPEGGIO_LETTER in after[0].words.split() + + def one_undo_takes_one_back(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + history.undo() + + screen.expect(lambda: history.lines()[1].current, bool, description="one step back") + assert instruments.envelope(ChannelName.PULSE1, FeatureKey.ARPEGGIO) == standing[0] + + screen.scenario(drag_the_volume, drag_the_arpeggio, one_undo_takes_one_back, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/history/test_voice_gestures.py b/tests/screens/sequencer/history/test_voice_gestures.py new file mode 100644 index 000000000..9baf13028 --- /dev/null +++ b/tests/screens/sequencer/history/test_voice_gestures.py @@ -0,0 +1,148 @@ +from typing import Callable, Final, List, Tuple + +from automation.screen import Screen +from automation.steps.reconstructions import titled +from automation.views.history import HistoryLine +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.history.cases import GESTURES, Gesture +from tests.screens.sequencer.history.constants import PAD_POSITION, POSITION_MARK +from tests.screens.sequencer.history.steps import pick, rename +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +LINE_POSITION: Final[str] = "00" + + +def move_down(screen: Screen, name: str) -> None: + """Moves the voice ``name`` one place down.""" + pick(screen, name) + screen.press_shortcut(ShortcutId.VOICES_MOVE_VOICE_DOWN) + + +def done(lines: Tuple[HistoryLine, ...]) -> Tuple[HistoryLine, ...]: + """The lines of the entries done, from the one in force down, leaving out those an undo set aside.""" + current = next(index for index, line in enumerate(lines) if line.current) + return lines[current:] + + +def voice_segment_color(line: HistoryLine, name: str) -> Tuple[float, ...]: + """The color of the piece of ``line`` naming the voice ``name``.""" + return next(segment.color for segment in line.segments if name in segment.words) + + +def naming_color(line: HistoryLine, voice: str, position: str) -> Tuple[float, ...]: + """The color of the first piece of ``line`` naming the voice, by its name or by its ``position``.""" + return next( + segment.color + for segment in line.segments + if voice in segment.words or segment.words.rstrip(POSITION_MARK) == position + ) + + +def one_more_than(count: int) -> Callable[[Tuple[HistoryLine, ...]], bool]: + """Whether a reading of the lines done holds one more than ``count``.""" + return lambda lines: len(lines) == count + 1 + + +def names_by_position_and_name(line: HistoryLine, voice: str, position: str) -> bool: + """Whether ``line`` names the voice both by its ``position`` and by its name.""" + words = [segment.words.rstrip(POSITION_MARK) for segment in line.segments] + return position in words and any(voice in word for word in words) + + +def one_entry_per_gesture(gesture_name: str, gesture: Gesture, voice: str, position: str) -> Callable[[Screen], None]: + """The step making ``gesture`` on ``voice``: one entry in its kind's color, undone and redone.""" + + def step(screen: Screen) -> None: + voices = screen.sequencer.voices + history = screen.sequencer.history + before_names = voices.names() + before = done(history.lines()) + kind = voices.kind_color(voice) + + gesture(screen, voice) + + after = screen.expect( + lambda: done(history.lines()), lambda lines: len(lines) == len(before) + 1, description="one entry" + ) + after_names = voices.names() + assert after_names != before_names + assert [line.segments for line in after[1:]] == [line.segments for line in before] + assert naming_color(after[0], voice, position) == kind + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, before_names.__eq__, description=f"{gesture_name} undone") + assert [line.segments for line in done(history.lines())] == [line.segments for line in before] + screen.press_shortcut(ShortcutId.REDO) + screen.expect(voices.names, after_names.__eq__, description=f"{gesture_name} redone") + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(voices.names, before_names.__eq__, description=f"{gesture_name} undone again") + + step.__name__ = f"{gesture_name}_{voice.lower()}_is_one_entry" + return step + + +def leave_as_opened(screen: Screen) -> None: + """Exits a project whose gestures were all undone; the window closes at once.""" + assert screen.title() == titled(screen, ARRANGED_PROJECT.stem) + + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + + +class TestEachVoiceGestureIsOneEntry: + """Renaming, duplicating, moving and removing a sample or an instrument each make one entry. + + The entry names the voice in the color of its kind, and one undo and one redo take it back and forth. + """ + + def test_a_sample_and_an_instrument(self, screen: Screen) -> None: + """Each of the four gestures on the sample and on the pad instrument adds one entry, and undo, redo and + undo again move the voice list back and forth. + """ + steps = [ + one_entry_per_gesture(name, gesture, voice, position) + for voice, position in ((LINE, LINE_POSITION), (PAD, PAD_POSITION)) + for name, gesture in GESTURES + ] + + screen.scenario(*steps, leave_as_opened).run() + + def test_every_line_names_the_voice_by_position_and_name(self, screen: Screen) -> None: + """The entries of a renamed and of a moved sample each name it by its position and by its name.""" + history = screen.sequencer.history + lines: List[HistoryLine] = [] + for gesture in (rename, move_down): + before = len(done(history.lines())) + gesture(screen, LINE) + lines.append( + screen.expect(lambda: done(history.lines()), one_more_than(before), description="its entry")[0] + ) + screen.press_shortcut(ShortcutId.UNDO) + screen.expect(screen.sequencer.voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="undone") + + assert all(names_by_position_and_name(line, LINE, LINE_POSITION) for line in lines) + + def test_a_new_instrument_is_one_entry(self, screen: Screen) -> None: + """A new instrument adds one entry in the instrument color, and one undo removes the instrument.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + + def add_one(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + before = history.lines() + + voices.new_instrument() + + after = screen.expect(history.lines, lambda lines: len(lines) == len(before) + 1, description="one entry") + assert len(voices.names()) == 4 + name = voices.names()[-1] + assert voice_segment_color(after[0], name) == voices.kind_color(name) + assert voices.kind_color(name) == voices.kind_color(PAD) + + def undo_takes_it_away(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(voices.names, [LINE, BASS_VOICE, PAD].__eq__, description="the instrument gone") + + screen.scenario(add_one, undo_takes_it_away, leave_as_opened).run() diff --git a/tests/screens/sequencer/samples/__init__.py b/tests/screens/sequencer/samples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/samples/cases.py b/tests/screens/sequencer/samples/cases.py new file mode 100644 index 000000000..12c5a07b2 --- /dev/null +++ b/tests/screens/sequencer/samples/cases.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Tuple + +from automation.screen import Screen +from tests.screens.sequencer.samples.steps import ( + add_by_double_click, + add_from_the_reconstruction_menu, + add_from_the_reconstructions_browser, + add_from_the_sequencer_browser, + add_from_the_voice_menu, + add_from_the_voices_list, +) + + +@dataclass(frozen=True) +class Door: + """One way a reader adds a reconstruction file to the project's voices. + + Attributes: + name: What the row is called among the scenarios. + add: Adds the reconstruction stored at a path through this door. + """ + + name: str + add: Callable[[Screen, Path], None] + + +DOORS: Final[Tuple[Door, ...]] = ( + Door("reconstructions_browser", add_from_the_reconstructions_browser), + Door("sequencer_browser", add_from_the_sequencer_browser), + Door("double_click", add_by_double_click), + Door("voices_list", add_from_the_voices_list), + Door("reconstruction_menu", add_from_the_reconstruction_menu), + Door("voice_menu", add_from_the_voice_menu), +) diff --git a/tests/screens/sequencer/samples/conftest.py b/tests/screens/sequencer/samples/conftest.py new file mode 100644 index 000000000..87cbb292e --- /dev/null +++ b/tests/screens/sequencer/samples/conftest.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest + +from automation.worlds.home import World +from sampletones_shared.constants.nes import DEFAULT_NES_FREQUENCY +from tests.screens.sequencer.samples.constants import ( + OTHER_RATE, + SOUND_AT_OTHER_RATE, + UNSOUND_AT_OTHER_RATE, + UNSOUND_RECONSTRUCTION, +) +from tests.suite.screens.seeds.reconstructions import PlayableReconstruction, UnsoundReconstruction +from tests.suite.screens.worlds.recordings import STEM_FRAMES, STEM_TAKES +from tests.suite.screens.worlds.songs import sequencer_world + + +@pytest.fixture +def world() -> World: + """The Sequencer's home, holding besides an unsound reconstruction at the project's rate and at another, + and a sound one at the other rate. + + Each plays the stem takes the home holds; the unsound ones name all three, and their record states no + scale, which a record of several recordings always states. + """ + sequencer = sequencer_world() + takes = tuple(Path.cwd() / name for name in STEM_TAKES) + return World( + state=sequencer.state, + application_config=None, + config=None, + files=( + *sequencer.files, + UnsoundReconstruction(UNSOUND_RECONSTRUCTION, takes, STEM_FRAMES, DEFAULT_NES_FREQUENCY), + UnsoundReconstruction(UNSOUND_AT_OTHER_RATE, takes, STEM_FRAMES, OTHER_RATE), + PlayableReconstruction(SOUND_AT_OTHER_RATE, takes[:1], STEM_FRAMES, OTHER_RATE), + ), + ) diff --git a/tests/screens/sequencer/samples/constants.py b/tests/screens/sequencer/samples/constants.py new file mode 100644 index 000000000..10dbd8af0 --- /dev/null +++ b/tests/screens/sequencer/samples/constants.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Final, List + +from pydantic import ValidationError + +from sampletones_shared.constants.nes import PAL_FREQUENCY +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +UNSOUND_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Unsound.stn" +UNSOUND_AT_OTHER_RATE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Unsound PAL.stn" +SOUND_AT_OTHER_RATE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Sound PAL.stn" +OTHER_RATE: Final[int] = PAL_FREQUENCY +UNSOUND_FAILURE: Final[str] = ValidationError.__name__ +UNEXPECTED_FAILURE: Final[str] = "global.dialog.message.unexpected_failure" +ADD_TO_SEQUENCER: Final[str] = "global.context.label.add_to_sequencer" +ADD_SAMPLE_FROM_FILE: Final[str] = "sequencer.voices.label.add_sample" +REPLACE_SAMPLE: Final[str] = "global.context.template.replace_sample" +LINE_POSITION: Final[str] = "00" +SAMPLE_KEY: Final[str] = "sample" +PROJECT_TITLE_PART: Final[int] = 1 +PLACEHOLDER_START: Final[str] = "{" +FREQUENCY_MISMATCH: Final[str] = "global.dialog.message.frequency_mismatch" +ATTEMPTS: Final[int] = 2 +ARRANGED_VOICES: Final[List[str]] = [LINE, BASS_VOICE, PAD] diff --git a/tests/screens/sequencer/samples/steps.py b/tests/screens/sequencer/samples/steps.py new file mode 100644 index 000000000..c4a7e7029 --- /dev/null +++ b/tests/screens/sequencer/samples/steps.py @@ -0,0 +1,177 @@ +import operator +from functools import partial +from pathlib import Path + +from automation.boundaries.dialogs import DialogKind +from automation.dearpygui.items.types import Item +from automation.screen import Screen +from automation.steps.reconstructions import BY_CONFIGURATION, expect_open +from automation.steps.sequencer import voice_row +from automation.views.browsers import FileTree +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.general import ( + TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_ADD_TO_SEQUENCER, + TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.constants.symbols import TITLE_SEPARATOR +from tests.screens.sequencer.samples.constants import ( + ADD_SAMPLE_FROM_FILE, + ADD_TO_SEQUENCER, + PROJECT_TITLE_PART, + REPLACE_SAMPLE, + SAMPLE_KEY, + UNEXPECTED_FAILURE, + UNSOUND_FAILURE, +) + + +def listed_row(screen: Screen, browser: FileTree, path: Path) -> Item: + """The row of ``path`` under the heading listing reconstructions by configuration in ``browser``. + + Every row above it that stands closed is opened with a click, so the row is in reach. + """ + heading = screen.expect_item( + partial(browser.heading, screen.words(BY_CONFIGURATION)), + description="the heading listing reconstructions by configuration", + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + screen.expect(partial(browser.is_open, heading), bool, description="the heading open") + + row = screen.expect_item(partial(browser.file_row, path), description=f"the row of {path.name}") + for above in browser.rows_above(row): + if not browser.is_open(above): + browser.open_by_click(above) + screen.expect(partial(browser.is_open, above), bool, description=f"the rows above {path.name} open") + + return row + + +def choose_on_the_row(screen: Screen, browser: FileTree, path: Path, label: str) -> None: + """Right-clicks the row of ``path`` in ``browser`` and clicks the entry reading ``label`` on its menu.""" + browser.right_click(listed_row(screen, browser, path)) + screen.expect(screen.context_menu.is_shown, bool, description=f"the menu of {path.name}") + screen.context_menu.choose(label) + + +def add_from_the_reconstructions_browser(screen: Screen, path: Path) -> None: + """Adds ``path`` through Add to Sequencer on its row's menu on the Reconstructions tab.""" + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + choose_on_the_row(screen, screen.reconstructions.browser, path, screen.words(ADD_TO_SEQUENCER)) + + +def add_from_the_sequencer_browser(screen: Screen, path: Path) -> None: + """Adds ``path`` through Add to Sequencer on its row's menu on the Sequencer tab.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + choose_on_the_row(screen, screen.sequencer.browser, path, screen.words(ADD_TO_SEQUENCER)) + + +def add_by_double_click(screen: Screen, path: Path) -> None: + """Adds ``path`` by double-clicking its row on the Sequencer tab.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + browser = screen.sequencer.browser + browser.double_click(listed_row(screen, browser, path)) + + +def add_from_the_voices_list(screen: Screen, path: Path) -> None: + """Adds ``path`` through Add sample from file... on the voices list's own menu, answering the file + dialog with it. + """ + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.answer_next_dialog(DialogKind.OPEN, path) + screen.sequencer.voices.right_click_below_the_rows() + screen.expect(screen.context_menu.is_shown, bool, description="the menu of the voices list") + screen.context_menu.choose(screen.words(ADD_SAMPLE_FROM_FILE)) + + +def add_from_the_reconstruction_menu(screen: Screen, path: Path) -> None: + """Opens ``path`` on the Reconstructions tab and adds it through Reconstruction ▸ Add to Sequencer.""" + add_the_open_document(screen, path, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_ADD_TO_SEQUENCER) + + +def add_from_the_voice_menu(screen: Screen, path: Path) -> None: + """Opens ``path`` on the Reconstructions tab and adds it through Voice ▸ Add to Sequencer.""" + add_the_open_document(screen, path, TAG_GLOBAL_MENU_ITEM_VOICE_ADD_TO_SEQUENCER) + + +def add_the_open_document(screen: Screen, path: Path, entry: str) -> None: + """Opens ``path`` with a double click on the Reconstructions tab, and chooses the menu bar's ``entry`` + once it answers. + """ + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + browser = screen.reconstructions.browser + browser.double_click(listed_row(screen, browser, path)) + expect_open(screen, path) + screen.expect( + partial(screen.menu.is_tagged_enabled, entry), bool, description=f"Add to Sequencer answering for {path.name}" + ) + + screen.menu.choose_tagged(entry) + + +def replace_entry(screen: Screen, label: str) -> str: + """The words of the entry replacing the sample listed as ``label``, as the language file has them.""" + return screen.words(REPLACE_SAMPLE).format(**{SAMPLE_KEY: label}) + + +def replace_from_the_sequencer_browser( + screen: Screen, + path: Path, + *, + voice: str, + label: str, +) -> None: + """Picks ``voice``, and replaces it with ``path`` through the Replace entry naming ``label`` on the row's + menu on the Sequencer tab. + """ + voices = screen.sequencer.voices + voices.pick(voice_row(screen, voice)) + choose_on_the_row(screen, screen.sequencer.browser, path, replace_entry(screen, label)) + + +def expect_refused(screen: Screen) -> None: + """Waits for the error notice, checks it says the last action stopped on the unsound record's failure, + takes that failure as provoked and dismisses the notice. + """ + notice = screen.error_notice + screen.expect(notice.is_shown, bool, description="the error notice") + words = notice.words() + screen.claim_error(UNSOUND_FAILURE) + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the error notice dismissed") + assert screen.words(UNEXPECTED_FAILURE) in words + assert UNSOUND_FAILURE in words + + +def project_part(screen: Screen) -> str: + """The piece of the window title naming the project, marked while it holds unsaved changes.""" + return screen.title().split(TITLE_SEPARATOR)[PROJECT_TITLE_PART] + + +def add_anyway(screen: Screen, path: Path) -> str: + """Double-clicks ``path`` on the Sequencer tab and answers Add anyway to the question about its rate. + + Returns: + str: Everything the question said. + """ + prompt = screen.sequencer.frequency_prompt + add_by_double_click(screen, path) + screen.expect(prompt.is_shown, bool, description="the question about the rate") + words = prompt.words() + + prompt.confirm() + + screen.expect(prompt.is_shown, operator.not_, description="the question answered") + return words + + +def leave_unchanged(screen: Screen, project: str) -> None: + """Exits while the project ``project`` holds nothing unsaved; the window closes at once.""" + assert project_part(screen) == project + + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() diff --git a/tests/screens/sequencer/samples/test_adding.py b/tests/screens/sequencer/samples/test_adding.py new file mode 100644 index 000000000..86df433bd --- /dev/null +++ b/tests/screens/sequencer/samples/test_adding.py @@ -0,0 +1,207 @@ +from functools import partial +from typing import Final, List + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.reconstructions import UNTITLED, marked +from automation.steps.sequencer import leave_letting_the_project_go +from sampletones_shared.constants.nes import DEFAULT_NES_FREQUENCY +from tests.screens.sequencer.samples.cases import DOORS, Door +from tests.screens.sequencer.samples.constants import ( + ARRANGED_VOICES, + ATTEMPTS, + FREQUENCY_MISMATCH, + OTHER_RATE, + SOUND_AT_OTHER_RATE, + UNSOUND_AT_OTHER_RATE, + UNSOUND_RECONSTRUCTION, +) +from tests.screens.sequencer.samples.steps import ( + add_anyway, + add_by_double_click, + add_from_the_sequencer_browser, + expect_refused, + project_part, +) +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + + +class TestAnUnsoundReconstructionAtEveryDoor: + """A reconstruction that fails as it joins the project is refused with the error notice at every door + that adds one, and the project stays as it was. + + Each door adds the unsound reconstruction, and the notice names the failure. The same door then adds a + sound one, and the voices, the history and the title show that it alone joined. + """ + + @pytest.mark.parametrize("door", DOORS, ids=lambda door: door.name) + def test_it_is_refused_and_a_sound_one_joins(self, screen: Screen, door: Door) -> None: + """The notice names the failure, and the sound reconstruction joins as the one new voice and entry.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + + def the_unsound_one_is_refused(screen: Screen) -> None: + before = history.lines() + + door.add(screen, UNSOUND_RECONSTRUCTION) + + expect_refused(screen) + assert voices.names() == ARRANGED_VOICES + assert history.lines() == before + assert project_part(screen) == ARRANGED_PROJECT.stem + + def the_same_door_adds_a_sound_one(screen: Screen) -> None: + before = history.lines() + + door.add(screen, SHORT_RECONSTRUCTION) + + screen.expect( + voices.names, + [*ARRANGED_VOICES, SHORT_RECONSTRUCTION.stem].__eq__, + description="the sound reconstruction joined", + ) + lines = screen.expect(history.lines, lambda lines: len(lines) == len(before) + 1, description="one entry") + assert SHORT_RECONSTRUCTION.stem in lines[0].words + assert project_part(screen) == marked(ARRANGED_PROJECT.stem, unsaved=True) + + screen.scenario( + the_unsound_one_is_refused, + the_same_door_adds_a_sound_one, + leave_letting_the_project_go, + ).run() + + +class TestAnUnsoundReconstructionAddedAnyway: + """A reconstruction made at another rate asks about the rate first, and one that fails as it joins is + refused with the error notice once the reader adds it anyway. + + The project keeps its voices, its history and its rate. The witness adds anyway a sound reconstruction + made at the same other rate, which joins at the project's rate. + """ + + def test_it_is_refused_after_the_question(self, screen: Screen) -> None: + """The question names both rates, the notice names the failure, and the sound one then joins.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + module = screen.sequencer.module + question = screen.words(FREQUENCY_MISMATCH).format(reconstruction=OTHER_RATE, project=DEFAULT_NES_FREQUENCY) + + def the_unsound_one_is_refused(screen: Screen) -> None: + before = history.lines() + + asked = add_anyway(screen, UNSOUND_AT_OTHER_RATE) + + expect_refused(screen) + assert question in asked + assert voices.names() == ARRANGED_VOICES + assert history.lines() == before + assert module.nes_frequency() == DEFAULT_NES_FREQUENCY + assert project_part(screen) == ARRANGED_PROJECT.stem + + def a_sound_one_joins_the_same_way(screen: Screen) -> None: + before = history.lines() + + add_anyway(screen, SOUND_AT_OTHER_RATE) + + screen.expect( + voices.names, + [*ARRANGED_VOICES, SOUND_AT_OTHER_RATE.stem].__eq__, + description="the sound reconstruction joined", + ) + screen.expect(history.lines, lambda lines: len(lines) == len(before) + 1, description="one entry") + assert module.nes_frequency() == DEFAULT_NES_FREQUENCY + + screen.scenario( + the_unsound_one_is_refused, + a_sound_one_joins_the_same_way, + leave_letting_the_project_go, + ).run() + + +class TestAnUnsoundReconstructionAddedTwice: + """Each attempt to add a reconstruction that fails as it joins shows the error notice once. + + The unsound reconstruction is added twice through the Sequencer browser's menu, and each attempt shows + one notice. The witness adds a sound reconstruction the same way. + """ + + def test_each_attempt_shows_one_notice(self, screen: Screen) -> None: + """One notice stands after each attempt, and the sound one then joins as the one new voice.""" + voices = screen.sequencer.voices + notice = screen.error_notice + + def each_attempt_is_refused_once(screen: Screen) -> None: + for _ in range(ATTEMPTS): + add_from_the_sequencer_browser(screen, UNSOUND_RECONSTRUCTION) + screen.expect(notice.is_shown, bool, description="the error notice") + assert len(notice.prompt.shown_windows()) == 1 + + expect_refused(screen) + + def a_sound_one_joins_the_same_way(screen: Screen) -> None: + add_from_the_sequencer_browser(screen, SHORT_RECONSTRUCTION) + + screen.expect( + voices.names, + [*ARRANGED_VOICES, SHORT_RECONSTRUCTION.stem].__eq__, + description="the sound reconstruction joined", + ) + + screen.scenario( + each_attempt_is_refused_once, + a_sound_one_joins_the_same_way, + leave_letting_the_project_go, + ).run() + + +class TestAnEmptyProjectRefusingItsFirstSample: + """An empty project takes the rate of the first reconstruction to join it, and a reconstruction that + fails as it joins leaves the rate, the history and the title as they were. + + File > New project starts the empty project. The unsound reconstruction made at another rate is + refused, and the witness adds a sound one made at that rate, which sets the project's rate. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens with nothing loaded, so File > New project starts at once.""" + return Startup(reconstruction=None, project=None) + + def test_a_refused_one_leaves_the_rate(self, screen: Screen) -> None: + """The rate stays the new project's, and the sound reconstruction then joins and sets its own.""" + voices = screen.sequencer.voices + history = screen.sequencer.history + module = screen.sequencer.module + + def start_an_empty_project(screen: Screen) -> None: + screen.project.create() + + screen.expect(voices.names, [].__eq__, description="an empty project") + screen.expect(partial(project_part, screen), screen.words(UNTITLED).__eq__, description="the new project") + + def the_unsound_one_is_refused(screen: Screen) -> None: + rate = module.nes_frequency() + before = history.lines() + + add_by_double_click(screen, UNSOUND_AT_OTHER_RATE) + + expect_refused(screen) + assert module.nes_frequency() == rate + assert history.lines() == before + assert project_part(screen) == screen.words(UNTITLED) + + def a_sound_one_sets_the_rate(screen: Screen) -> None: + add_by_double_click(screen, SOUND_AT_OTHER_RATE) + + screen.expect(voices.names, [SOUND_AT_OTHER_RATE.stem].__eq__, description="the sound reconstruction") + assert module.nes_frequency() == OTHER_RATE + + screen.scenario( + start_an_empty_project, + the_unsound_one_is_refused, + a_sound_one_sets_the_rate, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/samples/test_replacing.py b/tests/screens/sequencer/samples/test_replacing.py new file mode 100644 index 000000000..5e0257640 --- /dev/null +++ b/tests/screens/sequencer/samples/test_replacing.py @@ -0,0 +1,106 @@ +from typing import Final, List + +from automation.screen import Screen +from automation.steps.reconstructions import marked +from automation.steps.sequencer import leave_letting_the_project_go, voice_row +from tests.screens.sequencer.samples.constants import ( + ARRANGED_VOICES, + LINE_POSITION, + PLACEHOLDER_START, + REPLACE_SAMPLE, + UNSOUND_RECONSTRUCTION, +) +from tests.screens.sequencer.samples.steps import ( + expect_refused, + leave_unchanged, + listed_row, + project_part, + replace_entry, + replace_from_the_sequencer_browser, +) +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD + +LINE_LABEL: Final[str] = f"{LINE_POSITION}: {LINE}" + + +class TestReplacingWithAnUnsoundReconstruction: + """Replacing a sample with a reconstruction that fails as it joins is refused with the error notice, and + the sample keeps its name and its sound. + + The Line sample is replaced with the unsound reconstruction from the Sequencer's browser. The witness + replaces it with a sound one, which takes its place and its name. + """ + + def test_it_is_refused_and_a_sound_one_takes_its_place(self, screen: Screen) -> None: + """The notice names the failure, and the sound reconstruction then takes the sample's place as one + entry. + """ + voices = screen.sequencer.voices + history = screen.sequencer.history + + def the_unsound_one_is_refused(screen: Screen) -> None: + before = history.lines() + + replace_from_the_sequencer_browser(screen, UNSOUND_RECONSTRUCTION, voice=LINE, label=LINE_LABEL) + + expect_refused(screen) + assert voices.names() == ARRANGED_VOICES + assert history.lines() == before + assert project_part(screen) == ARRANGED_PROJECT.stem + + def a_sound_one_takes_its_place(screen: Screen) -> None: + before = history.lines() + + replace_from_the_sequencer_browser(screen, SHORT_RECONSTRUCTION, voice=LINE, label=LINE_LABEL) + + screen.expect( + voices.names, + [SHORT_RECONSTRUCTION.stem, BASS_VOICE, PAD].__eq__, + description="the sound reconstruction in the sample's place", + ) + screen.expect(history.lines, lambda lines: len(lines) == len(before) + 1, description="one entry") + assert project_part(screen) == marked(ARRANGED_PROJECT.stem, unsaved=True) + + screen.scenario( + the_unsound_one_is_refused, + a_sound_one_takes_its_place, + leave_letting_the_project_go, + ).run() + + +class TestReplaceIsOfferedForSamples: + """A browser row's menu offers to replace the picked voice only while that voice is a sample. + + With the Pad instrument picked, the menu of a reconstruction's row offers no Replace entry. The witness + picks the Line sample, and the same menu offers to replace it. + """ + + def test_an_instrument_picked_offers_no_replace(self, screen: Screen) -> None: + """No entry of the menu reads as a replacement while Pad is picked, and the one naming Line stands once + Line is.""" + voices = screen.sequencer.voices + browser = screen.sequencer.browser + menu = screen.context_menu + replacing = screen.words(REPLACE_SAMPLE).partition(PLACEHOLDER_START)[0] + + def offered_with(name: str) -> List[str]: + voices.pick(voice_row(screen, name)) + browser.right_click(listed_row(screen, browser, SHORT_RECONSTRUCTION)) + screen.expect(menu.is_shown, bool, description=f"the row's menu with {name} picked") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, lambda shown: not shown, description="the menu put away") + return labels + + def only_a_sample_is_offered(screen: Screen) -> None: + with_the_instrument = offered_with(PAD) + with_the_sample = offered_with(LINE) + + assert not [label for label in with_the_instrument if label.startswith(replacing)] + assert replace_entry(screen, LINE_LABEL) in with_the_sample + + screen.scenario( + only_a_sample_is_offered, + lambda screen: leave_unchanged(screen, ARRANGED_PROJECT.stem), + ).run() diff --git a/tests/screens/sequencer/song/__init__.py b/tests/screens/sequencer/song/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/song/constants.py b/tests/screens/sequencer/song/constants.py new file mode 100644 index 000000000..0ef134494 --- /dev/null +++ b/tests/screens/sequencer/song/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +SETTLING_FRAMES: Final[int] = 20 +EMPTY_VOICE: Final[str] = ".." +LINE_NUMBER: Final[str] = "00" diff --git a/tests/screens/sequencer/song/test_channel_keys.py b/tests/screens/sequencer/song/test_channel_keys.py new file mode 100644 index 000000000..080928aef --- /dev/null +++ b/tests/screens/sequencer/song/test_channel_keys.py @@ -0,0 +1,67 @@ +from functools import partial +from typing import Final, List + +from automation.screen import Screen +from automation.steps.sequencer import ( + channels_sounding, + on_the_sequencer, + sounding_but, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.song.constants import SETTLING_FRAMES + +FIRST_POSITION: Final[int] = 0 +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 + + +class TestTheChannelKeysBesideTheOrder: + """With the cursor on an order entry, Pulse 2's chord mutes Pulse 2 and types nothing, while its key types + a digit. + + The chord mutes Pulse 2 and leaves the entry as it read, and the chord again brings it back. The key then + types its digit into the same entry, which is what a chord that typed would have shown. + """ + + def test_the_chord_mutes_pulse_two_and_the_key_types(self, screen: Screen) -> None: + order = screen.sequencer.order + entry: List[str] = [] + + def entry_label() -> str: + return order.label(ChannelName.PULSE2, FIRST_POSITION) + + def the_chord_mutes_pulse_two_and_types_nothing(screen: Screen) -> None: + on_the_sequencer(screen) + entry.append(entry_label()) + order.click(ChannelName.PULSE2, FIRST_POSITION) + + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect( + partial(channels_sounding, screen), + sounding_but(ChannelName.PULSE2).__eq__, + description="Pulse 2 muted", + ) + screen.frames(SETTLING_FRAMES) + assert entry_label() == entry[0] + + def the_chord_brings_it_back(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect( + partial(channels_sounding, screen), + sounding_but().__eq__, + description="every channel sounding again", + ) + + def the_key_types_into_the_entry(screen: Screen) -> None: + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.expect(entry_label, entry[0].__ne__, description="a digit typed into the entry") + assert channels_sounding(screen) == sounding_but() + + screen.scenario( + the_chord_mutes_pulse_two_and_types_nothing, + the_chord_brings_it_back, + the_key_types_into_the_entry, + ).run() diff --git a/tests/screens/sequencer/song/test_master_gain.py b/tests/screens/sequencer/song/test_master_gain.py new file mode 100644 index 000000000..60f6519a4 --- /dev/null +++ b/tests/screens/sequencer/song/test_master_gain.py @@ -0,0 +1,69 @@ +import math +import operator +from typing import Final, List, Tuple + +from automation.screen import Screen +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + +UNITY: Final[float] = 1.0 +LOUDEST_GAIN: Final[float] = 2.0 +BEYOND_THE_LEFT: Final[float] = -0.5 +BEYOND_THE_RIGHT: Final[float] = 1.5 +NEAR_THE_RIGHT: Final[float] = 0.97 +FROM_THE_MIDDLE: Final[float] = 0.5 +DECIBELS_PER_DOUBLING: Final[float] = 20.0 +SILENT_GAIN: Final[str] = "settings.audio.message.master_gain_silent" +GAIN_TEMPLATE: Final[str] = "settings.audio.template.master_gain_db" + + +class TestTheMasterGain: + """The decibel line of Audio settings follows the master gain slider. + + The dialog opens at unity. Dragging the slider past the left end reads silence in the unity color; + dragging past the right end reads the top level in the warning color; dragging back to the middle + reads that level in decibels, and Cancel closes the dialog. + """ + + def test_the_line_follows_the_slider(self, screen: Screen) -> None: + """The decibel line shows silence, the top level and a middle level as the slider moves.""" + settings = screen.audio_settings + unity_color: List[Tuple[float, ...]] = [] + + def open_audio_settings(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) + screen.expect(settings.is_shown, bool, description="Audio settings") + + assert settings.gain() == UNITY + unity_color.append(settings.decibels_color()) + + def all_the_way_down_reads_silence(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_LEFT) + + screen.expect(settings.gain, (0.0).__eq__, description="the gain at zero") + assert settings.decibels() == screen.words(SILENT_GAIN) + assert settings.decibels_color() == unity_color[0] + + def all_the_way_up_reads_the_level_in_warning(screen: Screen) -> None: + settings.drag_gain(FROM_THE_MIDDLE, BEYOND_THE_RIGHT) + + screen.expect(settings.gain, LOUDEST_GAIN.__eq__, description="the gain at its top") + level = DECIBELS_PER_DOUBLING * math.log10(LOUDEST_GAIN) + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format(decibels=level) + assert settings.decibels_color() != unity_color[0] + + def back_to_unity(screen: Screen) -> None: + settings.drag_gain(NEAR_THE_RIGHT, FROM_THE_MIDDLE) + + gain = screen.expect(settings.gain, lambda found: 0.0 < found < LOUDEST_GAIN, description="a middle gain") + assert settings.decibels() == screen.words(GAIN_TEMPLATE).format( + decibels=DECIBELS_PER_DOUBLING * math.log10(gain) + ) + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + + screen.scenario( + open_audio_settings, + all_the_way_down_reads_silence, + all_the_way_up_reads_the_level_in_warning, + back_to_unity, + ).run() diff --git a/tests/screens/sequencer/song/test_order_and_highlights.py b/tests/screens/sequencer/song/test_order_and_highlights.py new file mode 100644 index 000000000..54e8d65d3 --- /dev/null +++ b/tests/screens/sequencer/song/test_order_and_highlights.py @@ -0,0 +1,89 @@ +import operator +from functools import partial +from typing import Final + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.song.constants import EMPTY_VOICE, LINE_NUMBER + +FIRST_PATTERN: Final[str] = "00" +SECOND_PATTERN: Final[str] = "01" +FIRST_HIGHLIGHT: Final[int] = 3 +SECOND_HIGHLIGHT: Final[int] = 6 +TINTED_ROWS_READ: Final[int] = 13 + + +class TestTheOrderTable: + """Typing an order entry names the pattern a channel plays at that frame, and the tracker follows it. + + Pattern 01 typed on the first frame of the first pulse channel empties its first line; typing 00 again + brings the line back. + """ + + def test_patterns_follow_the_order(self, screen: Screen) -> None: + """The tracker shows the pattern the order entry names.""" + order = screen.sequencer.order + tracker = screen.sequencer.tracker + + def a_new_pattern_number_shows_a_new_pattern(screen: Screen) -> None: + on_the_sequencer(screen) + assert order.label(ChannelName.PULSE1, 0) == FIRST_PATTERN + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(SECOND_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), SECOND_PATTERN.__eq__, description="pattern 01") + assert tracker.label(0, ChannelName.PULSE1, SubColumn.VOICE) == EMPTY_VOICE + + def the_first_number_brings_the_line_back(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 0) + + screen.hand.type_text(FIRST_PATTERN) + + screen.expect(partial(order.label, ChannelName.PULSE1, 0), FIRST_PATTERN.__eq__, description="pattern 00") + screen.expect( + partial(tracker.label, 0, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the line back", + ) + + screen.scenario( + a_new_pattern_number_shows_a_new_pattern, + the_first_number_brings_the_line_back, + leave_letting_the_project_go, + ).run() + + +class TestTheHighlights: + """The tracker tints the rows the project's highlights name. + + Highlights of three and six are typed in Project properties; every third row ends up tinted. + """ + + def test_rows_on_the_highlight_are_tinted(self, screen: Screen) -> None: + """Exactly the rows on the first highlight are tinted.""" + properties = screen.project.properties + tracker = screen.sequencer.tracker + + def set_highlights_of_three_and_six(screen: Screen) -> None: + on_the_sequencer(screen) + properties.open() + screen.expect(properties.is_shown, bool, description="Project properties") + + properties.retype_highlights(FIRST_HIGHLIGHT, SECOND_HIGHLIGHT) + properties.confirm() + + screen.expect(properties.is_shown, operator.not_, description="Project properties closed") + + def every_third_row_is_tinted(screen: Screen) -> None: + expected = [row % FIRST_HIGHLIGHT == 0 for row in range(TINTED_ROWS_READ)] + + screen.expect( + lambda: [tracker.is_row_tinted(row) for row in range(TINTED_ROWS_READ)], + expected.__eq__, + description="every third row tinted", + ) + + screen.scenario(set_highlights_of_three_and_six, every_third_row_is_tinted, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/song/test_retuning_and_saving.py b/tests/screens/sequencer/song/test_retuning_and_saving.py new file mode 100644 index 000000000..e02323845 --- /dev/null +++ b/tests/screens/sequencer/song/test_retuning_and_saving.py @@ -0,0 +1,100 @@ +import operator +from typing import Final, List, Tuple + +from automation.screen import Screen +from automation.steps.reconstructions import marked, raise_the_first_level, titled, voice_title +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer, open_voice +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from tests.screens.sequencer.song.constants import LINE_NUMBER, SETTLING_FRAMES +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, LINE + +LINE_ORDINAL: Final[int] = 0 +FIFTY_HERTZ: Final[int] = 50 +SIXTY_HERTZ: Final[int] = 60 +REFIT_TOLERANCE: Final[float] = 0.02 + + +class TestRetuningWithASampleOpen: + """A new NES frequency re-fits the sample open on the Reconstructions tab and keeps its edit. + + The edit is made on the noise, whose last frame is silent, so the sample keeps its frames and the + waveform stretches by the ratio of the two rates. The scenario opens the sample, raises a level, + retypes the frequency from 60 to 50 Hz, confirms the retune and expects the stretched waveform and + the same envelope. + """ + + def test_the_waveform_refits_and_the_edit_stays(self, screen: Screen) -> None: + """The waveform stretches by the ratio of the rates and the edited envelope stays.""" + module = screen.sequencer.module + reconstructions = screen.reconstructions + typed: List[str] = [] + limits: List[Tuple[float, float]] = [] + + def edit_the_line(screen: Screen) -> None: + open_voice(screen, LINE) + screen.expect( + screen.title, + voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=False).__eq__, + description="the line open", + ) + + typed.append( + raise_the_first_level( + screen, + ChannelName.NOISE, + title=voice_title(screen, ARRANGED_PROJECT.stem, LINE_ORDINAL, LINE, unsaved=True), + ) + ) + limits.append(reconstructions.waveform.limits()) + + def retune_to_fifty(screen: Screen) -> None: + on_the_sequencer(screen) + assert module.nes_frequency() == SIXTY_HERTZ + + module.retype_nes_frequency(FIFTY_HERTZ) + + screen.expect(module.retune_prompt.is_shown, bool, description="the question about retuning") + module.retune_prompt.confirm() + screen.expect(module.retune_prompt.is_shown, operator.not_, description="the question answered") + + def the_waveform_refits_and_the_edit_stays(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + low, high = limits[0] + + refitted = screen.expect( + reconstructions.waveform.limits, + lambda found: abs(found[1] / high - SIXTY_HERTZ / FIFTY_HERTZ) <= REFIT_TOLERANCE, + description="the waveform re-fitted to the longer frames", + ) + assert refitted[0] == low + assert reconstructions.instruments.envelope(ChannelName.NOISE, FeatureKey.VOLUME) == typed[0] + + screen.scenario( + edit_the_line, retune_to_fifty, the_waveform_refits_and_the_edit_stays, leave_letting_the_project_go + ).run() + + +class TestSavingAProjectOpenedAtStart: + """Save writes a project opened at start to its own file and goes through at once. + + A cell is edited so the project is marked unsaved, then Save is pressed. The scenario expects the + saved notice and no file dialog. + """ + + def test_save_writes_its_file(self, screen: Screen) -> None: + """Save shows the saved notice and opens no dialog.""" + on_the_sequencer(screen) + screen.sequencer.tracker.click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(LINE_NUMBER) + screen.expect( + screen.title, titled(screen, marked(ARRANGED_PROJECT.stem, unsaved=True)).__eq__, description="changed" + ) + + screen.press_shortcut(ShortcutId.SAVE_PROJECT) + + screen.frames(SETTLING_FRAMES) + assert screen.dialog_requests() == () + screen.expect(screen.project.saved_notice.is_shown, bool, description="the project saved notice") diff --git a/tests/screens/sequencer/song/test_volume_without_a_sample.py b/tests/screens/sequencer/song/test_volume_without_a_sample.py new file mode 100644 index 000000000..e0be2743f --- /dev/null +++ b/tests/screens/sequencer/song/test_volume_without_a_sample.py @@ -0,0 +1,51 @@ +from typing import Final, List, Optional, Tuple + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK +from tests.screens.sequencer.song.constants import EMPTY_VOICE, SETTLING_FRAMES + +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None + + +class TestAVolumeWhereNoSamplePlays: + """A volume typed in the Sample column of an empty frame keeps the cell blank, adds no history entry and + creates no pattern. + + An empty second frame is added, a volume is typed on its first row, and the scenario expects the cell, + the history and the order entries of that frame to stay as they were. + """ + + def test_the_frame_stays_empty(self, screen: Screen) -> None: + """The frame stays empty after the volume is typed.""" + order = screen.sequencer.order + tracker = screen.sequencer.tracker + history = screen.sequencer.history + before: List[int] = [] + + def add_an_empty_frame(screen: Screen) -> None: + on_the_sequencer(screen) + order.click(ChannelName.PULSE1, 0) + + screen.press_shortcut(ShortcutId.ORDER_ADD_FRAME) + + screen.expect(order.positions, (2).__eq__, description="a second frame") + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + def type_a_volume_on_it(screen: Screen) -> None: + order.click(ChannelName.PULSE1, 1) + before.append(len(history.lines())) + tracker.click(0, SAMPLE_COLUMN, SubColumn.VOLUME) + + screen.hand.type_text("5") + + screen.frames(SETTLING_FRAMES) + assert tracker.label(0, SAMPLE_COLUMN, SubColumn.VOLUME) == BLANK + assert len(history.lines()) == before[0] + assert [order.label(channel, 1) for channel in EVERY_CHANNEL] == [EMPTY_VOICE] * len(EVERY_CHANNEL) + + screen.scenario(add_an_empty_frame, type_a_volume_on_it, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/tracker/__init__.py b/tests/screens/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/tracker/band/__init__.py b/tests/screens/sequencer/tracker/band/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/tracker/band/conftest.py b/tests/screens/sequencer/tracker/band/conftest.py new file mode 100644 index 000000000..db0533a2c --- /dev/null +++ b/tests/screens/sequencer/tracker/band/conftest.py @@ -0,0 +1,31 @@ +import pytest + +from automation.application.startup import Startup +from automation.worlds.home import World +from tests.suite.screens.worlds.songs import ( + LOOPING_ORDER_FRAMES, + LOOPING_PROJECT, + arranged_project, + sequencer_world, +) + + +@pytest.fixture +def world() -> World: + """The Sequencer's home, holding besides the arranged project with its pattern played twice in the order.""" + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=None, + config=None, + files=( + *sequencer.files, + arranged_project(LOOPING_PROJECT, LOOPING_ORDER_FRAMES), + ), + ) + + +@pytest.fixture +def startup() -> Startup: + """The application opens on the two-frame song with no reconstruction loaded.""" + return Startup(reconstruction=None, project=LOOPING_PROJECT) diff --git a/tests/screens/sequencer/tracker/band/constants.py b/tests/screens/sequencer/tracker/band/constants.py new file mode 100644 index 000000000..d3fa25376 --- /dev/null +++ b/tests/screens/sequencer/tracker/band/constants.py @@ -0,0 +1,21 @@ +from typing import Final, Tuple + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import display_id +from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN + +FIRST_FRAME: Final[int] = 0 +SECOND_FRAME: Final[int] = 1 +LAST_ROW: Final[int] = DEFAULT_ROWS_PER_PATTERN - 1 +CLICKED_ROW: Final[int] = 2 +STEPS_DOWN: Final[int] = 3 +NEIGHBORS: Final[int] = 3 +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SLOT: Final[SubColumn] = SubColumn.VOICE +PLAYING_FRAMES: Final[int] = 90 +BLANK: Final[str] = "" +LAST_ROWS_OF_A_FRAME: Final[Tuple[str, ...]] = tuple( + display_id(row) for row in range(DEFAULT_ROWS_PER_PATTERN - NEIGHBORS, DEFAULT_ROWS_PER_PATTERN) +) +FIRST_ROWS_OF_A_FRAME: Final[Tuple[str, ...]] = tuple(display_id(row) for row in range(NEIGHBORS)) diff --git a/tests/screens/sequencer/tracker/band/steps.py b/tests/screens/sequencer/tracker/band/steps.py new file mode 100644 index 000000000..2ccccc9bb --- /dev/null +++ b/tests/screens/sequencer/tracker/band/steps.py @@ -0,0 +1,48 @@ +from automation.screen import Screen +from automation.vocabulary.playback import PAUSE, RESUME +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.tracker.band.constants import CHANNEL, SLOT + + +def expect_centered(screen: Screen, row: int) -> None: + """Waits until ``row`` of the shown frame stands at the middle of the tracker's band.""" + screen.expect( + screen.sequencer.tracker.centered_row, + row.__eq__, + description=f"row {row} at the band's center", + ) + + +def show_frame(screen: Screen, frame: int) -> None: + """Shows ``frame`` in the tracker from the Order grid, then hands the keys back to the tracker's first row.""" + screen.sequencer.order.click(CHANNEL, frame) + screen.sequencer.tracker.click(0, CHANNEL, SLOT) + screen.expect( + lambda: screen.sequencer.tracker.has_caret(0, CHANNEL), + bool, + description="the caret on the frame's first row", + ) + + +def press(screen: Screen, shortcut_id: ShortcutId, times: int) -> None: + """Presses the keys the scheme gives ``shortcut_id`` ``times`` times over.""" + for _ in range(times): + screen.press_shortcut(shortcut_id) + + +def expect_playing(screen: Screen) -> None: + """Waits until the Playback menu offers to pause, which it does while the song plays.""" + playback = screen.sequencer.playback + screen.expect(playback.play_entry, screen.words(PAUSE).__eq__, description="the song playing") + + +def expect_paused(screen: Screen) -> None: + """Waits until the Playback menu offers to resume, which it does while the song stands paused.""" + playback = screen.sequencer.playback + screen.expect(playback.play_entry, screen.words(RESUME).__eq__, description="the song paused") + + +def expect_stopped(screen: Screen) -> None: + """Waits until Stop no longer answers, which a stopped song reads as.""" + playback = screen.sequencer.playback + screen.expect(lambda: not playback.can_stop(), bool, description="the song stopped") diff --git a/tests/screens/sequencer/tracker/band/test_band.py b/tests/screens/sequencer/tracker/band/test_band.py new file mode 100644 index 000000000..ccd87ec4d --- /dev/null +++ b/tests/screens/sequencer/tracker/band/test_band.py @@ -0,0 +1,255 @@ +from dataclasses import dataclass +from typing import Final, FrozenSet, List, Optional, Tuple + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.screen import Screen +from automation.steps.sequencer import on_the_sequencer +from automation.views.tracker import tracker_cell +from sampletones_application.tags.sequencer import TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.tracker.band.constants import ( + BLANK, + CHANNEL, + CLICKED_ROW, + FIRST_ROWS_OF_A_FRAME, + LAST_ROW, + LAST_ROWS_OF_A_FRAME, + NEIGHBORS, + PLAYING_FRAMES, + SECOND_FRAME, + SLOT, + STEPS_DOWN, +) +from tests.screens.sequencer.tracker.band.steps import ( + expect_centered, + expect_paused, + expect_playing, + expect_stopped, + press, + show_frame, +) + +SETTLING_FRAMES: Final[int] = 8 +ALPHA: Final[int] = 3 + + +def cell_box(screen: Screen, row: int) -> Optional[Rect]: + """Where the clicked channel's slot on ``row`` stands on the screen.""" + return screen.bridge.ask(lambda: read_item(tracker_cell(row, CHANNEL, SLOT)).rect) + + +def alpha(color: Optional[Tuple[float, ...]]) -> float: + """How opaque a color is drawn.""" + assert color is not None + return color[ALPHA] + + +@dataclass(frozen=True) +class PausedReading: + """What a paused song left on the band: the row at its middle, and every row drawn with a tint.""" + + center: int + tinted: FrozenSet[int] + + +def center_on_the_first_row(screen: Screen) -> None: + """Steps the caret off the first row and back, which a key landing on a row brings to the center.""" + press(screen, ShortcutId.TRACKER_NEXT_ROW, 1) + press(screen, ShortcutId.TRACKER_PREVIOUS_ROW, 1) + expect_centered(screen, 0) + + +class TestTheCursorStandsAtTheCenter: + """A key carrying the caret to another row brings that row to the middle of the band. + + A click on a row puts the caret there and leaves the grid where it stands. Keys then carry the caret + down: the row each lands on stands at the middle, the last row of the frame among them. + """ + + def test_keys_carry_the_rows_under_the_middle(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + + def a_click_leaves_the_grid_where_it_stands(screen: Screen) -> None: + on_the_sequencer(screen) + before = cell_box(screen, CLICKED_ROW) + + tracker.click(CLICKED_ROW, CHANNEL, SLOT) + screen.expect(lambda: tracker.has_caret(CLICKED_ROW, CHANNEL), bool, description="the caret placed") + screen.frames(SETTLING_FRAMES) + + assert cell_box(screen, CLICKED_ROW) == before + + def a_step_brings_its_row_to_the_center(screen: Screen) -> None: + press(screen, ShortcutId.TRACKER_NEXT_ROW, STEPS_DOWN) + + expect_centered(screen, CLICKED_ROW + STEPS_DOWN) + assert tracker.has_caret(CLICKED_ROW + STEPS_DOWN, CHANNEL) + + def a_page_brings_its_row_to_the_center(screen: Screen) -> None: + press(screen, ShortcutId.TRACKER_PAGE_DOWN, 1) + + row = screen.expect( + lambda: tracker.caret_row(CHANNEL), + lambda landed: landed is not None and landed > CLICKED_ROW + STEPS_DOWN, + description="the caret a page further down", + ) + assert row is not None + expect_centered(screen, row) + + def the_last_row_reaches_the_center(screen: Screen) -> None: + press(screen, ShortcutId.TRACKER_LAST_ROW, 1) + + expect_centered(screen, LAST_ROW) + + screen.scenario( + a_click_leaves_the_grid_where_it_stands, + a_step_brings_its_row_to_the_center, + a_page_brings_its_row_to_the_center, + the_last_row_reaches_the_center, + ).run() + + +class TestTheRowsAroundTheFrame: + """The rows beside the shown frame are the song's own, dimmed, and blank where the song ends. + + Above the first frame's first row the rows stand blank, and below its last row stand the next frame's + first rows. Above the second frame's first row stand the first frame's last rows, dimmed beside the + frame's own, and below its last the song has ended. + """ + + def test_the_song_reads_on_past_the_frame(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + + def the_first_frame_stands_below_blank_rows(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(0, CHANNEL, SLOT) + center_on_the_first_row(screen) + + assert tracker.numbers_above(NEIGHBORS) == (BLANK,) * NEIGHBORS + + def the_next_frame_stands_below_the_last_row(screen: Screen) -> None: + press(screen, ShortcutId.TRACKER_LAST_ROW, 1) + expect_centered(screen, LAST_ROW) + + assert tracker.numbers_below(NEIGHBORS) == FIRST_ROWS_OF_A_FRAME + + def the_second_frame_stands_below_the_first_frames_last_rows(screen: Screen) -> None: + show_frame(screen, SECOND_FRAME) + center_on_the_first_row(screen) + + assert tracker.numbers_above(NEIGHBORS) == LAST_ROWS_OF_A_FRAME + assert alpha(tracker.number_color_above(1)) < alpha(tracker.number_color(0)) + assert tracker.numbers_below(NEIGHBORS) == (BLANK,) * NEIGHBORS + + screen.scenario( + the_first_frame_stands_below_blank_rows, + the_next_frame_stands_below_the_last_row, + the_second_frame_stands_below_the_first_frames_last_rows, + ).run() + + +class TestARowOfAnotherFrameTakesNoClick: + """A click on a row beside the frame leaves the caret where it stood; a click on the frame's own row moves it.""" + + def test_a_click_beside_the_frame_moves_nothing(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + + def a_click_above_the_frame_leaves_the_caret(screen: Screen) -> None: + on_the_sequencer(screen) + show_frame(screen, SECOND_FRAME) + center_on_the_first_row(screen) + + tracker.click_above(1, CHANNEL, SLOT) + screen.frames(SETTLING_FRAMES) + + assert tracker.has_caret(0, CHANNEL) + + def a_click_on_the_frames_row_moves_the_caret(screen: Screen) -> None: + tracker.click(1, CHANNEL, SLOT) + + screen.expect(lambda: tracker.has_caret(1, CHANNEL), bool, description="the caret moved") + + screen.scenario( + a_click_above_the_frame_leaves_the_caret, + a_click_on_the_frames_row_moves_the_caret, + ).run() + + +class TestFollowRows: + """While Follow rows plays the song, the sounding row stands at the middle of the band. + + The song plays from its start and pauses, which keeps the playhead's mark where it stood. Stopping takes + the mark away, so the one tint stopping removes is the row that sounded, and it stood at the middle. + """ + + def test_the_sounding_row_stands_at_the_center(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + readings: List[PausedReading] = [] + + def a_paused_song_holds_its_row_at_the_center(screen: Screen) -> None: + on_the_sequencer(screen) + screen.press_shortcut(ShortcutId.PLAY_FROM_START) + expect_playing(screen) + screen.frames(PLAYING_FRAMES) + + screen.press_shortcut(ShortcutId.PLAY) + expect_paused(screen) + screen.frames(SETTLING_FRAMES) + + center = tracker.centered_row() + tinted = tracker.tinted_rows_in_view() + assert center is not None + assert center in tinted + readings.append(PausedReading(center=center, tinted=tinted)) + + def stopping_takes_the_mark_from_the_center(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + expect_stopped(screen) + screen.frames(SETTLING_FRAMES) + + paused = readings[-1] + assert paused.tinted - tracker.tinted_rows_in_view() <= {paused.center} + + screen.scenario( + a_paused_song_holds_its_row_at_the_center, + stopping_takes_the_mark_from_the_center, + ).run() + + +class TestTheBandFollowsItsHeight: + """Folding the card above the tracker makes the band taller, so more of the song stands either side. + + The row the caret stands on stays at the middle, and unfolding the card brings the band back. + """ + + def test_folding_the_order_card_widens_the_reach(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + order = screen.main.card(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD) + reach: List[int] = [] + + def the_first_row_stands_at_the_center(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(0, CHANNEL, SLOT) + center_on_the_first_row(screen) + reach.append(tracker.rows_above_the_frame()) + + def a_folded_card_reaches_further(screen: Screen) -> None: + order.toggle() + screen.expect(order.is_collapsed, bool, description="the order card folded") + + screen.expect(tracker.rows_above_the_frame, lambda rows: rows > reach[0], description="a wider reach") + expect_centered(screen, 0) + + def an_unfolded_card_reaches_as_before(screen: Screen) -> None: + order.toggle() + screen.expect(lambda: not order.is_collapsed(), bool, description="the order card unfolded") + + screen.expect(tracker.rows_above_the_frame, reach[0].__eq__, description="the reach as before") + expect_centered(screen, 0) + + screen.scenario( + the_first_row_stands_at_the_center, + a_folded_card_reaches_further, + an_unfolded_card_reaches_as_before, + ).run() diff --git a/tests/screens/sequencer/tracker/constants.py b/tests/screens/sequencer/tracker/constants.py new file mode 100644 index 000000000..94c2aa89d --- /dev/null +++ b/tests/screens/sequencer/tracker/constants.py @@ -0,0 +1,11 @@ +from typing import Final, Optional + +from automation.dearpygui.keys import IMGUI_LETTER_A +from sampletones_core.constants.enums import ChannelName + +PIANO_C: Final[int] = IMGUI_LETTER_A + ord("z") - ord("a") +TYPING_FRAMES: Final[int] = 10 +LINE_NUMBER: Final[str] = "00" +PAD_NUMBER: Final[str] = "02" +SAMPLE_COLUMN: Final[Optional[ChannelName]] = None +PLAY_FROM_THIS_FRAME: Final[str] = "sequencer.tracker.label.context_play_from_frame" diff --git a/tests/screens/sequencer/tracker/steps.py b/tests/screens/sequencer/tracker/steps.py new file mode 100644 index 000000000..630654c8a --- /dev/null +++ b/tests/screens/sequencer/tracker/steps.py @@ -0,0 +1,22 @@ +from typing import Optional + +from automation.screen import Screen +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.constants import TYPING_FRAMES + + +def play_a_note(screen: Screen, row: int, channel: ChannelName, key: int) -> None: + """Clicks the transpose slot of a channel at a row, presses a piano key and lets the typing settle.""" + screen.sequencer.tracker.click(row, channel, SubColumn.TRANSPOSE) + screen.hand.press_key(key, modifiers=[]) + screen.frames(TYPING_FRAMES) + + +def type_into(screen: Screen, row: int, channel: Optional[ChannelName], subcolumn: SubColumn, text: str) -> None: + """Clicks a slot of a channel (or of the Sample column, when the channel is None), types the text + and lets the typing settle. + """ + screen.sequencer.tracker.click(row, channel, subcolumn) + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) diff --git a/tests/screens/sequencer/tracker/test_cell_status.py b/tests/screens/sequencer/tracker/test_cell_status.py new file mode 100644 index 000000000..8f4f9441a --- /dev/null +++ b/tests/screens/sequencer/tracker/test_cell_status.py @@ -0,0 +1,92 @@ +from typing import Final + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import display_voice_label +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.worlds.songs import LINE, PAD, PAD_ROW + +STATUS_VOICE: Final[str] = "sequencer.tracker.template.status_voice" +STATUS_SAMPLE: Final[str] = "sequencer.tracker.template.status_sample" +STATUS_NOTE: Final[str] = "sequencer.tracker.template.status_note" +STATUS_TRANSPOSE: Final[str] = "sequencer.tracker.template.status_transpose" +STATUS_VOLUME: Final[str] = "sequencer.tracker.template.status_volume" +STATUS_PITCH: Final[str] = "sequencer.tracker.label.status_pitch" +STATUS_DIFFERENT_PITCHES: Final[str] = "sequencer.tracker.label.status_different_pitches" +PAD_POSITION: Final[int] = 2 +LINE_POSITION: Final[int] = 0 +LINE_ROW: Final[int] = 0 +EMPTY_ROW: Final[int] = 2 +SAMPLE_ROW: Final[int] = 5 +STEP_TYPED: Final[str] = "03" +STEP_SAID: Final[str] = "+3" +VOLUME_TYPED: Final[str] = "D" +VOLUME_SAID: Final[int] = 13 +C_AT_OCTAVE_TWO: Final[str] = "C-2" + + +class TestHoveringASlot: + """The status line says what the slot under the pointer holds: a voice by its number and name, a pitch + by the face it was typed in, a volume by its level and an empty slot by its name. A Sample column slot + whose channels differ says so, and the line clears once the pointer leaves the grid. + """ + + def test_the_status_names_the_slot(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + + def says(expected: str) -> None: + screen.expect(screen.status, expected.__eq__, description=f"the status reading '{expected}'") + + def a_voice_and_a_step(screen: Screen) -> None: + on_the_sequencer(screen) + type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE, STEP_TYPED) + + tracker.hover(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + says(screen.words(STATUS_VOICE).format(voice=display_voice_label(PAD_POSITION, PAD))) + + tracker.hover(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + says(screen.words(STATUS_TRANSPOSE).format(step=STEP_SAID)) + + def a_note_and_a_volume(screen: Screen) -> None: + play_a_note(screen, LINE_ROW, ChannelName.PULSE1, PIANO_C) + type_into(screen, LINE_ROW, ChannelName.PULSE1, SubColumn.VOLUME, VOLUME_TYPED) + + tracker.hover(LINE_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + says(screen.words(STATUS_NOTE).format(note=C_AT_OCTAVE_TWO)) + + tracker.hover(LINE_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + says(screen.words(STATUS_VOLUME).format(volume=VOLUME_SAID)) + + def an_empty_slot_names_itself(screen: Screen) -> None: + tracker.hover(EMPTY_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + + says(screen.words(STATUS_PITCH)) + + def the_sample_column_says_when_its_channels_differ(screen: Screen) -> None: + type_into(screen, SAMPLE_ROW, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) + tracker.click(SAMPLE_ROW, SAMPLE_COLUMN, SubColumn.TRANSPOSE) + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + tracker.hover(SAMPLE_ROW, SAMPLE_COLUMN, SubColumn.VOICE) + says(screen.words(STATUS_SAMPLE).format(voice=display_voice_label(LINE_POSITION, LINE))) + + tracker.hover(SAMPLE_ROW, SAMPLE_COLUMN, SubColumn.TRANSPOSE) + says(screen.words(STATUS_DIFFERENT_PITCHES)) + + def leaving_the_grid_clears_the_line(screen: Screen) -> None: + tracker.leave() + + says("") + + screen.scenario( + a_voice_and_a_step, + a_note_and_a_volume, + an_empty_slot_names_itself, + the_sample_column_says_when_its_channels_differ, + leaving_the_grid_clears_the_line, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/tracker/test_channel_keys.py b/tests/screens/sequencer/tracker/test_channel_keys.py new file mode 100644 index 000000000..82ae0a94f --- /dev/null +++ b/tests/screens/sequencer/tracker/test_channel_keys.py @@ -0,0 +1,85 @@ +from functools import partial +from typing import Final, List + +from automation.screen import Screen +from automation.steps.sequencer import ( + channels_sounding, + leave_letting_the_project_go, + on_the_sequencer, + sounding_but, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.constants import TYPING_FRAMES +from tests.suite.screens.worlds.songs import PAD_ROW + +CARET_ROW: Final[int] = PAD_ROW + 1 +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 +STEP_OF_TWO_TWOS: Final[str] = "+22" + + +class TestTheChannelKeysBesideTheSteps: + """With the cursor in a pitch column, Pulse 2's key types the digit it names into a step, and its chord + mutes Pulse 2 all the same. + + The key pressed twice types a step on the Pad row, which moves the caret a row down, and the mix stays + whole. The chord then mutes Pulse 2 and types nothing on the caret's row, and the chord again brings it + back. The key pressed twice more types a step on that row, which is where a chord that typed would have + written. + """ + + def test_the_key_types_a_step_and_the_chord_mutes_pulse_two(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + caret_row: List[str] = [] + + def caret_row_label() -> str: + return tracker.label(CARET_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + + def the_key_types_a_step(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + + screen.press_shortcut(TOGGLE_PULSE_TWO) + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.frames(TYPING_FRAMES) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == STEP_OF_TWO_TWOS + assert tracker.has_caret(CARET_ROW, ChannelName.PULSE2) + assert channels_sounding(screen) == sounding_but() + caret_row.append(caret_row_label()) + + def the_chord_mutes_pulse_two_and_types_nothing(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect( + partial(channels_sounding, screen), + sounding_but(ChannelName.PULSE2).__eq__, + description="Pulse 2 muted", + ) + screen.frames(TYPING_FRAMES) + assert caret_row_label() == caret_row[0] + + def the_chord_brings_it_back(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect( + partial(channels_sounding, screen), + sounding_but().__eq__, + description="every channel sounding again", + ) + + def the_key_types_on_the_caret_row(screen: Screen) -> None: + screen.press_shortcut(TOGGLE_PULSE_TWO) + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.expect(caret_row_label, STEP_OF_TWO_TWOS.__eq__, description="a step typed on the caret's row") + assert channels_sounding(screen) == sounding_but() + + screen.scenario( + the_key_types_a_step, + the_chord_mutes_pulse_two_and_types_nothing, + the_chord_brings_it_back, + the_key_types_on_the_caret_row, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/tracker/test_marked_voice.py b/tests/screens/sequencer/tracker/test_marked_voice.py new file mode 100644 index 000000000..451743334 --- /dev/null +++ b/tests/screens/sequencer/tracker/test_marked_voice.py @@ -0,0 +1,130 @@ +from functools import partial +from typing import Dict, Final + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_BLANK, display_id +from tests.screens.sequencer.tracker.constants import ( + LINE_NUMBER, + PAD_NUMBER, + PIANO_C, + SAMPLE_COLUMN, + TYPING_FRAMES, +) +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.worlds.songs import LINE, PAD + +LINE_POSITION: Final[int] = 0 +PAD_POSITION: Final[int] = 2 +EMPTY_VOICE: Final[str] = display_id(None) +C_AT_OCTAVE_TWO: Final[str] = "C-2" +PERIOD_OF_C: Final[str] = "0-#" +STEP_TYPED: Final[str] = "03" +STEP_SHOWN: Final[str] = "+03" +TYPED_ROW: Final[int] = 1 +SAMPLE_ROW: Final[int] = 5 +FREE_ROW: Final[int] = 7 + + +class TestTheMarkedVoice: + """A voice clicked in the Voices list stays marked while the grid has the caret, and every pitch typed + there places it: a channel column takes either kind, and the Sample column takes a sample and writes + the pitch alone for an instrument. The grid keeps the keys meanwhile, and a click below the list's + rows ends the marking. + """ + + def test_pitches_carry_the_marked_voice(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + voices = screen.sequencer.voices + + def mark(name: str, position: int) -> None: + row = screen.expect_item(partial(voices.row, name), description=f"the row of {name}") + voices.pick(row) + screen.expect(partial(voices.is_picked, position), bool, description=f"{name} marked") + + def pitches_at(row: int) -> Dict[ChannelName, str]: + return {channel: tracker.label(row, channel, SubColumn.TRANSPOSE) for channel in ChannelName.items()} + + def a_note_places_the_marked_pad(screen: Screen) -> None: + on_the_sequencer(screen) + mark(PAD, PAD_POSITION) + + play_a_note(screen, TYPED_ROW, ChannelName.PULSE1, PIANO_C) + + assert tracker.label(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER + assert tracker.label(TYPED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) == C_AT_OCTAVE_TWO + assert tracker.has_caret(TYPED_ROW + 1, ChannelName.PULSE1) + assert voices.is_picked(PAD_POSITION) + + def a_step_places_it_too(screen: Screen) -> None: + type_into(screen, TYPED_ROW + 1, ChannelName.PULSE1, SubColumn.TRANSPOSE, STEP_TYPED) + + assert tracker.label(TYPED_ROW + 1, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER + assert tracker.label(TYPED_ROW + 1, ChannelName.PULSE1, SubColumn.TRANSPOSE) == STEP_SHOWN + + def a_marked_sample_fills_the_sample_column(screen: Screen) -> None: + mark(LINE, LINE_POSITION) + tracker.click(SAMPLE_ROW, SAMPLE_COLUMN, SubColumn.TRANSPOSE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert tracker.label(SAMPLE_ROW, SAMPLE_COLUMN, SubColumn.VOICE) == LINE_NUMBER + assert pitches_at(SAMPLE_ROW) == { + ChannelName.PULSE1: C_AT_OCTAVE_TWO, + ChannelName.PULSE2: NOTE_BLANK, + ChannelName.TRIANGLE: C_AT_OCTAVE_TWO, + ChannelName.NOISE: PERIOD_OF_C, + } + + def a_marked_instrument_writes_the_pitch_alone_there(screen: Screen) -> None: + mark(PAD, PAD_POSITION) + tracker.click(SAMPLE_ROW + 1, SAMPLE_COLUMN, SubColumn.TRANSPOSE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert tracker.label(SAMPLE_ROW + 1, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + assert pitches_at(SAMPLE_ROW + 1) == { + ChannelName.PULSE1: C_AT_OCTAVE_TWO, + ChannelName.PULSE2: NOTE_BLANK, + ChannelName.TRIANGLE: C_AT_OCTAVE_TWO, + ChannelName.NOISE: PERIOD_OF_C, + } + + def the_grid_keeps_the_delete_key(screen: Screen) -> None: + names = voices.names() + tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_CLEAR_ROW) + + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + EMPTY_VOICE.__eq__, + description="the cell cleared", + ) + assert voices.names() == names + assert not voices.remove_prompt.is_shown() + assert voices.is_picked(PAD_POSITION) + + def a_click_below_the_rows_ends_the_marking(screen: Screen) -> None: + voices.click_below_the_rows() + screen.expect(lambda: not voices.is_picked(PAD_POSITION), bool, description="the mark cleared") + + play_a_note(screen, FREE_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(FREE_ROW, ChannelName.PULSE2, SubColumn.VOICE) == EMPTY_VOICE + assert tracker.label(FREE_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == C_AT_OCTAVE_TWO + + screen.scenario( + a_note_places_the_marked_pad, + a_step_places_it_too, + a_marked_sample_fills_the_sample_column, + a_marked_instrument_writes_the_pitch_alone_there, + the_grid_keeps_the_delete_key, + a_click_below_the_rows_ends_the_marking, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py new file mode 100644 index 000000000..d8e59c06c --- /dev/null +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -0,0 +1,230 @@ +import operator +from functools import partial +from typing import Final, List, Optional, Tuple + +from automation.dearpygui.items.texts import read_label +from automation.dearpygui.keys import IMGUI_LETTER_A +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from automation.views.tracker import tracker_cell, tracker_cell_theme +from automation.vocabulary.playback import PAUSE, PLAY +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.constants import ( + LINE_NUMBER, + PAD_NUMBER, + PIANO_C, + PLAY_FROM_THIS_FRAME, + SAMPLE_COLUMN, + TYPING_FRAMES, +) +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.worlds.songs import PAD_ROW + +PIANO_C_UP: Final[int] = IMGUI_LETTER_A + ord("q") - ord("a") +NOTE_AT_OCTAVE_TWO: Final[str] = "C-2" +NOTE_AT_OCTAVE_THREE: Final[str] = "C-3" +NOTE_AT_OCTAVE_FOUR: Final[str] = "C-4" +HIGHER_OCTAVE: Final[int] = 3 +NO_VOICE_ROW: Final[int] = 2 + + +def history_size(screen: Screen) -> int: + """The number of lines in the Sequencer's history.""" + return len(screen.sequencer.history.lines()) + + +class TestNotesTypedPianoStyle: + """A note key writes the note at the octave in force into a pitch slot, whatever the channel carries. + + A voice slot stays as it was, and the history with it. + """ + + def test_notes_land_in_the_pitch_slots_and_nowhere_else(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + history = screen.sequencer.history + + def a_note_lands_at_the_octave_in_force(screen: Screen) -> None: + on_the_sequencer(screen) + assert tracker.octave() == 2 + before = history_size(screen) + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_TWO + assert tracker.has_caret(PAD_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + 1 + assert history.current().words.endswith(f"t {NOTE_AT_OCTAVE_TWO}") + + def a_new_octave_lands_an_octave_up(screen: Screen) -> None: + tracker.raise_octave() + screen.expect(tracker.octave, HIGHER_OCTAVE.__eq__, description="the octave raised") + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C_UP) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_FOUR + + def the_voice_slot_takes_no_note(screen: Screen) -> None: + before = history_size(screen) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) == PAD_NUMBER + assert history_size(screen) == before + + def a_row_carrying_no_voice_takes_the_note(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, NO_VOICE_ROW, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(NO_VOICE_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + assert tracker.has_caret(NO_VOICE_ROW + 1, ChannelName.PULSE2) + assert history_size(screen) == before + 1 + + def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: + before = history_size(screen) + + play_a_note(screen, PAD_ROW + 1, ChannelName.PULSE2, PIANO_C) + + assert tracker.label(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.TRANSPOSE) == NOTE_AT_OCTAVE_THREE + assert history_size(screen) == before + 1 + + screen.scenario( + a_note_lands_at_the_octave_in_force, + a_new_octave_lands_an_octave_up, + the_voice_slot_takes_no_note, + a_row_carrying_no_voice_takes_the_note, + a_note_where_the_voice_plays_on_lands, + leave_letting_the_project_go, + ).run() + + +class TestAVoiceTyped: + """A voice number typed into a slot arrives with its kind's color in the same frame. + + An instrument typed into the Sample column keeps the number and the color as they were. + """ + + def test_the_color_comes_with_the_number(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + typed_row = 9 + + def cell_in_frame(row: int, channel: Optional[ChannelName]) -> Tuple[str, Optional[int]]: + return read_label(tracker_cell(row, channel, SubColumn.VOICE)), tracker_cell_theme( + row, channel, SubColumn.VOICE + ) + + def frames_while_typing(row: int, channel: Optional[ChannelName], text: str) -> List[Tuple[str, Optional[int]]]: + tracker.click(row, channel, SubColumn.VOICE) + with screen.record(partial(cell_in_frame, row, channel)) as recording: + screen.hand.type_text(text) + screen.frames(TYPING_FRAMES) + + return recording.values() + + def an_instrument_arrives_in_its_color(screen: Screen) -> None: + on_the_sequencer(screen) + instrument = tracker.theme(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + frames = frames_while_typing(typed_row, ChannelName.PULSE1, PAD_NUMBER) + + numbered = [theme for label, theme in frames if label == PAD_NUMBER] + assert numbered + assert all(theme == instrument for theme in numbered) + + def a_sample_arrives_in_its_color(screen: Screen) -> None: + sample = screen.sequencer.tracker.theme(0, ChannelName.PULSE1, SubColumn.VOICE) + + frames = frames_while_typing(typed_row + 2, ChannelName.PULSE1, LINE_NUMBER) + + numbered = [theme for label, theme in frames if label == LINE_NUMBER] + assert numbered + assert all(theme == sample for theme in numbered) + + def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: + standing = tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE), tracker.theme( + typed_row, SAMPLE_COLUMN, SubColumn.VOICE + ) + before = history_size(screen) + + frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) + + assert all(frame[1] == standing[1] for frame in frames) + assert all(frame[0] != PAD_NUMBER for frame in frames) + assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] + assert history_size(screen) == before + + screen.scenario( + an_instrument_arrives_in_its_color, + a_sample_arrives_in_its_color, + an_instrument_in_the_sample_column_changes_nothing, + leave_letting_the_project_go, + ).run() + + +def reads_as_playing(screen: Screen) -> None: + """Expects the Playback menu to offer Pause, with Stop answering.""" + playback = screen.sequencer.playback + screen.expect(playback.can_stop, bool, description="Stop answering") + assert playback.play_entry() == screen.words(PAUSE) + + +def reads_as_stopped(screen: Screen) -> None: + """Expects the Playback menu to offer Play, with Stop greyed out.""" + playback = screen.sequencer.playback + screen.expect(lambda: not playback.can_stop(), bool, description="Stop greyed out") + assert playback.play_entry() == screen.words(PLAY) + + +class TestPlayingFromATrackerRow: + """Playing the song from the tracker reads as playing in the Playback menu, Stop answering, whichever way it + started, and stopping it reads as stopped again. + """ + + def test_the_menu_follows(self, screen: Screen) -> None: + """The cell menu's Play from this frame and the play-from-row shortcut each turn the menu to Pause, and + the Stop key and Stop on the menu each turn it back to Play. + + Every gesture on a cell comes before the song first plays, since a playing song moves the grid. + """ + tracker = screen.sequencer.tracker + menu = screen.context_menu + + def the_cell_menu_plays_from_the_frame(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) + reads_as_stopped(screen) + tracker.right_click(1, ChannelName.PULSE1, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + menu.choose(screen.words(PLAY_FROM_THIS_FRAME)) + + screen.expect(menu.is_shown, operator.not_, description="the menu answered") + reads_as_playing(screen) + + def the_stop_key_reads_as_stopped(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + reads_as_stopped(screen) + + def the_shortcut_plays_from_the_row(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) + + reads_as_playing(screen) + + def stop_from_the_menu_reads_as_stopped(screen: Screen) -> None: + screen.sequencer.playback.stop() + + reads_as_stopped(screen) + + screen.scenario( + the_cell_menu_plays_from_the_frame, + the_stop_key_reads_as_stopped, + the_shortcut_plays_from_the_row, + stop_from_the_menu_reads_as_stopped, + ).run() diff --git a/tests/screens/sequencer/tracker/test_pitch_faces.py b/tests/screens/sequencer/tracker/test_pitch_faces.py new file mode 100644 index 000000000..cf38e9707 --- /dev/null +++ b/tests/screens/sequencer/tracker/test_pitch_faces.py @@ -0,0 +1,87 @@ +from typing import Dict, Final + +from automation.dearpygui.keys import IMGUI_LETTER_A +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import NOTE_BLANK +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.worlds.songs import PAD_ROW + +PIANO_D_UP: Final[int] = IMGUI_LETTER_A + ord("w") - ord("a") +STEP_TYPED: Final[str] = "03" +STEP_SHOWN: Final[str] = "+03" +DIGITS_TYPED: Final[str] = "22" +DIGITS_SHOWN: Final[str] = "+22" +C_AT_OCTAVE_TWO: Final[str] = "C-2" +D_ABOVE_THE_OCTAVE: Final[str] = "D-3" +PERIOD_OF_C: Final[str] = "0-#" +LINE_ROW: Final[int] = 0 +DIGIT_ROW: Final[int] = 2 +PLACED_ROW: Final[int] = 6 + + +class TestThePitchCellTakesEitherFace: + """A pitch cell takes a note or a step whatever voice its channel carries: the digit keys type a + step, the piano keys a note, and the noise channel reads a note as one of its periods. The Sample + column hands a note to the channels still playing the sample, each reading it its own way. + """ + + def test_steps_and_notes_land_in_any_column(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + history = screen.sequencer.history + + def pitches_at(row: int) -> Dict[ChannelName, str]: + return {channel: tracker.label(row, channel, SubColumn.TRANSPOSE) for channel in ChannelName.items()} + + def a_step_lands_on_an_instrument(screen: Screen) -> None: + on_the_sequencer(screen) + + type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE, STEP_TYPED) + + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == STEP_SHOWN + assert history.current().words.endswith(f"t {STEP_SHOWN}") + + def a_note_lands_on_a_sample(screen: Screen) -> None: + play_a_note(screen, LINE_ROW, ChannelName.PULSE1, PIANO_C) + + assert tracker.label(LINE_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) == C_AT_OCTAVE_TWO + assert history.current().words.endswith(f"t {C_AT_OCTAVE_TWO}") + + def a_digit_types_a_step_and_the_key_beside_it_a_note(screen: Screen) -> None: + type_into(screen, DIGIT_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE, DIGITS_TYPED) + play_a_note(screen, DIGIT_ROW + 1, ChannelName.PULSE1, PIANO_D_UP) + + assert tracker.label(DIGIT_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) == DIGITS_SHOWN + assert tracker.label(DIGIT_ROW + 1, ChannelName.PULSE1, SubColumn.TRANSPOSE) == D_ABOVE_THE_OCTAVE + + def the_sample_column_hands_a_note_to_the_channels_playing(screen: Screen) -> None: + type_into(screen, PLACED_ROW, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) + tracker.click(PLACED_ROW + 1, SAMPLE_COLUMN, SubColumn.TRANSPOSE) + + screen.hand.press_key(PIANO_C, modifiers=[]) + screen.frames(TYPING_FRAMES) + + assert pitches_at(PLACED_ROW + 1) == { + ChannelName.PULSE1: C_AT_OCTAVE_TWO, + ChannelName.PULSE2: NOTE_BLANK, + ChannelName.TRIANGLE: C_AT_OCTAVE_TWO, + ChannelName.NOISE: PERIOD_OF_C, + } + + def the_noise_channel_reads_a_note_as_a_period(screen: Screen) -> None: + play_a_note(screen, PLACED_ROW + 2, ChannelName.NOISE, PIANO_C) + + assert tracker.label(PLACED_ROW + 2, ChannelName.NOISE, SubColumn.TRANSPOSE) == PERIOD_OF_C + assert tracker.label(PLACED_ROW + 2, ChannelName.PULSE1, SubColumn.TRANSPOSE) == NOTE_BLANK + + screen.scenario( + a_step_lands_on_an_instrument, + a_note_lands_on_a_sample, + a_digit_types_a_step_and_the_key_beside_it_a_note, + the_sample_column_hands_a_note_to_the_channels_playing, + the_noise_channel_reads_a_note_as_a_period, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/tracker/test_sample_column.py b/tests/screens/sequencer/tracker/test_sample_column.py new file mode 100644 index 000000000..fc28bc435 --- /dev/null +++ b/tests/screens/sequencer/tracker/test_sample_column.py @@ -0,0 +1,220 @@ +import operator +from functools import partial +from typing import Final, List, Tuple + +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go, on_the_sequencer +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import BLANK, NOTE_OFF +from tests.screens.sequencer.tracker.constants import LINE_NUMBER, PAD_NUMBER, PIANO_C, SAMPLE_COLUMN, TYPING_FRAMES +from tests.screens.sequencer.tracker.steps import play_a_note, type_into +from tests.suite.screens.worlds.songs import BASS_ROW, PAD_ROW + +EMPTY_VOICE: Final[str] = ".." +BASS_NUMBER: Final[str] = "01" +SAMPLE_HEADER: Final[str] = "sequencer.tracker.label.column_sample" +SET_VOICE: Final[str] = "sequencer.tracker.label.context_set_voice" +CLEAR_SUBCOLUMN: Final[str] = "sequencer.tracker.label.context_clear_subcolumn" + +SUBCOLUMN_LETTERS: Final[Tuple[Tuple[SubColumn, str], ...]] = ( + (SubColumn.VOICE, "n"), + (SubColumn.TRANSPOSE, "t"), + (SubColumn.VOLUME, "v"), +) + +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +LINE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE) + + +class TestTheSampleColumn: + """The Sample column reads Sample, offers only samples, and reads a row by the samples its channels play.""" + + def test_its_header_its_menu_and_its_readings(self, screen: Screen) -> None: + """The header, the Set voice menu and the readings of an instrument, a cut and a bass row follow + the column's rule. + """ + tracker = screen.sequencer.tracker + menu = screen.context_menu + cut_row = 6 + instrument_row = 2 + + def the_header_reads_sample(screen: Screen) -> None: + on_the_sequencer(screen) + + assert tracker.header(SAMPLE_COLUMN) == screen.words(SAMPLE_HEADER) + + def set_voice_offers_samples_alone(screen: Screen) -> None: + tracker.right_click(1, SAMPLE_COLUMN, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert [enabled for _, enabled in offered] == [True, True, False] + + def a_channel_cell_offers_every_voice(screen: Screen) -> None: + tracker.right_click(1, ChannelName.PULSE2, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + offered = menu.submenu(screen.words(SET_VOICE)) + + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert len(offered) == len(screen.sequencer.voices.names()) + assert all(enabled for _, enabled in offered) + + def an_instrument_alone_reads_empty(screen: Screen) -> None: + type_into(screen, instrument_row, ChannelName.PULSE1, SubColumn.VOICE, PAD_NUMBER) + + assert tracker.label(instrument_row, ChannelName.PULSE1, SubColumn.VOICE) == PAD_NUMBER + assert tracker.label(instrument_row, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + + def a_row_cut_everywhere_reads_the_cut(screen: Screen) -> None: + for channel in EVERY_CHANNEL: + type_into(screen, cut_row, channel, SubColumn.VOICE, "-") + + assert all(tracker.label(cut_row, channel, SubColumn.VOICE) == NOTE_OFF for channel in EVERY_CHANNEL) + assert tracker.label(cut_row, SAMPLE_COLUMN, SubColumn.VOICE) == NOTE_OFF + + def the_bass_alone_reads_as_itself(screen: Screen) -> None: + assert screen.sequencer.tracker.label(BASS_ROW, SAMPLE_COLUMN, SubColumn.VOICE) == BASS_NUMBER + + screen.scenario( + the_header_reads_sample, + set_voice_offers_samples_alone, + a_channel_cell_offers_every_voice, + an_instrument_alone_reads_empty, + a_row_cut_everywhere_reads_the_cut, + the_bass_alone_reads_as_itself, + leave_letting_the_project_go, + ).run() + + def test_a_volume_lands_where_the_sample_still_plays(self, screen: Screen) -> None: + """A volume typed into the Sample column reaches the channels that still play the sample. + + A channel cut or given an instrument on its own is left out. + """ + tracker = screen.sequencer.tracker + placed_row = 1 + + def place_the_line_across_its_channels(screen: Screen) -> None: + on_the_sequencer(screen) + + type_into(screen, placed_row, SAMPLE_COLUMN, SubColumn.VOICE, LINE_NUMBER) + + assert [tracker.label(placed_row, channel, SubColumn.VOICE) for channel in LINE_CHANNELS] == [ + LINE_NUMBER + ] * 3 + assert tracker.label(placed_row, SAMPLE_COLUMN, SubColumn.VOICE) == LINE_NUMBER + + def a_volume_reaches_every_channel_it_plays(screen: Screen) -> None: + type_into(screen, placed_row + 1, SAMPLE_COLUMN, SubColumn.VOLUME, "8") + + assert {channel: tracker.label(placed_row + 1, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "8", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "8", + ChannelName.NOISE: "8", + } + + def a_channel_cut_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 2, ChannelName.NOISE, SubColumn.VOICE, "-") + + type_into(screen, placed_row + 3, SAMPLE_COLUMN, SubColumn.VOLUME, "9") + + assert {channel: tracker.label(placed_row + 3, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "9", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: "9", + ChannelName.NOISE: BLANK, + } + + def a_channel_given_an_instrument_stops_taking_it(screen: Screen) -> None: + type_into(screen, placed_row + 4, ChannelName.TRIANGLE, SubColumn.VOICE, PAD_NUMBER) + + type_into(screen, placed_row + 5, SAMPLE_COLUMN, SubColumn.VOLUME, "A") + + assert {channel: tracker.label(placed_row + 5, channel, SubColumn.VOLUME) for channel in EVERY_CHANNEL} == { + ChannelName.PULSE1: "A", + ChannelName.PULSE2: BLANK, + ChannelName.TRIANGLE: BLANK, + ChannelName.NOISE: BLANK, + } + + screen.scenario( + place_the_line_across_its_channels, + a_volume_reaches_every_channel_it_plays, + a_channel_cut_stops_taking_it, + a_channel_given_an_instrument_stops_taking_it, + leave_letting_the_project_go, + ).run() + + +class TestABlockPastedOnTheSampleColumn: + """A block holding an instrument pasted onto the Sample column lands its samples and leaves the instrument's cells empty.""" + + def test_the_instrument_is_passed_over(self, screen: Screen) -> None: + """The pasted sample appears below the anchor, and no cell takes the instrument's number.""" + tracker = screen.sequencer.tracker + anchor = 9 + + def copy_an_instrument_and_a_sample(screen: Screen) -> None: + on_the_sequencer(screen) + type_into(screen, PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE, LINE_NUMBER) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + tracker.shift_click(PAD_ROW + 1, ChannelName.PULSE2, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_COPY_BLOCK) + + def paste_it_on_the_sample_column(screen: Screen) -> None: + tracker.click(anchor, SAMPLE_COLUMN, SubColumn.VOICE) + + screen.press_shortcut(ShortcutId.TRACKER_PASTE_BLOCK) + + screen.expect( + partial(tracker.label, anchor + 1, SAMPLE_COLUMN, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the sample landed", + ) + assert tracker.label(anchor, SAMPLE_COLUMN, SubColumn.VOICE) == EMPTY_VOICE + assert all(tracker.label(anchor, channel, SubColumn.VOICE) != PAD_NUMBER for channel in EVERY_CHANNEL) + + screen.scenario( + copy_an_instrument_and_a_sample, paste_it_on_the_sample_column, leave_letting_the_project_go + ).run() + + +class TestHistoryLinesNameTheSlot: + """A history line clearing a slot names it n for the voice, t for the transpose and v for the volume.""" + + def test_each_slot_has_its_letter(self, screen: Screen) -> None: + """Clearing the volume, the transpose and the voice of one cell in turn gives three history lines, + each with its own letter. + """ + tracker = screen.sequencer.tracker + history = screen.sequencer.history + menu = screen.context_menu + letters: List[str] = [] + + def clear_each_slot_of_the_pad(screen: Screen) -> None: + on_the_sequencer(screen) + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, PIANO_C) + type_into(screen, PAD_ROW, ChannelName.PULSE2, SubColumn.VOLUME, "9") + + for subcolumn, _ in reversed(SUBCOLUMN_LETTERS): + tracker.right_click(PAD_ROW, ChannelName.PULSE2, subcolumn) + screen.expect(menu.is_shown, bool, description="the cell's menu") + menu.choose(screen.words(CLEAR_SUBCOLUMN)) + screen.expect(menu.is_shown, operator.not_, description="the menu answered") + screen.frames(TYPING_FRAMES) + letters.append(history.current().words) + + for line, (_, letter) in zip(letters, reversed(SUBCOLUMN_LETTERS)): + assert letter in line.split() + + assert len(set(letters)) == len(SUBCOLUMN_LETTERS) + + screen.scenario(clear_each_slot_of_the_pad, leave_letting_the_project_go).run() diff --git a/tests/screens/sequencer/voices/__init__.py b/tests/screens/sequencer/voices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/voices/test_no_project_key.py b/tests/screens/sequencer/voices/test_no_project_key.py new file mode 100644 index 000000000..2aaae0085 --- /dev/null +++ b/tests/screens/sequencer/voices/test_no_project_key.py @@ -0,0 +1,83 @@ +import operator +from typing import Final + +import pytest + +from automation.application.startup import Startup +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go +from automation.worlds.home import World +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.shortcuts import ShortcutsConfig +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.worlds.songs import sequencer_world + +NEW_INSTRUMENT_KEYS: Final[KeyCombination] = KeyCombination.parse("F12") +NO_PROJECT_TITLE: Final[str] = "global.dialog.title.no_project_open" +NO_PROJECT_MESSAGE: Final[str] = "global.dialog.message.no_project_open" +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def world() -> World: + """The Sequencer's home, its settings giving New instrument a key of its own.""" + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=ApplicationConfig( + shortcuts=ShortcutsConfig( + overrides={ShortcutId.NEW_INSTRUMENT.value: NEW_INSTRUMENT_KEYS.display()}, + ), + ), + config=None, + files=sequencer.files, + ) + + +@pytest.fixture +def startup() -> Startup: + """The application opens with no project and no reconstruction.""" + return Startup(reconstruction=None, project=None) + + +class TestTheNewInstrumentKeyWithNoProjectOpen: + """With no project open, the key given to New instrument tells the reader to open a project and adds no voice. + + The key brings up the notice in the words the language file gives it, OK dismisses it, and the voice list + stays empty. A new project then takes the same key, which adds one voice. + """ + + def test_the_key_tells_the_reader_and_adds_nothing(self, screen: Screen) -> None: + voices = screen.sequencer.voices + notice = screen.no_project_notice + + def the_key_tells_the_reader(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.frames(SETTLING_FRAMES) + assert voices.names() == [] + + screen.press_shortcut(ShortcutId.NEW_INSTRUMENT) + + screen.expect(notice.is_shown, bool, description="the notice that no project is open") + assert notice.prompt.title() == screen.words(NO_PROJECT_TITLE) + assert notice.words() == screen.words(NO_PROJECT_MESSAGE) + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert voices.names() == [] + + def a_new_project_takes_the_key(screen: Screen) -> None: + screen.project.create() + screen.expect(voices.new_instrument_answers, bool, description="New instrument answering") + + screen.press_shortcut(ShortcutId.NEW_INSTRUMENT) + + screen.expect(lambda: len(voices.names()), (1).__eq__, description="one voice") + assert not notice.is_shown() + + screen.scenario( + the_key_tells_the_reader, + a_new_project_takes_the_key, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py new file mode 100644 index 000000000..f0d83f8c6 --- /dev/null +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -0,0 +1,306 @@ +import operator +import re +from functools import partial +from typing import Final, List, Tuple + +from automation.dearpygui.geometry import Point +from automation.dearpygui.items.regions import enclosing_regions, read_region_view +from automation.dearpygui.items.types import Item +from automation.keyboard import press_combination +from automation.screen import Screen +from automation.steps.reconstructions import BY_CONFIGURATION +from automation.steps.sequencer import leave_letting_the_project_go +from automation.views.menus import MenuEntry +from automation.vocabulary.playback import PAUSE +from sampletones_application.categories.elements.global_ import MenuElements +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_TABLE +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.worlds.recordings import SHORT_RECONSTRUCTION +from tests.suite.screens.worlds.songs import BASS_VOICE, LINE, PAD + +POOL_ACTIONS: Final[Tuple[str, ...]] = ( + "sequencer.voices.label.new_instrument", + "sequencer.voices.label.add_sample", + "sequencer.voices.label.import_instrument", +) + +PLAY_VOICE: Final[str] = "global.context.label.play" +NEW_INSTRUMENT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument" +NO_PROJECT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument_no_project" +STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample" +STATUS_SEPARATOR: Final[str] = "sequencer.voices.template.status_channel_separator" + +LINE_CHANNEL_NAMES: Final[Tuple[str, ...]] = ( + "global.context.label.pulse_1", + "global.context.label.triangle", + "global.context.label.noise", +) + +EMPTY_SPACE_INSET: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 20 +MOVE_DOWN_KEYS: Final[str] = "Alt+Down" +PLACED_ROW: Final[int] = 1 +ADDED_NUMBER: Final[str] = "03" + + +def voice_row(screen: Screen, name: str) -> Item: + """Brings the Sequencer tab to the front and returns the list row of the voice called ``name``.""" + screen.tabs.bring_to_front(Tab.SEQUENCER) + return screen.expect_item(partial(screen.sequencer.voices.row, name), description=f"the row of {name}") + + +def right_click_the_empty_list(screen: Screen) -> None: + """Presses the right button over the list, below its rows.""" + view = screen.bridge.ask(lambda: read_region_view(enclosing_regions(TAG_SEQUENCER_VOICES_TABLE)[0])) + assert view is not None + screen.hand.right_click_at( + Point(x=round(view.x + view.width / 2), y=round(view.y + view.height - EMPTY_SPACE_INSET)) + ) + + +def voice_actions(entries: List[str], pool: Tuple[str, ...]) -> List[str]: + """The entries acting on the voice picked, which leaves out the pool's own.""" + return [entry for entry in entries if entry not in pool] + + +def menu_on_the_bar(screen: Screen, group: MenuElements) -> List[MenuEntry]: + """Opens the menu ``group`` with a click, reads what it offers once it settles, and puts it away.""" + screen.menu.open(group) + screen.frames(SETTLING_FRAMES) + entries = screen.menu.entries(group) + screen.menu.close() + screen.frames(SETTLING_FRAMES) + return entries + + +class TestTheVoicesMenus: + """A voice row's menu offers the voice's actions and the pool's; the empty list offers only the pool's. + + The Voice and Edit menus name the voice's actions the row's menu names, Play aside, and the keys + they print are the keys that fire them. + """ + + def test_menus_agree_and_their_keys_fire(self, screen: Screen) -> None: + """The row's menu, the empty list's menu and the two bar menus agree, and the printed Move down keys move the voice.""" + voices = screen.sequencer.voices + menu = screen.context_menu + pool = tuple(screen.words(key) for key in POOL_ACTIONS) + row_actions: List[str] = [] + + def the_row_offers_both(screen: Screen) -> None: + voices.right_click(voice_row(screen, LINE)) + screen.expect(menu.is_shown, bool, description="the row's menu") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + + assert tuple(labels[-len(pool) :]) == pool + row_actions.extend(voice_actions(labels, pool)) + + def the_empty_list_offers_the_pool_alone(screen: Screen) -> None: + right_click_the_empty_list(screen) + + screen.expect(menu.is_shown, bool, description="the list's menu") + labels = menu.labels() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + assert tuple(labels) == pool + + def the_bar_menus_name_the_same_actions(screen: Screen) -> None: + voices.pick(voice_row(screen, LINE)) + screen.expect(partial(voices.is_picked, 0), bool, description="the line picked") + + voice_menu = menu_on_the_bar(screen, MenuElements.GROUP_VOICE) + edit_menu = menu_on_the_bar(screen, MenuElements.GROUP_EDIT) + + from_voice = [entry for entry in voice_menu if entry.label in row_actions] + from_edit = [entry for entry in edit_menu if entry.label in row_actions] + assert [entry.label for entry in from_voice] == [entry.label for entry in from_edit] + assert [entry.keys for entry in from_voice] == [entry.keys for entry in from_edit] + assert set(row_actions) - {entry.label for entry in from_voice} == {screen.words(PLAY_VOICE)} + + def the_printed_keys_fire(screen: Screen) -> None: + moves = [ + entry for entry in menu_on_the_bar(screen, MenuElements.GROUP_VOICE) if entry.keys == MOVE_DOWN_KEYS + ] + assert moves + + press_combination(screen.hand, KeyCombination.parse(moves[0].keys)) + + screen.expect(voices.names, [BASS_VOICE, LINE, PAD].__eq__, description="the line moved down") + + screen.scenario( + the_row_offers_both, + the_empty_list_offers_the_pool_alone, + the_bar_menus_name_the_same_actions, + the_printed_keys_fire, + leave_letting_the_project_go, + ).run() + + +class TestHoveringAVoice: + """The status line says what the voice under the pointer is: its name, its kind and the channels it plays.""" + + def test_the_status_names_the_sample(self, screen: Screen) -> None: + """Hovering a sample's row fills the status line with its name and its channels.""" + row = voice_row(screen, LINE) + separator = screen.words(STATUS_SEPARATOR) + channels = separator.join(screen.words(key) for key in LINE_CHANNEL_NAMES) + template = screen.words(STATUS_SAMPLE).format(name=LINE, channels=channels, bytes="{bytes}") + expected = re.compile(re.escape(template).replace(re.escape("{bytes}"), ".+")) + + screen.hand.move_to(Point(x=0, y=0)) + screen.hand.hover(row) + + screen.expect(screen.status, lambda status: expected.fullmatch(status) is not None, description="the status") + + +class TestTheKeysOfACollapsedVoicesCard: + """With the Voices card collapsed, the keys of the voice picked keep the voices as they were; open, they act. + + The line is picked and the card collapsed; Move down and Remove leave the list and the windows as they were. Opening the card makes Move down move the line. + """ + + def test_keys_wait_for_the_card(self, screen: Screen) -> None: + """The keys act once the card is open again.""" + voices = screen.sequencer.voices + + def pick_the_line_and_collapse_the_card(screen: Screen) -> None: + voices.pick(voice_row(screen, LINE)) + screen.expect(partial(voices.is_picked, 0), bool, description="the line picked") + + voices.card.toggle() + + screen.expect(voices.card.is_collapsed, bool, description="the card collapsed") + + def its_keys_change_nothing(screen: Screen) -> None: + press_combination(screen.hand, KeyCombination.parse(MOVE_DOWN_KEYS)) + screen.press_shortcut(ShortcutId.VOICES_REMOVE_VOICE) + + screen.frames(SETTLING_FRAMES) + assert voices.names() == [LINE, BASS_VOICE, PAD] + assert screen.shown_windows() == () + + def open_they_act(screen: Screen) -> None: + voices.card.toggle() + screen.expect(voices.card.is_collapsed, operator.not_, description="the card open") + voices.pick(voice_row(screen, LINE)) + + press_combination(screen.hand, KeyCombination.parse(MOVE_DOWN_KEYS)) + + screen.expect(voices.names, [BASS_VOICE, LINE, PAD].__eq__, description="the line moved down") + + screen.scenario( + pick_the_line_and_collapse_the_card, + its_keys_change_nothing, + open_they_act, + leave_letting_the_project_go, + ).run() + + +class TestNewInstrumentWithNoProjectOpen: + """With no project open, every way a voice comes in stands greyed out and the voice list stays empty. + + Hovering the greyed-out New instrument says what brings it back. A new project brings the ways back, + hovering New instrument then says what it adds, and a click adds one voice. + """ + + def test_nothing_is_offered_until_a_project_opens(self, screen: Screen) -> None: + """The card's button, the list's menu and the Voice menu grey out the ways in, and a new project answers.""" + voices = screen.sequencer.voices + menu = screen.context_menu + pool = tuple(screen.words(key) for key in POOL_ACTIONS) + + def close_the_project(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.project.close() + + screen.expect(voices.names, operator.not_, description="no voices listed") + + def every_way_in_stands_greyed_out(screen: Screen) -> None: + screen.expect(lambda: not voices.new_instrument_answers(), bool, description="New instrument greyed out") + voices.hover_new_instrument() + screen.expect( + partial(screen.shows_text, screen.words(NO_PROJECT_TIP)), + bool, + description="the tooltip saying what brings New instrument back", + ) + assert not screen.shows_text(screen.words(NEW_INSTRUMENT_TIP)) + + right_click_the_empty_list(screen) + screen.expect(menu.is_shown, bool, description="the list's menu") + entries = menu.entries() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + from_the_bar = [entry for entry in menu_on_the_bar(screen, MenuElements.GROUP_VOICE) if entry.label in pool] + + assert [(entry.label, entry.enabled) for entry in entries] == [(label, False) for label in pool] + assert [(entry.label, entry.enabled) for entry in from_the_bar] == [(label, False) for label in pool] + assert voices.names() == [] + + def a_new_project_takes_a_new_instrument(screen: Screen) -> None: + screen.project.create() + screen.expect(voices.new_instrument_answers, bool, description="New instrument answering") + voices.hover_new_instrument() + screen.expect( + partial(screen.shows_text, screen.words(NEW_INSTRUMENT_TIP)), + bool, + description="New instrument's own tooltip", + ) + assert not screen.shows_text(screen.words(NO_PROJECT_TIP)) + + voices.new_instrument() + + screen.expect(lambda: len(voices.names()), (1).__eq__, description="one voice") + + screen.scenario( + close_the_project, + every_way_in_stands_greyed_out, + a_new_project_takes_a_new_instrument, + leave_letting_the_project_go, + ).run() + + +class TestASampleAddedFromTheBrowser: + """A reconstruction double-clicked in the Sequencer's browser joins the voices; placed, the song plays it.""" + + def test_it_joins_and_plays(self, screen: Screen) -> None: + """The voices list a fourth voice after the double click, and playing the song shows Pause once the sample is placed.""" + voices = screen.sequencer.voices + browser = screen.sequencer.browser + tracker = screen.sequencer.tracker + + def double_click_it_in_the_browser(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + heading = screen.expect_item( + partial(browser.heading, screen.words(BY_CONFIGURATION)), description="the heading" + ) + if not browser.is_open(heading): + browser.open_by_click(heading) + row = screen.expect_item(partial(browser.file_row, SHORT_RECONSTRUCTION), description="its row") + + browser.double_click(row) + + screen.expect(lambda: len(voices.names()), (4).__eq__, description="a fourth voice") + assert voices.names()[:3] == [LINE, BASS_VOICE, PAD] + + def placed_the_song_plays_it(screen: Screen) -> None: + tracker.click(PLACED_ROW, ChannelName.PULSE2, SubColumn.VOICE) + screen.hand.type_text(ADDED_NUMBER) + screen.expect( + partial(tracker.label, PLACED_ROW, ChannelName.PULSE2, SubColumn.VOICE), + ADDED_NUMBER.__eq__, + description="the sample placed", + ) + + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(screen.sequencer.playback.play_entry, screen.words(PAUSE).__eq__, description="playing") + screen.press_shortcut(ShortcutId.STOP) + + screen.scenario(double_click_it_in_the_browser, placed_the_song_plays_it, leave_letting_the_project_go).run() diff --git a/tests/suite/analysis.py b/tests/suite/analysis.py new file mode 100644 index 000000000..a8050176f --- /dev/null +++ b/tests/suite/analysis.py @@ -0,0 +1,14 @@ +from typing import Final + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.instructions import PulseInstruction + +LIBRARY_TONE: Final[PulseInstruction] = PulseInstruction(on=True, pitch=57, volume=12, duty_cycle=2) + + +def analyzed_config(method: SpectrumMethod, *, gamma: int) -> Config: + """The default configuration analyzing audio by ``method`` through the transform at ``gamma``.""" + base = Config() + library = base.library.model_copy(update={"spectrum_method": method, "transformation_gamma": gamma}) + return base.model_copy(update={"library": library}) diff --git a/tests/suite/application.py b/tests/suite/application.py index e8df8a13a..f9d152628 100644 --- a/tests/suite/application.py +++ b/tests/suite/application.py @@ -1,15 +1,24 @@ -from typing import Iterator +from typing import Any, Final, Iterator, List, Tuple from unittest.mock import patch import pytest 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 +from sampletones_shared.types.callback import Callback, VoidCallback + +REAL_QUEUE_ADD: Final = CallbackQueue.add +FRAME_BUDGET_SECONDS: Final[float] = 1.0 +DELAY_CLOCK: Final[str] = "sampletones_application.utils.callbacks.delay.time.monotonic" +CLOCK_START: Final[float] = 100.0 @pytest.fixture(autouse=True) @@ -46,11 +55,10 @@ 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, - reconstruction_update=0, cancel=0, ), priorities=SchedulingPriorities( @@ -61,3 +69,83 @@ def scheduling() -> SchedulingBehavior: emit=SchedulingEmit(priority=0, batch_size=128), queue_budget_seconds=0.005, ) + + +class HeldQueue: + """The render loop's queue, holding what another thread hands over until a case drains it.""" + + def __init__(self) -> None: + self._held: List[Tuple[Callback, Tuple[Any, ...]]] = [] + + def add(self, callback: Callback, *args: Any, **_scheduling: Any) -> None: + self._held.append((callback, args)) + + @property + def held(self) -> int: + """How many callbacks stand queued.""" + return len(self._held) + + def drain(self) -> None: + """Runs what stands queued in the order it arrived, along with what running it queues.""" + while self._held: + callback, args = self._held.pop(0) + callback(*args) + + +def settled(gesture: VoidCallback) -> None: + """Runs ``gesture``, waits for the workers it started, and runs what they reported. + + A worker reports through the queue the render loop drains, so the reports are held while the + workers run and drained once they have ended, in the order they arrived. + """ + queue = HeldQueue() + with patch.object(CallbackQueue, "add", queue.add): + gesture() + SingleThreadExecutor.join_all() + queue.drain() + + +@pytest.fixture +def held_queue(monkeypatch: pytest.MonkeyPatch) -> HeldQueue: + """``CallbackQueue.add`` holding each callback until the case drains the queue.""" + queue = HeldQueue() + monkeypatch.setattr(CallbackQueue, "add", queue.add) + return queue + + +@pytest.fixture +def live_queue() -> Iterator[None]: + """The real ``CallbackQueue`` for the case, which every case starts empty and live. + + The real ``add`` is pinned over the call-through ``synchronous_queue`` installs, so what a case + queues waits for a drain the way it does in the render loop. + """ + with patch.object(CallbackQueue, "add", REAL_QUEUE_ADD): + yield + + +def draw_frame() -> None: + """One frame of the render loop as the queue meets it: the count moves on, then the drain.""" + CallbackQueue.notify_frame() + CallbackQueue.process(FRAME_BUDGET_SECONDS) + + +class ManualClock: + """The monotonic clock a wait on the render thread reads, moved by the case alone.""" + + def __init__(self, now: float) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +@pytest.fixture +def delay_clock(monkeypatch: pytest.MonkeyPatch) -> ManualClock: + """The clock ``call_after`` measures its wait by, standing still until the case moves it.""" + clock = ManualClock(CLOCK_START) + monkeypatch.setattr(DELAY_CLOCK, clock) + return clock diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index c511104d6..8047c02aa 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -1,8 +1,22 @@ import gzip import json +import math from dataclasses import dataclass from typing import Any, Dict, Final, List, Optional, Tuple +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MIN_PLAYED_PITCH +from sampletones_core.formats.bitphase.btp import project_to_bytes +from sampletones_core.formats.bitphase.model.pattern import NoteCell +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, CHANNEL_TO_INDEX +from sampletones_core.formats.bitphase.specification.patterns import ( + FIRST_OCTAVE, + NOTE_INDEX_PITCH_OFFSET, + NOTE_RANGE, + NoteName, +) + BITPHASE_DEFAULT_NAME: Final[str] = "" BITPHASE_DEFAULT_AUTHOR: Final[str] = "" BITPHASE_DEFAULT_LOOP_POINT: Final[int] = 0 @@ -11,6 +25,7 @@ BITPHASE_DEFAULT_ROW_COUNT: Final[int] = 64 BITPHASE_DEFAULT_INTERRUPT_FREQUENCY: Final[int] = 50 BITPHASE_DEFAULT_INITIAL_SPEED: Final[int] = 3 +BITPHASE_SPEED_CLOCK_TEMPO: Final[int] = 0 BITPHASE_DEFAULT_CHIP_VARIANT: Final[str] = "NTSC" BITPHASE_DEFAULT_A4_TUNING: Final[float] = 440.0 BITPHASE_DEFAULT_CHIP_TYPE: Final[str] = "ay" @@ -22,15 +37,69 @@ BITPHASE_DEFAULT_EFFECT: Final[int] = 0 BITPHASE_DEFAULT_EFFECT_DELAY: Final[int] = 0 BITPHASE_DEFAULT_EFFECT_PARAMETER: Final[int] = 0 +BITPHASE_FIRST_TABLE_INDEX: Final[int] = 0 BITPHASE_NO_EFFECTS: Final[Tuple[None, ...]] = (None,) +BITPHASE_MIN_EFFECT_COLUMNS: Final[int] = 1 +BITPHASE_MAX_EFFECT_COLUMNS: Final[int] = 4 BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" BITPHASE_DEFAULT_LOOP: Final[int] = 0 BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 -BITPHASE_DEFAULT_PULSE_WIDTH: Final[int] = 2 -BITPHASE_DEFAULT_VOLUME_OR_RATE: Final[int] = 15 +BITPHASE_MAX_MACRO_LENGTH: Final[int] = 512 +BITPHASE_SILENT_PERIOD: Final[int] = 0 +BITPHASE_MAX_PERIOD: Final[int] = 2048 +BITPHASE_OPENING_PATTERN_VOLUME: Final[int] = 15 +BITPHASE_STORED_VOLUME_OFF: Final[int] = -1 +BITPHASE_SILENCED_PATTERN_VOLUME: Final[int] = 0 +BITPHASE_MACRO_DEFAULTS: Final[Dict[str, Any]] = { + "pulseWidth": 2, + "volumeOrRate": 15, + "envelope": False, + "retrigger": False, + "soundLength": 0, + "toneAdd": 0, + "toneAccumulation": False, + "sweep": False, + "sweepRate": 0, + "sweepShift": 0, +} + +BITPHASE_NOISE_PERIOD_COUNT: Final[int] = 16 +BITPHASE_NOTE_OFF: Final[int] = 1 +BITPHASE_NO_NOTE: Final[int] = 0 +BITPHASE_FIRST_NOTE_NAME: Final[int] = 2 +BITPHASE_NOTE_RANGE: Final[int] = 12 +BITPHASE_FIRST_OCTAVE: Final[int] = 1 +BITPHASE_TABLE_OFF: Final[int] = -1 +BITPHASE_TABLE_COLUMN_OFFSET: Final[int] = 1 +BITPHASE_ORNAMENT_POSITION: Final[int] = 5 +BITPHASE_ORNAMENT_POSITION_MASK: Final[int] = 0xFF +BITPHASE_SPEED_EFFECT: Final[int] = ord("S") +BITPHASE_FIRST_STEP: Final[int] = 0 +BITPHASE_NOISE_TIMERS: Final[Tuple[int, ...]] = ( + 4, + 8, + 16, + 32, + 64, + 96, + 128, + 160, + 202, + 254, + 380, + 508, + 762, + 1016, + 2034, + 4068, +) MIN_INITIAL_SPEED: Final[int] = 1 MAX_INITIAL_SPEED: Final[int] = 255 +MIN_TEMPO: Final[int] = 0 +MAX_TEMPO: Final[int] = 255 +MIN_PATTERN_LENGTH: Final[int] = 1 +MAX_PATTERN_LENGTH: Final[int] = 256 @dataclass(frozen=True) @@ -60,6 +129,7 @@ class LoadedRow: class LoadedChannel: label: str rows: List[LoadedRow] + effect_column_count: int @dataclass(frozen=True) @@ -70,32 +140,47 @@ class LoadedPattern: @dataclass(frozen=True) -class LoadedInstrumentRow: - pulse_width: int - volume_or_rate: int - envelope: bool - sound_length: int - tone_add: int - tone_accumulation: bool - retrigger: bool - sweep: bool - sweep_rate: int - sweep_shift: int +class LoadedMacro: + values: List[Any] + loop: int @dataclass(frozen=True) class LoadedInstrument: id: str chip_type: str - loop: int name: str - rows: List[LoadedInstrumentRow] + macros: Dict[str, LoadedMacro] @property def number(self) -> int: """The value a pattern's instrument column carries to play this instrument.""" return int(self.id, 36) + def macro(self, field: str) -> LoadedMacro: + """The macro Bitphase reads a field from, which one the instrument leaves out defaults. + + Args: + field: The instrument field, named as Bitphase keys it. + + Returns: + LoadedMacro: The field's own macro, or the single default value it takes. + """ + return self.macros.get(field, LoadedMacro(values=[BITPHASE_MACRO_DEFAULTS[field]], loop=0)) + + def value(self, field: str, tick: int) -> Any: + """The value a field takes on a tick of a sounding note. + + Args: + field: The instrument field, named as Bitphase keys it. + tick: Ticks since the note started. + + Returns: + Any: The value the engine samples for that field. + """ + macro = self.macro(field) + return macro.values[sample_index(tick, len(macro.values), macro.loop)] + @dataclass(frozen=True) class LoadedTable: @@ -103,6 +188,11 @@ class LoadedTable: loop: int name: str rows: List[int] + additive: bool + + def step(self, tick: int) -> int: + """The semitone step the table moves the note by on a tick of a sounding note.""" + return self.rows[sample_index(tick, len(self.rows), self.loop)] @dataclass(frozen=True) @@ -113,6 +203,8 @@ class LoadedSong: interrupt_frequency: int a4_tuning_hz: float initial_speed: int + tempo: int + default_pattern_length: int tuning_table: List[int] patterns: List[LoadedPattern] @@ -128,6 +220,128 @@ class LoadedProject: instruments: List[LoadedInstrument] +def sample_index(tick: int, length: int, loop: int) -> int: + """The index a per-tick list stands at on a tick, as Bitphase's engine advances it. + + An instrument macro and a table each advance one entry per tick and circle once they run + out, from the loop entry where it stands among them and from the first otherwise. Read from + ``sampleInstrumentMacroIndex`` and ``processTables`` of the tracker at commit ``aa91809``. + + Args: + tick: Ticks since the note started. + length: Entries the list holds. + loop: Entry the list circles from. + + Returns: + int: The entry to read. + """ + entries = length if length > 0 else 1 + if tick < entries: + return max(tick, 0) + + start = loop if 0 < loop < entries else 0 + span = entries - start + if span <= 0: + return entries - 1 + + return start + (tick - entries) % span + + +def sounded_period( + tuning_table: List[int], + note_index: int, + instrument: LoadedInstrument, + table: LoadedTable, + tick: int, +) -> int: + """The channel period a tone channel sounds on a tick, as the engine resolves it. + + The table moves the note, the tuning table resolves the period that note sounds at, and the + instrument's tone offset moves it from there. Read from ``_applyToneOffset`` of + ``nes-audio-driver.js`` of the tracker at commit ``aa91809``, which holds the sum within + ``0..2048``, where a period of zero silences the channel. + + Args: + tuning_table: The song's period per note index. + note_index: The note the pattern cell names. + instrument: The instrument the cell triggers. + table: The table the cell attaches. + tick: Ticks since the note started. + + Returns: + int: The period the channel holds, within the timer's range. + """ + moved = reached_note(tuning_table, note_index, table, tick) + period = tuning_table[moved] + int(instrument.value("toneAdd", tick)) + return min(max(period, BITPHASE_SILENT_PERIOD), BITPHASE_MAX_PERIOD) + + +def noise_register(note_index: int) -> int: + """The period register value the noise channel writes for the note it reaches, as the engine resolves it. + + The note it reaches is the base note, its table step and the instrument's tone offset added + together. The driver counts that index down from the top of each cycle of sixteen, and the + register selects the timer from ``BITPHASE_NOISE_TIMERS``, the NTSC table fastest first. Read from + ``resolveNesNoisePeriodFromSemitoneOffset`` of ``nes-audio-driver.js``, the ``$400E`` write of + ``nes-apu-engine.js`` and ``wavlen_table`` of ``nsfplug/nes_dmc.c`` at commit ``aa91809``. + + Args: + note_index: The note the channel reaches, its table step and tone offset added. + + Returns: + int: The value the period register holds. + """ + return BITPHASE_NOISE_PERIOD_COUNT - 1 - note_index % BITPHASE_NOISE_PERIOD_COUNT + + +def reached_note( + tuning_table: List[int], + note_index: int, + table: LoadedTable, + tick: int, +) -> int: + """The note a channel reaches on a tick of a sounding note, held within the tuning table. + + Read from ``processTables`` of ``tracker-pattern-processor.js`` at commit ``aa91809``. + + Args: + tuning_table: The song's period per note index. + note_index: The note the pattern cell names. + table: The table the cell attaches. + tick: Ticks since the table started. + + Returns: + int: The note index the channel sounds. + """ + return min(max(note_index + table.step(tick), 0), len(tuning_table) - 1) + + +def pattern_volume(carried: int, stored: int) -> int: + """The level a channel plays at once a row's stored volume cell is read, as the engine reads it. + + A stored ``-1`` silences the channel, a level above zero replaces the one it carries, and any + other value leaves the carried level alone. A channel opens at the full level. Read from + ``_processVolume`` of ``tracker-pattern-processor.js`` and ``nes-state.js`` of the tracker at + commit ``aa91809``. The triangle sounds a full-level instrument while this level is above zero, + since the driver enables it on the PT3 product of the two, which for a full instrument is the + pattern level itself. + + Args: + carried: The level the channel carries into the row. + stored: The row's stored volume cell. + + Returns: + int: The level the channel plays the row at. + """ + if stored == BITPHASE_STORED_VOLUME_OFF: + return BITPHASE_SILENCED_PATTERN_VOLUME + + if stored > BITPHASE_SILENCED_PATTERN_VOLUME: + return stored + + return carried + + def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: source = data or {} return LoadedNote( @@ -136,6 +350,23 @@ def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: ) +def _table_index(data: Dict[str, Any]) -> Optional[int]: + """The table an effect reads, which Bitphase takes from any index of zero or above. + + A cell stating no index at all is driven by its own parameter, and one carrying an + empty value reads as the first table, since that is what the comparison Bitphase + makes says of it. + """ + if "tableIndex" not in data: + return None + + index = data["tableIndex"] + if index is None: + return BITPHASE_FIRST_TABLE_INDEX + + return index if index >= BITPHASE_FIRST_TABLE_INDEX else None + + def _effect(data: Optional[Dict[str, Any]]) -> Optional[LoadedEffect]: if data is None: return None @@ -144,7 +375,7 @@ def _effect(data: Optional[Dict[str, Any]]) -> Optional[LoadedEffect]: effect=data.get("effect", BITPHASE_DEFAULT_EFFECT), delay=data.get("delay", BITPHASE_DEFAULT_EFFECT_DELAY), parameter=data.get("parameter", BITPHASE_DEFAULT_EFFECT_PARAMETER), - table_index=data.get("tableIndex"), + table_index=_table_index(data), ) @@ -166,12 +397,30 @@ def _row(data: Dict[str, Any]) -> LoadedRow: ) -def _channel(data: Dict[str, Any], label: str) -> LoadedChannel: - rows = data.get("rows") - if rows is None: - return LoadedChannel(label=label, rows=[]) +def _effect_column_count(data: Dict[str, Any], rows: List[LoadedRow]) -> int: + """How many effect columns a channel lays out, which its widest line states. + + Bitphase takes the count the channel carries where it holds one, and reads it off the + lines otherwise, so a channel written without the field lays out as many columns as its + lines fill. + """ + stated = data.get("effectColumnCount") + if isinstance(stated, int): + return min(max(stated, BITPHASE_MIN_EFFECT_COLUMNS), BITPHASE_MAX_EFFECT_COLUMNS) + + return max( + (len(row.effects) for row in rows), + default=BITPHASE_MIN_EFFECT_COLUMNS, + ) - return LoadedChannel(label=label, rows=[_row(row) for row in rows]) + +def _channel(data: Dict[str, Any], label: str) -> LoadedChannel: + rows = [_row(row) for row in data.get("rows") or []] + return LoadedChannel( + label=label, + rows=rows, + effect_column_count=_effect_column_count(data, rows), + ) def _pattern(data: Dict[str, Any], labels: List[str]) -> LoadedPattern: @@ -186,19 +435,16 @@ def _pattern(data: Dict[str, Any], labels: List[str]) -> LoadedPattern: ) -def _instrument_row(data: Dict[str, Any]) -> LoadedInstrumentRow: - return LoadedInstrumentRow( - pulse_width=data.get("pulseWidth", BITPHASE_DEFAULT_PULSE_WIDTH), - volume_or_rate=data.get("volumeOrRate", BITPHASE_DEFAULT_VOLUME_OR_RATE), - envelope=bool(data.get("envelope", False)), - sound_length=data.get("soundLength", 0), - tone_add=data.get("toneAdd", 0), - tone_accumulation=bool(data.get("toneAccumulation", False)), - retrigger=bool(data.get("retrigger", False)), - sweep=bool(data.get("sweep", False)), - sweep_rate=data.get("sweepRate", 0), - sweep_shift=data.get("sweepShift", 0), - ) +def _macro(data: Dict[str, Any]) -> LoadedMacro: + """One macro as Bitphase resolves it, within the values it stores and the loop they hold.""" + values = list(data.get("values") or [])[:BITPHASE_MAX_MACRO_LENGTH] + loop = data.get("loop", BITPHASE_DEFAULT_LOOP) + return LoadedMacro(values=values, loop=min(max(loop, 0), max(len(values) - 1, 0))) + + +def _macros(data: Dict[str, Any]) -> Dict[str, LoadedMacro]: + macros = data.get("macros") or {} + return {field: _macro(macro) for field, macro in macros.items()} def _instrument(data: Dict[str, Any]) -> LoadedInstrument: @@ -207,9 +453,8 @@ def _instrument(data: Dict[str, Any]) -> LoadedInstrument: return LoadedInstrument( id=identifier if isinstance(identifier, str) else BITPHASE_DEFAULT_INSTRUMENT_ID, chip_type=chip_type if isinstance(chip_type, str) else BITPHASE_DEFAULT_CHIP_TYPE, - loop=data.get("loop", BITPHASE_DEFAULT_LOOP), name=data.get("name", BITPHASE_DEFAULT_NAME), - rows=[_instrument_row(row) for row in data.get("rows") or []], + macros=_macros(data), ) @@ -219,6 +464,7 @@ def _table(data: Dict[str, Any]) -> LoadedTable: loop=data.get("loop", BITPHASE_DEFAULT_LOOP), name=data.get("name", BITPHASE_DEFAULT_NAME), rows=list(data.get("rows") or []), + additive=bool(data.get("additive", False)), ) @@ -230,6 +476,28 @@ def _initial_speed(data: Dict[str, Any]) -> int: return BITPHASE_DEFAULT_INITIAL_SPEED +def _tempo(data: Dict[str, Any]) -> int: + """The tempo a song plays at, as ``reconstructSong`` of ``file-import.ts`` reads it. + + The loader floors a tempo within its range and plays any other value, a missing one included, + on the speed clock, which ``song-timeline.js`` runs while the tempo is zero. + """ + tempo = data.get("tempo") + if isinstance(tempo, (int, float)) and MIN_TEMPO <= tempo <= MAX_TEMPO: + return math.floor(tempo) + + return BITPHASE_SPEED_CLOCK_TEMPO + + +def _default_pattern_length(data: Dict[str, Any]) -> int: + """The line count a pattern added to the song takes, within the range Bitphase keeps.""" + length = data.get("defaultPatternLength") + if isinstance(length, int) and MIN_PATTERN_LENGTH <= length <= MAX_PATTERN_LENGTH: + return length + + return BITPHASE_DEFAULT_PATTERN_LENGTH + + def _song(data: Dict[str, Any], labels: List[str]) -> LoadedSong: return LoadedSong( chip_type=data.get("chipType"), @@ -238,6 +506,8 @@ def _song(data: Dict[str, Any], labels: List[str]) -> LoadedSong: interrupt_frequency=data.get("interruptFrequency", BITPHASE_DEFAULT_INTERRUPT_FREQUENCY), a4_tuning_hz=data.get("a4TuningHz", BITPHASE_DEFAULT_A4_TUNING), initial_speed=_initial_speed(data), + tempo=_tempo(data), + default_pattern_length=_default_pattern_length(data), tuning_table=list(data.get("tuningTable") or []), patterns=[_pattern(pattern, labels) for pattern in data.get("patterns") or []], ) @@ -268,3 +538,135 @@ def parse_btp(data: bytes, channel_labels: List[str]) -> LoadedProject: tables=[_table(table) for table in document.get("tables") or []], instruments=[_instrument(instrument) for instrument in document.get("instruments") or []], ) + + +def note_value(note: LoadedNote) -> int: + """The note index a pattern cell names, as ``_processNote`` of the tracker at commit ``aa91809`` reads it.""" + return note.name - BITPHASE_FIRST_NOTE_NAME + (note.octave - BITPHASE_FIRST_OCTAVE) * BITPHASE_NOTE_RANGE + + +def _next_step(position: int, table: LoadedTable) -> int: + """The step a table moves to after a tick, circling from its loop where it stands among the steps.""" + following = position + 1 + if following < len(table.rows): + return following + + return table.loop if 0 < table.loop < len(table.rows) else BITPHASE_FIRST_STEP + + +def row_speeds(document: LoadedProject) -> List[List[int]]: + """The ticks each row of every pattern lasts, the order played once through as the engine reads it. + + The rows run on the speed clock, which ``advancePosition`` of ``song-timeline.js`` keeps while the + song's tempo is zero. The song starts at its initial speed, and a speed effect sets the speed from its row on, the last + one on a row winning across its channels, as ``readLastSpeedCommandOnRow`` of + ``playback-speed.ts`` reads it. Every speed the export states rides a speed effect's own + parameter. + + Args: + document: The document as Bitphase loads it. + + Returns: + List[List[int]]: One list per pattern of the order, one speed per row. + """ + song = document.songs[0] + patterns = {pattern.id: pattern for pattern in song.patterns} + speed = song.initial_speed + speeds: List[List[int]] = [] + for pattern_id in document.pattern_order: + pattern = patterns[pattern_id] + pattern_speeds: List[int] = [] + for row_index in range(pattern.length): + for channel in pattern.channels: + for effect in channel.rows[row_index].effects: + if effect is not None and effect.effect == BITPHASE_SPEED_EFFECT and effect.parameter > 0: + speed = effect.parameter + + pattern_speeds.append(speed) + + speeds.append(pattern_speeds) + + return speeds + + +@dataclass +class _ChannelReplay: + """What one channel carries from row to row while the document plays.""" + + note: Optional[int] = None + table: Optional[LoadedTable] = None + position: int = BITPHASE_FIRST_STEP + + def read(self, row: LoadedRow, tables: Dict[int, LoadedTable]) -> None: + """Moves the channel onto one row: its note, then its table, then its effects. + + Read from ``parsePatternRow``, ``_processNote``, ``_processTable`` and + ``_initChannelOrnamentPosition`` of ``tracker-pattern-processor.js`` at commit ``aa91809``. + """ + if row.note.name == BITPHASE_NOTE_OFF: + self.note = None + elif row.note.name != BITPHASE_NO_NOTE: + self.note = note_value(row.note) + self.position = BITPHASE_FIRST_STEP + + if row.table == BITPHASE_TABLE_OFF: + self.table = None + self.position = BITPHASE_FIRST_STEP + elif row.table > 0: + self.table = tables[row.table - BITPHASE_TABLE_COLUMN_OFFSET] + self.position = BITPHASE_FIRST_STEP + + for effect in row.effects: + if effect is not None and effect.effect == BITPHASE_ORNAMENT_POSITION: + self.position = effect.parameter & BITPHASE_ORNAMENT_POSITION_MASK + + def tick(self, tuning_table: List[int]) -> Optional[int]: + """The note the channel sounds on one tick, its table stepping on after it. + + Read from ``processTables`` of ``tracker-pattern-processor.js`` at commit ``aa91809``. + """ + if self.note is None or self.table is None: + return self.note + + step = self.table.rows[self.position] if self.position < len(self.table.rows) else BITPHASE_FIRST_STEP + self.position = _next_step(self.position, self.table) + return min(max(self.note + step, 0), len(tuning_table) - 1) + + +def played_notes(document: LoadedProject, channel_index: int) -> List[Optional[int]]: + """The note index one channel sounds on each tick, the order played once through as the engine reads it. + + Args: + document: The document as Bitphase loads it. + channel_index: The channel whose notes are read. + + Returns: + List[Optional[int]]: One note per tick, and ``None`` where the channel holds no note. + """ + song = document.songs[0] + patterns = {pattern.id: pattern for pattern in song.patterns} + tables = {table.id: table for table in document.tables} + replay = _ChannelReplay() + notes: List[Optional[int]] = [] + for pattern_id, speeds in zip(document.pattern_order, row_speeds(document)): + pattern = patterns[pattern_id] + for row, speed in zip(pattern.channels[channel_index].rows, speeds): + replay.read(row, tables) + notes.extend(replay.tick(song.tuning_table) for _ in range(speed)) + + return notes + + +def cell_pitch(note: NoteCell) -> int: + """The pitch a pattern cell's note column names.""" + return (note.name - int(NoteName.C)) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE + NOTE_INDEX_PITCH_OFFSET + + +def replayed_pitches(document: BitphaseProject, channel_name: ChannelName) -> List[Optional[int]]: + """What Bitphase sounds on one channel each tick: the pitch, or on noise the period register.""" + loaded = parse_btp(project_to_bytes(document), list(CHANNEL_LABELS)) + notes = played_notes(loaded, int(CHANNEL_TO_INDEX[channel_name])) + if channel_name == ChannelName.NOISE: + return [None if index is None else noise_register(index) for index in notes] + + return [None if index is None else index + MIN_PLAYED_PITCH for index in notes] diff --git a/tests/suite/bootstrap.py b/tests/suite/bootstrap.py new file mode 100644 index 000000000..bb7ff5258 --- /dev/null +++ b/tests/suite/bootstrap.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Final, List, Mapping, Optional, Sequence, Tuple + +from bootstrap.layout import PROJECT_FILE +from bootstrap.project import BUILD_EXTRA, DEVELOPMENT_GROUP, GPU_CUDA11_EXTRA, GPU_EXTRA + +PROJECT_NAME: Final[str] = "sampletones" +PROJECT_VERSION: Final[str] = "0.3.0" +PROJECT_DOCUMENT: Final[str] = f""" +[project] +name = "{PROJECT_NAME}" +version = "{PROJECT_VERSION}" + +[project.scripts] +{PROJECT_NAME} = "{PROJECT_NAME}.__main__:main" + +[project.optional-dependencies] +{BUILD_EXTRA} = ["pyinstaller"] +{GPU_EXTRA} = ["cupy-cuda12x"] +{GPU_CUDA11_EXTRA} = ["cupy-cuda11x"] + +[dependency-groups] +{DEVELOPMENT_GROUP} = ["pytest"] + +[tool.hatch.build.targets.wheel] +packages = ["src/{PROJECT_NAME}", "src/{PROJECT_NAME}_core"] +""" + + +@dataclass(frozen=True) +class RecordedCommand: + """One command a recording runner was asked to run. + + Attributes: + command: The program and its arguments. + cwd: The directory it was to run in. + environment: The variables it was to see. + quiet: Whether its output was to be captured. + """ + + command: Tuple[str, ...] + cwd: Path + environment: Dict[str, str] + quiet: bool + + @property + def line(self) -> str: + """The command as one line.""" + return " ".join(self.command) + + +class RecordingRunner: + """A runner that records every command and answers with the status a test assigned it. + + A status is assigned by a fragment of the command line, so a probe such as ``import pyaudio`` + can be made to fail while everything else succeeds. A callback runs on every command, which + lets a test leave behind the files a real command would have written. + """ + + def __init__( + self, + statuses: Mapping[str, int], + on_run: Optional[Callable[[Sequence[str]], None]], + ) -> None: + self.commands: List[RecordedCommand] = [] + self._statuses = dict(statuses) + self._on_run = on_run + + def __call__( + self, + command: Sequence[str], + *, + cwd: Path, + environment: Mapping[str, str], + quiet: bool, + ) -> int: + recorded = RecordedCommand( + command=tuple(command), + cwd=cwd, + environment=dict(environment), + quiet=quiet, + ) + self.commands.append(recorded) + if self._on_run is not None: + self._on_run(command) + + for fragment, status in self._statuses.items(): + if fragment in recorded.line: + return status + + return 0 + + @property + def lines(self) -> List[str]: + """Every recorded command as one line, in the order run.""" + return [recorded.line for recorded in self.commands] + + +def write_project(root: Path) -> Path: + """Writes a ``pyproject.toml`` under ``root`` stating what the scripts read, and answers with ``root``.""" + root.mkdir(parents=True, exist_ok=True) + (root / PROJECT_FILE).write_text(PROJECT_DOCUMENT, encoding="utf-8") + return root diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 45eff6f26..937ad1478 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -1,8 +1,21 @@ +from __future__ import annotations + from collections import defaultdict from dataclasses import dataclass, replace from pathlib import Path from textwrap import dedent -from typing import AbstractSet, Dict, Final, Iterable, List, Mapping, Optional, Sequence, Set, Tuple +from typing import ( + AbstractSet, + Dict, + Final, + Iterable, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, +) from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.ui.elements.tree.colors import TreeColors @@ -134,7 +147,7 @@ class _Row: def label(self) -> str: return self.text.removesuffix(HIDDEN_MARKER) - def opened(self) -> "_Row": + def opened(self) -> _Row: return replace(self, marker=OPEN_MARKER) if self.marker == CLOSED_MARKER else self @@ -218,7 +231,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 +240,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 +248,7 @@ def config_fields( nf=nes_frequency, sm=spectrum_method, tg=transformation_gamma, - gn=generators, + gn=channels, ch=config_hash, ) @@ -245,7 +258,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 +266,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 +274,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 +282,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 +290,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 +298,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 +461,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" @@ -540,17 +553,6 @@ def set_row_expanded( panel._expansion.remember(panel._generate_node_tag(node), expanded=expanded) -def set_filter( - panel: GUITreePanel, - *, - favorites_only: bool, - query: str = "", -) -> None: - """States what the browser is now asked to show, as a change of the control or the search box.""" - panel._filter = TreeFilter(query=query, favorites_only=favorites_only) - panel._resolve_filter() - - def select_favorites(panel: GUITreePanel) -> None: """Switches the favorites mode on the way the reader's click does, and resolves the pass it starts. diff --git a/tests/suite/clipboard.py b/tests/suite/clipboard.py new file mode 100644 index 000000000..ef93bb2c8 --- /dev/null +++ b/tests/suite/clipboard.py @@ -0,0 +1,42 @@ +from typing import List, Optional + +from sampletones_application.utils.gui.clipboard.protocol import ClipboardTextCallback + + +class FakeTextClipboard: + """The desktop's clipboard, held in memory so a test reads what a copy put there. + + A read is answered at once, the way DearPyGui's own clipboard answers, until a case holds the + answers back to stand for an application that hands its text over later, or none at all. + """ + + def __init__(self) -> None: + self.text: str = "" + self.unanswered: List[ClipboardTextCallback] = [] + self._answers_held: bool = False + + def read(self, on_text: ClipboardTextCallback) -> None: + if self._answers_held: + self.unanswered.append(on_text) + return + + on_text(self.text) + + def write(self, text: str) -> None: + self.text = text + + def hold_answers(self) -> None: + self._answers_held = True + + def answer(self) -> None: + """Hands the text standing now to every read still waiting, in the order they asked.""" + self._hand_over(self.text) + + def silence(self) -> None: + """Leaves every read still waiting with no answer, the way an owner that never replies does.""" + self._hand_over(None) + + def _hand_over(self, text: Optional[str]) -> None: + waiting, self.unanswered = self.unanswered, [] + for on_text in waiting: + on_text(text) diff --git a/tests/suite/commands.py b/tests/suite/commands.py new file mode 100644 index 000000000..c10ba206a --- /dev/null +++ b/tests/suite/commands.py @@ -0,0 +1,26 @@ +from pathlib import Path +from typing import Dict, List, Optional + + +class RecordedApplication: + """A stand-in for the application launcher that records what each start was given.""" + + def __init__(self) -> None: + self.starts: List[Dict[str, Optional[Path]]] = [] + + def __call__( + self, + config_path: Optional[Path] = None, + *, + library_path: Optional[Path] = None, + reconstruction_path: Optional[Path] = None, + project_path: Optional[Path] = None, + ) -> None: + self.starts.append( + { + "config": config_path, + "library": library_path, + "reconstruction": reconstruction_path, + "project": project_path, + } + ) diff --git a/tests/suite/compatibility.py b/tests/suite/compatibility.py new file mode 100644 index 000000000..0e985459c --- /dev/null +++ b/tests/suite/compatibility.py @@ -0,0 +1,87 @@ +import io +import json +import zipfile +from pathlib import Path +from typing import Any, Dict, Final, Optional + +import msgpack + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import read_version +from sampletones_core.data.document import decompress_document +from sampletones_tools.compatibility.paths import CORPUS_DIRECTORY, archived_path + +RECONSTRUCTION_VERSION: Final[str] = "2.1" +LIBRARY_VERSION: Final[str] = "2.0" +PROJECT_VERSION: Final[str] = "1.0" +WRITING_RELEASE: Final[str] = "0.3.1" +PROJECT_DOCUMENT: Final[str] = "project.json" +FORMAT_VERSION_FIELD: Final[str] = "format_version" +METADATA_FIELD: Final[str] = "metadata" +VERSION_FIELD: Final[str] = "version" + +ARCHIVED_VERSIONS: Final[Dict[ObjectKind, str]] = { + ObjectKind.RECONSTRUCTION: RECONSTRUCTION_VERSION, + ObjectKind.LIBRARY: LIBRARY_VERSION, + ObjectKind.PROJECT: PROJECT_VERSION, +} + + +def archived(kind: ObjectKind, version: str) -> Path: + """The archived file one format wrote at ``version``, which the corpus keeps.""" + return archived_path(kind, version, CORPUS_DIRECTORY) + + +def stored_document(path: Path) -> Dict[str, Any]: + """What one stored file holds, read without a model so the version can be read first. + + A project keeps its document inside an archive, while a reconstruction and a library are the + payload itself, deflated by the builds that frame their documents, so this answers with the + mapping whichever of these the file is. + """ + if path.suffix == ".stp": + with zipfile.ZipFile(path) as archive: + document: Dict[str, Any] = json.loads(archive.read(PROJECT_DOCUMENT)) + return document + + payload: Dict[str, Any] = msgpack.unpackb(decompress_document(path.read_bytes()), raw=False) + return payload + + +def stored_version(kind: ObjectKind, path: Path) -> Optional[str]: + """The data version an archived file states, read off the stored payload where its format's load reads it.""" + return read_version(kind, stored_document(path)) + + +def writing_release(path: Path) -> Optional[str]: + """The release that wrote an archived file, which every stored document names.""" + document = stored_document(path) + metadata = document.get(METADATA_FIELD, {}) + release = metadata.get(VERSION_FIELD) + return release if isinstance(release, str) else None + + +def restated_document(path: Path, kind: ObjectKind, version: str) -> bytes: + """The bytes of the archived file at ``path`` with its stored data version replaced by ``version``. + + Everything else stays as the writing build left it, so a load refusing the result refuses it + for its version alone. + """ + document = stored_document(path) + if kind is ObjectKind.PROJECT: + document[FORMAT_VERSION_FIELD] = version + return _rearchived(path, json.dumps(document).encode()) + + document[METADATA_FIELD][f"{kind.value}_data_version"] = version + packed: bytes = msgpack.packb(document, use_bin_type=True) + return packed + + +def _rearchived(path: Path, project_document: bytes) -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(path) as source, zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target: + for member in source.infolist(): + content = project_document if member.filename == PROJECT_DOCUMENT else source.read(member) + target.writestr(member, content) + + return buffer.getvalue() diff --git a/tests/suite/conversion.py b/tests/suite/conversion.py new file mode 100644 index 000000000..07c263941 --- /dev/null +++ b/tests/suite/conversion.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import AbstractSet, Sequence + +from automation.holds.conversion import COUNTED_STAGES, FAKE_FRAMES, HALFWAY +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +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 + + +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, + channels: AbstractSet[ChannelName], + release_path: Path, + *, + frames: int = FAKE_FRAMES, + ) -> None: + self.config = config + self.channels = channels + 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/device.py b/tests/suite/device.py new file mode 100644 index 000000000..6e2f5bdec --- /dev/null +++ b/tests/suite/device.py @@ -0,0 +1,70 @@ +from typing import Any, Callable, Optional + +import numpy as np + +from sampletones_core.constants.audio import START_OF_AUDIO + + +class FakeAudioDevice: + """The shared output as a player sees it: who holds it, where it stands, and whether it waits. + + It follows `AudioDeviceManager` in the facts a player reads — ownership, the clamped position, + the pause — so a player's logic runs against it inside the unit suite. + """ + + def __init__(self) -> None: + self.owner: Optional[Any] = None + self.paused: bool = False + self.position: int = START_OF_AUDIO + self.audio: Optional[np.ndarray] = None + + def set_position_callback(self, _callback: Optional[Callable[[int], None]]) -> None: + return None + + def replace_audio(self, _audio: np.ndarray, *, owner: Optional[Any] = None) -> bool: + return False + + def play( + self, + audio: np.ndarray, + *, + priority: int, + owner: Optional[Any], + start: int, + ) -> bool: + self.audio = audio + self.owner = owner + self.paused = False + self.position = self._clamped(start) + return True + + def pause(self) -> None: + self.paused = True + + def resume(self) -> None: + self.paused = False + + def stop(self) -> None: + self.owner = None + self.paused = False + self.position = START_OF_AUDIO + + def set_position(self, position: int, *, owner: Optional[Any]) -> bool: + if self.audio is None or not self.is_owned_by(owner): + return False + + self.position = self._clamped(position) + return True + + def is_owned_by(self, owner: Any) -> bool: + return owner is not None and self.owner is owner + + def is_paused(self) -> bool: + return self.paused + + def position_of(self, owner: Any) -> int: + return self.position if self.is_owned_by(owner) else START_OF_AUDIO + + def _clamped(self, position: int) -> int: + length = 0 if self.audio is None else len(self.audio) + return max(START_OF_AUDIO, min(position, length)) diff --git a/tests/suite/famitracker.py b/tests/suite/famitracker.py index d1245c514..ca338e1f6 100644 --- a/tests/suite/famitracker.py +++ b/tests/suite/famitracker.py @@ -1,8 +1,14 @@ import struct -from dataclasses import dataclass -from typing import Dict, List, Tuple - +from dataclasses import dataclass, field +from typing import Dict, Final, List, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MIN_PLAYED_PITCH +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.model.pattern import RowCell +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH +from sampletones_core.formats.famitracker.specification.channels import CHANNEL_TO_ID from sampletones_core.formats.famitracker.specification.file import ( FTM_END_MARKER, FTM_MAGIC, @@ -11,9 +17,178 @@ DPCM_KEY_ASSIGNMENTS, DPCM_KEY_BYTES, ) +from sampletones_core.formats.famitracker.specification.patterns import NOTE_RANGE, PITCH_OCTAVE_OFFSET, NoteValue from sampletones_core.formats.famitracker.specification.sequences import ( SEQUENCE_COUNT_2A03, ) +from sampletones_core.project.project import Project +from sampletones_core.timing.bounds import SONG_TICK_BOUNDS +from sampletones_core.timing.song import SongTiming + +FAMITRACKER_OPENING_VOLUME: Final[int] = 15 +FAMITRACKER_EMPTY_VOLUME: Final[int] = 0x10 +FAMITRACKER_EMPTY_INSTRUMENT: Final[int] = 0x40 +FAMITRACKER_FIRST_NOTE: Final[int] = 1 +FAMITRACKER_LAST_NOTE: Final[int] = 12 +FAMITRACKER_HALT: Final[int] = 14 +FAMITRACKER_NOTE_RANGE: Final[int] = 12 +FAMITRACKER_NOTE_COUNT: Final[int] = 96 +FAMITRACKER_NOISE_CHANNEL: Final[int] = 3 +FAMITRACKER_NOISE_MASK: Final[int] = 0x0F +FAMITRACKER_SLIDE_UP: Final[int] = 20 +FAMITRACKER_SLIDE_DOWN: Final[int] = 21 +FAMITRACKER_SLIDE_SEMITONES_MASK: Final[int] = 0x0F +FAMITRACKER_ARPEGGIO: Final[int] = 1 +FAMITRACKER_NO_LOOP: Final[int] = -1 + + +def column_volume(carried: int, stored: int) -> int: + """The level a channel plays at once a row's volume cell is read, as FamiTracker reads it. + + A stored level below the empty value replaces the level the channel carries, and the empty value + leaves it alone. A channel opens at the full level. The triangle sounds while this level is above + zero and its instrument's volume is too. Read from ``CChannelHandler::ResetChannel`` and + ``CChannelHandler::HandleNoteData`` in ``ChannelHandler.cpp`` and ``CTriangleChan::RefreshChannel`` in + ``Channels2A03.cpp`` of 0CC-FamiTracker 0.3.15.3. + + Args: + carried: The level the channel carries into the row. + stored: The row's stored volume cell. + + Returns: + int: The level the channel plays the row at. + """ + if stored < FAMITRACKER_EMPTY_VOLUME: + return stored + + return carried + + +@dataclass +class _ArpeggioRun: + """An instrument's arpeggio sequence as the channel steps through it, one item per tick.""" + + items: List[int] + loop_point: int + pointer: int = field(default=0) + running: bool = field(default=True) + + def step(self) -> int: + """The item this tick reads, the sequence moving on after it and halting past its end without a loop.""" + item = self.items[self.pointer] + self.pointer += 1 + if self.pointer >= len(self.items): + if self.loop_point == FAMITRACKER_NO_LOOP: + self.running = False + else: + self.pointer = self.loop_point + + return item + + +@dataclass +class _ChannelReplay: + """What one channel carries from row to row while the module plays.""" + + noise: bool + note: Optional[int] = None + sounded: Optional[int] = None + instrument: int = field(default=FAMITRACKER_EMPTY_INSTRUMENT) + arpeggio: Optional[_ArpeggioRun] = None + + def read(self, row: "ParsedRow", arpeggios: Dict[int, Optional[_ArpeggioRun]]) -> None: + """Moves the channel onto one row: its slides, then its note, which triggers the instrument. + + Read from ``CChannelHandler::HandleNoteData`` and ``CChannelHandler::SetupSlide`` in + ``ChannelHandler.cpp`` and ``CNoiseChan::HandleNote`` in ``Channels2A03.cpp`` of 0CC-FamiTracker + 0.3.15.3. A slide moves the note by its semitones at once, and a halt silences the channel. + """ + for effect, parameter in row.effects: + if self.note is None: + continue + if effect == FAMITRACKER_SLIDE_UP: + self.note += parameter & FAMITRACKER_SLIDE_SEMITONES_MASK + elif effect == FAMITRACKER_SLIDE_DOWN: + self.note -= parameter & FAMITRACKER_SLIDE_SEMITONES_MASK + + if row.note == FAMITRACKER_HALT: + self.note = None + elif FAMITRACKER_FIRST_NOTE <= row.note <= FAMITRACKER_LAST_NOTE: + value = row.octave * FAMITRACKER_NOTE_RANGE + row.note - FAMITRACKER_FIRST_NOTE + self.note = value & FAMITRACKER_NOISE_MASK if self.noise else value + self.sounded = self._resolved(self.note) + if row.instrument != FAMITRACKER_EMPTY_INSTRUMENT: + self.instrument = row.instrument + + template = arpeggios.get(self.instrument) + self.arpeggio = None if template is None else _ArpeggioRun(template.items, template.loop_point) + + def tick(self) -> Optional[int]: + """The note the channel sounds on one tick. + + While the arpeggio runs it reloads the period from the note moved by its item every tick; + once it halts, or where the instrument has none, the period stays where it was. Read from + ``CSeqInstHandler::ProcessSequence`` and ``CSeqInstHandler::UpdateInstrument`` in + ``SeqInstHandler.cpp`` of 0CC-FamiTracker 0.3.15.3. + """ + if self.note is None: + return None + + if self.arpeggio is not None and self.arpeggio.running: + self.sounded = self._resolved(self.note + self.arpeggio.step()) + + return self.sounded + + def _resolved(self, note: int) -> int: + """The note a period is read for: the low four bits on noise, the note table's range elsewhere.""" + if self.noise: + return note & FAMITRACKER_NOISE_MASK + + return min(max(note, 0), FAMITRACKER_NOTE_COUNT - 1) + + +def played_notes( + module: "ParsedModule", + channel_id: int, + timing: SongTiming, +) -> List[Optional[int]]: + """The note one channel sounds on each tick, the order played once through as the tracker reads it. + + The song's timing is given, so the replay follows the rows of a song whose timing it compares + against. The note counts semitones from C-0 on the tonal channels and is the period on noise. + + Args: + module: The module as it was written. + channel_id: The channel whose notes are read. + timing: How many ticks every row of the song lasts. + + Returns: + List[Optional[int]]: One note per tick, and ``None`` where the channel holds no note. + """ + sequences = {(sequence.sequence_type, sequence.index): sequence for sequence in module.sequences} + arpeggios: Dict[int, Optional[_ArpeggioRun]] = {} + for instrument in module.instruments: + enabled, index = instrument.sequence_refs[FAMITRACKER_ARPEGGIO] + arpeggio = sequences[(FAMITRACKER_ARPEGGIO, index)] if enabled else None + arpeggios[instrument.index] = None if arpeggio is None else _ArpeggioRun(arpeggio.items, arpeggio.loop_point) + + patterns = { + pattern.index: {row.row_number: row for row in pattern.rows} + for pattern in module.patterns + if pattern.channel == channel_id + } + replay = _ChannelReplay(noise=channel_id == FAMITRACKER_NOISE_CHANNEL) + notes: List[Optional[int]] = [] + for frame_index, frame in enumerate(module.frames.order): + rows = patterns.get(frame[channel_id], {}) + for row_number in range(module.frames.pattern_length): + row = rows.get(row_number) + if row is not None: + replay.read(row, arpeggios) + + notes.extend(replay.tick() for _ in range(timing.row_ticks(frame_index, row_number))) + + return notes class _Cursor: @@ -294,7 +469,7 @@ def _parse_patterns( octave = cursor.read_int8() instrument = cursor.read_int8() volume = cursor.read_int8() - effects = [(cursor.read_int8(), cursor.read_int8()) for _ in range(effect_columns_by_channel[channel])] + effects = [(cursor.read_int8(), cursor.read_uint8()) for _ in range(effect_columns_by_channel[channel])] rows.append( ParsedRow( row_number=row_number, @@ -358,3 +533,40 @@ def parse_ftm(data: bytes) -> ParsedModule: EXPECTED_SEQUENCE_COUNT = SEQUENCE_COUNT_2A03 + + +def module_cell( + document: FamiTrackerModule, + channel_name: ChannelName, + pattern_index: int, + row_number: int, +) -> Optional[RowCell]: + """The cell a module's pattern stores at a row, or ``None`` where the row is empty.""" + channel_id = CHANNEL_TO_ID[channel_name] + for pattern in document.track.patterns: + if pattern.channel == channel_id and pattern.index == pattern_index: + return next((row for row in pattern.rows if row.row_number == row_number), None) + + return None + + +def cell_pitch(cell: RowCell) -> int: + """The pitch a pattern cell's note and octave name.""" + return (cell.octave + PITCH_OCTAVE_OFFSET) * NOTE_RANGE + cell.note - int(NoteValue.C) + + +def replayed_pitches( + document: FamiTrackerModule, + project: Project, + channel_name: ChannelName, +) -> List[Optional[int]]: + """What FamiTracker sounds on one channel each tick: the pitch, or on noise the period register.""" + notes = played_notes( + parse_ftm(module_to_ftm_bytes(document)), + int(CHANNEL_TO_ID[channel_name]), + SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS), + ) + if channel_name == ChannelName.NOISE: + return [None if value is None else MAX_PERIOD - value for value in notes] + + return [None if value is None else value + MIN_PLAYED_PITCH for value in notes] diff --git a/tests/suite/files.py b/tests/suite/files.py new file mode 100644 index 000000000..4b498db6c --- /dev/null +++ b/tests/suite/files.py @@ -0,0 +1,63 @@ +import os +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Final, Iterator + +import pytest + +OPEN_FOLDER: Final[int] = 0o755 +LOCKED_FOLDER: Final[int] = 0o000 +NAMES_ONLY_FOLDER: Final[int] = 0o444 +FOLDER_PERMISSIONS_HOLD: Final[bool] = sys.platform != "win32" and os.geteuid() != 0 + +requires_folder_permissions = pytest.mark.skipif( + not FOLDER_PERMISSIONS_HOLD, + reason="a folder's permissions lock it on a POSIX system for a reader other than root", +) + + +def _symlinks_are_permitted() -> bool: + """Reports whether this machine lets an unprivileged process create a symlink. + + Windows grants the privilege only under Developer Mode or elevation, so the probe + creates one in a throwaway directory and reads the answer from the attempt. + """ + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + try: + (root / "probe").symlink_to(root, target_is_directory=True) + except OSError: + return False + + return True + + +SYMLINKS_PERMITTED: Final[bool] = _symlinks_are_permitted() + +requires_symlinks = pytest.mark.skipif( + not SYMLINKS_PERMITTED, + reason="creating a symlink requires a privilege this machine withholds", +) + + +def empty_file(directory: Path, name: str) -> Path: + """Writes an empty file called ``name`` under ``directory``, which a check that reads only names and presence accepts.""" + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + return path + + +@contextmanager +def held_at(folder: Path, mode: int) -> Iterator[Path]: + """Holds ``folder`` at the permissions ``mode`` while the block runs, and opens it again after. + + The folder opens again however the block ends, so the case's temporary tree can be cleared. + """ + folder.chmod(mode) + try: + yield folder + finally: + folder.chmod(OPEN_FOLDER) diff --git a/tests/suite/fragments.py b/tests/suite/fragments.py new file mode 100644 index 000000000..afa5c640f --- /dev/null +++ b/tests/suite/fragments.py @@ -0,0 +1,22 @@ +import numpy as np + +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor + + +def amplified(extractor: FeatureExtractor, fragment: Fragment, gain: float) -> Fragment: + """The fragment played ``gain`` times as loud. + + A waveform follows the gain and the power a feature describes follows its square, so the + feature is scaled as power through the transform, in double precision. A case reads this + where it needs one recording louder than another, which is what a stem carrying more sound + stands on. + """ + power_gain = gain**2 + precision = fragment.feature.values.dtype + feature = extractor.transformer.apply(lambda power: power * power_gain, fragment.feature.astype(np.float64)) + return Fragment( + audio=fragment.audio * gain, + feature=feature.astype(precision), + config=fragment.config, + ) diff --git a/tests/suite/frames.py b/tests/suite/frames.py new file mode 100644 index 000000000..7236013e4 --- /dev/null +++ b/tests/suite/frames.py @@ -0,0 +1,90 @@ +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_shared.types.callback import VoidCallback + +ONE_FRAME: Final[int] = 1 + + +class Frames: + """The frames a widget defers work to, carried out when a case says one was rendered. + + DearPyGui runs a frame callback from the render loop, which a suite never starts, so work a + widget holds back until its rows are placed waits here. Holding it rather than running it as + it arrives is what lets a case say how many frames passed: a region asking for another pass + gets one per rendered frame, the way it would on screen, and a case reads how much work still + stands waiting. + """ + + def __init__(self) -> None: + self._held: List[Tuple[int, VoidCallback]] = [] + + def hold(self, callback: VoidCallback, frame_count: int = ONE_FRAME) -> None: + """Take work a widget hands over, standing in for ``FrameCallbackManager``. + + ``frame_count`` is how many frames the work waits through, which is what the manager + counts from the frame the widget handed it over on. + """ + self._held.append((max(ONE_FRAME, frame_count), callback)) + + @property + def pending(self) -> int: + """How many pieces of work stand waiting on a frame.""" + return len(self._held) + + def render(self, frames: int = ONE_FRAME) -> None: + """Carry out the work each of this many frames would, in the order it was handed over.""" + for _ in range(frames): + counted = [(waiting - 1, callback) for waiting, callback in self._held] + due = [callback for waiting, callback in counted if not waiting] + self._held = [(waiting, callback) for waiting, callback in counted if waiting] + for callback in due: + callback() + + +@pytest.fixture +def held_frames(monkeypatch: pytest.MonkeyPatch) -> Frames: + """``FrameCallbackManager`` holding the work handed to it until the case renders a frame.""" + frames = Frames() + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", frames.hold) + return frames + + +VISIBLE_HANDLER: Final[str] = "mvAppItemType::mvVisibleHandler" + + +class DrawnFrames: + """The frames a widget waiting on an item's visible handler is drawn in, rendered by a case. + + DearPyGui reports an item visible on each frame it draws it in, and a suite renders no frame, so + a case says how many frames passed and whether the widgets stood on screen through them. Each + frame runs every visible handler standing on, the way DearPyGui would for an item it drew. + """ + + def __init__(self) -> None: + self.on_screen = True + + @property + def pending(self) -> int: + """How many visible handlers stand on, waiting for a frame their item is drawn in.""" + return len(self._watching()) + + def render(self, frames: int = ONE_FRAME) -> None: + """Run the visible handlers standing on through this many frames, where the items are drawn.""" + for _ in range(frames): + if not self.on_screen: + continue + + for handler in self._watching(): + dpg.get_item_callback(handler)() + + @staticmethod + def _watching() -> List[int]: + return [ + item + for item in dpg.get_all_items() + if dpg.get_item_type(item) == VISIBLE_HANDLER and dpg.get_item_configuration(item)["show"] + ] diff --git a/tests/suite/gates.py b/tests/suite/gates.py new file mode 100644 index 000000000..5c54f9f9c --- /dev/null +++ b/tests/suite/gates.py @@ -0,0 +1,40 @@ +from typing import List + +import pytest + +from sampletones_shared.types.callback import VoidCallback + + +class HeldGate: + """A gate a case opens by hand, the way the open reconstruction's edits hold a gesture until they land.""" + + def __init__(self) -> None: + self.holding = False + self._held: List[VoidCallback] = [] + + def __call__(self, proceed: VoidCallback) -> None: + if self.holding: + self._held.append(proceed) + return + + proceed() + + @property + def held(self) -> int: + """How many gestures wait at the gate.""" + return len(self._held) + + def release(self) -> None: + """Opens the gate, which runs every gesture waiting at it in the order it arrived.""" + self.holding = False + held, self._held = self._held, [] + for proceed in held: + proceed() + + +@pytest.fixture +def held_gate() -> HeldGate: + """A gate holding every gesture that reaches it until the case releases it.""" + gate = HeldGate() + gate.holding = True + return gate diff --git a/tests/suite/gestures.py b/tests/suite/gestures.py new file mode 100644 index 000000000..4f17b0e27 --- /dev/null +++ b/tests/suite/gestures.py @@ -0,0 +1,30 @@ +from typing import Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import SUF_TEXT +from sampletones_application.ui.elements.stems.tags import StemsTags + +CLICKED: Final[str] = "mvAppItemType::mvClickedHandler" +DOUBLE_CLICKED: Final[str] = "mvAppItemType::mvDoubleClickedHandler" +HOVERED: Final[str] = "mvAppItemType::mvHoverHandler" + + +def handler_of(registry: str, kind: str) -> int: + """The handler answering one kind of gesture in a registry, found by what it is. + + A registry holds its handlers in the order they were added, so reading one by its position + names a different gesture as soon as another is registered beside it. + """ + for handler in dpg.get_item_children(registry, 1): + if dpg.get_item_info(handler)["type"] == kind: + return int(handler) + + raise AssertionError(f"{registry} registers no {kind}") + + +def click_row_name(tags: StemsTags, key: str, *, kind: str, button: int) -> None: + """Land a mouse gesture on one row's name the way DearPyGui reports one.""" + name_tag = tags.row(key, SUF_TEXT) + callback = dpg.get_item_callback(handler_of(tags.handlers(SUF_TEXT), kind)) + callback(name_tag, (button, dpg.get_alias_id(name_tag))) diff --git a/tests/suite/groove.py b/tests/suite/groove.py new file mode 100644 index 000000000..bbb296acb --- /dev/null +++ b/tests/suite/groove.py @@ -0,0 +1,53 @@ +from fractions import Fraction +from itertools import accumulate +from math import ceil, floor +from typing import List, Sequence, Tuple + +from sampletones_core.timing.meter import Meter + + +def bar_rows(meter: Meter, frames: int) -> Tuple[int, ...]: + """The row every bar of ``frames`` patterns starts on, counted from the song's first row. + + The meter restarts at every pattern, so each pattern opens a bar. + """ + starts: List[int] = [] + for frame in range(frames): + row = frame * meter.rows + for beats in meter.spans: + starts.append(row) + row += sum(beats) + + return tuple(starts) + + +def bar_line_drift( + ticks: Sequence[int], + starts: Sequence[int], + row_ticks: Fraction, +) -> Fraction: + """The furthest a bar starts from its exact start, in ticks, over a run of rows. + + A row's exact start is its index times ``row_ticks``, counted from the run's first row, and its + played start is the ticks every row before it lasted. + + Args: + ticks: The ticks each row of the run lasts. + starts: The row every bar starts on. + row_ticks: The exact ticks one row lasts. + + Returns: + Fraction: The largest gap between a bar's played start and its exact one. + """ + played = (0, *accumulate(ticks)) + return max(abs(played[row] - row * row_ticks) for row in starts) + + +def is_proportional(ticks: Sequence[int], row_ticks: Fraction) -> bool: + """Whether every row lasts the floor or the ceiling of its exact length.""" + return all(floor(row_ticks) <= row <= ceil(row_ticks) for row in ticks) + + +def surplus_rows(beat: Sequence[int], row_ticks: Fraction) -> Tuple[int, ...]: + """The rows of a beat that last the ceiling of a fractional row length, by their place in the beat.""" + return tuple(index for index, row in enumerate(beat) if row > floor(row_ticks)) diff --git a/tests/suite/headless.py b/tests/suite/headless.py new file mode 100644 index 000000000..ddce2f68e --- /dev/null +++ b/tests/suite/headless.py @@ -0,0 +1,113 @@ +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import Any, Final, Iterator, List, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.application import Application +from sampletones_application.config.profile import UserProfile +from sampletones_application.utils.parallelization.background import stop_background_workers +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.configs import Config +from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY + +DISPLAY_FUNCTIONS: Final[Tuple[str, ...]] = ( + "create_context", + "create_viewport", + "setup_dearpygui", + "show_viewport", + "render_dearpygui_frame", + "set_viewport_clear_color", + "set_viewport_pos", + "set_viewport_width", + "set_viewport_height", + "set_viewport_title", + "set_viewport_decorated", + "set_viewport_resize_callback", + "toggle_viewport_fullscreen", + "set_exit_callback", + "set_primary_window", +) +VIEWPORT_CLIENT_WIDTH: Final[int] = 1280 +VIEWPORT_CLIENT_HEIGHT: Final[int] = 720 + + +def display_patches() -> List[Any]: + """The display calls a headless run stands in for, and the queue it keeps from starting.""" + patches = [patch(f"dearpygui.dearpygui.{name}", return_value=None) for name in DISPLAY_FUNCTIONS] + patches.append( + patch( + "dearpygui.dearpygui.get_viewport_client_width", + return_value=VIEWPORT_CLIENT_WIDTH, + ) + ) + patches.append( + patch( + "dearpygui.dearpygui.get_viewport_client_height", + return_value=VIEWPORT_CLIENT_HEIGHT, + ) + ) + patches.append(patch("sampletones_application.utils.callbacks.queue.CallbackQueue.start")) + return patches + + +def profile_in(directory: Path) -> UserProfile: + """Starts the application on a profile of its own, in the state a first run finds. + + The settings and the keys an application comes up on are read from its profile, so a suite + given the user's own answers for whatever that machine prefers. A directory per test is what + holds a run to the shipped defaults. + """ + return UserProfile( + config=directory / "config.yaml", + state=directory / "state.yaml", + ) + + +def settings_in(directory: Path) -> Path: + """Writes the settings a run starts on: the shipped defaults, with the instruction library and + the reconstructions held in the test's own directory. + + A startup lists the reconstructions and loads the library from the directories its settings + name, so a run reads only the files a test places beside it. + """ + config = Config() + general = config.general.model_copy( + update={ + "library_directory": str(directory / LIBRARY_DIRECTORY.name), + "reconstructions_directory": str(directory / RECONSTRUCTIONS_DIRECTORY.name), + } + ) + path = directory / CONFIG_PATH.name + config.model_copy(update={"general": general}).save(path) + return path + + +def headless_application(directory: Path) -> Application: + """The whole application on a profile and settings of its own in ``directory``.""" + return Application(profile=profile_in(directory), config_path=settings_in(directory)) + + +@contextmanager +def headless_context() -> Iterator[None]: + """A DearPyGui context with the display stood in, torn down with the workers a run started.""" + dpg.create_context() + try: + with ExitStack() as stack: + for display_patch in display_patches(): + stack.enter_context(display_patch) + + yield + finally: + stop_background_workers() + SingleThreadExecutor.reset_shutdown() + dpg.destroy_context() + + +@pytest.fixture +def app(tmp_path: Path) -> Iterator[Application]: + """The whole application built headless on a profile of the case's own.""" + with headless_context(): + yield headless_application(tmp_path) diff --git a/tests/suite/history/__init__.py b/tests/suite/history/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/history/audit.py b/tests/suite/history/audit.py new file mode 100644 index 000000000..280552c04 --- /dev/null +++ b/tests/suite/history/audit.py @@ -0,0 +1,426 @@ +from collections.abc import Hashable +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Callable, Iterator, List, Optional, Protocol, Sequence, Tuple + +import numpy as np + +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.fingerprint import ReconstructionHashCache, fingerprint_project +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_core.constants.enums import ChannelName +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.reconstructions import Reconstruction +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.hashing import hash_model + +Settle = Callable[[VoidCallback], None] +CommitKey = Tuple[HistoryAction, Hashable] + + +def fresh_fingerprint(project: Project) -> str: + """The project's whole state, every reconstruction hashed afresh rather than through a memo.""" + return fingerprint_project(project, reconstruction_hash=hash_model) + + +def immediately(gesture: VoidCallback) -> None: + """Runs a gesture whose whole effect lands before it returns.""" + gesture() + + +class HistoryDoors(Protocol): + """The gestures that move through the history, as the tier under test reaches them.""" + + def undo(self) -> None: ... + + def redo(self) -> None: ... + + def jump_to(self, index: int) -> None: ... + + +@dataclass(frozen=True) +class ExpectedEntry: + action: HistoryAction + fingerprint: str + + +@dataclass +class StackModel: + """The history's rules restated plainly: the stack a correct history holds after each step. + + A commit appends past the cursor and drops the redo branch. A commit naming the action and + target of the commit before it replaces the top entry instead, while the cursor stands on the + top, nothing was restored since, and the top is not the save point. A save marks the cursor, + and the budget drops the oldest entries, taking the save point along when it falls out. + """ + + budget: int + entries: List[ExpectedEntry] = field(default_factory=list) + cursor: int = -1 + saved: Optional[int] = None + last_key: Optional[CommitKey] = None + + def reset( + self, + fingerprint: Optional[str], + *, + clean: bool, + ) -> None: + self.last_key = None + if fingerprint is None: + self.entries = [] + self.cursor = -1 + self.saved = None + return + + self.entries = [ExpectedEntry(HistoryAction.INITIAL, fingerprint)] + self.cursor = 0 + self.saved = 0 if clean else None + + def commit( + self, + action: HistoryAction, + target: Optional[Hashable], + fingerprint: str, + ) -> None: + key = (action, target) if target is not None else None + entry = ExpectedEntry(action, fingerprint) + if self.continues_run(action, target): + self.entries[self.cursor] = entry + else: + if self.saved is not None and self.saved > self.cursor: + self.saved = None + + del self.entries[self.cursor + 1 :] + self.entries.append(entry) + self.cursor = len(self.entries) - 1 + self._evict() + + self.last_key = key + + def continues_run( + self, + action: HistoryAction, + target: Optional[Hashable], + ) -> bool: + """Whether a commit of ``action`` on ``target`` replaces the top entry rather than appending.""" + key = (action, target) if target is not None else None + return ( + key is not None + and key == self.last_key + and self.cursor == len(self.entries) - 1 + and self.cursor != self.saved + ) + + def _evict(self) -> None: + overflow = len(self.entries) - self.budget + if overflow <= 0: + return + + del self.entries[:overflow] + self.cursor -= overflow + if self.saved is not None: + self.saved = self.saved - overflow if self.saved >= overflow else None + + def undo(self) -> None: + if self.cursor > 0: + self.move(self.cursor - 1) + + def redo(self) -> None: + if self.cursor < len(self.entries) - 1: + self.move(self.cursor + 1) + + def move(self, index: int) -> None: + if 0 <= index < len(self.entries) and index != self.cursor: + self.cursor = index + self.last_key = None + + def save(self) -> None: + self.saved = self.cursor + + @property + def live(self) -> ExpectedEntry: + return self.entries[self.cursor] + + +def _same( + first: object, + second: object, +) -> bool: + """Whether two derived views hold the same content, arrays compared element by element.""" + if isinstance(first, np.ndarray) and isinstance(second, np.ndarray): + return first.shape == second.shape and bool(np.array_equal(first, second)) + + if isinstance(first, dict) and isinstance(second, dict): + return first.keys() == second.keys() and all(_same(first[key], second[key]) for key in first) + + if isinstance(first, (list, tuple)) and isinstance(second, (list, tuple)): + return len(first) == len(second) and all(_same(left, right) for left, right in zip(first, second)) + + return first == second + + +def _fresh_reconstruction(reconstruction: Reconstruction) -> Reconstruction: + """The same fields in a new object, whose every derived view is read afresh.""" + return Reconstruction.model_construct(**dict(reconstruction)) + + +def stale_reconstruction_views(reconstruction: Reconstruction) -> List[str]: + """The derived views of a reconstruction that disagree with the ones its fields give afresh.""" + fresh = _fresh_reconstruction(reconstruction) + views = { + "streams": (reconstruction.streams, fresh.streams), + "instructions": (reconstruction.instructions, fresh.instructions), + "initial_pitches": (reconstruction.initial_pitches, fresh.initial_pitches), + "held_features": (reconstruction.held_features, fresh.held_features), + "playing_channels": (reconstruction.playing_channels, fresh.playing_channels), + } + return [name for name, (held, recomputed) in views.items() if not _same(held, recomputed)] + + +def stale_instrument_channels(instrument: Instrument) -> List[ChannelName]: + """The channels whose memoized frames disagree with the frames the envelopes give afresh.""" + fresh = instrument.model_copy() + fresh.invalidate() + return [ + channel_name + for channel_name in ChannelName.items() + if not _same(instrument.instructions(channel_name), fresh.instructions(channel_name)) + ] + + +def voices_index_faults(project: Project) -> List[str]: + """Where the voices collection's lookups disagree with the order it iterates in.""" + faults: List[str] = [] + voices = project.voices + for position, voice in enumerate(voices): + if voices[position] is not voice: + faults.append(f"position {position} answers another voice than {voice.name!r}") + + if voices.get(voice.id) is not voice: + faults.append(f"id of {voice.name!r} answers another voice") + continue + + if voices.get_index(voice.id) != position: + faults.append(f"id of {voice.name!r} answers position {voices.get_index(voice.id)}") + + return faults + + +def derived_faults(projects: Sequence[Project]) -> List[str]: + """Every derived view in the projects that disagrees with what its fields give afresh. + + Entries share voices and reconstructions, so each object is read once however many + projects hold it. + """ + faults: List[str] = [] + voices = {id(voice): voice for project in projects for voice in project.voices} + for voice in voices.values(): + match voice: + case Sample(): + stale = stale_reconstruction_views(voice.reconstruction) + faults.extend(f"{voice.name!r} holds a stale {name}" for name in stale) + case Instrument(): + channels = stale_instrument_channels(voice) + faults.extend(f"{voice.name!r} holds stale {channel} frames" for channel in channels) + + return faults + + +class HistoryAudit: + """Holds a history to its rules and to the states it recorded, after every step a case takes. + + The audit keeps its own model of the stack (:class:`StackModel`) and reads each expected state + from the live project right after the gesture that made it, hashing every reconstruction + afresh. After every step it asserts that the live project is the state at the cursor, that + every stored entry still holds exactly the state it recorded, so each value shared between + entries stands as it was, that derived views agree with their fields, that the voices + collection finds each voice where it iterates it, and that the stack's shape and the dirty + flag follow the model. + + Each step runs through ``settle``, which returns once everything the step started has landed: + a worker's report as well as the gesture itself. Each of ``observers`` asserts what the tier + under test adds to every check, such as the voice a tab shows. + """ + + def __init__( + self, + controller: ProjectController, + history: HistoryManager, + *, + budget: int, + doors: HistoryDoors, + settle: Settle, + observers: Sequence[VoidCallback], + ) -> None: + self._controller = controller + self._history = history + self._doors = doors + self._settle = settle + self._observers = observers + self.model = StackModel(budget=budget) + self.observe_reset() + + def observe_reset(self) -> None: + """Takes the stack a project transition left as the new baseline, after checking it.""" + fingerprint = fresh_fingerprint(self._controller.project) if self._controller.is_open else None + self.model.reset(fingerprint, clean=not self._controller.is_dirty) + self.check() + + def transition(self, gesture: VoidCallback) -> None: + """Runs a new, open or close and takes the stack it leaves as the new baseline.""" + self._settle(gesture) + self.observe_reset() + + def perform( + self, + gesture: VoidCallback, + *, + action: HistoryAction, + target: Optional[Hashable], + ) -> None: + """Runs a gesture that changes the project and checks the one commit it records. + + ``target`` names what the gesture changes as the history's coalescing sees it: two + gestures of one action and one target in a row make one entry. + """ + self.check() + before = fresh_fingerprint(self._controller.project) + self._settle(gesture) + after = fresh_fingerprint(self._controller.project) + assert after != before, f"A {action} gesture left the project as it stood" + self.model.commit(action, target, after) + self.check() + + def attempt( + self, + gesture: VoidCallback, + *, + action: HistoryAction, + target: Optional[Hashable], + ) -> None: + """Runs a gesture the state before it may leave nothing to change, and checks what it records. + + A gesture that changes the project records its one commit, and one that leaves it as it + stood records nothing. A pair of gestures reaches this where the first leaves the second + nothing to act on, such as a transpose over a block a cut has emptied. + """ + self.check() + before = fresh_fingerprint(self._controller.project) + self._settle(gesture) + after = fresh_fingerprint(self._controller.project) + if after != before: + self.model.commit(action, target, after) + + self.check() + + def refused(self, gesture: VoidCallback) -> None: + """Runs a gesture meant to leave the project as it stands, and checks the history stays as it was.""" + self.check() + self._settle(gesture) + self.check() + + @contextmanager + def settling(self, settle: Settle) -> Iterator[None]: + """Runs the steps inside the scope through ``settle``, such as one leaving a worker's report held.""" + standing = self._settle + self._settle = settle + try: + yield + finally: + self._settle = standing + + def adopt_landed(self, actions: Sequence[Tuple[HistoryAction, Optional[Hashable]]]) -> None: + """Takes commits that landed together, out of the case's sight, as the stack now records them. + + Work held back and then released lands several commits at once, so the states between them + never stand live. Their order and their actions are what the case states. Each state is read + from the entry recording it, and every later restore is then held to it. The case states + what else the release did, such as an undo that waited for the edits, before the next check. + """ + for action, target in actions: + index = self.model.cursor if self.model.continues_run(action, target) else self.model.cursor + 1 + self.model.commit(action, target, fresh_fingerprint(self._history.entries[index].project)) + + def undo(self) -> None: + self._settle(self._doors.undo) + self.model.undo() + self.check() + + def redo(self) -> None: + self._settle(self._doors.redo) + self.model.redo() + self.check() + + def jump_to(self, index: int) -> None: + self._settle(lambda: self._doors.jump_to(index)) + self.model.move(index) + self.check() + + def save(self, gesture: VoidCallback) -> None: + """Runs a save, which makes the state at the cursor the one on disk.""" + self._settle(gesture) + self.model.save() + self.check() + + def walk(self) -> None: + """Visits every entry from the cursor to the first, then to the last, then back again.""" + start = self.model.cursor + while self.model.cursor > 0: + self.undo() + + while self.model.cursor < len(self.model.entries) - 1: + self.redo() + + self.jump_to(0) + self.jump_to(len(self.model.entries) - 1) + self.jump_to(start) + + def check(self) -> None: + self._check_shape() + hashes = ReconstructionHashCache(reconstruction_hash=hash_model) + projects = [entry.project for entry in self._history.entries] + if self._controller.is_open: + projects.append(self._controller.project) + + self._check_entries(hashes) + for project in projects: + assert not voices_index_faults(project), voices_index_faults(project) + + assert not derived_faults(projects), derived_faults(projects) + if self._controller.is_open: + self._check_live(hashes) + + for observer in self._observers: + observer() + + def _check_shape(self) -> None: + recorded = [entry.action for entry in self._history.entries] + expected = [entry.action for entry in self.model.entries] + assert recorded == expected, f"The history holds {recorded} where {expected} belongs" + assert ( + self._history.cursor == self.model.cursor + ), f"The cursor stands at {self._history.cursor} where {self.model.cursor} belongs" + assert self._history.can_undo == (self.model.cursor > 0) + assert self._history.can_redo == (self.model.cursor < len(self.model.entries) - 1) + + def _check_entries(self, hashes: ReconstructionHashCache) -> None: + for index, (entry, expected) in enumerate(zip(self._history.entries, self.model.entries)): + fingerprint = fingerprint_project(entry.project, reconstruction_hash=hashes.hash) + assert ( + fingerprint == expected.fingerprint + ), f"Entry {index} ({entry.action}) no longer holds the state it recorded" + + def _check_live(self, hashes: ReconstructionHashCache) -> None: + fingerprint = fingerprint_project(self._controller.project, reconstruction_hash=hashes.hash) + assert ( + fingerprint == self.model.live.fingerprint + ), f"The live project differs from entry {self.model.cursor} ({self.model.live.action})" + dirty = self._controller.is_dirty + assert dirty == (self.model.cursor != self.model.saved), ( + f"The project reads {'dirty' if dirty else 'clean'} at entry {self.model.cursor} " + f"with the save point at {self.model.saved}" + ) diff --git a/tests/suite/history/gestures.py b/tests/suite/history/gestures.py new file mode 100644 index 000000000..a384e1de9 --- /dev/null +++ b/tests/suite/history/gestures.py @@ -0,0 +1,624 @@ +from collections.abc import Hashable +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Callable, Dict, Final, Optional, Tuple + +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.rewrites.steps import StemRemovalRequest +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.view_model.sequencer.region import OrderCell, OrderRegion, TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, column_slot_base +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.patterns.pitch import Note, Step +from tests.suite.history.projects import BASS, LEAD, PAD, PLUCK +from tests.suite.history.session import HistorySession +from tests.suite.sequencer import sample_reconstruction +from tests.suite.stems import STEM_B_ID, recorded_from + +FIRST_FRAME: Final[int] = 0 +SECOND_FRAME: Final[int] = 1 +MODULE_TARGET: Final[Tuple[()]] = () +ARRIVING_SAMPLE: Final[str] = "arriving.stn" +ARRIVING_RECORDINGS: Final[Tuple[Path, Path]] = (Path("arriving-a.wav"), Path("arriving-b.wav")) +RETUNED_RATE: Final[int] = 50 +RETUNED_AGAIN_RATE: Final[int] = 48 +EDITED_ENVELOPE: Final[Envelope[int]] = Envelope[int](items=(11, 9, 7)) +EDITED_REFERENCE: Final[int] = 64 +REMOVED_STEM_NAME: Final[str] = "b" + +Target = Optional[Hashable] + + +class Part(StrEnum): + """The parts of a project a gesture changes, each the unit an entry owns for it.""" + + SONG_CELLS = "song cells" + ORDER = "order" + PATTERN_LENGTH = "pattern length" + VOICES = "voices" + INSTRUMENT = "instrument" + RECONSTRUCTION_CHANNEL = "reconstruction channel" + STEMS = "stems" + RATE = "rate" + SETTINGS = "settings" + PROPERTIES = "properties" + + +@dataclass(frozen=True) +class Gesture: + """One user gesture, run through the hook its panel calls. + + ``target`` names what the gesture changes the way the history's coalescing reads it, worked + out before the gesture runs, so two gestures of one action and one target in a row make one + entry. ``prepare`` runs first and records nothing: opening a voice on the Reconstructions tab, + copying a block. + """ + + label: str + action: HistoryAction + part: Part + run: Callable[[HistorySession], None] + target: Callable[[HistorySession], Target] + prepare: Callable[[HistorySession], None] + + +def _nothing(_session: HistorySession) -> None: + return None + + +def _untargeted(_session: HistorySession) -> Target: + return None + + +def _module_target(_session: HistorySession) -> Target: + return MODULE_TARGET + + +def _cell_target( + channel: ChannelName, + row: int, +) -> Callable[[HistorySession], Target]: + def target(session: HistorySession) -> Target: + return (session.sequencer._sequencer_tracker_logic.frame_index, channel, row) + + return target + + +def _edit_row_target( + channel: ChannelName, + row: int, + *, + voice: bool, + pitch: bool, + volume: bool, +) -> Callable[[HistorySession], Target]: + def target(session: HistorySession) -> Target: + return (session.sequencer._sequencer_tracker_logic.frame_index, channel, row, voice, pitch, volume) + + return target + + +def _cell_region( + channel: ChannelName, + first_row: int, + last_row: int, +) -> TrackerRegion: + base = column_slot_base(channel) + return TrackerRegion( + first_row=first_row, + last_row=last_row, + first_slot=base, + last_slot=base + len(SUBCOLUMNS) - 1, + ) + + +LEAD_REGION: Final[TrackerRegion] = _cell_region(ChannelName.PULSE1, 0, 2) +ORDER_REGION: Final[OrderRegion] = OrderRegion(first_row=1, last_row=2, first_position=0, last_position=0) + + +def _adjustment_target(region: TrackerRegion) -> Callable[[HistorySession], Target]: + def target(session: HistorySession) -> Target: + frame = session.sequencer._sequencer_tracker_logic.frame_index + return (frame, region.first_row, region.last_row, region.first_slot, region.last_slot) + + return target + + +def _voice_target(name: str) -> Callable[[HistorySession], Target]: + def target(session: HistorySession) -> Target: + return (session.voice_id(name),) + + return target + + +def _instrument_target( + name: str, + feature_key: FeatureKey, +) -> Callable[[HistorySession], Target]: + def target(session: HistorySession) -> Target: + return (session.voice_id(name), feature_key) + + return target + + +def _opening(name: str) -> Callable[[HistorySession], None]: + def prepare(session: HistorySession) -> None: + session.open_voice(name) + + return prepare + + +def write_arriving_sample(session: HistorySession) -> Path: + """Writes a document beside the project, the file an Add or a Replace from the browser reads.""" + path = session.project_file.parent / ARRIVING_SAMPLE + recorded_from(sample_reconstruction([ChannelName.PULSE2]), ARRIVING_RECORDINGS).save(path) + return path + + +def _tracker(session: HistorySession) -> GUISequencerTrackerPanel: + return session.sequencer._sequencer_tracker_panel + + +def _order(session: HistorySession) -> GUISequencerOrderPanel: + return session.sequencer._sequencer_order_panel + + +def _module(session: HistorySession) -> GUISequencerModulePanel: + return session.sequencer._sequencer_module_panel + + +def _voices(session: HistorySession) -> GUISequencerVoicesPanel: + return session.sequencer._sequencer_voices_panel + + +def _type_a_sample_note(session: HistorySession) -> None: + _tracker(session).on_set_row(1, ChannelName.PULSE1, session.voice_id(LEAD), Note(value=62), None) + + +def _type_an_instrument_note(session: HistorySession) -> None: + _tracker(session).on_set_row(3, ChannelName.PULSE2, session.voice_id(PAD), Note(value=65), 7) + + +def _type_a_volume(session: HistorySession) -> None: + _tracker(session).on_set_row(0, ChannelName.PULSE1, None, None, 5) + + +def _type_a_step(session: HistorySession) -> None: + _tracker(session).on_set_row(2, ChannelName.TRIANGLE, None, Step(value=-3), None) + + +def _cut_a_note(session: HistorySession) -> None: + _tracker(session).on_set_note_off(1, ChannelName.PULSE2) + + +def _clear_a_row(session: HistorySession) -> None: + _tracker(session).on_clear_row(0, ChannelName.PULSE1) + + +def _clear_a_volume(session: HistorySession) -> None: + _tracker(session).on_clear_subcolumn(0, ChannelName.PULSE1, SubColumn.VOLUME) + + +def _transpose_a_block(session: HistorySession) -> None: + _tracker(session).on_adjust_transpose(LEAD_REGION, 1) + + +def _quiet_a_block(session: HistorySession) -> None: + _tracker(session).on_adjust_volume(LEAD_REGION, -1) + + +def _cut_a_tracker_block(session: HistorySession) -> None: + _tracker(session).on_cut_block(LEAD_REGION) + + +def _delete_a_tracker_block(session: HistorySession) -> None: + _tracker(session).on_delete_block(LEAD_REGION) + + +def _copy_a_tracker_block(session: HistorySession) -> None: + _tracker(session).on_copy_block(LEAD_REGION) + + +def _paste_a_tracker_block(session: HistorySession) -> None: + _tracker(session).on_paste_block(TrackerCell(row=4, channel=ChannelName.PULSE2)) + + +def _insert_a_frame(session: HistorySession) -> None: + _order(session).on_insert_requested(SECOND_FRAME) + + +def _remove_a_frame(session: HistorySession) -> None: + _order(session).on_remove_requested(SECOND_FRAME) + + +def _duplicate_a_frame(session: HistorySession) -> None: + _order(session).on_duplicate_requested(FIRST_FRAME) + + +def _clone_a_frame(session: HistorySession) -> None: + _order(session).on_clone_requested(FIRST_FRAME) + + +def _clear_a_frame(session: HistorySession) -> None: + _order(session).on_clear_requested(SECOND_FRAME) + + +def _move_a_frame(session: HistorySession) -> None: + _order(session).on_move_requested(SECOND_FRAME, FIRST_FRAME) + + +def _set_an_order_entry(session: HistorySession) -> None: + _order(session).on_set_order_entry(ChannelName.PULSE1, FIRST_FRAME, 1) + + +def _set_a_master_entry(session: HistorySession) -> None: + _order(session).on_set_master_entry(SECOND_FRAME, 0) + + +def _cut_an_order_block(session: HistorySession) -> None: + _order(session).on_cut_block(ORDER_REGION) + + +def _delete_an_order_block(session: HistorySession) -> None: + _order(session).on_delete_block(ORDER_REGION) + + +def _copy_an_order_block(session: HistorySession) -> None: + _order(session).on_copy_block(ORDER_REGION) + + +def _paste_an_order_block(session: HistorySession) -> None: + _order(session).on_paste_block(OrderCell(channel=ChannelName.TRIANGLE, position=SECOND_FRAME)) + + +def _lengthen_patterns(session: HistorySession) -> None: + _module(session).on_rows_per_pattern(12) + + +def _shorten_patterns(session: HistorySession) -> None: + _module(session).on_rows_per_pattern(4) + + +def _set_the_tempo(session: HistorySession) -> None: + _module(session).on_tempo(120) + + +def _set_the_speed(session: HistorySession) -> None: + _module(session).on_speed(3) + + +def _edit_the_properties(session: HistorySession) -> None: + info = session.project.info + settings = session.project.settings + session.app.project_properties_window.on_commit( + f"{info.title} again", + info.author, + info.comment, + settings.first_highlight + 1, + settings.second_highlight, + ) + + +def _add_a_sample(session: HistorySession) -> None: + session.sequencer.import_reconstruction(write_arriving_sample(session)) + + +def _replace_a_sample(session: HistorySession) -> None: + session.mark_voice(BASS) + session.sequencer.replace_reconstruction(write_arriving_sample(session)) + + +def _add_an_instrument(session: HistorySession) -> None: + _voices(session).on_new_instrument_requested() + + +def _take_a_channel(session: HistorySession) -> None: + _voices(session).on_instrument_from_channel_requested(session.voice_id(LEAD), ChannelName.PULSE1) + + +def _renaming(name: str) -> Callable[[HistorySession], None]: + def run(session: HistorySession) -> None: + _voices(session).on_rename_committed(session.voice_id(name), f"{name} again") + + return run + + +def _moving(name: str) -> Callable[[HistorySession], None]: + def run(session: HistorySession) -> None: + _voices(session).on_move_requested(session.voice_id(name), len(session.project.voices) - 1) + + return run + + +def _duplicating(name: str) -> Callable[[HistorySession], None]: + def run(session: HistorySession) -> None: + _voices(session).on_duplicate_requested(session.voice_id(name)) + + return run + + +def _removing(name: str) -> Callable[[HistorySession], None]: + def run(session: HistorySession) -> None: + _voices(session).on_remove_requested(session.voice_id(name)) + + return run + + +def _edit_an_instrument_envelope(session: HistorySession) -> None: + session.instruments.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, EDITED_ENVELOPE) + + +def _edit_a_channel_envelope(session: HistorySession) -> None: + session.instruments.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, EDITED_ENVELOPE) + + +def _move_a_channel_reference(session: HistorySession) -> None: + session.instruments.handle_pitch_value_changed(ChannelName.PULSE1, EDITED_REFERENCE) + + +def _remove_a_recording(session: HistorySession) -> None: + session.app._reconstruction_coordinator.request_rewrite( + StemRemovalRequest(stem_id=STEM_B_ID, stem_name=REMOVED_STEM_NAME) + ) + + +def _retune_the_project(session: HistorySession) -> None: + session.sequencer._nes_frequency_change_acknowledged = True + _module(session).on_nes_frequency(RETUNED_RATE) + + +def _rate_target(_session: HistorySession) -> Target: + return (RETUNED_RATE,) + + +def _retune_the_project_again(session: HistorySession) -> None: + session.sequencer._nes_frequency_change_acknowledged = True + _module(session).on_nes_frequency(RETUNED_AGAIN_RATE) + + +def _rate_again_target(_session: HistorySession) -> Target: + return (RETUNED_AGAIN_RATE,) + + +def _gesture( + label: str, + action: HistoryAction, + part: Part, + run: Callable[[HistorySession], None], + *, + target: Callable[[HistorySession], Target] = _untargeted, + prepare: Callable[[HistorySession], None] = _nothing, +) -> Gesture: + return Gesture(label=label, action=action, part=part, run=run, target=target, prepare=prepare) + + +GESTURES: Final[Tuple[Gesture, ...]] = ( + _gesture( + "type a note with a sample", + HistoryAction.EDIT_ROW, + Part.SONG_CELLS, + _type_a_sample_note, + target=_edit_row_target(ChannelName.PULSE1, 1, voice=True, pitch=True, volume=False), + ), + _gesture( + "type a note with an instrument", + HistoryAction.EDIT_ROW, + Part.SONG_CELLS, + _type_an_instrument_note, + target=_edit_row_target(ChannelName.PULSE2, 3, voice=True, pitch=True, volume=True), + ), + _gesture( + "type a volume", + HistoryAction.EDIT_ROW, + Part.SONG_CELLS, + _type_a_volume, + target=_edit_row_target(ChannelName.PULSE1, 0, voice=False, pitch=False, volume=True), + ), + _gesture( + "type a step", + HistoryAction.EDIT_ROW, + Part.SONG_CELLS, + _type_a_step, + target=_edit_row_target(ChannelName.TRIANGLE, 2, voice=False, pitch=True, volume=False), + ), + _gesture( + "cut a note", + HistoryAction.NOTE_OFF, + Part.SONG_CELLS, + _cut_a_note, + target=_cell_target(ChannelName.PULSE2, 1), + ), + _gesture("clear a row", HistoryAction.CLEAR_ROW, Part.SONG_CELLS, _clear_a_row), + _gesture("clear a volume", HistoryAction.CLEAR_SUBCOLUMN, Part.SONG_CELLS, _clear_a_volume), + _gesture( + "transpose a block", + HistoryAction.ADJUST_TRANSPOSE, + Part.SONG_CELLS, + _transpose_a_block, + target=_adjustment_target(LEAD_REGION), + ), + _gesture( + "quiet a block", + HistoryAction.ADJUST_VOLUME, + Part.SONG_CELLS, + _quiet_a_block, + target=_adjustment_target(LEAD_REGION), + ), + _gesture("cut a tracker block", HistoryAction.CUT_BLOCK, Part.SONG_CELLS, _cut_a_tracker_block), + _gesture("delete a tracker block", HistoryAction.DELETE_BLOCK, Part.SONG_CELLS, _delete_a_tracker_block), + _gesture( + "paste a tracker block", + HistoryAction.PASTE_BLOCK, + Part.SONG_CELLS, + _paste_a_tracker_block, + prepare=_copy_a_tracker_block, + ), + _gesture("insert a frame", HistoryAction.ADD_FRAME, Part.ORDER, _insert_a_frame), + _gesture("remove a frame", HistoryAction.REMOVE_FRAME, Part.ORDER, _remove_a_frame), + _gesture("duplicate a frame", HistoryAction.DUPLICATE_FRAME, Part.ORDER, _duplicate_a_frame), + _gesture("clone a frame", HistoryAction.CLONE_FRAME, Part.ORDER, _clone_a_frame), + _gesture("clear a frame", HistoryAction.CLEAR_FRAME, Part.ORDER, _clear_a_frame), + _gesture("move a frame", HistoryAction.MOVE_FRAME, Part.ORDER, _move_a_frame), + _gesture("set an order entry", HistoryAction.SET_ORDER_ENTRY, Part.ORDER, _set_an_order_entry), + _gesture("set a master entry", HistoryAction.SET_ORDER_ENTRY, Part.ORDER, _set_a_master_entry), + _gesture("cut an order block", HistoryAction.CUT_BLOCK, Part.ORDER, _cut_an_order_block), + _gesture("delete an order block", HistoryAction.DELETE_BLOCK, Part.ORDER, _delete_an_order_block), + _gesture( + "paste an order block", + HistoryAction.PASTE_BLOCK, + Part.ORDER, + _paste_an_order_block, + prepare=_copy_an_order_block, + ), + _gesture( + "lengthen the patterns", + HistoryAction.SET_ROWS_PER_PATTERN, + Part.PATTERN_LENGTH, + _lengthen_patterns, + target=_module_target, + ), + _gesture( + "shorten the patterns", + HistoryAction.SET_ROWS_PER_PATTERN, + Part.PATTERN_LENGTH, + _shorten_patterns, + target=_module_target, + ), + _gesture("set the tempo", HistoryAction.SET_TEMPO, Part.SETTINGS, _set_the_tempo, target=_module_target), + _gesture("set the speed", HistoryAction.SET_SPEED, Part.SETTINGS, _set_the_speed, target=_module_target), + _gesture( + "edit the properties", + HistoryAction.EDIT_PROJECT_PROPERTIES, + Part.PROPERTIES, + _edit_the_properties, + ), + _gesture("add a sample", HistoryAction.ADD_SAMPLE, Part.VOICES, _add_a_sample), + _gesture("replace a sample", HistoryAction.REPLACE_SAMPLE, Part.VOICES, _replace_a_sample), + _gesture("add an instrument", HistoryAction.ADD_INSTRUMENT, Part.VOICES, _add_an_instrument), + _gesture("take a channel as an instrument", HistoryAction.ADD_INSTRUMENT, Part.VOICES, _take_a_channel), + _gesture("rename a sample", HistoryAction.RENAME_VOICE, Part.VOICES, _renaming(LEAD)), + _gesture("rename an instrument", HistoryAction.RENAME_VOICE, Part.VOICES, _renaming(PAD)), + _gesture("move a sample", HistoryAction.MOVE_VOICE, Part.VOICES, _moving(LEAD)), + _gesture("move an instrument", HistoryAction.MOVE_VOICE, Part.VOICES, _moving(PAD)), + _gesture("duplicate a sample", HistoryAction.DUPLICATE_VOICE, Part.VOICES, _duplicating(BASS)), + _gesture("duplicate an instrument", HistoryAction.DUPLICATE_VOICE, Part.VOICES, _duplicating(PLUCK)), + _gesture("remove a sample", HistoryAction.REMOVE_VOICE, Part.VOICES, _removing(LEAD)), + _gesture("remove an instrument", HistoryAction.REMOVE_VOICE, Part.VOICES, _removing(PAD)), + _gesture("remove another instrument", HistoryAction.REMOVE_VOICE, Part.VOICES, _removing(PLUCK)), + _gesture( + "edit an instrument's envelope", + HistoryAction.EDIT_INSTRUMENT, + Part.INSTRUMENT, + _edit_an_instrument_envelope, + target=_instrument_target(PAD, FeatureKey.VOLUME), + prepare=_opening(PAD), + ), + _gesture( + "edit a channel's envelope", + HistoryAction.EDIT_RECONSTRUCTION, + Part.RECONSTRUCTION_CHANNEL, + _edit_a_channel_envelope, + target=_voice_target(LEAD), + prepare=_opening(LEAD), + ), + _gesture( + "move a channel's reference", + HistoryAction.EDIT_RECONSTRUCTION, + Part.RECONSTRUCTION_CHANNEL, + _move_a_channel_reference, + target=_voice_target(LEAD), + prepare=_opening(LEAD), + ), + _gesture( + "remove a recording", + HistoryAction.EDIT_RECONSTRUCTION, + Part.STEMS, + _remove_a_recording, + prepare=_opening(LEAD), + ), + _gesture( + "retune the project", + HistoryAction.SET_NES_FREQUENCY, + Part.RATE, + _retune_the_project, + target=_rate_target, + ), + _gesture( + "retune the project again", + HistoryAction.SET_NES_FREQUENCY, + Part.RATE, + _retune_the_project_again, + target=_rate_again_target, + ), + _gesture( + "retune the project with a sample open", + HistoryAction.SET_NES_FREQUENCY, + Part.RATE, + _retune_the_project, + target=_rate_target, + prepare=_opening(LEAD), + ), +) + +GESTURES_BY_LABEL: Final[Dict[str, Gesture]] = {gesture.label: gesture for gesture in GESTURES} + + +def perform( + session: HistorySession, + gesture: Gesture, +) -> None: + """Prepares a gesture, then runs it under the audit, which checks the one entry it records.""" + gesture.prepare(session) + target = gesture.target(session) + session.audit.perform(lambda: gesture.run(session), action=gesture.action, target=target) + + +PAIR_KINDS: Final[Dict[HistoryAction, Tuple[str, str]]] = { + HistoryAction.EDIT_ROW: ("type a note with a sample", "type a note with a sample"), + HistoryAction.NOTE_OFF: ("cut a note", "cut a note"), + HistoryAction.CLEAR_ROW: ("clear a row", "clear a row"), + HistoryAction.CLEAR_SUBCOLUMN: ("clear a volume", "clear a volume"), + HistoryAction.ADJUST_TRANSPOSE: ("transpose a block", "transpose a block"), + HistoryAction.ADJUST_VOLUME: ("quiet a block", "quiet a block"), + HistoryAction.CUT_BLOCK: ("cut a tracker block", "cut an order block"), + HistoryAction.DELETE_BLOCK: ("delete an order block", "delete a tracker block"), + HistoryAction.PASTE_BLOCK: ("paste a tracker block", "paste an order block"), + HistoryAction.ADD_FRAME: ("insert a frame", "insert a frame"), + HistoryAction.REMOVE_FRAME: ("remove a frame", "remove a frame"), + HistoryAction.DUPLICATE_FRAME: ("duplicate a frame", "duplicate a frame"), + HistoryAction.CLONE_FRAME: ("clone a frame", "clone a frame"), + HistoryAction.CLEAR_FRAME: ("clear a frame", "clear a frame"), + HistoryAction.MOVE_FRAME: ("move a frame", "move a frame"), + HistoryAction.SET_ORDER_ENTRY: ("set an order entry", "set a master entry"), + HistoryAction.SET_ROWS_PER_PATTERN: ("lengthen the patterns", "shorten the patterns"), + HistoryAction.SET_TEMPO: ("set the tempo", "set the tempo"), + HistoryAction.SET_SPEED: ("set the speed", "set the speed"), + HistoryAction.EDIT_PROJECT_PROPERTIES: ("edit the properties", "edit the properties"), + HistoryAction.ADD_SAMPLE: ("add a sample", "add a sample"), + HistoryAction.REPLACE_SAMPLE: ("replace a sample", "replace a sample"), + HistoryAction.ADD_INSTRUMENT: ("add an instrument", "take a channel as an instrument"), + HistoryAction.RENAME_VOICE: ("rename an instrument", "rename a sample"), + HistoryAction.MOVE_VOICE: ("move a sample", "move an instrument"), + HistoryAction.DUPLICATE_VOICE: ("duplicate a sample", "duplicate an instrument"), + HistoryAction.REMOVE_VOICE: ("remove another instrument", "remove an instrument"), + HistoryAction.EDIT_INSTRUMENT: ("edit an instrument's envelope", "edit an instrument's envelope"), + HistoryAction.EDIT_RECONSTRUCTION: ("edit a channel's envelope", "move a channel's reference"), + HistoryAction.SET_NES_FREQUENCY: ("retune the project", "retune the project again"), +} + + +def attempt( + session: HistorySession, + gesture: Gesture, +) -> None: + """Prepares a gesture and runs it under the audit, which takes a gesture left nothing to change as none.""" + gesture.prepare(session) + target = gesture.target(session) + session.audit.attempt(lambda: gesture.run(session), action=gesture.action, target=target) diff --git a/tests/suite/history/perturbation.py b/tests/suite/history/perturbation.py new file mode 100644 index 000000000..bfa230889 --- /dev/null +++ b/tests/suite/history/perturbation.py @@ -0,0 +1,186 @@ +import copy +from collections.abc import Hashable +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum +from pathlib import PurePath +from typing import Any, Final, List, Literal, Set, Tuple, Union, get_origin + +import numpy as np +from pydantic import BaseModel + +from sampletones_core.project import Project +from sampletones_core.project.voices.sample import Sample +from sampletones_core.structures import IdentifiedCollection + +PROJECT_PARTS: Final[Tuple[str, ...]] = ("metadata", "info", "settings", "song", "voices") +SAMPLE_PARTS: Final[Tuple[str, ...]] = ("id", "name", "reconstruction") +TEXT_MARK: Final[str] = "~" +PATH_MARK: Final[str] = "elsewhere" +NUMBER_STEP: Final[float] = 0.5 +TIME_STEP: Final[timedelta] = timedelta(seconds=1) + + +@dataclass(frozen=True) +class Attribute: + name: str + + +@dataclass(frozen=True) +class Item: + key: Hashable + + +PathStep = Union[Attribute, Item] + + +@dataclass(frozen=True) +class Leaf: + """One field of the project, named by the model declaring it and reached by ``path``.""" + + label: str + path: Tuple[PathStep, ...] + + +def _child( + node: Any, + step: PathStep, +) -> Any: + match step: + case Attribute(name=name): + return vars(node)[name] + case Item(key=key): + return node[key] + + +def _with_child( + node: Any, + step: PathStep, + child: Any, +) -> Any: + """``node`` holding ``child`` at ``step``: changed where it stands, or rebuilt where it is a tuple.""" + match step: + case Attribute(name=name): + object.__setattr__(node, name, child) + return node + case Item(key=key) if isinstance(node, tuple) and isinstance(key, int): + return node[:key] + (child,) + node[key + 1 :] + case Item(key=key): + node[key] = child + return node + + +def _replaced( + node: Any, + path: Tuple[PathStep, ...], + leaf: Any, +) -> Any: + if not path: + return leaf + + step, rest = path[0], path[1:] + child = _child(node, step) + changed = _replaced(child, rest, leaf) + return node if changed is child else _with_child(node, step, changed) + + +def _perturbed(value: Any) -> Any: + """A value of the same kind that differs from ``value``.""" + match value: + case bool(): + return not value + case Enum(): + members = list(type(value)) + return members[(members.index(value) + 1) % len(members)] + case int(): + return value + 1 + case float(): + return value + NUMBER_STEP + case str(): + return value + TEXT_MARK + case PurePath(): + return value / PATH_MARK + case datetime(): + return value + TIME_STEP + case np.ndarray(): + return value + 1 + + raise TypeError(f"No perturbation for {type(value).__name__}") + + +def _is_leaf(value: Any) -> bool: + return isinstance(value, (bool, Enum, int, float, str, PurePath, datetime, np.ndarray)) + + +class _LeafWalk: + """Collects one leaf per declaring model and field, the first instance the walk meets.""" + + def __init__(self) -> None: + self.leaves: List[Leaf] = [] + self._labels: Set[str] = set() + + def visit( + self, + node: Any, + path: Tuple[PathStep, ...], + label: str, + ) -> None: + match node: + case None: + return + case _ if _is_leaf(node): + self._take(label, path) + case Sample(): + for name in SAMPLE_PARTS: + self.visit(vars(node)[name], path + (Attribute(name),), f"Sample.{name}") + case IdentifiedCollection(): + for position, voice in enumerate(node): + self.visit(voice, path + (Item(position),), label) + case BaseModel(): + for name, info in type(node).model_fields.items(): + if get_origin(info.annotation) is not Literal: + self.visit(vars(node)[name], path + (Attribute(name),), f"{type(node).__name__}.{name}") + case list() | tuple(): + for index, value in enumerate(node): + self.visit(value, path + (Item(index),), f"{label}[]") + case dict(): + for key, value in node.items(): + self.visit(value, path + (Item(key),), f"{label}{{}}") + case _: + raise TypeError(f"The walk meets a {type(node).__name__} at {label}") + + def _take( + self, + label: str, + path: Tuple[PathStep, ...], + ) -> None: + if label not in self._labels: + self._labels.add(label) + self.leaves.append(Leaf(label=label, path=path)) + + +def project_leaves(project: Project) -> List[Leaf]: + """One leaf for every field any model in the project declares, wherever it first holds a value. + + The walk takes the fields holding a value. A literal tag names its model's kind with its one + value, so the walk passes over it. + """ + walk = _LeafWalk() + for name in PROJECT_PARTS: + walk.visit(vars(project)[name], (Attribute(name),), f"Project.{name}") + + return walk.leaves + + +def perturbed_project( + project: Project, + leaf: Leaf, +) -> Project: + """An independent copy of ``project`` with the one field ``leaf`` names changed.""" + copied = copy.deepcopy(project) + value = copied + for step in leaf.path: + value = _child(value, step) + + _replaced(copied, leaf.path, _perturbed(value)) + return copied diff --git a/tests/suite/history/projects.py b/tests/suite/history/projects.py new file mode 100644 index 000000000..5c476d2b9 --- /dev/null +++ b/tests/suite/history/projects.py @@ -0,0 +1,207 @@ +from pathlib import Path +from typing import Dict, Final, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.project import Project +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +from sampletones_core.project.patterns.row import NoteCommand, Row +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.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 tests.suite.sequencer import sample_reconstruction +from tests.suite.stems import recorded_from, taking_turns_reconstruction + +LEAD: Final[str] = "Lead" +BASS: Final[str] = "Bass" +PAD: Final[str] = "Pad" +PLUCK: Final[str] = "Pluck" + +EVERY_PART_TITLE: Final[str] = "Every part" +EVERY_PART_AUTHOR: Final[str] = "History" +EVERY_PART_COMMENT: Final[str] = "Each part a gesture can reach." +EVERY_PART_ROWS: Final[int] = 8 +EVERY_PART_TEMPO: Final[int] = 140 +EVERY_PART_SPEED: Final[int] = 5 +EVERY_PART_FIRST_HIGHLIGHT: Final[int] = 2 +EVERY_PART_SECOND_HIGHLIGHT: Final[int] = 8 + +FIRST_PATTERN: Final[int] = 0 +SECOND_PATTERN: Final[int] = 1 + +EVERY_PART_FILENAME: Final[str] = "every-part.stp" + +LEAD_RECORDINGS: Final[Tuple[Path, Path]] = (Path("lead-a.wav"), Path("lead-b.wav")) +BASS_RECORDINGS: Final[Tuple[Path, Path]] = (Path("bass-a.wav"), Path("bass-b.wav")) + + +def lead_reconstruction() -> Reconstruction: + """Two recordings taking turns on pulse 1, the second holding pulse 2 alone, so a removal applies.""" + reconstruction = taking_turns_reconstruction(LEAD_RECORDINGS) + reconstruction = reconstruction.detached() + return reconstruction + + +def bass_reconstruction() -> Reconstruction: + """Two recordings each playing the triangle and the noise in turn.""" + reconstruction = recorded_from( + sample_reconstruction([ChannelName.TRIANGLE, ChannelName.NOISE]), + BASS_RECORDINGS, + ) + reconstruction = reconstruction.detached() + return reconstruction + + +def pad_instrument() -> Instrument: + return Instrument( + name=PAD, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=(15, 12, 9, 6)), + arpeggio=Envelope[int](items=(0, 4, 7), loop_point=0), + ), + initial_pitch=57, + ) + + +def pluck_instrument() -> Instrument: + return Instrument( + name=PLUCK, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=(15, 8, 4, 0)), + duty_cycle=Envelope[int](items=(2, 1)), + ), + initial_period=3, + ) + + +def _row( + command: Optional[NoteCommand] = None, + pitch: Optional[RowPitch] = None, + volume: Optional[int] = None, +) -> Row: + return Row(command=command, pitch=pitch, volume=volume) + + +def _write( + project: Project, + channel_name: ChannelName, + pattern_index: int, + rows: Dict[int, Row], +) -> None: + channel = project.song.channels[channel_name] + channel.ensure_pattern(pattern_index, project.song.rows_per_pattern) + for row_index, row in rows.items(): + channel.set_row(pattern_index, row_index, row) + + +def every_part_project() -> Project: + """A small project holding something in every part a history gesture can reach. + + Two samples carry two recordings each, so a channel edit, a stem removal and a retune all + apply, and two instruments carry envelopes. The song plays three frames whose patterns name + every voice through notes, steps, volumes and note-offs, and the settings and the + properties all differ from a new project's. + """ + project = Project.create( + title=EVERY_PART_TITLE, + author=EVERY_PART_AUTHOR, + comment=EVERY_PART_COMMENT, + rows_per_pattern=EVERY_PART_ROWS, + settings=ProjectSettings( + tempo=EVERY_PART_TEMPO, + speed=EVERY_PART_SPEED, + first_highlight=EVERY_PART_FIRST_HIGHLIGHT, + second_highlight=EVERY_PART_SECOND_HIGHLIGHT, + ), + ) + lead = Sample(name=LEAD, reconstruction=lead_reconstruction()) + bass = Sample(name=BASS, reconstruction=bass_reconstruction()) + pad = pad_instrument() + pluck = pluck_instrument() + for voice in (lead, pad, bass, pluck): + project.voices.append(voice) + + _write( + project, + ChannelName.PULSE1, + FIRST_PATTERN, + { + 0: _row(NoteOn(voice_id=lead.id), Note(value=60), 12), + 2: _row(pitch=Step(value=3)), + 4: _row(NoteOff()), + 6: _row(NoteOn(voice_id=pad.id), Note(value=64), 9), + }, + ) + _write( + project, + ChannelName.PULSE1, + SECOND_PATTERN, + { + 0: _row(NoteOn(voice_id=lead.id), Step(value=-2), 8), + 3: _row(volume=4), + }, + ) + _write( + project, + ChannelName.PULSE2, + FIRST_PATTERN, + { + 0: _row(NoteOn(voice_id=pluck.id), Note(value=67)), + 5: _row(NoteOff()), + }, + ) + _write( + project, + ChannelName.TRIANGLE, + FIRST_PATTERN, + { + 0: _row(NoteOn(voice_id=bass.id), Note(value=45)), + 4: _row(pitch=Step(value=5)), + }, + ) + _write( + project, + ChannelName.NOISE, + FIRST_PATTERN, + { + 0: _row(NoteOn(voice_id=bass.id)), + 2: _row(NoteOn(voice_id=pluck.id), Note(value=5), 10), + }, + ) + project.song.order[0][ChannelName.NOISE] = None + project.song.order.append( + { + ChannelName.PULSE1: SECOND_PATTERN, + ChannelName.PULSE2: FIRST_PATTERN, + ChannelName.TRIANGLE: None, + ChannelName.NOISE: FIRST_PATTERN, + } + ) + project.song.order.append( + { + ChannelName.PULSE1: FIRST_PATTERN, + ChannelName.PULSE2: None, + ChannelName.TRIANGLE: FIRST_PATTERN, + ChannelName.NOISE: FIRST_PATTERN, + } + ) + return project + + +def write_every_part_project(path: Path) -> Path: + """Saves the every-part project where ``path`` names, the file a session opens.""" + ProjectContainer.save(every_part_project(), path) + return path + + +@pytest.fixture +def every_part_file(tmp_path: Path) -> Path: + """The every-part project saved to a file of its own, ready to open.""" + return write_every_part_project(tmp_path / EVERY_PART_FILENAME) diff --git a/tests/suite/history/session.py b/tests/suite/history/session.py new file mode 100644 index 000000000..a89e05faf --- /dev/null +++ b/tests/suite/history/session.py @@ -0,0 +1,176 @@ +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Final, Iterator, List +from unittest.mock import patch + +from sampletones_application.application import Application +from sampletones_application.coordinators.tabs.sequencer.coordinator import SequencerTabCoordinator +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.reconstruction.instruments import ReconstructionInstrumentsLogic +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_core.project import Project +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_shared.types.callback import Callback +from tests.suite.application import settled +from tests.suite.clipboard import FakeTextClipboard +from tests.suite.headless import headless_application, headless_context +from tests.suite.history.audit import HistoryAudit, fresh_fingerprint +from tests.suite.history.projects import EVERY_PART_FILENAME, write_every_part_project + +TEXT_CLIPBOARD: Final[str] = "sampletones_application.coordinators.tabs.sequencer.coordinator.select_text_clipboard" + + +@dataclass(frozen=True) +class SequencerDoors: + """Undo, redo and a jump as the application reaches them, each waiting for the edits before it.""" + + coordinator: SequencerTabCoordinator + + def undo(self) -> None: + self.coordinator.undo() + + def redo(self) -> None: + self.coordinator.redo() + + def jump_to(self, index: int) -> None: + self.coordinator.jump_to_history(index) + + +CONFIRM_POSITION: Final[int] = 3 + + +def _confirmed( + _renderer: DialogsRenderer, + *arguments: Any, + **keywords: Any, +) -> None: + """A reader answering every confirmation with its confirming choice, however it was asked.""" + on_confirm: Callback = keywords["on_confirm"] if "on_confirm" in keywords else arguments[CONFIRM_POSITION] + on_confirm() + + +def open_voice_faults(app: Application) -> List[str]: + """Where the Reconstructions tab shows a voice the project holds otherwise, or no longer holds.""" + voice_id = app.reconstruction_manager.voice_id + if voice_id is None: + return [] + + voice = app.project_controller.project.voices.get(voice_id) + match voice: + case None: + return [f"The Reconstructions tab shows voice {voice_id}, which the project no longer holds"] + case Sample() if app.reconstruction_manager.reconstruction is not voice.reconstruction: + return [f"The Reconstructions tab shows another document than {voice.name!r} holds"] + + return [] + + +@dataclass(frozen=True) +class HistorySession: + """The whole application over the every-part project, its history held by an audit. + + A step reaches the application through the hook its panel calls, and the audit checks the + history after it, the voice the Reconstructions tab shows included. + """ + + app: Application + audit: HistoryAudit + project_file: Path + ids: Dict[str, str] + + @property + def controller(self) -> ProjectController: + return self.app.project_controller + + @property + def history(self) -> HistoryManager: + return self.app.history + + @property + def project(self) -> Project: + return self.app.project_controller.project + + @property + def sequencer(self) -> SequencerTabCoordinator: + return self.app._sequencer_tab + + @property + def instruments(self) -> ReconstructionInstrumentsLogic: + return self.app._reconstructions_tab._reconstruction_instruments_logic + + def sample(self, name: str) -> Sample: + return next(voice for voice in self.project.voices if isinstance(voice, Sample) and voice.name == name) + + def instrument(self, name: str) -> Instrument: + return next(voice for voice in self.project.voices if isinstance(voice, Instrument) and voice.name == name) + + def voice_id(self, name: str) -> str: + """The id the opened file gave the voice called ``name``, which a rename or a copy leaves standing.""" + return self.ids[name] + + def reopen(self) -> None: + """Opens the every-part project afresh, the stack starting over from it.""" + self.sequencer._sequencer_voices_panel.deselect() + self.audit.transition(lambda: self.app._project_coordinator.load_project_safely(self.project_file)) + + def open_voice(self, name: str) -> None: + """Opens a voice on the Reconstructions tab, the way the voices list's Edit does.""" + self.open_voice_id(self.voice_id(name)) + + def open_voice_id(self, voice_id: str) -> None: + """Opens the voice ``voice_id`` names, which tells apart two voices of one name.""" + settled(lambda: self.app._reconstruction_coordinator.open_project_voice(voice_id)) + + def mark_voice(self, name: str) -> None: + """Marks a voice in the voices list, the target a replacement from the browser takes.""" + panel = self.sequencer._sequencer_voices_panel + panel._selected_voice_id = self.voice_id(name) + panel._selected_row = self.project.voices.get_index(self.voice_id(name)) + + def save(self) -> None: + """Saves the project to the file it was opened from, as the menu's Save does, after the edits on their way.""" + self.audit.save(self.save_door) + + def save_door(self) -> None: + """The menu's Save, which waits for the edits of the open document made before it.""" + self.app._reconstruction_coordinator.after_edits(self.app._project_coordinator.save) + + def stored_fingerprint(self) -> str: + """The state the project file holds, read back by a project manager of its own.""" + return fresh_fingerprint(ProjectContainer.load(self.project_file)) + + +@contextmanager +def history_session(directory: Path) -> Iterator[HistorySession]: + """Opens the every-part project in a headless application whose history the audit holds. + + The desktop's clipboard stands in memory, and a confirmation a gesture asks for is answered by + confirming, so every gesture runs down the path a reader who agrees takes. + """ + project_file = write_every_part_project(directory / EVERY_PART_FILENAME) + with ( + patch.object(DialogsRenderer, "show_confirmation", _confirmed), + patch(TEXT_CLIPBOARD, FakeTextClipboard), + headless_context(), + ): + app = headless_application(directory) + settled(lambda: app._project_coordinator.load_project_safely(project_file)) + audit = HistoryAudit( + app.project_controller, + app.history, + budget=app.session_manager.history_budget, + doors=SequencerDoors(app._sequencer_tab), + settle=settled, + observers=(lambda: _check_open_voice(app),), + ) + ids = {voice.name: voice.id for voice in app.project_controller.project.voices} + yield HistorySession(app=app, audit=audit, project_file=project_file, ids=ids) + + +def _check_open_voice(app: Application) -> None: + faults = open_voice_faults(app) + assert not faults, faults diff --git a/tests/suite/history/wiring.py b/tests/suite/history/wiring.py new file mode 100644 index 000000000..f81b4e9c4 --- /dev/null +++ b/tests/suite/history/wiring.py @@ -0,0 +1,19 @@ +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController + + +def wired_history( + controller: ProjectController, + *, + budget: int, + strict: bool, +) -> HistoryManager: + """A history over ``controller``, wired the way the composition root wires it. + + Every mutation reaches the history as it lands, so under ``strict`` one outside a + transaction raises, and every save marks the save point. + """ + history = HistoryManager(controller, budget=budget, strict=strict) + controller.on_mutation = history.handle_mutation + controller.on_saved = history.mark_saved + return history 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/library.py b/tests/suite/library.py new file mode 100644 index 000000000..5dc7e0d02 --- /dev/null +++ b/tests/suite/library.py @@ -0,0 +1,74 @@ +from pathlib import Path +from typing import Final, Tuple + +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.view_model.main.updates import AdvancedSettingsUpdate +from sampletones_core.configs import Config +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import get_generators_by_channels +from sampletones_core.generators.maps import GENERATOR_TO_INSTRUCTION_MAP +from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from sampletones_core.library.creator.creation import generate_instruction +from sampletones_core.library.key import InstructionLibraryKey +from sampletones_tools.corpus.catalog import CHANNELS, build_mini_library + +OTHER_LIBRARIES: Final[str] = "other_libraries" +LINKED_LIBRARIES: Final[str] = "linked_libraries" + + +class WrittenLibrary: + """Library data standing in for a generated library, written as an empty library this build + reads, which is the file the catalog lists.""" + + def save(self, path: Path) -> None: + write_empty_library(path) + + +def write_empty_library(path: Path) -> None: + """Writes a library holding no entries at ``path``, stated at the version this build reads.""" + path.parent.mkdir(parents=True, exist_ok=True) + InstructionLibraryData.create(Config(), {}).save(path) + + +def aim_library_directory(config_manager: ConfigManager, directory: Path) -> None: + """Points the configuration's library directory at ``directory``, as the advanced settings do.""" + config_manager.apply_advanced_settings( + AdvancedSettingsUpdate( + max_workers=config_manager.config.general.max_workers, + spectrum_method=config_manager.config.library.spectrum_method, + transformation_gamma=config_manager.config.library.transformation_gamma, + library_directory=directory, + reconstructions_directory=config_manager.get_reconstructions_directory(), + ) + ) + + +def build_served_library(config: Config) -> Tuple[InstructionLibrary, InstructionLibraryKey]: + """The mini library for ``config``, holding each generator's default instruction beside its samples. + + A converter run matches against any library, while the Instructions tab opens a generator on + its default instruction, so a library the interface browses holds that one too. + + Returns: + Tuple[InstructionLibrary, InstructionLibraryKey]: The library, and the key it keeps the data under. + """ + library = build_mini_library(config) + window = Window.from_config(config) + key = library.create_key(config, window) + extractor = get_feature_extractor(config, window) + generators = { + generator.class_name(): generator for generator in get_generators_by_channels(config, CHANNELS).values() + } + data = dict(library.data[key].data) + data.update( + generate_instruction( + generators, + class_name, + GENERATOR_TO_INSTRUCTION_MAP[type(generator)].default_instruction(), + extractor, + ) + for class_name, generator in generators.items() + ) + library.data[key] = InstructionLibraryData.create(config, data) + return library, key diff --git a/tests/suite/memory.py b/tests/suite/memory.py new file mode 100644 index 000000000..717b76721 --- /dev/null +++ b/tests/suite/memory.py @@ -0,0 +1,23 @@ +import gc +import tracemalloc +from typing import Callable + + +def retained_bytes(work: Callable[[], object]) -> int: + """The bytes the work leaves allocated once the collector has run, its answer included. + + Tracing starts with the work, so what is counted is what the work built and something still + reaches: the answer it returns, and whatever it stored in what lived before it ran. + """ + gc.collect() + tracemalloc.start() + try: + before, _ = tracemalloc.get_traced_memory() + answer = work() + gc.collect() + after, _ = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + + del answer + return after - before diff --git a/tests/suite/nsf.py b/tests/suite/nsf.py new file mode 100644 index 000000000..db64b708b --- /dev/null +++ b/tests/suite/nsf.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Tuple +from unittest.mock import MagicMock + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME, NO_FRAMES, NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.request import ProjectExport, SampleExport +from sampletones_core.exports.scope import ExportScope +from sampletones_player.compression.scheme import offered_schemes +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.paths.extensions import EXT_FILE_NSF + +ORDER_FRAMES: Final[int] = 12 +SAMPLE_CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +PROGRAM_TITLE: Final[str] = "Rainy Day Theme" +PROGRAM_ARTIST: Final[str] = "J. Kowalski" + + +def project_offer() -> NSFExportOffer: + """What a project laid out in a dozen frames offers: every channel, frame and scheme.""" + return NSFExportOffer( + channels=tuple(ChannelName.items()), + frame_count=ORDER_FRAMES, + schemes=offered_schemes(seeded=True), + ) + + +def sample_offer() -> NSFExportOffer: + """What a reconstruction sounding two channels offers.""" + return NSFExportOffer( + channels=SAMPLE_CHANNELS, + frame_count=NO_FRAMES, + schemes=offered_schemes(seeded=False), + ) + + +def standing_choices(offer: NSFExportOffer) -> NSFExportChoices: + """Choices sounding every channel ``offer`` holds and repeating from the start.""" + return NSFExportChoices( + information=NSFInformation(title=PROGRAM_TITLE, artist=PROGRAM_ARTIST), + channels=frozenset(offer.channels), + repeat=NSFRepeat.FROM_START, + loop_frame=FIRST_FRAME, + scheme=offer.schemes[-1], + ) + + +@dataclass(frozen=True) +class ProjectRun: + destination: Path + backend: ExportBackend + request: ProjectExport + + +@dataclass(frozen=True) +class SampleRun: + destination: Path + backend: ExportBackend + request: SampleExport + + +class FakeNSFExportService: + """The export service as an NSF setup hands its run over, holding each run it was given.""" + + def __init__(self) -> None: + self.projects: List[ProjectRun] = [] + self.samples: List[SampleRun] = [] + + def export_project( + self, + destination: Path, + backend: ExportBackend, + request: ProjectExport, + ) -> None: + self.projects.append(ProjectRun(destination=destination, backend=backend, request=request)) + + def export_sample( + self, + destination: Path, + backend: ExportBackend, + request: SampleExport, + ) -> None: + self.samples.append(SampleRun(destination=destination, backend=backend, request=request)) + + +class FakeProgramBackend: + """The NSF backend as a setup binds it, holding each program chosen and the backend it gave.""" + + def __init__(self) -> None: + self.programs: List[NSFProgram] = [] + self.chosen: List[ExportBackend] = [] + + @property + def program(self) -> NSFProgram: + assert self.programs, "A program was expected to be chosen" + return self.programs[-1] + + def choosing(self, program: NSFProgram) -> ExportBackend: + backend = MagicMock(spec=ExportBackend) + self.programs.append(program) + self.chosen.append(backend) + return backend + + def extension(self, scope: ExportScope) -> str: # pylint: disable=unused-argument + return EXT_FILE_NSF diff --git a/tests/suite/parallelization.py b/tests/suite/parallelization.py new file mode 100644 index 000000000..a7b11b48a --- /dev/null +++ b/tests/suite/parallelization.py @@ -0,0 +1,179 @@ +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) + + +def failing_task(task: CountingTask) -> int: + """Raises in its worker once it has counted halfway, so the run ends on a task's error. + + Raises: + ValueError: Always, once the task reaches its halfway mark. + """ + for completed in range(1, HALFWAY + 1): + task.report( + TaskStep( + stage=COUNTING_STAGE, + completed=completed, + total=STEP_COUNT, + fraction=completed / STEP_COUNT, + ) + ) + + raise ValueError(f"task {task.index} failed at its halfway mark") + + +class FailingProcessor(CountingProcessor): + """A counting run whose every task raises, so what a test reads is how a failed run ends.""" + + def _get_task_function(self) -> Callable[[CountingTask], int]: + return failing_task + + +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..50ebeea20 --- /dev/null +++ b/tests/suite/performance.py @@ -0,0 +1,199 @@ +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 ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, +) +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.performance.rows import apply_row, resolve_row +from sampletones_core.performance.state import ChannelPerformance +from sampletones_core.project.patterns.pitch import Step +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_position import SongPosition +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample +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( + instructions=channel_instructions, + config=Config(), + coefficient=1.0, + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(DEFAULT_CHANNELS), + channel_instructions, + ), + ) + + +def reconstruction_of( + channel_name: ChannelName, + instructions: List[InstructionUnion], +) -> Reconstruction: + """A reconstruction sounding ``instructions`` on one channel, every other channel standing by.""" + return _reconstruction(channel_name, instructions) + + +def make_pulse_reconstruction( + *, + pitch: int = 60, + 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 = reconstruction.with_channel_data( + ChannelName.PULSE1, + list(instructions), + reconstruction.initial_pitches[ChannelName.PULSE1], + held_features, + heard=reconstruction.recorded_stem_ids, + ) + + 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 an instrument 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.""" + channel = project.song.channels[channel_name] + channel.ensure_pattern( + pattern_index, + project.song.rows_per_pattern, + ) + channel.set_row( + pattern_index, + row_index, + Row( + command=NoteOn(voice_id=sample.id), + pitch=Step(value=transpose) if transpose is not None else None, + volume=volume, + ), + ) + + +def song_row_volumes(project: Project, channel_name: ChannelName) -> List[Optional[int]]: + """The level the song walk plays one channel at on each row of the order, ``None`` where it rests. + + Rows come frame by frame, each frame lasting the song's rows per pattern, so an index into the + list names the same row a tracker document laying the order out in turn plays there. + """ + song = project.song + performance = ChannelPerformance() + volumes: List[Optional[int]] = [] + position = SongPosition() + while position.order_position < song.order_length(): + row = resolve_row(song, position, channel_name) + if row is not None: + apply_row(performance, row, channel_name, project.voice) + + volumes.append(performance.volume if performance.voice_id is not None else None) + position.advance(song.rows_per_pattern, song.order_length()) + + return volumes diff --git a/tests/suite/playback.py b/tests/suite/playback.py new file mode 100644 index 000000000..88127353f --- /dev/null +++ b/tests/suite/playback.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import Final, List, Tuple + +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.project.project import Project +from sampletones_tools.tracker_playback.targets.protocol import TargetPlayback +from sampletones_tools.tracker_playback.trace.application import application_trace + +REPLAYING_TITLE: Final[str] = "Replay" +REPLAYING_PLAYER: Final[str] = "the application itself" +REPLAYED_EXTENSION: Final[str] = ".txt" + + +class ReplayingTarget: + """A playback target that plays every project back exactly as the application plays it. + + It writes a stand-in file per project and records what it was asked to play, so a case checks the + run around a target without any tracker installed. + """ + + title: str = REPLAYING_TITLE + player: str = REPLAYING_PLAYER + + def __init__(self) -> None: + self.played: List[Tuple[Path, str]] = [] + + def play( + self, + project: Project, + directory: Path, + name: str, + ) -> TargetPlayback: + self.played.append((directory, name)) + document = directory / f"{name}{REPLAYED_EXTENSION}" + document.write_text(name, encoding="utf-8") + return TargetPlayback( + document=document, + trace=application_trace(project), + skipped_rows=NO_SKIPPED_ROWS, + truncation=None, + ) diff --git a/tests/suite/player.py b/tests/suite/player.py new file mode 100644 index 000000000..9461b4294 --- /dev/null +++ b/tests/suite/player.py @@ -0,0 +1,384 @@ +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 ( + DEFAULT_CHANNELS, + 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.flags import flagged_value +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.separate import planes_from_streams +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.registers.noise import NoiseRegisters +from sampletones_player.registers.pulse import PulseRegisters +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, +) +from sampletones_player.specification.planes import ( + PLANE_COUNT, + PLANES, + Plane, + PlaneRole, + plane_index, +) +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 nearest_anchor(timer: int) -> int: + """The pitch a timer is counted from, the one lying nearest it.""" + return PLAYER_PITCHES.pitch(PLAYER_PITCHES.nearest[timer].pitch) + + +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, + anchor=nearest_anchor(timer), + ) + + +RESTING_PITCH: Final[int] = 0 + + +def triangle_tick(sounding: bool, timer: int) -> TriangleRegisters: + """A triangle channel's registers for one tick, spelled the way the encoder spells them. + + A resting tick states no pitch, so it carries the divider the channel would be holding: the + lowest the table reaches, which is where the encoder leaves a channel that has yet to sound. + """ + reload_value = TRIANGLE_SOUNDING_RELOAD if sounding else TRIANGLE_SILENT_RELOAD + held = timer if sounding else PLAYER_PITCHES.timers[RESTING_PITCH] + return TriangleRegisters( + linear_counter=TRIANGLE_COUNTER_CONTROL | reload_value, + timer_low=held & MAX_REGISTER_VALUE, + timer_high=held >> TIMER_HIGH_SHIFT, + anchor=nearest_anchor(held), + ) + + +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=(), + scheme=CompressionScheme.SEARCH, + ) + + +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 stating the plane outright reaches bends + no encoder writes — past half the gap to a neighboring pitch — which is what holds the driver's + own arithmetic to the divider each tick is meant to sound at across the whole signed byte. + + 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( + planes=planes.planes._replace( + pulse1_value=bytes(flagged_value(pitch_index, bend != 0) for bend in bends), + pulse1_bend=bytes(unsigned_byte(bend) for bend in bends if bend), + ) + ) + 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 playable(plane: Plane, values: bytes) -> bytes: + """Arbitrary bytes read as values the plane can play, through its own form.""" + return bytes(plane.form.value(value) for value in values) + + +def sounding_planes( + control: bytes, + value: bytes, + bend: bytes, +) -> SongPlanes: + """A song's planes, the first pulse channel sounding what it is given and the rest resting. + + Every other plane stands at the value its own register fixes, which is where the driver seeds + it and what makes it absent. + """ + resting = [b"" if plane.spans_flagged_ticks else bytes((plane.seeded,)) * len(control) for plane in PLANES] + for role, played in zip((PlaneRole.CONTROL, PlaneRole.VALUE, PlaneRole.BEND), (control, value, bend)): + resting[plane_index(ChannelName.PULSE1, role)] = played + + return SongPlanes(planes=PlaneOrder.across(resting)) + + +STREAM_START: Final[int] = 0 + + +def every_plane_spelling(stream: bytes) -> PlaneOrder: + """One stream standing for every plane the song block writes.""" + return PlaneOrder.across((stream,) * PLANE_COUNT) + + +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=every_plane_spelling(stream), + ticks=ticks, + loop_entries=(STREAM_START,) * PLANE_COUNT, + ), + 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( + instructions=instructions, + config=config, + coefficient=1.0, + audio_filepath=(Path(os.devnull),), + stems_data=single_entry_stems_data(list(DEFAULT_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/questions.py b/tests/suite/questions.py new file mode 100644 index 000000000..bcafa3507 --- /dev/null +++ b/tests/suite/questions.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from typing import Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.utils.callbacks.gates import Gate, asking +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames + +STANDING_WINDOW: Final[str] = "standing window" + + +class StandingWindow: + """Another conversation holding the screen, such as a dialog the reader has open.""" + + def __init__(self, frames: Frames) -> None: + self._frames = frames + + def stand(self) -> None: + ModalQueue.open(STANDING_WINDOW, lambda: None) + + def leave(self) -> None: + """Takes the window off the screen and renders the frame the line moves on in.""" + ModalQueue.leave(STANDING_WINDOW) + self._frames.render() + + +@pytest.fixture +def standing_window(held_frames: Frames) -> StandingWindow: + """A window standing on the screen, which a case takes away when it wants the screen free.""" + window = StandingWindow(held_frames) + window.stand() + return window + + +def dialogs_on_the_line() -> MagicMock: + """A stand-in for the dialogs renderer whose wait for the screen is the modal line's own turn.""" + dialogs = MagicMock() + dialogs.when_free.side_effect = ModalQueue.when_free + return dialogs + + +@dataclass(frozen=True) +class StandingQuestion: + """A question on the screen, with the two ways out of it.""" + + tag: str + proceed: VoidCallback + decline: VoidCallback + + +class OnScreenDocument: + """Something unfinished whose owner asks about it on the screen, the way the application's guards do. + + A guard asks once the screen is free for its question, and reads whether the thing is unfinished + then. An answer leaves the screen first and runs a frame later, as a dialog's answer does, so a case + renders a frame between the answer and what it lets through. + """ + + def __init__(self, name: str, asked: List[str]) -> None: + self.name = name + self.unfinished = False + self._asked = asked + self._standing: Optional[StandingQuestion] = None + + def guard(self, question: str) -> Gate: + """The gate that asks ``question`` while the thing is unfinished, and lets the request through otherwise. + + The question is recorded in the shared ``asked`` list as ``" "`` once it reaches + the screen. + """ + + tag = f"{self.name} {question}" + + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + def build() -> None: + self._asked.append(tag) + self._standing = StandingQuestion(tag=tag, proceed=proceed, decline=decline) + + ModalQueue.open(tag, build) + + return asking(lambda: self.unfinished, ask, ModalQueue.when_free) + + def finish(self) -> None: + """Settles the thing, the way closing a document lets its changes go or a run comes to its end.""" + self.unfinished = False + + def go_on(self) -> None: + """The reader answering the question standing with Save, Discard or Exit.""" + question = self._take_question() + ModalQueue.hand_off(question.proceed) + + def cancel(self) -> None: + """The reader answering the question standing with Cancel.""" + question = self._take_question() + ModalQueue.hand_off(question.decline) + + def _take_question(self) -> StandingQuestion: + """Takes the question standing off the screen, the way an answered dialog leaves before its answer runs.""" + assert self._standing is not None, f"No question about the {self.name} stands" + question, self._standing = self._standing, None + ModalQueue.leave(question.tag) + return question + + +def assert_the_answers_reach( + *, + confirm: VoidCallback, + cancel: VoidCallback, + proceed: MagicMock, + decline: MagicMock, +) -> None: + """Holds a question's answers to the gate that asked it: the one going on reaches ``proceed``, Cancel ``decline``.""" + confirm() + proceed.assert_called_once_with() + decline.assert_not_called() + + cancel() + decline.assert_called_once_with() diff --git a/tests/suite/regeneration.py b/tests/suite/regeneration.py new file mode 100644 index 000000000..5832f2862 --- /dev/null +++ b/tests/suite/regeneration.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from typing import AbstractSet, Callable, List, Tuple + +from sampletones_application.services.regeneration.result import ( + RegeneratedInstrument, + RegenerationResult, +) +from sampletones_application.services.regeneration.service import RegenerationService +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class HeldRebuild: + """One rebuild the regeneration was asked for, as it was asked.""" + + reconstruction: Reconstruction + channel_name: ChannelName + features: Features + heard: AbstractSet[int] + + +class HeldRegeneration: + """The regeneration service holding each rebuild it is asked for until a case lets it finish. + + A rebuild runs on a worker thread in the application, so its result arrives some time after it + was asked for. Holding it lets a case put gestures in between, and finishing it runs the real + rebuild. + """ + + def __init__(self) -> None: + self._service = RegenerationService() + self._held: List[HeldRebuild] = [] + + def subscribe(self, handler: Callable[[RegenerationResult], None]) -> None: + self._service.subscribe(handler) + + def start( + self, + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, + heard: AbstractSet[int], + ) -> None: + self._held.append(HeldRebuild(reconstruction, channel_name, features, heard)) + + @property + def held(self) -> Tuple[HeldRebuild, ...]: + """The rebuilds asked for and not yet finished, the earliest first.""" + return tuple(self._held) + + def finish(self) -> None: + """Runs the earliest held rebuild and delivers what it produced.""" + rebuild = self._held.pop(0) + self._service._run(rebuild.reconstruction, rebuild.channel_name, rebuild.features, rebuild.heard) + + def finish_with(self, reconstruction: Reconstruction) -> None: + """Delivers ``reconstruction`` as what the earliest held rebuild produced.""" + self._held.pop(0) + self._service._emit(ServiceSuccess(value=RegeneratedInstrument(reconstruction=reconstruction))) + + def fail(self, exception: Exception) -> None: + """Delivers ``exception`` as the earliest held rebuild's failure.""" + self._held.pop(0) + self._service._emit(ServiceError(exception=exception)) diff --git a/tests/suite/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/screens/__init__.py b/tests/suite/screens/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/seeds/__init__.py b/tests/suite/screens/seeds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/seeds/archives.py b/tests/suite/screens/seeds/archives.py new file mode 100644 index 000000000..c678a2162 --- /dev/null +++ b/tests/suite/screens/seeds/archives.py @@ -0,0 +1,96 @@ +import shutil +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Final + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.fft import Window +from sampletones_core.library.key import InstructionLibraryKey +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, restated_document, stored_document + +FOREIGN_BYTES: Final[bytes] = b"These bytes were written by another program and belong to no SampleToNES document.\n" +TRUNCATED_FRACTION: Final[int] = 2 +CONFIG_FIELD: Final[str] = "config" + + +class Damage(StrEnum): + """What went wrong with a stored document before the application met it.""" + + OLDER_VERSION = "older version" + FUTURE_VERSION = "future version" + TRUNCATED = "truncated" + FOREIGN_BYTES = "foreign bytes" + + +@dataclass(frozen=True) +class CopiedFile: + """A file laid in the home as it stands elsewhere.""" + + source: Path + destination: Path + + def write(self) -> None: + """Copies the source file to the destination, creating its folder.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.source, self.destination) + + +@dataclass(frozen=True) +class WrittenBytes: + """A file in the home holding exactly ``content``.""" + + destination: Path + content: bytes + + def write(self) -> None: + """Writes the content to the destination, creating its folder.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(self.content) + + +def archived_document(kind: ObjectKind, destination: Path) -> CopiedFile: + """The document of ``kind`` a release wrote, which the compatibility corpus keeps, laid at + ``destination``. + """ + return CopiedFile( + source=archived(kind, ARCHIVED_VERSIONS[kind]), + destination=destination, + ) + + +def archived_library(folder: Path) -> CopiedFile: + """The library a release built, laid in ``folder`` under the name its settings give a library file.""" + source = archived(ObjectKind.LIBRARY, ARCHIVED_VERSIONS[ObjectKind.LIBRARY]) + config = Config(library=InstructionsLibraryConfig.model_validate(stored_document(source)[CONFIG_FIELD])) + key = InstructionLibraryKey.create(config.library, Window.from_config(config)) + return CopiedFile(source=source, destination=folder / key.filename) + + +def damaged_document( + kind: ObjectKind, + damage: Damage, + *, + destination: Path, + older_version: str, + future_version: str, +) -> WrittenBytes: + """The archived document of ``kind`` with ``damage`` done to it, laid at ``destination``. + + A document restated at ``older_version`` or ``future_version`` is whole apart from its version, so + a refusal of it is a refusal of that version alone. + """ + source = archived(kind, ARCHIVED_VERSIONS[kind]) + match damage: + case Damage.OLDER_VERSION: + content = restated_document(source, kind, older_version) + case Damage.FUTURE_VERSION: + content = restated_document(source, kind, future_version) + case Damage.TRUNCATED: + whole = source.read_bytes() + content = whole[: len(whole) // TRUNCATED_FRACTION] + case Damage.FOREIGN_BYTES: + content = FOREIGN_BYTES + + return WrittenBytes(destination=destination, content=content) diff --git a/tests/suite/screens/seeds/constants.py b/tests/suite/screens/seeds/constants.py new file mode 100644 index 000000000..e3c07ec34 --- /dev/null +++ b/tests/suite/screens/seeds/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +TURNING_LEVELS: Final[int] = 15 +KICK: Final[str] = "kick.wav" +SNARE: Final[str] = "snare.wav" diff --git a/tests/suite/screens/seeds/libraries.py b/tests/suite/screens/seeds/libraries.py new file mode 100644 index 000000000..3f1aad606 --- /dev/null +++ b/tests/suite/screens/seeds/libraries.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from sampletones_core.configs import Config +from tests.suite.library import build_served_library + + +@dataclass(frozen=True) +class MiniLibrary: + """A small library built for ``config``, saved where the application looks for that configuration's + library. + + A conversion matches against it in seconds, and the Instructions tab browses it. + """ + + config: Config + + def write(self) -> None: + """Builds the library and saves it where the application looks for it.""" + library, key = build_served_library(self.config) + library.save_data(key, library.data[key]) diff --git a/tests/suite/screens/seeds/projects.py b/tests/suite/screens/seeds/projects.py new file mode 100644 index 000000000..6f365474a --- /dev/null +++ b/tests/suite/screens/seeds/projects.py @@ -0,0 +1,241 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.famitracker.instrument import 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.project import ProjectContainer +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.sample import Sample +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from tests.suite.history.projects import every_part_project +from tests.suite.performance import place_instrument +from tests.suite.screens.seeds.constants import TURNING_LEVELS +from tests.suite.screens.seeds.reconstructions import ( + _detached_reconstruction, + _playable_instructions, + _stored_reconstruction, + _turning_instructions, +) + +ARRANGED_FRAMES: Final[int] = 30 +PAD_VOLUME: Final[Tuple[int, ...]] = (12, 8, 4) +PAD_ARPEGGIO: Final[Tuple[int, ...]] = (0, 0, 0) +FIRST_FRAME: Final[int] = 0 +ONE_FRAME: Final[int] = 1 +FIRST_ROW: Final[int] = 0 +LOUDEST_LEVEL: Final[int] = 15 +RELEASING_VOLUME: Final[Tuple[int, ...]] = (15, 8) +RELEASE_POINT: Final[int] = 1 + + +@dataclass(frozen=True) +class StoredProject: + """A project holding the stored reconstruction as the sample ``sample`` and the instrument + ``instrument``. + """ + + destination: Path + sample: str + instrument: str + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + project.voices.append(Sample(name=self.sample, reconstruction=_stored_reconstruction())) + project.voices.append(Instrument(name=self.instrument)) + self.destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, self.destination) + + +@dataclass(frozen=True) +class EveryPartProject: + """The project holding something in every part a history gesture reaches, as the history tiers below build it.""" + + destination: Path + + def write(self) -> None: + """Saves the project to the destination.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(every_part_project(), self.destination) + + +@dataclass(frozen=True) +class ArrangedProject: + """A project with two samples and an instrument, each placed once on the song's first pattern. + + ``line`` is a sample sounding Pulse 1, the triangle and the noise; ``bass`` a sample sounding the + triangle alone; ``pad`` an instrument fading on Pulse 2, its arpeggio flat. The line starts + the pattern on Pulse 1, the instrument comes in on Pulse 2 at ``pad_row``, and the bass on + the triangle at ``bass_row``. The order plays that pattern ``order_frames`` times. + """ + + destination: Path + line: str + bass: str + pad: str + pad_row: int + bass_row: int + order_frames: int + + def write(self) -> None: + """Saves the project to the destination.""" + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, Config())) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, Config())) + pad = Instrument( + name=self.pad, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=PAD_VOLUME), + arpeggio=Envelope[int](items=PAD_ARPEGGIO), + ), + ) + for voice in (line, bass, pad): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=line) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=self.pad_row, sample=pad) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=self.bass_row, sample=bass) + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class OverlongProject: + """A project whose song changes on every channel at every tick, through ``order_frames`` frames. + + Its one sample, ``sample``, plays a new value on each channel at every tick for as long as a + frame lasts, and every frame of the order plays it from the top. Stored tick by tick, the song + outgrows the room an NSF program has; stored with each repeat saved once, it fits. + """ + + destination: Path + sample: str + order_frames: int + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + ticks = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(ONE_FRAME) + sample = Sample( + name=self.sample, reconstruction=_detached_reconstruction(_turning_instructions(ticks), Config()) + ) + project.voices.append(sample) + for channel in ChannelName.items(): + place_instrument(project, channel_name=channel, row_index=FIRST_ROW, sample=sample) + + _repeat_first_frame(project, self.order_frames) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class TwoTuningsProject: + """A project whose sample ``line`` was converted at the default tuning, and ``bass`` with A4 at + ``a4_frequency`` hertz. + + The line starts the first pattern on Pulse 1 and the bass the triangle, so the song plays both. + """ + + destination: Path + line: str + bass: str + a4_frequency: float + + def write(self) -> None: + """Saves the project to the destination.""" + line_instructions = _playable_instructions(ARRANGED_FRAMES) + bass_instructions = {ChannelName.TRIANGLE: line_instructions[ChannelName.TRIANGLE]} + default = Config() + retuned = default.model_copy( + update={"library": default.library.model_copy(update={"a4_frequency": self.a4_frequency})} + ) + project = Project.create() + line = Sample(name=self.line, reconstruction=_detached_reconstruction(line_instructions, default)) + bass = Sample(name=self.bass, reconstruction=_detached_reconstruction(bass_instructions, retuned)) + for voice in (line, bass): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=line) + place_instrument(project, channel_name=ChannelName.TRIANGLE, row_index=FIRST_ROW, sample=bass) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class LongEnvelopeProject: + """A project holding two instruments whose volume envelopes run long, both placed on the first + pattern. + + ``long`` fades through ``long_items`` items and ``middling`` through ``middling_items``, neither + of them ending in silence. + """ + + destination: Path + long: str + long_items: int + middling: str + middling_items: int + + def write(self) -> None: + """Saves the project to the destination.""" + project = Project.create() + long = _fading_instrument(self.long, self.long_items) + middling = _fading_instrument(self.middling, self.middling_items) + for voice in (long, middling): + project.voices.append(voice) + + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=FIRST_ROW, sample=long) + place_instrument(project, channel_name=ChannelName.PULSE2, row_index=FIRST_ROW, sample=middling) + _save_project(project, self.destination) + + +@dataclass(frozen=True) +class ReleasingInstrumentFile: + """A FamiTracker instrument file of the voice ``name``, whose volume sequence has a release point.""" + + destination: Path + name: str + + def write(self) -> None: + """Writes the instrument file to the destination.""" + sequences = {kind: InstrumentSequence(kind=kind) for kind in SequenceKind} + sequences[SequenceKind.VOLUME] = InstrumentSequence( + kind=SequenceKind.VOLUME, + items=RELEASING_VOLUME, + release_point=RELEASE_POINT, + ) + instrument = Instrument2A03(index=STANDALONE_INSTRUMENT_INDEX, name=self.name, sequences=sequences) + self.destination.parent.mkdir(parents=True, exist_ok=True) + self.destination.write_bytes(instrument_to_fti_bytes(instrument)) + + +def _fading_instrument(name: str, items: int) -> Instrument: + """An instrument whose volume falls from the loudest level through the quieter ones, over and + over. + """ + return Instrument( + name=name, + envelopes=InstrumentEnvelopes( + volume=Envelope[int](items=tuple(LOUDEST_LEVEL - index % TURNING_LEVELS for index in range(items))), + ), + ) + + +def _repeat_first_frame(project: Project, order_frames: int) -> None: + """Lays ``order_frames`` frames in the order, each playing the patterns the first one plays.""" + for _ in range(order_frames - 1): + project.song.duplicate_frame(FIRST_FRAME) + + +def _save_project(project: Project, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(project, destination) diff --git a/tests/suite/screens/seeds/reconstructions.py b/tests/suite/screens/seeds/reconstructions.py new file mode 100644 index 000000000..a4c0a0aa0 --- /dev/null +++ b/tests/suite/screens/seeds/reconstructions.py @@ -0,0 +1,206 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Tuple + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions import Reconstruction +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived +from tests.suite.screens.seeds.constants import TURNING_LEVELS +from tests.suite.stems import recorded_from, single_entry_stems_data + +LINE_START_PITCH: Final[int] = 60 +LINE_STEP_FRAMES: Final[int] = 15 +LINE_SPAN: Final[int] = 12 +LINE_VOLUME: Final[int] = 12 +LINE_DUTY_CYCLE: Final[int] = 2 +BASS_PITCH: Final[int] = 45 +BEAT_FRAMES: Final[int] = 15 +BEAT_LENGTH: Final[int] = 3 +BEAT_PERIOD: Final[int] = 4 +BEAT_VOLUME: Final[int] = 10 +TURNING_PULSE_PITCH: Final[int] = 40 +TURNING_PULSE_SPAN: Final[int] = 24 +TURNING_SECOND_PITCH: Final[int] = 30 +TURNING_SECOND_SPAN: Final[int] = 31 +TURNING_TRIANGLE_PITCH: Final[int] = 30 +TURNING_TRIANGLE_SPAN: Final[int] = 29 +TURNING_PERIODS: Final[int] = 16 +TURNING_DUTY_CYCLES: Final[int] = 4 +TURNING_SECOND_LEVEL_STEP: Final[int] = 7 +TURNING_NOISE_LEVEL_STEP: Final[int] = 5 +TURNING_SECOND_DUTY_TICKS: Final[int] = 3 +UNMEASURED_SCALE_FIELD: Final[str] = "scale" +STEMS_DATA_FIELD: Final[str] = "stems_data" +SEVERAL_RECORDINGS: Final[int] = 2 + + +@dataclass(frozen=True) +class StoredReconstruction: + """The reconstruction the last release wrote, laid at ``destination`` the way this build writes it. + + It names the recording :func:`stored_recording` lays in the home, so it opens with its source. + """ + + destination: Path + + def write(self) -> None: + """Saves the reconstruction to the destination.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + _stored_reconstruction().save(self.destination) + + +@dataclass(frozen=True) +class PlayableReconstruction: + """A reconstruction sounding a rising line on Pulse 1, a bass on the triangle and a beat on the noise. + + Each recording of ``recordings`` plays the ``frames`` frames once, in turn, so the document + lasts as long as the recordings laid at those paths, one :class:`Recording` of ``frames`` frames + each, and opens with its sources. It was converted at ``nes_frequency`` ticks a second. + """ + + destination: Path + recordings: Tuple[Path, ...] + frames: int + nes_frequency: int + + def write(self) -> None: + """Saves the reconstruction to the destination, naming each recording as a source.""" + _save(self.destination, _playable_reconstruction(self.recordings, self.frames, self.nes_frequency)) + + +@dataclass(frozen=True) +class UnsoundReconstruction: + """A :class:`PlayableReconstruction` over several recordings whose record states no scale. + + A record naming several recordings always states the scale their conversion measured, so this one + breaks a rule of its own record. A document is read without its rules, so it opens; the rule is + checked once the record is built again, as adding the document to a project does when it lets go of + where the recordings live. + """ + + destination: Path + recordings: Tuple[Path, ...] + frames: int + nes_frequency: int + + def write(self) -> None: + """Saves the reconstruction to the destination, its record restated with no scale. + + Raises: + ValueError: If fewer than two recordings are named, since a lone recording may go unscaled. + """ + if len(self.recordings) < SEVERAL_RECORDINGS: + raise ValueError(f"An unsound record names several recordings, not {len(self.recordings)}") + + reconstruction = _playable_reconstruction(self.recordings, self.frames, self.nes_frequency) + unscaled = reconstruction.stems_data.model_copy(update={UNMEASURED_SCALE_FIELD: None}) + _save(self.destination, reconstruction.model_copy(update={STEMS_DATA_FIELD: unscaled})) + + +def _playable_instructions(frames: int) -> Dict[ChannelName, List[InstructionUnion]]: + """The instructions of ``frames`` frames: a rising line, a bass and a beat on three channels.""" + line: List[InstructionUnion] = [ + PulseInstruction( + on=True, + pitch=LINE_START_PITCH + (frame // LINE_STEP_FRAMES) % LINE_SPAN, + volume=LINE_VOLUME, + duty_cycle=LINE_DUTY_CYCLE, + ) + for frame in range(frames) + ] + bass: List[InstructionUnion] = [TriangleInstruction(on=True, pitch=BASS_PITCH) for _ in range(frames)] + beat: List[InstructionUnion] = [ + NoiseInstruction( + on=frame % BEAT_FRAMES < BEAT_LENGTH, + period=BEAT_PERIOD, + volume=BEAT_VOLUME if frame % BEAT_FRAMES < BEAT_LENGTH else 0, + short=False, + ) + for frame in range(frames) + ] + return {ChannelName.PULSE1: line, ChannelName.TRIANGLE: bass, ChannelName.NOISE: beat} + + +def _turning_instructions(ticks: int) -> Dict[ChannelName, List[InstructionUnion]]: + """Every channel sounding a new pitch, level or period at each of ``ticks`` ticks.""" + return { + ChannelName.PULSE1: [ + PulseInstruction( + on=True, + pitch=TURNING_PULSE_PITCH + tick % TURNING_PULSE_SPAN, + volume=1 + tick % TURNING_LEVELS, + duty_cycle=tick % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.PULSE2: [ + PulseInstruction( + on=True, + pitch=TURNING_SECOND_PITCH + tick % TURNING_SECOND_SPAN, + volume=1 + (tick * TURNING_SECOND_LEVEL_STEP) % TURNING_LEVELS, + duty_cycle=(tick // TURNING_SECOND_DUTY_TICKS) % TURNING_DUTY_CYCLES, + ) + for tick in range(ticks) + ], + ChannelName.TRIANGLE: [ + TriangleInstruction(on=True, pitch=TURNING_TRIANGLE_PITCH + tick % TURNING_TRIANGLE_SPAN) + for tick in range(ticks) + ], + ChannelName.NOISE: [ + NoiseInstruction( + on=True, + period=tick % TURNING_PERIODS, + volume=1 + (tick * TURNING_NOISE_LEVEL_STEP) % TURNING_LEVELS, + short=bool(tick % 2), + ) + for tick in range(ticks) + ], + } + + +def _playable_reconstruction( + recordings: Tuple[Path, ...], + frames: int, + nes_frequency: int, +) -> Reconstruction: + """The playable reconstruction of ``frames`` frames per recording, converted at ``nes_frequency``.""" + instructions = _playable_instructions(frames) + reconstruction = Reconstruction.create( + instructions=instructions, + config=Config().with_library(nes_frequency=nes_frequency), + coefficient=1.0, + audio_filepath=recordings[:1], + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + if len(recordings) > 1: + return recorded_from(reconstruction, recordings) + + return reconstruction + + +def _save(destination: Path, reconstruction: Reconstruction) -> None: + """Saves ``reconstruction`` at ``destination``, creating its folder.""" + destination.parent.mkdir(parents=True, exist_ok=True) + reconstruction.save(destination) + + +def _detached_reconstruction( + instructions: Dict[ChannelName, List[InstructionUnion]], + config: Config, +) -> Reconstruction: + """A reconstruction of ``instructions`` naming no recording, the way a project stores a sample.""" + return Reconstruction.create( + instructions=instructions, + config=config, + coefficient=1.0, + audio_filepath=(), + stems_data=single_entry_stems_data(list(instructions), instructions), + ) + + +def _stored_reconstruction() -> Reconstruction: + """The reconstruction the compatibility corpus keeps for the release that wrote it.""" + return Reconstruction.load(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) diff --git a/tests/suite/screens/seeds/recordings.py b/tests/suite/screens/seeds/recordings.py new file mode 100644 index 000000000..a3deb9d33 --- /dev/null +++ b/tests/suite/screens/seeds/recordings.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import numpy as np +import soundfile + +from sampletones_core.compatibility.kind import ObjectKind +from tests.suite.compatibility import ARCHIVED_VERSIONS, archived, stored_document + +RECORDING_SAMPLE_RATE: Final[int] = 44100 +RECORDING_AMPLITUDE: Final[float] = 0.5 +STORED_RECORDING_SECONDS: Final[float] = 0.5 +STORED_RECORDING_FREQUENCY: Final[float] = 220.0 +AUDIO_PATH_FIELD: Final[str] = "audio_filepath" + + +@dataclass(frozen=True) +class Recording: + """A recording in the home: a sine tone of ``frequency`` hertz lasting ``seconds``.""" + + destination: Path + seconds: float + frequency: float + + def write(self) -> None: + """Writes the sine tone to the destination as a sound file.""" + self.destination.parent.mkdir(parents=True, exist_ok=True) + times = np.arange(round(self.seconds * RECORDING_SAMPLE_RATE)) / RECORDING_SAMPLE_RATE + tone = RECORDING_AMPLITUDE * np.sin(2.0 * np.pi * self.frequency * times) + soundfile.write(self.destination, tone, RECORDING_SAMPLE_RATE) + + +def stored_recording() -> Recording: + """The recording the archived reconstruction names, laid where its relative path leads from the home. + + The application runs in the home, so a relative path the document names resolves there. + """ + document = stored_document(archived(ObjectKind.RECONSTRUCTION, ARCHIVED_VERSIONS[ObjectKind.RECONSTRUCTION])) + return Recording( + destination=Path.cwd() / document[AUDIO_PATH_FIELD], + seconds=STORED_RECORDING_SECONDS, + frequency=STORED_RECORDING_FREQUENCY, + ) diff --git a/tests/suite/screens/worlds/__init__.py b/tests/suite/screens/worlds/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/suite/screens/worlds/history.py b/tests/suite/screens/worlds/history.py new file mode 100644 index 000000000..a5a9e9939 --- /dev/null +++ b/tests/suite/screens/worlds/history.py @@ -0,0 +1,18 @@ +from pathlib import Path +from typing import Final + +from automation.worlds.home import World, screen_filling_state +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.seeds.projects import EveryPartProject + +HISTORY_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "History.stp" + + +def history_world() -> World: + """A home holding :data:`HISTORY_PROJECT`, the project with something in every part a gesture reaches.""" + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=(EveryPartProject(HISTORY_PROJECT),), + ) diff --git a/tests/suite/screens/worlds/recordings.py b/tests/suite/screens/worlds/recordings.py new file mode 100644 index 000000000..9021c4c43 --- /dev/null +++ b/tests/suite/screens/worlds/recordings.py @@ -0,0 +1,100 @@ +from pathlib import Path +from typing import Final, Tuple + +from automation.worlds.home import HomeFile, World, one_worker_config, screen_filling_state +from sampletones_shared.constants.nes import DEFAULT_NES_FREQUENCY +from sampletones_shared.paths.user import PROJECTS_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from tests.suite.screens.seeds.libraries import MiniLibrary +from tests.suite.screens.seeds.projects import StoredProject +from tests.suite.screens.seeds.reconstructions import PlayableReconstruction, StoredReconstruction +from tests.suite.screens.seeds.recordings import Recording, stored_recording + +OPEN_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Open.stn" +OTHER_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Other.stn" +SONG: Final[Path] = PROJECTS_DIRECTORY / "Song.stp" +SONG_SAMPLE: Final[str] = "Lead" +SONG_INSTRUMENT: Final[str] = "Pad" +BASS: Final[str] = "bass.wav" +LEAD: Final[str] = "lead.wav" +DOCUMENT_RECORDING_SECONDS: Final[float] = 0.3 +DOCUMENT_RECORDING_FREQUENCY: Final[float] = 220.0 +PLAYABLE_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Playable.stn" +SECOND_PLAYABLE: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Second.stn" +STEMS_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Stems.stn" +SHORT_RECONSTRUCTION: Final[Path] = RECONSTRUCTIONS_DIRECTORY / "Short.stn" +TAKES: Final[Tuple[str, ...]] = ("take1.wav", "take2.wav", "take3.wav") +STEM_TAKES: Final[Tuple[str, ...]] = ("stem1.wav", "stem2.wav", "stem3.wav") +STEM_FRAMES: Final[int] = 60 +PLAYABLE_FRAMES: Final[int] = 600 +FRAMES_PER_SECOND: Final[int] = 60 +TAKE_FREQUENCY: Final[float] = 220.0 + + +def converting_world(files: Tuple[HomeFile, ...]) -> World: + """A home ready to convert: one-worker settings, a small library built for them, and ``files``.""" + config = one_worker_config() + return World( + state=screen_filling_state(), + application_config=None, + config=config, + files=(MiniLibrary(config), *files), + ) + + +def documents_world() -> World: + """A home ready to convert that holds the documents a user puts away and replaces. + + It holds two reconstructions, :data:`OPEN_RECONSTRUCTION` and :data:`OTHER_RECONSTRUCTION`, + with the recording they name; the project :data:`SONG` with a sample and an instrument; and + two recordings to convert, :data:`BASS` and :data:`LEAD`, in the home itself. + """ + recordings = tuple( + Recording( + destination=Path.cwd() / name, + seconds=DOCUMENT_RECORDING_SECONDS, + frequency=DOCUMENT_RECORDING_FREQUENCY, + ) + for name in (BASS, LEAD) + ) + return converting_world( + ( + *recordings, + StoredReconstruction(OPEN_RECONSTRUCTION), + StoredReconstruction(OTHER_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ) + ) + + +def playing_world() -> World: + """A home holding reconstructions that play: two of one recording each, and one of three recordings in + turn. + + :data:`PLAYABLE_RECONSTRUCTION` and :data:`SECOND_PLAYABLE` play the first and the second of + :data:`TAKES`, the shorter :data:`STEMS_RECONSTRUCTION` plays the three :data:`STEM_TAKES` in + turn, and :data:`SHORT_RECONSTRUCTION` plays the first of them alone. The home holds the stored + :data:`OPEN_RECONSTRUCTION`, sounding every channel, and the project :data:`SONG` besides. + """ + takes = tuple(Path.cwd() / name for name in TAKES) + stem_takes = tuple(Path.cwd() / name for name in STEM_TAKES) + recordings = tuple( + Recording(destination=take, seconds=frames / FRAMES_PER_SECOND, frequency=TAKE_FREQUENCY * (index + 1)) + for paths, frames in ((takes, PLAYABLE_FRAMES), (stem_takes, STEM_FRAMES)) + for index, take in enumerate(paths) + ) + return World( + state=screen_filling_state(), + application_config=None, + config=None, + files=( + *recordings, + PlayableReconstruction(PLAYABLE_RECONSTRUCTION, takes[:1], PLAYABLE_FRAMES, DEFAULT_NES_FREQUENCY), + PlayableReconstruction(SECOND_PLAYABLE, takes[1:2], PLAYABLE_FRAMES, DEFAULT_NES_FREQUENCY), + PlayableReconstruction(STEMS_RECONSTRUCTION, stem_takes, STEM_FRAMES, DEFAULT_NES_FREQUENCY), + PlayableReconstruction(SHORT_RECONSTRUCTION, stem_takes[:1], STEM_FRAMES, DEFAULT_NES_FREQUENCY), + StoredReconstruction(OPEN_RECONSTRUCTION), + stored_recording(), + StoredProject(SONG, sample=SONG_SAMPLE, instrument=SONG_INSTRUMENT), + ), + ) diff --git a/tests/suite/screens/worlds/songs.py b/tests/suite/screens/worlds/songs.py new file mode 100644 index 000000000..317224967 --- /dev/null +++ b/tests/suite/screens/worlds/songs.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Final + +from automation.worlds.home import World +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT +from sampletones_shared.paths.user import PROJECTS_DIRECTORY +from tests.suite.screens.seeds.projects import ( + ArrangedProject, + LongEnvelopeProject, + OverlongProject, + ReleasingInstrumentFile, + TwoTuningsProject, +) +from tests.suite.screens.worlds.recordings import playing_world + +ARRANGED_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Arranged.stp" +LINE: Final[str] = "Line" +BASS_VOICE: Final[str] = "Bass" +PAD: Final[str] = "Pad" +PAD_ROW: Final[int] = 4 +BASS_ROW: Final[int] = 8 +SINGLE_ORDER_FRAME: Final[int] = 1 +LOOPING_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Looping.stp" +LOOPING_ORDER_FRAMES: Final[int] = 2 +OVERLONG_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Overlong.stp" +OVERLONG_SAMPLE: Final[str] = "Turning" +OVERLONG_ORDER_FRAMES: Final[int] = 12 +TWO_TUNINGS_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Tunings.stp" +RETUNED_A4: Final[float] = 432.0 +LONG_ENVELOPES_PROJECT: Final[Path] = PROJECTS_DIRECTORY / "Envelopes.stp" +LONG_VOICE: Final[str] = "Long" +LONG_ITEMS: Final[int] = 600 +MIDDLING_VOICE: Final[str] = "Middling" +MIDDLING_ITEMS: Final[int] = 300 +INSTRUMENTS_FOLDER: Final[str] = "instruments" +RELEASING_INSTRUMENT: Final[str] = "Releasing" + + +def sequencer_world() -> World: + """A home holding :data:`ARRANGED_PROJECT`, its voices placed on its first pattern, and the playing + world's files. + + The project's sample :data:`LINE` starts the pattern on Pulse 1, its instrument :data:`PAD` + comes in on Pulse 2 at :data:`PAD_ROW`, and its sample :data:`BASS_VOICE` on the triangle at + :data:`BASS_ROW`. + """ + playing = playing_world() + return World( + state=playing.state, + application_config=None, + config=None, + files=( + *playing.files, + arranged_project(ARRANGED_PROJECT, SINGLE_ORDER_FRAME), + ), + ) + + +def exporting_world() -> World: + """The sequencer's home, holding besides the projects an export meets at its limits. + + :data:`LOOPING_PROJECT` is :data:`ARRANGED_PROJECT` with its pattern played twice in the order. + :data:`OVERLONG_PROJECT` changes every channel at every tick through :data:`OVERLONG_ORDER_FRAMES` + frames. :data:`TWO_TUNINGS_PROJECT` holds :data:`LINE` at the default tuning and + :data:`BASS_VOICE` with A4 at :data:`RETUNED_A4` hertz. :data:`LONG_ENVELOPES_PROJECT` holds + :data:`LONG_VOICE` and :data:`MIDDLING_VOICE`, whose volume envelopes run :data:`LONG_ITEMS` and + :data:`MIDDLING_ITEMS` items. The home's :data:`INSTRUMENTS_FOLDER` holds the FamiTracker instrument + :data:`RELEASING_INSTRUMENT`, whose volume states a release point. + """ + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=None, + config=None, + files=( + *sequencer.files, + arranged_project(LOOPING_PROJECT, LOOPING_ORDER_FRAMES), + OverlongProject(OVERLONG_PROJECT, sample=OVERLONG_SAMPLE, order_frames=OVERLONG_ORDER_FRAMES), + TwoTuningsProject(TWO_TUNINGS_PROJECT, line=LINE, bass=BASS_VOICE, a4_frequency=RETUNED_A4), + LongEnvelopeProject( + LONG_ENVELOPES_PROJECT, + long=LONG_VOICE, + long_items=LONG_ITEMS, + middling=MIDDLING_VOICE, + middling_items=MIDDLING_ITEMS, + ), + ReleasingInstrumentFile(releasing_instrument(), name=RELEASING_INSTRUMENT), + ), + ) + + +def releasing_instrument() -> Path: + """Where the home holds the FamiTracker instrument :data:`RELEASING_INSTRUMENT`.""" + return Path.cwd() / INSTRUMENTS_FOLDER / f"{RELEASING_INSTRUMENT}{EXT_FILE_INSTRUMENT}" + + +def arranged_project(destination: Path, order_frames: int) -> ArrangedProject: + """The arranged project laid at ``destination``, its pattern played ``order_frames`` times in the order.""" + return ArrangedProject( + destination, + line=LINE, + bass=BASS_VOICE, + pad=PAD, + pad_row=PAD_ROW, + bass_row=BASS_ROW, + order_frames=order_frames, + ) diff --git a/tests/suite/scripts.py b/tests/suite/scripts.py index 413fa9d7a..26e6393ee 100644 --- a/tests/suite/scripts.py +++ b/tests/suite/scripts.py @@ -1,7 +1,7 @@ import importlib.util from types import ModuleType -from sampletones_shared.paths.source import REPOSITORY_ROOT +from sampletones_tools.checks.paths import SCRIPTS_ROOT def load_script(relative_path: str) -> ModuleType: @@ -10,7 +10,7 @@ def load_script(relative_path: str) -> ModuleType: The scripts under ``scripts/`` are entry points invoked by path from workflows, hooks and the Makefile, so importing them the same way keeps a test exercising the module the tooling runs. """ - path = REPOSITORY_ROOT / "scripts" / relative_path + path = SCRIPTS_ROOT / relative_path spec = importlib.util.spec_from_file_location(path.stem, path) assert spec is not None and spec.loader is not None diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index ff4b13f71..ee574affd 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -1,26 +1,34 @@ from pathlib import Path from typing import Dict, Final, List, Optional, Sequence, Tuple -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.pitch import Note, RowPitch, Step 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 +36,12 @@ NOTE_OFF, display_id, ) +from sampletones_core.utils.frequencies import PERIOD_NAME_SUFFIX +from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PLAYED_PITCH_VALUE_KIND +from sampletones_shared.constants.general import DECIMAL_BASE, 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 +51,26 @@ 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 frames it describes. """ - instructions = {generator: [_instruction(generator)] for generator in generators} - approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators} + config = Config() + instructions = {channel: [_instruction(channel)] for channel in channels} return Reconstruction.create( - approximation=np.zeros(SAMPLE_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(channels), instructions), ) @@ -67,13 +78,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 ) @@ -83,24 +93,24 @@ def render_slots( ) -> str: """The pattern each channel plays at a frame, which is what tells a blank pattern from none. - A frame renders the same either way, so this is the reading that shows a write materialising a + A frame renders the same either way, so this is the reading that shows a write materializing a 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 +152,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 +162,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 +178,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: @@ -176,7 +186,7 @@ def parse_block( """ first_slot = SUBCOLUMNS.index(first_subcolumn) notes: Dict[BlockKey, Optional[BlockNote]] = {} - transposes: Dict[BlockKey, Optional[int]] = {} + pitches: Dict[BlockKey, Optional[RowPitch]] = {} volumes: Dict[BlockKey, Optional[int]] = {} lines = [_tokens(line, first_slot) for line in rows] widths = {len(tokens) for tokens in lines} @@ -191,16 +201,16 @@ 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) + pitches[key] = parse_pitch(token) case SubColumn.VOLUME: volumes[key] = parse_volume(token) return TrackerBlock( notes=notes, - transposes=transposes, + pitches=pitches, volumes=volumes, ) @@ -209,7 +219,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 +228,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,37 +252,42 @@ 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]: +def parse_pitch(token: str) -> Optional[RowPitch]: + """The pitch a token names, as the grid prints it: a signed decimal step, or a note name.""" if token == NOTE_BLANK: return None - magnitude = int(token[1:], 16) - return -magnitude if token.startswith(MINUS) else magnitude + if token.startswith((PLUS, MINUS)): + magnitude = int(token[1:], DECIMAL_BASE) + return Step(value=-magnitude if token.startswith(MINUS) else magnitude) + + kind = PERIOD_VALUE_KIND if token.endswith(PERIOD_NAME_SUFFIX) else PLAYED_PITCH_VALUE_KIND + return Note(value=kind.sanitized_name_to_value[kind.sanitize(token)]) 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,40 +306,40 @@ 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 + A cell is written whole where it carries anything, so the row it lands on materializes exactly once however many of its subcolumns hold a value. """ - note = parse_note(tokens[0], sample_ids) - transpose = parse_transpose(tokens[1]) + note = parse_note(tokens[0], voice_ids) + pitch = parse_pitch(tokens[1]) volume = parse_volume(tokens[2]) - if note is None and transpose is None and volume is None: + if note is None and pitch is None and volume is None: return tracker_logic.set_row( - generator, + channel, row_index, - command=_command(note, generator), - transpose=transpose, + command=_command(note, channel), + pitch=pitch, volume=volume, ) 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/shortcuts.py b/tests/suite/shortcuts.py index c7cf794cd..99f69887f 100644 --- a/tests/suite/shortcuts.py +++ b/tests/suite/shortcuts.py @@ -2,6 +2,7 @@ from sampletones_application.paths import KEYBINDINGS_DIRECTORY from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme from sampletones_application.utils.gui.shortcuts.source import ShortcutSource @@ -24,3 +25,16 @@ def shipped_source() -> ShortcutSource: changes what a press means shows up as a failure here. """ return ShortcutSource(shipped_scheme()) + + +def rebound_source(shortcut_id: ShortcutId, combination: str) -> ShortcutSource: + """A source over the shipped scheme with one action answering ``combination`` instead. + + A case reading the shipped keys on both sides passes whatever the production side does, so + what tells a combination read from the scheme apart from one written into the code is a + scheme that gives the action different keys. + """ + scheme = shipped_scheme() + rebound = dict(scheme.bindings) + rebound[shortcut_id] = rebound[shortcut_id].rebound((combination,)) + return ShortcutSource(ShortcutScheme(name=scheme.name, bindings=rebound)) diff --git a/tests/suite/silent_rows.py b/tests/suite/silent_rows.py new file mode 100644 index 000000000..886dd48e8 --- /dev/null +++ b/tests/suite/silent_rows.py @@ -0,0 +1,32 @@ +from typing import Final, List + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow, SkipReason +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_on import NoteOn + +MISSING_VOICE_ID: Final[str] = "a-voice-no-project-holds" +SILENT_ROW: Final[int] = 5 +SILENT_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def name_a_missing_voice(project: Project) -> SkippedRow: + """Puts a note-on on the first frame naming a voice that has no instrument on its channel. + + Returns: + SkippedRow: The row an export reports for it. + """ + rows: List[Row] = [Row() for _ in range(project.song.rows_per_pattern)] + rows[SILENT_ROW] = Row(command=NoteOn(voice_id=MISSING_VOICE_ID)) + project.song.channels[SILENT_CHANNEL] = Channel(name=SILENT_CHANNEL, patterns={0: Pattern(rows=rows)}) + project.song.order[0][SILENT_CHANNEL] = 0 + return SkippedRow( + voice_id=MISSING_VOICE_ID, + channel=SILENT_CHANNEL, + order_position=0, + row_index=SILENT_ROW, + reason=SkipReason.NO_INSTRUMENT, + ) diff --git a/tests/suite/source.py b/tests/suite/source.py index 003626b48..60f9078d3 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_tools.checks.source.bindings.scopes import Scope +from sampletones_tools.checks.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/status.py b/tests/suite/status.py new file mode 100644 index 000000000..e9b1bb5cc --- /dev/null +++ b/tests/suite/status.py @@ -0,0 +1,22 @@ +from typing import Any, List, Union + +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_shared.types.callback import MessageCallback + + +class RecordedStatusBar(GUIStatusBar): + """A status bar keeping every message it is given, with no window or theme to draw them with. + + A test reading what a hover or a gesture says reads the messages, in the order they were set. + """ + + def __init__(self) -> None: # pylint: disable=super-init-not-called + self.messages: List[str] = [] + + def set( + self, + message_or_function: Union[str, MessageCallback], + *args: Any, + **kwargs: Any, + ) -> None: + self.messages.append(self.get_message(message_or_function, *args, **kwargs)) diff --git a/tests/suite/stems.py b/tests/suite/stems.py new file mode 100644 index 000000000..078182ce4 --- /dev/null +++ b/tests/suite/stems.py @@ -0,0 +1,243 @@ +from pathlib import Path +from typing import Dict, Final, List, Mapping, Sequence, Tuple + +import numpy as np +import pytest + +from sampletones_core.audio import write_wave +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP, Features +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.data import StemsData +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 sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.types.path import Pathlike + +SINGLE_STEM_ID: Final[int] = 0 +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 +RECORDED_SCALE: Final[float] = 1.0 +RECORDING_SEED: Final[int] = 93 +SHARED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +SOLE_CHANNEL: Final[ChannelName] = ChannelName.PULSE2 +SHARED_OWNERS: Final[Tuple[int, ...]] = (STEM_A_ID, STEM_B_ID) +TAKING_TURNS_PITCH: Final[int] = 60 +TAKING_TURNS_VOLUME: Final[int] = 8 +TAKING_TURNS_DUTY_CYCLE: Final[int] = 0 + + +def single_entry_stems_data( + channels: List[ChannelName], + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], +) -> StemsData: + """The single-entry record for ``channels``, stem 0 owning each frame that sounds and bending them. + + Rest and silence name the same frames, so a silent frame takes the resting stem id, which is + the shape a conversion records. + """ + assignments = [ + ChannelAssignment( + channel_name=channel_name, + stem_ids=[SINGLE_STEM_ID if instruction.on else RESTING_STEM_ID for instruction in stream], + ) + for channel_name, stream in instructions.items() + if stream + ] + return StemsData.single_entry(StemSettings.covering(channels), assignments, RECORDED_SCALE) + + +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, each sounding one channel at a time. + """ + return StemsConfig( + entries=[ + StemEntry( + id=stem_id, + settings=StemSettings(channels=channels, bends=bending_channels(channels), channel_cap=1), + ) + 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, + ), + ) + + +def three_stem_reconstruction_config() -> Config: + """Builds a reconstruction config for the three-stem example.""" + return Config() + + +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(RECORDING_SEED).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 everything_heard(reconstruction: Reconstruction) -> StemSelection: + """The reader listening to every recording on every channel, as a fresh document reads.""" + return StemSelection.everywhere( + frozenset(reconstruction.stems_data.config.entries_by_id), + ChannelName.items(), + ) + + +def regenerated( + reconstruction: Reconstruction, + channel_name: ChannelName, + features: Features, +) -> Reconstruction: + """The document a regeneration leaves once it rebuilds one channel from ``features``, every recording heard.""" + rebuilt = reconstruction.with_channel_data( + channel_name, + list(CHANNEL_TO_EXPORTER_MAP[channel_name].from_features(features)), + features.initial_pitch, + features.held_features, + heard=reconstruction.recorded_stem_ids, + ) + return rebuilt + + +def recorded_from( + reconstruction: Reconstruction, + paths: Sequence[Path], +) -> Reconstruction: + """The document as though its recordings had been read from ``paths``, one per entry. + + A test states the files a conversion would have read, and the record takes its sources from + them; the entries follow, one per path over the channels the document already plays. Every + recording a document names holds a frame, so each channel plays its stream once per recording, + and the frames sounding in the n-th play answer to the n-th recording. + """ + settings = reconstruction.stems_data.config.entries[0].settings + stem_ids = list(range(len(paths))) + streams = dict(reconstruction.streams) + assignments: List[ChannelAssignment] = [] + for channel_name in reconstruction.playing_channels: + stream = reconstruction.instructions[channel_name] + streams[channel_name] = InstructionsItem.create( + channel_name=channel_name, + instructions=stream * len(paths), + initial_pitch=reconstruction.initial_pitches[channel_name], + held_features=reconstruction.held_features[channel_name], + ) + assignments.append( + ChannelAssignment( + channel_name=channel_name, + stem_ids=[ + stem_id if instruction.on else RESTING_STEM_ID for stem_id in stem_ids for instruction in stream + ], + ) + ) + + stems_data = StemsData( + config=StemsConfig( + entries=[StemEntry(id=stem_id, settings=settings) for stem_id in stem_ids], + hierarchy=StemsHierarchy(levels=[stem_ids]), + ), + assignments=assignments, + scale=RECORDED_SCALE, + ).with_sources(paths) + return reconstruction.rewritten(streams, stems_data) + + +def taking_turns_reconstruction(sources: Sequence[Path]) -> Reconstruction: + """Two recordings taking turns on the shared channel, the second holding the sole channel alone. + + The shared channel plays one frame per recording, the first recording's and then the second's, + and the sole channel plays one frame of the second recording. Taking the second recording out + therefore releases a frame on each channel, and leaves the sole channel standing by. + + Args: + sources: The files the two recordings were read from, the first recording's first. + """ + instruction = PulseInstruction( + on=True, + pitch=TAKING_TURNS_PITCH, + volume=TAKING_TURNS_VOLUME, + duty_cycle=TAKING_TURNS_DUTY_CYCLE, + ) + instructions: Dict[ChannelName, List[InstructionUnion]] = { + SHARED_CHANNEL: [instruction] * len(SHARED_OWNERS), + SOLE_CHANNEL: [instruction], + } + stems_data = StemsData( + config=StemsConfig( + entries=[ + StemEntry(id=STEM_A_ID, settings=StemSettings.covering([SHARED_CHANNEL])), + StemEntry(id=STEM_B_ID, settings=StemSettings.covering([SHARED_CHANNEL, SOLE_CHANNEL])), + ], + hierarchy=StemsHierarchy(levels=[[STEM_A_ID, STEM_B_ID]]), + ), + assignments=[ + ChannelAssignment(channel_name=SHARED_CHANNEL, stem_ids=list(SHARED_OWNERS)), + ChannelAssignment(channel_name=SOLE_CHANNEL, stem_ids=[STEM_B_ID]), + ], + scale=RECORDED_SCALE, + ).with_sources(sources) + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=tuple(sources), + stems_data=stems_data, + ) + + +@pytest.fixture +def taking_turns(tmp_path: Path) -> Reconstruction: + """The two-recording document, naming recordings this machine holds nowhere.""" + return taking_turns_reconstruction((tmp_path / "a.wav", tmp_path / "b.wav")) + + +@pytest.fixture +def taking_turns_file(tmp_path: Path) -> Path: + """The two-recording document saved to a file beside the recordings it was read from.""" + recordings = tmp_path / "recordings" + recordings.mkdir() + first, second, _ = write_three_stem_recordings(three_stem_reconstruction_config(), recordings) + path = tmp_path / "turns.stn" + taking_turns_reconstruction((first, second)).save(path) + return path diff --git a/tests/suite/study.py b/tests/suite/study.py new file mode 100644 index 000000000..08dc0f3c0 --- /dev/null +++ b/tests/suite/study.py @@ -0,0 +1,20 @@ +from typing import Final, Tuple + +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.planes import PLANES +from sampletones_shared.constants.music import LIMIT_MIN_PITCH +from sampletones_tools.codec.study.corpus.notes import TONE_ORDER +from sampletones_tools.codec.study.corpus.song import StudySlice + +NO_STUDY_SLICES: Final[Tuple[StudySlice, ...]] = () + + +def lowest_notes(ticks: int) -> Tuple[bytes, ...]: + """The notes of a hand-built study song, every tone channel naming the lowest pitch throughout.""" + return (bytes((LIMIT_MIN_PITCH,)) * ticks,) * len(TONE_ORDER) + + +def resting_planes(ticks: int) -> SongPlanes: + """A song's planes holding zero throughout, every tone channel bending nowhere.""" + return SongPlanes(planes=PlaneOrder.across(b"" if plane.spans_flagged_ticks else bytes(ticks) for plane in PLANES)) diff --git a/tests/suite/surface.py b/tests/suite/surface.py index 052ff2e9f..20409837a 100644 --- a/tests/suite/surface.py +++ b/tests/suite/surface.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from typing import Callable, Final, List, Optional @@ -12,6 +14,7 @@ CursorTargets, ) from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from sampletones_shared.types.callback import VoidCallback from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS from tests.suite.shortcuts import shipped_source @@ -27,7 +30,7 @@ class Target: region: str @classmethod - def at(cls, cell: str) -> "Target": + def at(cls, cell: str) -> Target: """The target a cell resolves to, which is the pair the fake state states for it.""" return cls(cell=cell, region=f"{cell} block") @@ -77,7 +80,10 @@ def __init__( self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") - self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + self.can_paste = can_paste + self.can_paste_block: Optional[Callable[[], bool]] = lambda: self.can_paste + self.refreshes: List[VoidCallback] = [] + self.refresh_paste_block: Optional[Callable[[VoidCallback], None]] = self.refreshes.append def owns_keys(self) -> bool: return self._owns diff --git a/tests/suite/timing.py b/tests/suite/timing.py new file mode 100644 index 000000000..e7854ea88 --- /dev/null +++ b/tests/suite/timing.py @@ -0,0 +1,24 @@ +from time import process_time +from timeit import Timer +from typing import Callable, Final, List + +REPEATS: Final[int] = 3 + + +def seconds(work: Callable[[], object]) -> float: + """What one run of the work costs, taken as the best of several batches. + + A process's own time is counted in steps: Linux counts it in nanoseconds, Windows in about a + sixtieth of a second, so a run of a few milliseconds reads there as either nothing at all or + as a whole step. ``Timer.autorange`` grows a batch until it stands well clear of one step, and + what comes back is the batch divided by the runs in it — a reading the coarsest clock can see. + + The collector is held off for each batch, since it runs on how much is live rather than on + what the work does: a batch building ten times the objects meets it more often and reads as + more than ten times the cost. What is left is how the work itself follows its input. + """ + timer = Timer(work, timer=process_time) + runs, reached = timer.autorange() + readings: List[float] = [reached / runs] + readings.extend(reading / runs for reading in timer.repeat(REPEATS - 1, runs)) + return min(readings) diff --git a/tests/suite/transposes.py b/tests/suite/transposes.py new file mode 100644 index 000000000..3a733381d --- /dev/null +++ b/tests/suite/transposes.py @@ -0,0 +1,160 @@ +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD +from sampletones_core.features import RESTING_REFERENCE_PERIOD, RESTING_REFERENCE_PITCH, speaks_in_periods +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.performance.song import song_instructions +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.pitch import Step +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.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_core.project.voices.voice import VoiceUnion +from tests.suite.performance import reconstruction_of + +TRANSPOSED_ROWS_PER_PATTERN: Final[int] = 16 +CONTOUR_VOLUME: Final[int] = 12 +CONTOUR_DUTY: Final[int] = 2 +CONTOURS: Final[Dict[ChannelName, Tuple[int, ...]]] = { + ChannelName.PULSE1: (69, 73, 76, 81), + ChannelName.TRIANGLE: (69, 76), + ChannelName.NOISE: (3, 4), +} +CONTOUR_TICKS: Final[Dict[ChannelName, int]] = { + ChannelName.PULSE1: 3, + ChannelName.TRIANGLE: 4, + ChannelName.NOISE: 5, +} +TRANSPOSED_CHANNELS: Final[Tuple[ChannelName, ...]] = tuple(CONTOURS) + + +def contour_instruction(channel_name: ChannelName, value: int) -> InstructionUnion: + """One sounding frame of a channel at a pitch, or at a period on noise.""" + match channel_name: + case ChannelName.NOISE: + return NoiseInstruction(on=True, period=value, volume=CONTOUR_VOLUME, short=False) + case ChannelName.TRIANGLE: + return TriangleInstruction(on=True, pitch=value) + case _: + return PulseInstruction(on=True, pitch=value, volume=CONTOUR_VOLUME, duty_cycle=CONTOUR_DUTY) + + +def contour_sample(channel_name: ChannelName, frames: int) -> Sample: + """A sample stepping through its channel's contour for ``frames`` frames, a few frames per step. + + A contour that keeps moving is what makes the step a note has reached audible, so a transpose + row placing its table at the wrong step shows up as a wrong pitch. + """ + contour = CONTOURS[channel_name] + hold = CONTOUR_TICKS[channel_name] + instructions = [contour_instruction(channel_name, contour[(tick // hold) % len(contour)]) for tick in range(frames)] + return Sample(name=f"Contour ({channel_name})", reconstruction=reconstruction_of(channel_name, instructions)) + + +def settling_sample(channel_name: ChannelName, frames: int) -> Sample: + """A sample stepping once through its channel's contour, then holding the last step for ``frames`` frames in all. + + Its arpeggio stops changing early while the note goes on sounding, so a tracker whose arpeggio + halts once it has played its items shows a transpose row landing after that. + """ + contour = CONTOURS[channel_name] + hold = CONTOUR_TICKS[channel_name] + instructions = [ + contour_instruction(channel_name, contour[min(tick // hold, len(contour) - 1)]) for tick in range(frames) + ] + return Sample(name=f"Settling ({channel_name})", reconstruction=reconstruction_of(channel_name, instructions)) + + +def flat_sample(channel_name: ChannelName, value: int, frames: int) -> Sample: + """A sample holding one pitch, or one period on noise, for ``frames`` frames.""" + instructions = [contour_instruction(channel_name, value)] * frames + return Sample(name=f"Flat ({channel_name})", reconstruction=reconstruction_of(channel_name, instructions)) + + +def flat_instrument(channel_name: ChannelName, value: int) -> Instrument: + """An instrument resting at one pitch, or one period on noise, holding the contour volume. + + It writes no contour of its own, so every tick sounds the pitch its row started it at, which + is what a case about an instrument taking the channel's pitch reads back. + """ + return Instrument( + name=f"Flat instrument ({channel_name})", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(CONTOUR_VOLUME,))), + initial_pitch=RESTING_REFERENCE_PITCH if speaks_in_periods(channel_name) else value, + initial_period=value if speaks_in_periods(channel_name) else RESTING_REFERENCE_PERIOD, + ) + + +def rows_with(*cells: Tuple[int, Row]) -> List[Row]: + """A pattern's rows, blank apart from the ones given by their index.""" + rows = [Row() for _ in range(TRANSPOSED_ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def note(voice: VoiceUnion, transpose: Optional[int] = None) -> Row: + """A row starting ``voice``, at ``transpose`` where one is given.""" + return Row(command=NoteOn(voice_id=voice.id), pitch=Step(value=transpose) if transpose is not None else None) + + +def one_channel_project( + voices: Sequence[VoiceUnion], + channel_name: ChannelName, + patterns: Mapping[int, List[Row]], + order: Sequence[Optional[int]], + *, + settings: ProjectSettings, +) -> Project: + """A project playing ``voices`` on one channel through the patterns ``order`` names.""" + channels = { + name: Channel( + name=name, + patterns={index: Pattern(rows=rows) for index, rows in patterns.items()} if name == channel_name else {}, + ) + for name in ChannelName.items() + } + project = Project.create(title="Transposes", author="Tester", settings=settings) + for voice in voices: + project.voices.append(voice) + + project.song = Song( + rows_per_pattern=TRANSPOSED_ROWS_PER_PATTERN, + order=[{channel_name: index} for index in order], + channels=channels, + ) + return project + + +def sounded_pitches(project: Project, channel_name: ChannelName) -> List[Optional[int]]: + """What the song's walk sounds on one channel each tick: the pitch, or on noise the period register. + + The noise register counts the periods from the fastest while the project counts them from the + slowest, so a period reaches the register as its complement. A tick the channel rests on is + ``None``. + """ + sounded: List[Optional[int]] = [] + for instruction in song_instructions(project)[channel_name]: + match instruction: + case PulseInstruction(): + sounded.append(instruction.pitch if instruction.on and instruction.volume else None) + case TriangleInstruction(): + sounded.append(instruction.pitch if instruction.on else None) + case NoiseInstruction(): + sounded.append(MAX_PERIOD - instruction.period if instruction.on and instruction.volume else None) + + return sounded diff --git a/tests/unit/assets/__init__.py b/tests/unit/assets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/demo/__init__.py b/tests/unit/assets/demo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/demo/test_render.py b/tests/unit/assets/demo/test_render.py new file mode 100644 index 000000000..9bf17b6d2 --- /dev/null +++ b/tests/unit/assets/demo/test_render.py @@ -0,0 +1,102 @@ +from typing import Final + +import numpy as np + +from assets.demo.render import leveled, pitched, render_stem, sounding +from assets.demo.specification import Note, Stem +from sampletones_tools.synthesis.oscillators.pulse import PulseOscillator +from sampletones_tools.synthesis.oscillators.white_noise import WhiteNoiseOscillator +from sampletones_tools.synthesis.voice.layer import Layer +from sampletones_tools.synthesis.voice.voice import Voice + +SAMPLE_RATE: Final[int] = 8000 +TEMPO: Final[float] = 120.0 +SECONDS_PER_BEAT: Final[float] = 0.5 +BEATS: Final[float] = 2.0 +SEED: Final[int] = 7 +PITCH: Final[int] = 60 +AMPLITUDE: Final[float] = 0.5 + +PULSE: Final[Voice] = Voice( + duration_seconds=0.25, + layers=(Layer(oscillator=PulseOscillator(kind="pulse", frequency=45, duty_cycle=0.5), envelopes=(), gain=1.0),), + filters=(), +) +NOISE: Final[Voice] = Voice( + duration_seconds=0.25, + layers=(Layer(oscillator=WhiteNoiseOscillator(kind="white_noise"), envelopes=(), gain=1.0),), + filters=(), +) + + +def rendered(stem: Stem, seed: int) -> np.ndarray: + return render_stem( + stem, + {"pulse": PULSE, "noise": NOISE}, + tempo=TEMPO, + beats=BEATS, + sample_rate=SAMPLE_RATE, + generator=np.random.default_rng(seed), + ) + + +class TestSounding: + def test_a_pitched_note_moves_a_pitched_oscillator(self) -> None: + voice = sounding(PULSE, Note(voice="pulse", start=0.0, length=1.0, pitch=PITCH), SECONDS_PER_BEAT) + + assert isinstance(voice.layers[0].oscillator, PulseOscillator) + assert voice.layers[0].oscillator.frequency == PITCH + + def test_a_pitched_note_leaves_an_unpitched_oscillator_as_it_is(self) -> None: + oscillator = NOISE.layers[0].oscillator + + assert pitched(oscillator, PITCH) == oscillator + + def test_a_note_with_a_length_lasts_that_many_beats(self) -> None: + voice = sounding(PULSE, Note(voice="pulse", start=0.0, length=1.5, pitch=None), SECONDS_PER_BEAT) + + assert voice.duration_seconds == 1.5 * SECONDS_PER_BEAT + + def test_a_note_without_a_length_lasts_the_voice_s_own(self) -> None: + voice = sounding(PULSE, Note(voice="pulse", start=0.0, length=None, pitch=None), SECONDS_PER_BEAT) + + assert voice.duration_seconds == PULSE.duration_seconds + + +class TestRenderStem: + def test_the_stem_lasts_the_piece(self) -> None: + stem = Stem(name="One", notes=(Note(voice="pulse", start=0.0, length=1.0, pitch=None),)) + + assert rendered(stem, SEED).shape[0] == round(BEATS * SECONDS_PER_BEAT * SAMPLE_RATE) + + def test_a_note_sounds_from_its_beat_and_silence_stands_before_it(self) -> None: + stem = Stem(name="Late", notes=(Note(voice="pulse", start=1.0, length=0.5, pitch=None),)) + audio = rendered(stem, SEED) + onset = round(1.0 * SECONDS_PER_BEAT * SAMPLE_RATE) + + assert not audio[:onset].any() + assert audio[onset:].any() + + def test_a_note_running_past_the_piece_is_cut_at_its_end(self) -> None: + stem = Stem(name="Long", notes=(Note(voice="pulse", start=1.5, length=4.0, pitch=None),)) + + assert rendered(stem, SEED).shape[0] == round(BEATS * SECONDS_PER_BEAT * SAMPLE_RATE) + + def test_the_same_seed_renders_the_same_noise(self) -> None: + stem = Stem(name="Noise", notes=(Note(voice="noise", start=0.0, length=None, pitch=None),)) + + assert np.array_equal(rendered(stem, SEED), rendered(stem, SEED)) + assert not np.array_equal(rendered(stem, SEED), rendered(stem, SEED + 1)) + + +class TestLeveled: + def test_the_peak_reaches_the_amplitude(self) -> None: + audio = np.array([0.1, -0.2, 0.05]) + + assert float(np.abs(leveled(audio, AMPLITUDE)).max()) == AMPLITUDE + + def test_silence_stays_silent(self) -> None: + assert not leveled(np.zeros(4), AMPLITUDE).any() + + def test_the_result_is_ready_to_write(self) -> None: + assert leveled(np.array([0.3, -0.3]), AMPLITUDE).dtype == np.float32 diff --git a/tests/unit/assets/demo/test_specification.py b/tests/unit/assets/demo/test_specification.py new file mode 100644 index 000000000..57dcc90bf --- /dev/null +++ b/tests/unit/assets/demo/test_specification.py @@ -0,0 +1,105 @@ +from typing import Dict, Final, Tuple + +import pytest +from pydantic import ValidationError + +from assets.demo.specification import ( + ConversionSpec, + DemoSpecification, + Hit, + Note, + Piece, + PieceConversion, + RecordingsSpec, + Stem, +) +from sampletones_core.constants.enums import ChannelName, HierarchyMode + +SHIPPED: Final[DemoSpecification] = DemoSpecification.load() +UNKNOWN_VOICE: Final[str] = "theremin" +STEM_CHANNELS: Final[Dict[str, Tuple[ChannelName, ...]]] = { + "Lead": (ChannelName.PULSE1,), + "Bass": (ChannelName.TRIANGLE,), +} + + +def recordings(*, hit_voice: str, note_voice: str) -> RecordingsSpec: + return RecordingsSpec( + sample_rate=44100, + tempo=120.0, + amplitude=0.8, + hits=(Hit(name="Kick", voice=hit_voice),), + piece=Piece( + name="Piece", + beats=4.0, + stems=(Stem(name="Lead", notes=(Note(voice=note_voice, start=0.0, length=1.0, pitch=69),)),), + ), + ) + + +class TestTheShippedSpecification: + """The four shipped files read as one specification whose names all reach what they name.""" + + def test_every_stem_is_converted_on_one_level(self) -> None: + conversion = SHIPPED.conversion.piece + named = [name for level in conversion.levels for name in level] + + assert sorted(named) == sorted(stem.name for stem in SHIPPED.recordings.piece.stems) + assert len(named) == len(set(named)) + + def test_every_hit_has_channels_to_take(self) -> None: + assert all(SHIPPED.conversion.hits[hit.name] for hit in SHIPPED.recordings.hits) + + def test_the_song_plays_hits_and_instruments_alone(self) -> None: + played = { + row.voice + for channel in SHIPPED.project.song.channels.values() + for rows in channel.patterns.values() + for row in rows + if row.voice is not None + } + offered = {hit.name for hit in SHIPPED.recordings.hits} | set(SHIPPED.project.instruments) + + assert played <= offered + + +class TestNamesAreHeldToEachOther: + """A specification naming what no file declares is refused as it is read.""" + + def test_a_note_striking_an_undeclared_voice_is_refused(self) -> None: + with pytest.raises(ValidationError, match=UNKNOWN_VOICE): + DemoSpecification( + voices=SHIPPED.voices, + recordings=recordings(hit_voice="kick", note_voice=UNKNOWN_VOICE), + conversion=SHIPPED.conversion, + project=SHIPPED.project, + ) + + def test_a_stem_named_on_two_levels_is_refused(self) -> None: + with pytest.raises(ValidationError, match="levels"): + PieceConversion( + mode=HierarchyMode.ROUND_ROBIN, + levels=(("Lead",), ("Lead", "Bass")), + stems=STEM_CHANNELS, + ) + + def test_a_stem_left_off_every_level_is_refused(self) -> None: + with pytest.raises(ValidationError, match="levels"): + PieceConversion(mode=HierarchyMode.STRICT, levels=(("Lead",),), stems=STEM_CHANNELS) + + def test_a_note_past_the_end_of_the_piece_is_refused(self) -> None: + with pytest.raises(ValidationError, match="past"): + Piece( + name="Piece", + beats=2.0, + stems=(Stem(name="Lead", notes=(Note(voice="lead", start=2.0, length=None, pitch=None),)),), + ) + + def test_a_hit_without_a_conversion_setup_is_refused(self) -> None: + with pytest.raises(ValidationError, match="hits"): + DemoSpecification( + voices=SHIPPED.voices, + recordings=recordings(hit_voice="kick", note_voice="lead"), + conversion=ConversionSpec(hits={}, piece=SHIPPED.conversion.piece), + project=SHIPPED.project, + ) diff --git a/tests/unit/assets/icons/mark/__init__.py b/tests/unit/assets/icons/mark/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/icons/mark/test_geometry.py b/tests/unit/assets/icons/mark/test_geometry.py new file mode 100644 index 000000000..72ad1a2d0 --- /dev/null +++ b/tests/unit/assets/icons/mark/test_geometry.py @@ -0,0 +1,69 @@ +import itertools +from typing import Final + +import pytest + +from assets.icons.mark.geometry import Rectangle, sine_points, square_rectangles +from assets.icons.mark.specification import Mark + +SAMPLES: Final[int] = 5 + + +def _overlap(first: Rectangle, second: Rectangle) -> float: + width = min(first.right, second.right) - max(first.left, second.left) + height = min(first.bottom, second.bottom) - max(first.top, second.top) + return min(width, height) + + +class TestSinePoints: + def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None: + sine = Mark.load().waves.sine + points = sine_points(sine, SAMPLES) + + assert points[0] == sine.start + assert points[-1].x == pytest.approx(sine.end.x) + assert points[-1].y == pytest.approx(sine.end.y) + + def test_every_segment_contributes_its_samples(self) -> None: + sine = Mark.load().waves.sine + assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1 + + def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None: + """The wave swings between the extremes its control points reach, keeping it inside the frame.""" + sine = Mark.load().waves.sine + controls = [sine.start] + [ + point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end) + ] + lowest = min(point.y for point in controls) + highest = max(point.y for point in controls) + + for point in sine_points(sine, SAMPLES): + assert lowest <= point.y <= highest + + +class TestSquareRectangles: + def test_one_rectangle_covers_each_segment(self) -> None: + square = Mark.load().waves.square + assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1 + + def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None: + square = Mark.load().waves.square + for rectangle in square_rectangles(square, width=4.0): + assert rectangle.left < rectangle.right + assert rectangle.top < rectangle.bottom + + def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None: + width = 4.0 + square = Mark.load().waves.square + for (start, end), rectangle in zip( + itertools.pairwise(square.points), + square_rectangles(square, width=width), + ): + across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left + assert across == pytest.approx(width) + + def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None: + """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke.""" + square = Mark.load().waves.square + for first, second in itertools.pairwise(square_rectangles(square, width=4.0)): + assert _overlap(first, second) > 0.0 diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/assets/icons/mark/test_raster.py similarity index 78% rename from tests/unit/sampletones_assets/mark/test_raster.py rename to tests/unit/assets/icons/mark/test_raster.py index 5ae61ddf3..06c070807 100644 --- a/tests/unit/sampletones_assets/mark/test_raster.py +++ b/tests/unit/assets/icons/mark/test_raster.py @@ -2,8 +2,8 @@ import pytest -from sampletones_assets.mark.raster import MarkRaster -from sampletones_assets.mark.specification import Mark +from assets.icons.mark.raster import MarkRaster +from assets.icons.mark.specification import Mark from sampletones_shared.utils.color import parse_hex_color CORNER: Final[Tuple[int, int]] = (0, 0) @@ -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/assets/icons/mark/test_specification.py b/tests/unit/assets/icons/mark/test_specification.py new file mode 100644 index 000000000..6a975942c --- /dev/null +++ b/tests/unit/assets/icons/mark/test_specification.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final + +import pytest +from pydantic import ValidationError + +from assets.icons.mark.specification import Mark +from tests.suite.case import BaseRegularTestCase + +VALID_FRAME: Final[Dict[str, Any]] = { + "grid": 64, + "corner_radius": 14, + "rim": {"inset": 1, "width": 2, "opacity": 0.14}, +} + +VALID_COLORS: Final[Dict[str, Any]] = { + "background": {"top": "#3a3650", "bottom": "#211d30"}, + "sine": "#64c8ff", + "square": "#ffc864", + "rim": "#cdb6ff", +} + +VALID_SINE: Final[Dict[str, Any]] = { + "start": {"x": 8, "y": 32}, + "curves": [ + { + "control_start": {"x": 11, "y": 16}, + "control_end": {"x": 15, "y": 16}, + "end": {"x": 18, "y": 32}, + }, + ], +} + +VALID_SQUARE: Final[Dict[str, Any]] = { + "points": [ + {"x": 18, "y": 32}, + {"x": 18, "y": 20}, + {"x": 28, "y": 20}, + ], +} + +VALID_WAVES: Final[Dict[str, Any]] = { + "width": 4, + "sine": VALID_SINE, + "square": VALID_SQUARE, +} + +VALID_RENDER: Final[Dict[str, Any]] = { + "supersample": 16, + "curve_samples": 96, + "raster_size": 256, + "windows_sizes": [256, 128, 64], +} + +VALID_FIELDS: Final[Dict[str, Any]] = { + "frame": VALID_FRAME, + "colors": VALID_COLORS, + "waves": VALID_WAVES, + "render": VALID_RENDER, +} + + +class TestMark: + @dataclass(frozen=True, kw_only=True) + class InvalidFieldCase(BaseRegularTestCase): + field: str + value: Any + + test_cases = ( + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "grid": 0}, + label="empty_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "corner_radius": 33}, + label="corner_radius_over_half_the_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}}, + label="rim_inset_outside_the_corner_radius", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}}, + label="rim_opacity_over_full", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "64c8ff"}, + label="color_without_a_hash", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "#64c8"}, + label="color_of_four_hex_digits", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "width": 0}, + label="wave_without_width", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}}, + label="smooth_half_without_curves", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}}, + label="stepped_half_without_a_segment", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]}, + }, + label="stepped_segment_turning_on_both_axes", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]}, + }, + label="halves_meeting_apart", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "supersample": 0}, + label="drawing_below_the_design_grid", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": []}, + label="windows_icon_without_a_frame", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [64, 128, 256]}, + label="windows_sizes_in_ascending_order", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [256, 256, 128]}, + label="repeated_windows_size", + ), + ) + + def test_the_packaged_definition_loads(self) -> None: + mark = Mark.load() + assert isinstance(mark, Mark) + + def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None: + """The mark draws one wave, so the stepped half carries on from where the smooth half arrives.""" + mark = Mark.load() + assert mark.waves.square.points[0] == mark.waves.sine.end + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None: + fields = {**VALID_FIELDS, case.field: case.value} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) + def test_a_missing_field_is_rejected(self, field: str) -> None: + fields = {key: value for key, value in VALID_FIELDS.items() if key != field} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + def test_an_unknown_field_is_rejected(self) -> None: + with pytest.raises(ValidationError): + Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}}) diff --git a/tests/unit/assets/icons/mark/test_suite.py b/tests/unit/assets/icons/mark/test_suite.py new file mode 100644 index 000000000..46eb26619 --- /dev/null +++ b/tests/unit/assets/icons/mark/test_suite.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from assets.icons.mark.specification import Mark +from assets.icons.mark.suite import write_icon_suite +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + +RGBA_MODE = "RGBA" +ICO_SIZES_KEY = "sizes" + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestWriteIconSuite: + def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None: + paths = write_icon_suite(tmp_path, mark) + assert [path.name for path in paths] == [ + ICON_VECTOR_FILENAME, + ICON_UNIX_FILENAME, + ICON_WIN_FILENAME, + ] + assert all(path.is_file() for path in paths) + + def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None: + directory = tmp_path / "icons" + write_icon_suite(directory, mark) + assert directory.is_dir() + + def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_UNIX_FILENAME) as image: + assert image.size == (mark.render.raster_size, mark.render.raster_size) + assert image.mode == RGBA_MODE + + def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_WIN_FILENAME) as image: + carried = {width for width, _ in image.info[ICO_SIZES_KEY]} + + assert carried == set(mark.render.windows_sizes) + + def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None: + """One definition produces one suite, so a rebuild leaves the shipped files as they were.""" + first = write_icon_suite(tmp_path / "first", mark) + second = write_icon_suite(tmp_path / "second", mark) + assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second] diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/assets/icons/mark/test_vector.py similarity index 85% rename from tests/unit/sampletones_assets/mark/test_vector.py rename to tests/unit/assets/icons/mark/test_vector.py index 081cb682d..6562a655e 100644 --- a/tests/unit/sampletones_assets/mark/test_vector.py +++ b/tests/unit/assets/icons/mark/test_vector.py @@ -1,8 +1,8 @@ from importlib.resources import files from pathlib import Path -from sampletones_assets.mark.specification import Mark -from sampletones_assets.mark.vector import render_vector +from assets.icons.mark.specification import Mark +from assets.icons.mark.vector import render_vector from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME PLACEHOLDER_PREFIX = "$" @@ -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/assets/icons/test_main.py b/tests/unit/assets/icons/test_main.py new file mode 100644 index 000000000..f195849dc --- /dev/null +++ b/tests/unit/assets/icons/test_main.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from assets.icons.__main__ import main +from assets.icons.mark.specification import Mark +from assets.icons.paths import ICONS_DIRECTORY + +WRITER: Final[str] = "assets.icons.__main__.write_icon_suite" + + +class RecordedSuite: + def __init__(self) -> None: + self.writes: List[Tuple[Path, Mark]] = [] + + def __call__(self, directory: Path, mark: Mark) -> List[Path]: + self.writes.append((directory, mark)) + return [directory / "sampletones.svg"] + + +class TestIcons: + def test_without_a_directory_the_shipped_icons_are_written( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + suite = RecordedSuite() + monkeypatch.setattr(WRITER, suite) + + assert main([]) == 0 + assert [directory for directory, _ in suite.writes] == [ICONS_DIRECTORY] + assert suite.writes[0][1] == Mark.load() + assert f"Wrote {ICONS_DIRECTORY / 'sampletones.svg'}" in capsys.readouterr().out + + def test_a_directory_of_its_own_is_written_where_it_says( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + suite = RecordedSuite() + monkeypatch.setattr(WRITER, suite) + + assert main(["--output", str(tmp_path)]) == 0 + assert [directory for directory, _ in suite.writes] == [tmp_path] diff --git a/tests/unit/assets/pictures/__init__.py b/tests/unit/assets/pictures/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/assets/pictures/test_boxes.py b/tests/unit/assets/pictures/test_boxes.py new file mode 100644 index 000000000..3169284fd --- /dev/null +++ b/tests/unit/assets/pictures/test_boxes.py @@ -0,0 +1,38 @@ +from typing import Final + +import pytest + +from assets.pictures.boxes import corner_box, crop_box, union +from automation.dearpygui.geometry import Rect + +LEFT: Final[Rect] = Rect(x=10.0, y=20.0, width=30.0, height=40.0) +RIGHT: Final[Rect] = Rect(x=50.0, y=5.0, width=10.0, height=10.0) + + +class TestUnion: + def test_one_box_is_its_own_union(self) -> None: + assert union([LEFT]) == LEFT + + def test_two_boxes_are_held_by_the_box_around_both(self) -> None: + assert union([LEFT, RIGHT]) == Rect(x=10.0, y=5.0, width=50.0, height=55.0) + + def test_no_box_is_refused(self) -> None: + with pytest.raises(ValueError): + union([]) + + +class TestCornerBox: + def test_the_box_starts_at_the_corner_and_reaches_the_farthest_edges(self) -> None: + assert corner_box([LEFT, RIGHT]) == Rect(x=0, y=0, width=60.0, height=60.0) + + def test_no_box_is_refused(self) -> None: + with pytest.raises(ValueError): + corner_box([]) + + +class TestCropBox: + def test_the_margin_surrounds_the_box(self) -> None: + assert crop_box(LEFT, 5, width=200, height=200) == (5, 15, 45, 65) + + def test_the_box_stays_inside_the_frame(self) -> None: + assert crop_box(LEFT, 20, width=35, height=30) == (0, 0, 35, 30) diff --git a/tests/unit/assets/pictures/test_frames.py b/tests/unit/assets/pictures/test_frames.py new file mode 100644 index 000000000..ddb60cdbb --- /dev/null +++ b/tests/unit/assets/pictures/test_frames.py @@ -0,0 +1,17 @@ +import numpy as np + +from assets.pictures.frames import to_image + + +class TestToImage: + def test_fractions_become_eight_bit_levels(self) -> None: + pixels = np.array([[[1.0, 0.5, 0.0, 1.0]]], dtype=np.float32) + + assert to_image(pixels).getpixel((0, 0)) == (255, 128, 0) + + def test_the_alpha_channel_is_left_behind(self) -> None: + pixels = np.zeros((2, 3, 4), dtype=np.float32) + image = to_image(pixels) + + assert image.mode == "RGB" + assert image.size == (3, 2) diff --git a/tests/unit/assets/pictures/test_jitter.py b/tests/unit/assets/pictures/test_jitter.py new file mode 100644 index 000000000..4e1ce9648 --- /dev/null +++ b/tests/unit/assets/pictures/test_jitter.py @@ -0,0 +1,60 @@ +from typing import Final + +import numpy as np +from PIL import Image + +from assets.pictures.jitter import block_counts, is_jitter + +SIZE: Final[tuple[int, int]] = (200, 100) + + +def picture() -> Image.Image: + return Image.new("RGB", SIZE, (40, 40, 60)) + + +def with_pixels(image: Image.Image, pixels: list[tuple[int, int]]) -> Image.Image: + changed = image.copy() + for x, y in pixels: + changed.putpixel((x, y), (200, 200, 200)) + return changed + + +class TestIsJitter: + def test_the_same_drawing_is_jitter(self) -> None: + assert is_jitter(picture(), picture()) + + def test_scattered_single_columns_on_edges_are_jitter(self) -> None: + drawn = with_pixels(picture(), [(10, y) for y in range(20, 31)] + [(150, 60), (150, 61), (151, 61)]) + + assert is_jitter(picture(), drawn) + + def test_a_block_of_changed_pixels_is_a_change(self) -> None: + drawn = with_pixels(picture(), [(x, y) for x in range(50, 54) for y in range(50, 54)]) + + assert not is_jitter(picture(), drawn) + + def test_a_scatter_covering_much_of_the_frame_is_a_change(self) -> None: + drawn = with_pixels(picture(), [(x, y) for x in range(0, 200, 4) for y in range(0, 100, 4)]) + + assert not is_jitter(picture(), drawn) + + def test_a_drawing_of_another_size_is_a_change(self) -> None: + assert not is_jitter(picture(), Image.new("RGB", (SIZE[0] + 1, SIZE[1]), (40, 40, 60))) + + +class TestBlockCounts: + def test_a_lone_pixel_counts_once_in_every_block_around_it(self) -> None: + changed = np.zeros((5, 5), dtype=bool) + changed[2, 2] = True + + counts = block_counts(changed) + + assert counts[2, 2] == 1 + assert counts[1:4, 1:4].sum() == 9 + assert counts[0, 0] == 0 + + def test_a_column_of_three_counts_three_at_its_middle(self) -> None: + changed = np.zeros((5, 5), dtype=bool) + changed[1:4, 2] = True + + assert block_counts(changed)[2, 2] == 3 diff --git a/tests/unit/assets/pictures/test_runner.py b/tests/unit/assets/pictures/test_runner.py new file mode 100644 index 000000000..517ff9cd8 --- /dev/null +++ b/tests/unit/assets/pictures/test_runner.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import Final + +from assets.pictures.__main__ import ALONE_MARKER, NAMING, pytest_arguments, run_environment +from automation.environment import HOMES_VARIABLE, KEPT_VARIABLE, SCREEN_VARIABLE + +SCENES: Final[Path] = Path("/checkout/assets/pictures/scenes") +KEPT: Final[Path] = Path("/checkout/build/pictures") +HOMES: Final[Path] = Path("/checkout/build/worlds/homes") + + +class TestPytestArguments: + def test_the_scenes_are_collected_under_their_own_naming(self) -> None: + arguments = pytest_arguments(SCENES, NAMING, workers="2", selection=f"not {ALONE_MARKER}") + + assert arguments[0] == str(SCENES) + assert "python_files=*_scenes.py" in arguments + assert "python_classes=*Scenes" in arguments + assert "python_functions=picture_*" in arguments + + def test_every_naming_rule_travels_as_an_override_beside_the_marker(self) -> None: + arguments = pytest_arguments(SCENES, NAMING, workers="2", selection=f"not {ALONE_MARKER}") + + assert arguments.count("-o") == len(NAMING) + 1 + assert arguments[arguments.index("-n") + 1] == "2" + assert arguments[arguments.index("-m") + 1] == f"not {ALONE_MARKER}" + + def test_the_scenes_drawn_alone_take_no_workers(self) -> None: + arguments = pytest_arguments(SCENES, NAMING, workers=None, selection=ALONE_MARKER) + + assert "-n" not in arguments + assert arguments[arguments.index("-m") + 1] == ALONE_MARKER + + +class TestRunEnvironment: + def test_the_screen_the_kept_records_and_the_homes_are_set(self) -> None: + environment = run_environment({}, kept=KEPT, homes=HOMES, repository=Path("/checkout")) + + assert environment[SCREEN_VARIABLE] == "1700x1300" + assert environment[KEPT_VARIABLE] == str(KEPT) + assert environment[HOMES_VARIABLE] == str(HOMES) + + def test_a_checkout_under_a_hidden_folder_leaves_the_homes_where_the_plugin_puts_them(self) -> None: + environment = run_environment({}, kept=KEPT, homes=HOMES, repository=Path("/work/.worktrees/x")) + + assert HOMES_VARIABLE not in environment + + def test_the_temporary_folder_travels_as_it_is(self) -> None: + environment = run_environment({"TMPDIR": "/tmp"}, kept=KEPT, homes=HOMES, repository=Path("/checkout")) + + assert environment["TMPDIR"] == "/tmp" + + def test_the_rest_of_the_environment_travels(self) -> None: + environment = run_environment({"DISPLAY": ":1"}, kept=KEPT, homes=HOMES, repository=Path("/checkout")) + + assert environment["DISPLAY"] == ":1" diff --git a/tests/unit/assets/pictures/test_worlds.py b/tests/unit/assets/pictures/test_worlds.py new file mode 100644 index 000000000..523a1aa5f --- /dev/null +++ b/tests/unit/assets/pictures/test_worlds.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import pytest + +from assets.demo.paths import LIBRARY_FOLDER, PROJECTS_FOLDER, RECONSTRUCTIONS_FOLDER, RECORDINGS_FOLDER +from assets.pictures.worlds import DemoTree, MissingDemoError + + +def demo_tree(source: Path) -> None: + for folder in (RECORDINGS_FOLDER, LIBRARY_FOLDER, RECONSTRUCTIONS_FOLDER, PROJECTS_FOLDER): + (source / folder).mkdir(parents=True) + (source / folder / "file").write_text(folder) + + +class TestDemoTree: + def test_the_recordings_go_beside_the_working_directory_and_the_documents_into_the_documents_folder( + self, + tmp_path: Path, + ) -> None: + source, home, documents = tmp_path / "source", tmp_path / "home", tmp_path / "home" / "Documents" / "App" + demo_tree(source) + + DemoTree(source=source, working_directory=home, documents=documents).write() + + assert (home / RECORDINGS_FOLDER / "file").read_text() == RECORDINGS_FOLDER + for folder in (LIBRARY_FOLDER, RECONSTRUCTIONS_FOLDER, PROJECTS_FOLDER): + assert (documents / folder / "file").read_text() == folder + + def test_a_missing_tree_is_named(self, tmp_path: Path) -> None: + with pytest.raises(MissingDemoError, match="make demo"): + DemoTree(source=tmp_path / "absent", working_directory=tmp_path, documents=tmp_path).write() diff --git a/tests/unit/automation/__init__.py b/tests/unit/automation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/automation/test_environment.py b/tests/unit/automation/test_environment.py new file mode 100644 index 000000000..a43057a01 --- /dev/null +++ b/tests/unit/automation/test_environment.py @@ -0,0 +1,74 @@ +import tempfile +from pathlib import Path +from typing import Final + +import pytest + +from automation.environment import ( + HOMES_VARIABLE, + SHARD_VARIABLE, + ScenarioFolders, + Shard, + ShardError, + child_environment, + homes_root, + shard, +) + +NODEID: Final[str] = "tests/screens/test_case.py::TestCase::test_it" +DISPLAY: Final[str] = ":9" + + +class TestWhereTheHomesGo: + def test_the_homes_go_to_the_system_s_temporary_folder(self) -> None: + assert homes_root({}) == Path(tempfile.gettempdir()) + + def test_the_variable_moves_them(self) -> None: + assert homes_root({HOMES_VARIABLE: "/work/homes"}) == Path("/work/homes") + + +class TestTheScenarioProcessEnvironment: + """The process keeps the machine's temporary folder, whose short path its socket files fit in.""" + + def test_the_temporary_folder_is_the_machine_s(self, tmp_path: Path) -> None: + folders = ScenarioFolders.of(NODEID, homes=tmp_path / "homes", kept=tmp_path / "kept") + + environment = child_environment({"TMPDIR": "/tmp"}, folders, display=DISPLAY) + + assert environment["TMPDIR"] == "/tmp" + assert environment["HOME"] == str(folders.home) + + def test_a_process_told_no_temporary_folder_is_told_none(self, tmp_path: Path) -> None: + folders = ScenarioFolders.of(NODEID, homes=tmp_path / "homes", kept=tmp_path / "kept") + + environment = child_environment({}, folders, display=DISPLAY) + + assert "TMPDIR" not in environment + + +class TestTheShard: + """A run spread over machines takes every count-th scenario from its own index, and the parts cover the + collection once. + """ + + def test_no_variable_means_the_whole_collection(self) -> None: + assert shard({}) is None + + def test_the_variable_names_the_part(self) -> None: + assert shard({SHARD_VARIABLE: "2/3"}) == Shard(index=2, count=3) + + def test_the_parts_cover_every_position_once(self) -> None: + parts = [Shard.of(f"{index}/3") for index in (1, 2, 3)] + + keepers = [[part for part in parts if part.keeps(position)] for position in range(7)] + + assert [len(kept) for kept in keepers] == [1] * 7 + assert [kept[0].index for kept in keepers] == [1, 2, 3, 1, 2, 3, 1] + + def test_one_part_keeps_everything(self) -> None: + assert all(Shard.of("1/1").keeps(position) for position in range(5)) + + @pytest.mark.parametrize("stated", ["3", "a/b", "0/3", "4/3", "1/0", "-1/3", "1/3/1"]) + def test_other_readings_are_refused(self, stated: str) -> None: + with pytest.raises(ShardError): + Shard.of(stated) diff --git a/tests/unit/automation/test_frames.py b/tests/unit/automation/test_frames.py new file mode 100644 index 000000000..62c3e87b7 --- /dev/null +++ b/tests/unit/automation/test_frames.py @@ -0,0 +1,39 @@ +from typing import Final + +import numpy as np +import pytest + +from automation.dearpygui.geometry import Rect +from automation.frames import NothingChangedError, changed_box + +FRAME: Final[tuple[int, int]] = (100, 80) + + +def frame(width: int, height: int) -> np.ndarray: + return np.zeros((height, width, 4), dtype=np.float32) + + +class TestChangedBox: + def test_the_box_surrounds_every_changed_pixel(self) -> None: + before = frame(*FRAME) + after = before.copy() + after[10, 20, 0] = 1.0 + after[30, 60, 2] = 1.0 + + assert changed_box(before, after) == Rect(x=20.0, y=10.0, width=41.0, height=21.0) + + def test_a_change_within_the_tolerance_is_no_change(self) -> None: + before = frame(*FRAME) + after = before.copy() + after[10, 20, 1] = 1.0 / 255.0 + + with pytest.raises(NothingChangedError): + changed_box(before, after) + + def test_alpha_alone_is_no_change(self) -> None: + before = frame(*FRAME) + after = before.copy() + after[:, :, 3] = 1.0 + + with pytest.raises(NothingChangedError): + changed_box(before, after) diff --git a/tests/unit/sampletones/commands/test_convert.py b/tests/unit/sampletones/commands/test_convert.py new file mode 100644 index 000000000..c98d8509e --- /dev/null +++ b/tests/unit/sampletones/commands/test_convert.py @@ -0,0 +1,147 @@ +import json +from pathlib import Path +from typing import List + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName +from sampletones_core.headless.conversion.request import ConversionRequest, classic_setup +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.configs.settings import StemSettings +from tests.suite.files import empty_file + +RECONSTRUCTION = "sampletones_core.headless.conversion.runners.reconstruct" +LOADER = "sampletones_core.headless.config.load_config" + + +class RecordedReconstruction: + def __init__(self) -> None: + self.requests: List[ConversionRequest] = [] + + def __call__(self, request: ConversionRequest, config: Config) -> None: + del config + self.requests.append(request) + + +@pytest.fixture(name="reconstruction") +def reconstruction_fixture(monkeypatch: pytest.MonkeyPatch) -> RecordedReconstruction: + recorded = RecordedReconstruction() + monkeypatch.setattr(RECONSTRUCTION, recorded) + monkeypatch.setattr(LOADER, lambda path: Config()) + return recorded + + +def _two_stems() -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1, ChannelName.PULSE2], bends=[ChannelName.PULSE1]), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.TRIANGLE], bends=[ChannelName.TRIANGLE]), + ), + ], + hierarchy=StemsHierarchy(levels=[[0], [1]]), + ) + + +class TestConvert: + def test_the_channels_named_become_one_stem(self, reconstruction: RecordedReconstruction, tmp_path: Path) -> None: + source = empty_file(tmp_path, "song.wav") + output = tmp_path / "song.stn" + + status = dispatch(COMMANDS, ["convert", str(source), "--channels", "pulse1,pulse2", "-o", str(output)]) + + assert status == 0 + request = reconstruction.requests[0] + assert request.sources == (source,) + assert request.stems == classic_setup([ChannelName.PULSE1, ChannelName.PULSE2]) + assert request.output_path == output + + def test_without_channels_the_usual_three_are_used( + self, + reconstruction: RecordedReconstruction, + tmp_path: Path, + ) -> None: + source = empty_file(tmp_path, "song.wav") + + assert dispatch(COMMANDS, ["convert", str(source)]) == 0 + assert reconstruction.requests[0].stems == classic_setup(DEFAULT_CHANNELS) + assert reconstruction.requests[0].output_path is None + + def test_a_stems_file_pairs_its_entries_with_the_sources_in_order( + self, + reconstruction: RecordedReconstruction, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + bass = empty_file(tmp_path, "bass.wav") + lead = empty_file(tmp_path, "lead.wav") + stems = _two_stems() + setup = tmp_path / "stems.json" + setup.write_text(json.dumps(stems.model_dump(mode="json")), encoding="utf-8") + + assert dispatch(COMMANDS, ["convert", str(bass), str(lead), "--stems", str(setup)]) == 0 + assert reconstruction.requests[0].stems == stems + printed = capsys.readouterr().out + assert "bass.wav: stem 0 on pulse1, pulse2, bending pulse1" in printed + assert "lead.wav: stem 1 on triangle, bending triangle" in printed + + def test_a_setup_pairing_wrong_is_refused(self, reconstruction: RecordedReconstruction, tmp_path: Path) -> None: + source = empty_file(tmp_path, "song.wav") + setup = tmp_path / "stems.json" + setup.write_text(json.dumps(_two_stems().model_dump(mode="json")), encoding="utf-8") + + with pytest.raises(SystemExit, match="1 sources for 2 stems"): + dispatch(COMMANDS, ["convert", str(source), "--stems", str(setup)]) + + assert reconstruction.requests == [] + + def test_an_unknown_channel_is_refused(self, reconstruction: RecordedReconstruction, tmp_path: Path) -> None: + source = empty_file(tmp_path, "song.wav") + + with pytest.raises(SystemExit, match="Unknown channel 'pulse3'"): + dispatch(COMMANDS, ["convert", str(source), "--channels", "pulse3"]) + + assert reconstruction.requests == [] + + def test_a_missing_source_is_refused_in_one_line( + self, reconstruction: RecordedReconstruction, tmp_path: Path + ) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["convert", str(tmp_path / "absent.wav")]) + + assert str(leaving.value) == f"No file at {tmp_path / 'absent.wav'}." + assert reconstruction.requests == [] + + def test_a_project_is_refused_as_no_recording(self, reconstruction: RecordedReconstruction, tmp_path: Path) -> None: + project = empty_file(tmp_path, "song.stp") + + with pytest.raises(SystemExit, match="is no recording") as leaving: + dispatch(COMMANDS, ["convert", str(project)]) + + assert len(str(leaving.value).splitlines()) == 1 + assert reconstruction.requests == [] + + def test_a_missing_stems_file_is_refused(self, reconstruction: RecordedReconstruction, tmp_path: Path) -> None: + source = empty_file(tmp_path, "song.wav") + + with pytest.raises(SystemExit, match="No stems file at"): + dispatch(COMMANDS, ["convert", str(source), "--stems", str(tmp_path / "absent.json")]) + + assert reconstruction.requests == [] + + def test_channels_and_stems_exclude_each_other(self, tmp_path: Path) -> None: + source = empty_file(tmp_path, "song.wav") + + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["convert", str(source), "--channels", "pulse1", "--stems", "stems.json"]) + + assert leaving.value.code == 2 diff --git a/tests/unit/sampletones/commands/test_library.py b/tests/unit/sampletones/commands/test_library.py new file mode 100644 index 000000000..02fbd2088 --- /dev/null +++ b/tests/unit/sampletones/commands/test_library.py @@ -0,0 +1,29 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_core.configs import Config + +GENERATOR = "sampletones_core.headless.library.generate_library" +LOADER = "sampletones_core.headless.config.load_config" + + +class TestLibrary: + def test_the_library_is_generated_for_the_configuration_named(self, monkeypatch: pytest.MonkeyPatch) -> None: + generated: List[Config] = [] + loaded: List[Path] = [] + configuration = Config() + + def load_config(path: Path) -> Config: + loaded.append(path) + return configuration + + monkeypatch.setattr(GENERATOR, generated.append) + monkeypatch.setattr(LOADER, load_config) + + assert dispatch(COMMANDS, ["library", "--config", "custom.json"]) == 0 + assert loaded == [Path("custom.json")] + assert generated == [configuration] diff --git a/tests/unit/sampletones/commands/test_open.py b/tests/unit/sampletones/commands/test_open.py new file mode 100644 index 000000000..0d9441f90 --- /dev/null +++ b/tests/unit/sampletones/commands/test_open.py @@ -0,0 +1,106 @@ +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from tests.suite.commands import RecordedApplication +from tests.suite.files import empty_file + +LAUNCHER = "sampletones.run.run_application" +ENGINE_MODULES: Final[Tuple[str, ...]] = ("numpy", "scipy", "sampletones_core.reconstructions") +REFUSAL_PROBE: Final[str] = """ +import json, sys +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +try: + dispatch(COMMANDS, ["open", sys.argv[1]]) +except SystemExit: + print(json.dumps(sorted(sys.modules))) +""" + + +class TestOpen: + @pytest.mark.parametrize( + ("name", "field"), + [("song.stp", "project"), ("song.stn", "reconstruction"), ("library.ins", "library")], + ) + def test_a_file_is_loaded_by_its_kind( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + name: str, + field: str, + ) -> None: + application = RecordedApplication() + monkeypatch.setattr(LAUNCHER, application) + path = empty_file(tmp_path, name) + + assert dispatch(COMMANDS, ["open", str(path), "--config", "custom.json"]) == 0 + start = application.starts[0] + assert start[field] == path + assert start["config"] == Path("custom.json") + assert [key for key, value in start.items() if value is None] == [ + key for key in ("library", "reconstruction", "project") if key != field + ] + + @pytest.mark.parametrize( + ("name", "field"), + [("song.stp", "project"), ("song.stn", "reconstruction"), ("library.ins", "library")], + ) + def test_a_file_named_from_the_working_folder_is_handed_on_whole( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + name: str, + field: str, + ) -> None: + """The session remembers the file the run opened, so a later run reopens it from any folder.""" + application = RecordedApplication() + monkeypatch.setattr(LAUNCHER, application) + empty_file(tmp_path, name) + monkeypatch.chdir(tmp_path) + + assert dispatch(COMMANDS, ["open", name]) == 0 + + assert application.starts[0][field] == (tmp_path / name).resolve() + + def test_a_recording_is_pointed_at_convert(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + application = RecordedApplication() + monkeypatch.setattr(LAUNCHER, application) + path = empty_file(tmp_path, "song.wav") + + with pytest.raises(SystemExit, match=re.escape(f"sampletones convert {path}")): + dispatch(COMMANDS, ["open", str(path)]) + + assert application.starts == [] + + def test_a_file_of_another_kind_is_refused(self, tmp_path: Path) -> None: + path = empty_file(tmp_path, "notes.txt") + + with pytest.raises(SystemExit, match="neither"): + dispatch(COMMANDS, ["open", str(path)]) + + def test_a_missing_file_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="No file at"): + dispatch(COMMANDS, ["open", str(tmp_path / "absent.stp")]) + + +class TestRefusingARecording: + """Pointing a recording at convert is a check on its name, which a fresh interpreter makes without the engine.""" + + def test_the_refusal_loads_no_engine(self, tmp_path: Path) -> None: + completed = subprocess.run( + [sys.executable, "-c", REFUSAL_PROBE, str(empty_file(tmp_path, "song.wav"))], + check=True, + capture_output=True, + text=True, + ) + + modules: List[str] = json.loads(completed.stdout) + assert set(modules) & set(ENGINE_MODULES) == set() diff --git a/tests/unit/sampletones/commands/test_registry.py b/tests/unit/sampletones/commands/test_registry.py new file mode 100644 index 000000000..c112f5557 --- /dev/null +++ b/tests/unit/sampletones/commands/test_registry.py @@ -0,0 +1,79 @@ +import json +import subprocess +import sys +from importlib.util import find_spec +from typing import Final, List, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS, USER_COMMANDS +from sampletones.dispatcher import DEFAULT_COMMAND, build_parser +from sampletones_tools.registry import DEVELOPER_COMMANDS + +TOOLS_PACKAGE: Final[str] = "sampletones_tools" +FACE_MODULES: Final[Tuple[str, ...]] = ("command", "registry") +FACE_PACKAGE: Final[str] = "commands" +HEAVY_MODULES: Final[Tuple[str, ...]] = ( + "numpy", + "scipy", + "librosa", + "cupy", + "PIL", + "py65", + "pytest", + "dearpygui", + "sampletones_shared.paths.user", +) +PROBE: Final[str] = "import json, sys, sampletones.commands.registry; print(json.dumps(sorted(sys.modules)))" + + +def _is_face(module: str) -> bool: + """Whether a tools module is one a command list reads: a package initializer, a command, a + registry, or a module under ``commands``.""" + spec = find_spec(module) + parts = module.split(".") + return ( + (spec is not None and spec.submodule_search_locations is not None) + or parts[-1] in FACE_MODULES + or FACE_PACKAGE in parts[:-1] + ) + + +@pytest.fixture(scope="module") +def loaded_modules() -> List[str]: + """Every module a fresh interpreter holds once it lists the commands.""" + completed = subprocess.run( + [sys.executable, "-c", PROBE], + check=True, + capture_output=True, + text=True, + ) + modules: List[str] = json.loads(completed.stdout) + return modules + + +class TestRegistry: + def test_the_user_commands_are_the_ones_the_guide_names(self) -> None: + assert [command.name for command in USER_COMMANDS] == ["run", "open", "convert", "library", "self-check"] + + def test_the_commands_are_the_user_ones_followed_by_the_developer_ones(self) -> None: + assert COMMANDS == (*USER_COMMANDS, *DEVELOPER_COMMANDS) + + def test_the_default_command_is_registered(self) -> None: + assert DEFAULT_COMMAND in {command.name for command in COMMANDS} + + def test_the_registry_builds_one_parser(self) -> None: + assert build_parser(COMMANDS).format_help() + + +class TestListingTheCommands: + """A module a tool needs, loaded with the command list, would slow and could break every invocation, the GUI included.""" + + def test_only_the_faces_of_the_tools_are_loaded(self, loaded_modules: List[str]) -> None: + tools = [module for module in loaded_modules if module.startswith(f"{TOOLS_PACKAGE}.")] + + assert tools + assert [module for module in tools if not _is_face(module)] == [] + + def test_no_heavy_module_is_loaded(self, loaded_modules: List[str]) -> None: + assert set(loaded_modules) & set(HEAVY_MODULES) == set() diff --git a/tests/unit/sampletones/commands/test_run.py b/tests/unit/sampletones/commands/test_run.py new file mode 100644 index 000000000..72a9f347d --- /dev/null +++ b/tests/unit/sampletones/commands/test_run.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from tests.suite.commands import RecordedApplication + +LAUNCHER = "sampletones.run.run_application" + + +class TestRun: + def test_no_arguments_start_the_application_with_the_saved_configuration( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + application = RecordedApplication() + monkeypatch.setattr(LAUNCHER, application) + + assert dispatch(COMMANDS, []) == 0 + assert application.starts == [{"config": None, "library": None, "reconstruction": None, "project": None}] + + def test_a_configuration_reaches_the_application(self, monkeypatch: pytest.MonkeyPatch) -> None: + application = RecordedApplication() + monkeypatch.setattr(LAUNCHER, application) + + assert dispatch(COMMANDS, ["run", "--config", "custom.json"]) == 0 + assert application.starts[0]["config"] == Path("custom.json") diff --git a/tests/unit/sampletones/commands/test_self_check.py b/tests/unit/sampletones/commands/test_self_check.py new file mode 100644 index 000000000..2748135e4 --- /dev/null +++ b/tests/unit/sampletones/commands/test_self_check.py @@ -0,0 +1,13 @@ +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch + +CHECK = "sampletones.self_check.run_self_check" + + +class TestSelfCheck: + def test_the_status_is_the_checks_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CHECK, lambda: 7) + + assert dispatch(COMMANDS, ["self-check"]) == 7 diff --git a/tests/unit/sampletones/test_dispatcher.py b/tests/unit/sampletones/test_dispatcher.py new file mode 100644 index 000000000..0a55f6eb3 --- /dev/null +++ b/tests/unit/sampletones/test_dispatcher.py @@ -0,0 +1,69 @@ +from argparse import ArgumentParser, Namespace +from typing import List, Tuple + +import pytest + +from sampletones.dispatcher import COMMAND_METAVAR, DEFAULT_COMMAND, PROGRAM, build_parser, dispatch +from sampletones_shared.application import SAMPLETONES_NAME_VERSION +from sampletones_shared.command import Command + + +def _command(name: str, calls: List[Tuple[str, bool]]) -> Command: + def configure(parser: ArgumentParser) -> None: + parser.add_argument("--flag", action="store_true") + + def run(arguments: Namespace) -> int: + calls.append((name, arguments.flag)) + return 3 + + return Command(name=name, help=f"{name} help", configure=configure, run=run) + + +class TestBuildParser: + def test_two_commands_sharing_a_name_are_refused(self) -> None: + calls: List[Tuple[str, bool]] = [] + + with pytest.raises(ValueError, match="share a name: twin"): + build_parser((_command("twin", calls), _command("twin", calls))) + + def test_the_version_is_a_flag_of_the_entry(self, capsys: pytest.CaptureFixture[str]) -> None: + parser = build_parser((_command(DEFAULT_COMMAND, []),)) + + with pytest.raises(SystemExit) as leaving: + parser.parse_args(["--version"]) + + assert leaving.value.code == 0 + assert SAMPLETONES_NAME_VERSION in capsys.readouterr().out + + def test_the_listing_names_the_line_a_developer_command_runs_from_a_checkout(self) -> None: + listing = " ".join(build_parser((_command("first", []),)).format_help().split()) + + assert f"uv run {PROGRAM} {COMMAND_METAVAR}" in listing + + def test_every_command_is_listed_with_its_help(self) -> None: + parser = build_parser((_command("first", []), _command("second", []))) + + listing = parser.format_help() + + assert "first help" in listing + assert "second help" in listing + + +class TestDispatch: + def test_no_arguments_run_the_default_command(self) -> None: + calls: List[Tuple[str, bool]] = [] + + assert dispatch((_command(DEFAULT_COMMAND, calls), _command("other", calls)), []) == 3 + assert calls == [(DEFAULT_COMMAND, False)] + + def test_a_named_command_runs_with_its_arguments(self) -> None: + calls: List[Tuple[str, bool]] = [] + + assert dispatch((_command(DEFAULT_COMMAND, calls), _command("other", calls)), ["other", "--flag"]) == 3 + assert calls == [("other", True)] + + def test_an_unknown_command_is_refused(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch((_command(DEFAULT_COMMAND, []),), ["absent"]) + + assert leaving.value.code == 2 diff --git a/tests/unit/sampletones/test_self_check.py b/tests/unit/sampletones/test_self_check.py new file mode 100644 index 000000000..ac2476f7d --- /dev/null +++ b/tests/unit/sampletones/test_self_check.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + +import pytest + +from sampletones.self_check import CHECKS, FAILURE_STATUS, SUCCESS_STATUS, run_self_check +from sampletones_shared.exceptions import FileDialogUnavailableError + +RESOURCES_MODULE = "sampletones_application.ui.resources.resources" +SELECTION_MODULE = "sampletones_application.utils.file_dialogs.selection" + + +class TestRunSelfCheck: + def test_source_checkout_passes(self, capsys: pytest.CaptureFixture[str]) -> None: + status = run_self_check() + output = capsys.readouterr().out + + assert status == SUCCESS_STATUS + for check in CHECKS: + assert check.name in output + + def test_missing_resource_fails(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch(f"{RESOURCES_MODULE}.get_font_path", side_effect=FileNotFoundError("Resource not found")): + status = run_self_check() + + captured = capsys.readouterr() + + assert status == FAILURE_STATUS + assert "resources" in captured.err + assert "FileNotFoundError" in captured.err + assert "file dialog backend" not in captured.out + + def test_unavailable_dialog_backend_fails(self, capsys: pytest.CaptureFixture[str]) -> None: + with patch( + f"{SELECTION_MODULE}.select_file_dialog_backend", + side_effect=FileDialogUnavailableError("No file dialog backend is available."), + ): + status = run_self_check() + + assert status == FAILURE_STATUS + assert "file dialog backend" in capsys.readouterr().err diff --git a/tests/unit/sampletones_application/categories/test_context.py b/tests/unit/sampletones_application/categories/test_context.py new file mode 100644 index 000000000..65484b168 --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_context.py @@ -0,0 +1,47 @@ +import pytest + +from sampletones_application.categories.context import channel_label, channel_letter +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.paths import LANG_EN +from sampletones_core.constants.enums import CHANNEL_ABBREVIATIONS, ChannelName + + +@pytest.fixture(name="language_manager") +def language_manager_fixture() -> LanguageManager: + return LanguageManager(LANG_EN) + + +class TestTheLetterAChannelIsMarkedBy: + """A lane one bar high names its channel by a letter, which the language file spells.""" + + @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda name: name.value) + def test_every_channel_answers_with_one_character( + self, + channel_name: ChannelName, + language_manager: LanguageManager, + ) -> None: + assert len(channel_letter(language_manager, channel_name)) == 1 + + def test_the_letters_tell_the_channels_apart(self, language_manager: LanguageManager) -> None: + letters = [channel_letter(language_manager, channel_name) for channel_name in ChannelName.items()] + + assert len(set(letters)) == len(letters) + + @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda name: name.value) + def test_a_letter_is_the_one_a_converted_folder_is_named_by( + self, + channel_name: ChannelName, + language_manager: LanguageManager, + ) -> None: + """A reader meets the same letter on screen and in the folder a conversion writes.""" + assert channel_letter(language_manager, channel_name) == CHANNEL_ABBREVIATIONS[channel_name] + + @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda name: name.value) + def test_a_letter_opens_the_name_the_channel_is_read_by( + self, + channel_name: ChannelName, + language_manager: LanguageManager, + ) -> None: + letter = channel_letter(language_manager, channel_name) + + assert channel_label(language_manager, channel_name).lower().startswith(letter.lower()) diff --git a/tests/unit/sampletones_application/categories/test_exports.py b/tests/unit/sampletones_application/categories/test_exports.py new file mode 100644 index 000000000..935d83255 --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_exports.py @@ -0,0 +1,109 @@ +from typing import Dict, FrozenSet, Set + +import pytest + +from sampletones_application.categories.exports import ( + EXPORT_INSTRUMENT_FILTERS, + EXPORT_PROJECT_ELEMENTS, + EXPORT_PROJECT_MENU_LABELS, + EXPORT_SAMPLE_MENU_LABELS, + INSTRUMENT_EXPORT_FORMATS, +) +from sampletones_application.exports import ExportBackends +from sampletones_application.utils.gui.shortcuts.ids import ( + PROJECT_EXPORT_SHORTCUT_IDS, + SAMPLE_EXPORT_SHORTCUT_IDS, +) +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[ExportFormat, ExportBackend]: + return ExportBackends.build().by_format + + +def formats_supporting( + backends: Dict[ExportFormat, ExportBackend], + scope: ExportScope, +) -> 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 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(EXPORT_PROJECT_MENU_LABELS), + frozenset(EXPORT_PROJECT_ELEMENTS), + frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), + frozenset(EXPORT_SAMPLE_MENU_LABELS), + frozenset(INSTRUMENT_EXPORT_FORMATS), + ], + ids=[ + "project_shortcuts", + "project_menu", + "project_elements", + "sample_shortcuts", + "sample_menu", + "instrument_button", + ], + ) + def test_the_registry_builds_every_offered_format( + self, + backends: Dict[ExportFormat, ExportBackend], + offered: FrozenSet[ExportFormat], + ) -> None: + assert offered <= frozenset(backends) + + +class TestTheMenusMatchTheSupportedScopes: + """The project submenu lists exactly the formats whose backend writes a project, so a + format gains its entry by declaring the scope rather than by a second edit in the UI. + + An instrument export offers a chosen few of the formats able to write its scope, so each + offered format is required to write that scope while the reverse stays a curated choice. + """ + + def test_the_project_export_menu_lists_the_formats_that_write_a_project( + self, + backends: Dict[ExportFormat, ExportBackend], + ) -> None: + 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[ExportFormat, ExportBackend], + ) -> None: + 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[ExportFormat, ExportBackend], + ) -> None: + assert set(INSTRUMENT_EXPORT_FORMATS) <= formats_supporting(backends, ExportScope.INSTRUMENT) + + +class TestEveryMenuEntryCarriesAnAction: + """A submenu builds its entries by pairing a shortcut id with a label, so the two maps + cover the same formats.""" + + def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(EXPORT_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) + + def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: + assert set(EXPORT_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) + + +class TestEveryOfferedFormatIsNamedInItsDialog: + """A save dialog offers each format under its own file type, so a format an export offers + without a type name would reach the dialog unnamed.""" + + def test_every_instrument_export_format_carries_a_file_type(self) -> None: + offered = set(INSTRUMENT_EXPORT_FORMATS) | set(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/categories/test_skipped.py b/tests/unit/sampletones_application/categories/test_skipped.py new file mode 100644 index 000000000..ad5f67894 --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_skipped.py @@ -0,0 +1,156 @@ +from typing import Final, List + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.skipped import MAX_REPORTED_ROWS, SkippedRowMessages +from sampletones_application.paths import LANG_EN +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow, SkipReason +from sampletones_core.project.voices.sample import Sample +from sampletones_core.structures import IdentifiedCollection +from tests.suite.sequencer import sample_reconstruction + +ROWS_LISTED: Final[int] = MAX_REPORTED_ROWS + + +def _skipped( + voice_id: str, + *, + order_position: int = 3, + row_index: int = 26, + reason: SkipReason = SkipReason.NO_INSTRUMENT, +) -> SkippedRow: + return SkippedRow( + voice_id=voice_id, + channel=ChannelName.PULSE1, + order_position=order_position, + row_index=row_index, + reason=reason, + ) + + +@pytest.fixture(name="messages") +def messages_fixture() -> SkippedRowMessages: + return SkippedRowMessages.build(LanguageManager(LANG_EN)) + + +@pytest.fixture(name="voices") +def voices_fixture() -> IdentifiedCollection[Sample]: + voices: IdentifiedCollection[Sample] = IdentifiedCollection() + for name in ("Lead", "Bass"): + voices.append(Sample(name=name, reconstruction=sample_reconstruction([ChannelName.TRIANGLE]))) + + return voices + + +class TestTheReportOfRowsLeftSilent: + def test_an_export_that_left_no_row_silent_has_nothing_to_report( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + assert messages.notice((), voices) is None + + def test_a_row_is_named_by_its_frame_channel_and_row_in_hexadecimal( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + bass = voices[1] + + notice = messages.notice((_skipped(bass.id),), voices) + + assert notice is not None + assert "Frame 03, Pulse 1, row 1A: " in notice + + def test_the_voice_reads_as_the_voices_list_prints_it( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + bass = voices[1] + + notice = messages.notice((_skipped(bass.id),), voices) + + assert notice is not None + assert notice.endswith("01: Bass") + + def test_a_voice_the_project_no_longer_holds_reads_blank( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + notice = messages.notice((_skipped("a-voice-no-project-holds"),), voices) + + assert notice is not None + assert notice.endswith(": ..") + + def test_the_report_opens_on_its_heading( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + notice = messages.notice((_skipped(voices[0].id),), voices) + + assert notice is not None + assert notice.splitlines()[0] == messages.headings[SkipReason.NO_INSTRUMENT] + + def test_a_short_list_is_printed_whole( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + rows = tuple(_skipped(voices[0].id, row_index=index) for index in range(ROWS_LISTED)) + + notice = messages.notice(rows, voices) + + assert notice is not None + assert len(notice.splitlines()) == 1 + ROWS_LISTED + assert "more" not in notice + + def test_a_long_list_is_cut_and_says_how_many_it_leaves_out( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + left_out = 5 + rows = tuple(_skipped(voices[0].id, row_index=index) for index in range(ROWS_LISTED + left_out)) + + notice = messages.notice(rows, voices) + + assert notice is not None + lines: List[str] = notice.splitlines() + assert len(lines) == 1 + ROWS_LISTED + 1 + assert lines[-1] == f"and {left_out} more" + + +class TestTheReportGivesEachReasonAParagraph: + """A note cut, a transpose left out and an instrument written at another frame's pitch are different + things for the reader to fix, so each reason is reported under a heading of its own, the note cuts + first. + """ + + def test_each_reason_opens_a_paragraph_of_its_own( + self, + messages: SkippedRowMessages, + voices: IdentifiedCollection[Sample], + ) -> None: + rows = ( + _skipped(voices[0].id, reason=SkipReason.CARRIED_PITCH), + _skipped(voices[0].id, reason=SkipReason.UNREACHED_TRANSPOSE), + _skipped(voices[1].id), + ) + + notice = messages.notice(rows, voices) + + assert notice is not None + paragraphs = notice.split("\n\n") + assert [paragraph.splitlines()[0] for paragraph in paragraphs] == [ + messages.headings[SkipReason.NO_INSTRUMENT], + messages.headings[SkipReason.UNREACHED_TRANSPOSE], + messages.headings[SkipReason.CARRIED_PITCH], + ] + + def test_every_reason_has_a_heading(self, messages: SkippedRowMessages) -> None: + assert set(messages.headings) == set(SkipReason) diff --git a/tests/unit/sampletones_application/categories/test_trackers.py b/tests/unit/sampletones_application/categories/test_trackers.py deleted file mode 100644 index 7f42d3e7f..000000000 --- a/tests/unit/sampletones_application/categories/test_trackers.py +++ /dev/null @@ -1,109 +0,0 @@ -from typing import Dict, FrozenSet, Set - -import pytest - -from sampletones_application.categories.trackers import ( - INSTRUMENT_EXPORT_FORMATS, - TRACKER_INSTRUMENT_FILTERS, - TRACKER_PROJECT_ELEMENTS, - TRACKER_PROJECT_MENU_LABELS, - TRACKER_SAMPLE_MENU_LABELS, -) -from sampletones_application.utils.gui.shortcuts.ids import ( - PROJECT_EXPORT_SHORTCUT_IDS, - SAMPLE_EXPORT_SHORTCUT_IDS, -) -from sampletones_core.trackers.backend import TrackerBackend -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends -from sampletones_core.trackers.scope import ExportScope - - -@pytest.fixture(name="backends") -def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: - return build_tracker_backends() - - -def formats_supporting( - backends: Dict[TrackerFormat, TrackerBackend], - scope: ExportScope, -) -> Set[TrackerFormat]: - return {tracker_format for tracker_format, backend in backends.items() if scope in backend.supported_scopes} - - -class TestEveryOfferedFormatHasABackend: - """A menu entry reaches a backend through the registry, so an entry the registry has no - backend for would raise a ``KeyError`` the moment it is chosen.""" - - @pytest.mark.parametrize( - "offered", - [ - frozenset(PROJECT_EXPORT_SHORTCUT_IDS), - frozenset(TRACKER_PROJECT_MENU_LABELS), - frozenset(TRACKER_PROJECT_ELEMENTS), - frozenset(SAMPLE_EXPORT_SHORTCUT_IDS), - frozenset(TRACKER_SAMPLE_MENU_LABELS), - frozenset(INSTRUMENT_EXPORT_FORMATS), - ], - ids=[ - "project_shortcuts", - "project_menu", - "project_elements", - "sample_shortcuts", - "sample_menu", - "instrument_button", - ], - ) - def test_the_registry_builds_every_offered_format( - self, - backends: Dict[TrackerFormat, TrackerBackend], - offered: FrozenSet[TrackerFormat], - ) -> None: - assert offered <= frozenset(backends) - - -class TestTheMenusMatchTheSupportedScopes: - """The project submenu lists exactly the formats whose backend writes a project, so a - format gains its entry by declaring the scope rather than by a second edit in the UI. - - An instrument export offers a chosen few of the formats able to write its scope, so each - offered format is required to write that scope while the reverse stays a curated choice. - """ - - def test_the_project_export_menu_lists_the_formats_that_write_a_project( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert set(TRACKER_PROJECT_MENU_LABELS) == formats_supporting(backends, ExportScope.PROJECT) - - def test_the_instruments_menu_offers_formats_that_write_a_whole_sample( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert set(TRACKER_SAMPLE_MENU_LABELS) <= formats_supporting(backends, ExportScope.SAMPLE) - - def test_the_instrument_button_offers_formats_that_write_one_slice( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert set(INSTRUMENT_EXPORT_FORMATS) <= formats_supporting(backends, ExportScope.INSTRUMENT) - - -class TestEveryMenuEntryCarriesAnAction: - """A submenu builds its entries by pairing a shortcut id with a label, so the two maps - cover the same formats.""" - - def test_the_project_menu_pairs_every_label_with_a_shortcut(self) -> None: - assert set(TRACKER_PROJECT_MENU_LABELS) == set(PROJECT_EXPORT_SHORTCUT_IDS) - - def test_the_instruments_menu_pairs_every_label_with_a_shortcut(self) -> None: - assert set(TRACKER_SAMPLE_MENU_LABELS) == set(SAMPLE_EXPORT_SHORTCUT_IDS) - - -class TestEveryOfferedFormatIsNamedInItsDialog: - """A save dialog offers each format under its own file type, so a format an export offers - without a type name would reach the dialog unnamed.""" - - def test_every_instrument_export_format_carries_a_file_type(self) -> None: - offered = set(INSTRUMENT_EXPORT_FORMATS) | set(TRACKER_SAMPLE_MENU_LABELS) - assert offered <= set(TRACKER_INSTRUMENT_FILTERS) diff --git a/tests/unit/sampletones_application/categories/test_truncation.py b/tests/unit/sampletones_application/categories/test_truncation.py new file mode 100644 index 000000000..a6da42da7 --- /dev/null +++ b/tests/unit/sampletones_application/categories/test_truncation.py @@ -0,0 +1,29 @@ +from typing import Final + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.truncation import TruncationMessages +from sampletones_application.paths import LANG_EN +from sampletones_core.exporters.truncation import EnvelopeTruncation + +SHORTENED: Final[EnvelopeTruncation] = EnvelopeTruncation(frames=512, source_frames=600, instruments=3) + + +@pytest.fixture(name="messages") +def messages_fixture() -> TruncationMessages: + return TruncationMessages.for_project(LanguageManager(LANG_EN)) + + +class TestTheReportOfShortenedInstruments: + def test_an_export_written_whole_has_nothing_to_report(self, messages: TruncationMessages) -> None: + assert messages.notice(None) is None + + def test_a_shortened_export_names_the_instruments_and_the_frames_they_keep( + self, + messages: TruncationMessages, + ) -> None: + notice = messages.notice(SHORTENED) + assert notice is not None + assert str(SHORTENED.instruments) in notice + assert str(SHORTENED.frames) 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..db09a7cc1 100644 --- a/tests/unit/sampletones_application/config/managers/test_config.py +++ b/tests/unit/sampletones_application/config/managers/test_config.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import List +from typing import Final, List, Optional import pytest @@ -13,13 +13,16 @@ from sampletones_application.view_model.main.updates import ( AdvancedSettingsUpdate, AudioSettingsUpdate, - GenerationSettingsUpdate, LibrarySettingsUpdate, ) -from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.fft import Window from sampletones_core.library import InstructionLibraryKey +OTHER_TUNING: Final[float] = 432.0 +STORED_GAMMA: Final[int] = 50 + def _manager(path: Path) -> ConfigManager: return ConfigManager(path) @@ -57,18 +60,18 @@ def test_recovery_preserves_valid_settings_and_lists_dropped(self, tmp_path: Pat json.dumps( { "library": {"sample_rate": 22050}, - "generation": {"drive": -5.0}, + "generation": {"decoder": {"top_k": 0}}, "obsolete_field": 1, } ) ) manager = _manager(path) assert manager.config.library.sample_rate == 22050 - assert manager.config.generation.drive == Config().generation.drive + assert manager.config.generation.decoder.top_k == Config().generation.decoder.top_k [outcome] = manager.pending_load_outcomes assert isinstance(outcome, ConfigRecovered) dropped = set(outcome.dropped) - assert ("generation", "drive") in dropped + assert ("generation", "decoder", "top_k") in dropped assert ("obsolete_field",) in dropped def test_config_without_metadata_recovers(self, tmp_path: Path) -> None: @@ -150,15 +153,6 @@ def test_apply_library_settings_updates_sample_rate( manager.apply_library_settings(update) assert manager.config.library.sample_rate == 22050 - 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], - ) - manager.apply_generation_settings(update) - assert manager.config.generation.drive == pytest.approx(0.5) - def test_apply_advanced_settings_updates_library_directory( self, tmp_path: Path, @@ -186,6 +180,49 @@ def test_apply_settings_fires_config_change_callbacks(self, tmp_path: Path) -> N assert fired == ["changed"] +class TestApplyingTheSettingsOfALibrary: + """Opening a library makes the settings it was built for the configuration's, so the + configuration names the library's own file.""" + + def test_the_settings_a_library_states_are_applied_whole(self, tmp_path: Path) -> None: + manager = _manager(tmp_path / "missing.json") + stored = _library_settings(manager).model_copy(update={"a4_frequency": OTHER_TUNING}) + key = _key_of(stored) + + manager.apply_library_config(key, stored) + + assert (manager.config.library, manager.key) == (stored, key) + + @pytest.mark.parametrize( + "stored_tuning", + [None, OTHER_TUNING], + ids=["no settings stated", "settings naming another file"], + ) + def test_the_filename_settings_stand_over_the_current_tuning( + self, + tmp_path: Path, + stored_tuning: Optional[float], + ) -> None: + manager = _manager(tmp_path / "missing.json") + named = _library_settings(manager) + stored = None if stored_tuning is None else named.model_copy(update={"a4_frequency": stored_tuning}) + + manager.apply_library_config(_key_of(named), stored) + + assert manager.config.library == named + + +def _library_settings(manager: ConfigManager) -> InstructionsLibraryConfig: + """Library settings the manager's configuration differs from, at the manager's own tuning.""" + return manager.config.library.model_copy( + update={"spectrum_method": SpectrumMethod.FFT, "transformation_gamma": STORED_GAMMA} + ) + + +def _key_of(library_config: InstructionsLibraryConfig) -> InstructionLibraryKey: + return InstructionLibraryKey.create(library_config, Window.from_config(library_config)) + + class TestConfigManagerProperties: def test_key_property_returns_instruction_library_key( self, diff --git a/tests/unit/sampletones_application/config/managers/test_session.py b/tests/unit/sampletones_application/config/managers/test_session.py index 03e31c704..cf7833b1c 100644 --- a/tests/unit/sampletones_application/config/managers/test_session.py +++ b/tests/unit/sampletones_application/config/managers/test_session.py @@ -111,10 +111,6 @@ def test_set_and_get_config_path(self, session: SessionManager, tmp_path: Path) session.set_config_path(tmp_path / "config.json") assert isinstance(session.get_config_path(), Path) - def test_set_and_get_library_path(self, session: SessionManager, tmp_path: Path) -> None: - session.set_library_path(tmp_path / "lib.nlib") - assert isinstance(session.get_library_path(), Path) - def test_set_and_get_instrument_path(self, session: SessionManager, tmp_path: Path) -> None: session.set_instrument_path(tmp_path / "instr.json") assert isinstance(session.get_instrument_path(), Path) diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index a01acd0ab..c85947dbf 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Type +from typing import Final, Type from unittest.mock import patch import pytest @@ -11,6 +11,8 @@ from sampletones_application.tags.reconstructions import TAG_RECONSTRUCTIONS_BROWSER_PANEL from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL +RETIRED_LAST_PATH: Final[str] = "library" + @pytest.fixture def manager(tmp_path: Path) -> ApplicationStateManager: @@ -28,6 +30,32 @@ def test_incompatible_field_preserves_remaining_state(self, tmp_path: Path) -> N assert manager.advanced_settings is True assert manager.window_width == ApplicationState().viewport.width + def test_a_retired_last_path_leaves_the_others_standing(self, tmp_path: Path) -> None: + """A state file naming a last path this build has retired loads with every path it still keeps.""" + path = tmp_path / "state.yaml" + path.write_text( + yaml.safe_dump( + { + "last_paths": { + RETIRED_LAST_PATH: str(tmp_path / "instructions"), + "project": str(tmp_path), + }, + } + ) + ) + + manager = ApplicationStateManager(path) + + assert manager.get_project_path() == tmp_path + + def test_the_next_save_drops_a_retired_last_path(self, tmp_path: Path) -> None: + path = tmp_path / "state.yaml" + path.write_text(yaml.safe_dump({"last_paths": {RETIRED_LAST_PATH: str(tmp_path)}})) + + ApplicationStateManager(path).save() + + assert RETIRED_LAST_PATH not in yaml.safe_load(path.read_text())["last_paths"] + class TestApplicationStateManagerInit: def test_instantiation_loads_application_state(self, tmp_path: Path) -> None: @@ -183,14 +211,6 @@ def test_set_config_path_stores_directory( manager.set_config_path(tmp_path / "config.json") assert isinstance(manager.get_config_path(), Path) - def test_set_library_path_stores_directory( - self, - manager: ApplicationStateManager, - tmp_path: Path, - ) -> None: - manager.set_library_path(tmp_path / "lib.json") - assert isinstance(manager.get_library_path(), Path) - def test_set_instrument_path_stores_directory( self, manager: ApplicationStateManager, diff --git a/tests/unit/sampletones_application/config/session/application/test_converter.py b/tests/unit/sampletones_application/config/session/application/test_converter.py new file mode 100644 index 000000000..74df6e203 --- /dev/null +++ b/tests/unit/sampletones_application/config/session/application/test_converter.py @@ -0,0 +1,51 @@ +from typing import Final + +from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.config.session.application.converter import ConverterConfig +from sampletones_application.constants.output import OutputKind +from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP, DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +LOUD_DRIVE: Final[float] = 2.0 + + +class TestDefaults: + def test_a_fresh_configuration_joins_on_the_usual_channels(self) -> None: + assert ConverterConfig().settings == StemSettings.covering(list(DEFAULT_CHANNELS)) + + def test_a_fresh_configuration_opens_on_the_shipped_run(self) -> None: + converter = ConverterConfig() + + assert (converter.output, converter.hierarchy_mode) == ( + OutputKind.PER_RECORDING, + DEFAULT_STEMS_HIERARCHY_MODE, + ) + + def test_the_application_configuration_carries_a_converter_section(self) -> None: + assert ApplicationConfig().converter == ConverterConfig() + + +class TestWhatTheFileCarries: + def test_the_settings_a_recording_joins_with_round_trip(self) -> None: + """The drives and the count a reader settled are read back on the next launch.""" + settings = StemSettings.covering([ChannelName.PULSE1, ChannelName.NOISE]) + settings = settings.with_drive(ChannelName.PULSE1, LOUD_DRIVE).with_channel_cap(1) + + written = ConverterConfig(settings=settings).model_dump(mode="json") + + assert ConverterConfig.model_validate(written).settings == settings + + def test_a_file_stating_no_drives_reads_them_as_calibrated(self) -> None: + """A settings file written before drives existed loads as one driving every channel at unit.""" + written = {"settings": {"channels": ["pulse1"], "bends": ["pulse1"]}} + + settings = ConverterConfig.model_validate(written).settings + + assert settings == StemSettings.covering([ChannelName.PULSE1]) + assert settings.channel_cap == ALL_STEMS_CHANNEL_CAP + + def test_a_file_still_naming_a_run_wide_count_loads_without_it(self) -> None: + written = {"channel_cap": 2, "output": "per_recording"} + + assert ConverterConfig.model_validate(written).settings == ConverterConfig().settings diff --git a/tests/unit/sampletones_application/config/session/application/test_display.py b/tests/unit/sampletones_application/config/session/application/test_display.py index 0d6ce7b5d..8f9f50f18 100644 --- a/tests/unit/sampletones_application/config/session/application/test_display.py +++ b/tests/unit/sampletones_application/config/session/application/test_display.py @@ -5,6 +5,7 @@ from sampletones_application.config.session.application.display import ( DEFAULT_BORDERLESS, DEFAULT_MAX_FPS, + DEFAULT_SHOW_FRAME_RATE, DEFAULT_VSYNC, DisplayConfig, ) @@ -19,10 +20,11 @@ def test_a_fresh_configuration_wears_the_default_palette(self) -> None: def test_a_fresh_configuration_paces_as_shipped(self) -> None: display = DisplayConfig() - assert (display.vsync, display.max_fps, display.borderless) == ( + assert (display.vsync, display.max_fps, display.borderless, display.show_frame_rate) == ( DEFAULT_VSYNC, DEFAULT_MAX_FPS, DEFAULT_BORDERLESS, + DEFAULT_SHOW_FRAME_RATE, ) def test_the_application_configuration_carries_a_display_section(self) -> None: @@ -45,6 +47,7 @@ def test_the_settings_survive_a_dump_and_a_reload(self) -> None: vsync=False, max_fps=UNLIMITED_FRAME_RATE, borderless=True, + show_frame_rate=False, ) assert DisplayConfig.model_validate(display.model_dump()) == display 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..70e3ea2a4 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -0,0 +1,251 @@ +from pathlib import Path +from typing import Dict, Final, List, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.categories.export import ExportMessages +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 ExportBackends +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 +from sampletones_shared.exceptions import ProjectTuningError + +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 = ExportBackends.build().by_format + return mock + + +@pytest.fixture +def dialogs() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def coordinator(logic: MagicMock, dialogs: MagicMock) -> InstrumentExportCoordinator: + language_manager = LanguageManager(LANG_EN) + return InstrumentExportCoordinator( + logic, + language_manager, + dialogs=dialogs, + messages=ExportMessages.build(language_manager), + ) + + +@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 TestAVoiceTheProjectCannotTune: + """A project whose samples were converted at two tunings states no one tuning for an instrument. + + The export stops before a file is asked for, and says so. + """ + + def test_the_export_stops_with_a_message_before_the_dialog( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + dialogs: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + refusal = ProjectTuningError("a project sounds one tuning") + logic.voice_instrument.side_effect = refusal + + coordinator.request_voice("lead-id", None) + + assert confirmed == [] + logic.export.assert_not_called() + dialogs.show_error.assert_called_once_with( + refusal, + LanguageManager(LANG_EN)["reconstructions.instruments.message.export_instrument_failed"], + ) + + def test_a_voice_the_project_tunes_asks_for_a_file_and_says_nothing( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + dialogs: 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 len(confirmed) == 1 + dialogs.show_error.assert_not_called() + + +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/export/test_nsf.py b/tests/unit/sampletones_application/coordinators/export/test_nsf.py new file mode 100644 index 000000000..570d68468 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/export/test_nsf.py @@ -0,0 +1,229 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.coordinators.export import nsf as nsf_module +from sampletones_application.coordinators.export.nsf import NSFExportCoordinator +from sampletones_application.logic.export.nsf.logic import NSFExportLogic +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.language import FakeLanguageManager +from tests.suite.nsf import FakeNSFExportService, FakeProgramBackend +from tests.suite.player import player_sample + +PROJECT_FILE: Final[Path] = Path("/home/user/projects/chiptune.stp") +PROJECT_DIRECTORY: Final[Path] = PROJECT_FILE.parent +INSTRUMENT_DIRECTORY: Final[Path] = Path("/home/user/instruments") +PROJECT_NAME: Final[str] = "chiptune" +SAMPLE_NAME: Final[str] = "Amen" +NTSC_FREQUENCY: Final[int] = 60 +CHOSEN: Final[Path] = Path("/home/user/programs/take one.nsf") + + +class _WindowRecorder: + """Stands in for the NSF export dialog, holding what it was told to show.""" + + def __init__(self) -> None: + self.view_models: List[NSFExportViewModel] = [] + self.visible = False + self.hides = 0 + self.on_choices_changed: Any = None + self.on_browse: Any = None + self.on_export: Any = None + self.on_close: Any = None + + def open(self, view_model: NSFExportViewModel) -> None: + self.visible = True + self.view_models.append(view_model) + + def update_view(self, view_model: NSFExportViewModel) -> None: + self.view_models.append(view_model) + + def hide(self) -> None: + self.hides += 1 + self.visible = False + + @property + def view(self) -> NSFExportViewModel: + assert self.view_models, "A view was expected to reach the window" + return self.view_models[-1] + + +class _SaveDialogRecorder: + """The OS save dialog as the coordinator asks it, answering with a stated path.""" + + def __init__(self) -> None: + self.answer: Optional[Path] = CHOSEN + self.requests: List[Dict[str, Any]] = [] + + def __call__(self, **kwargs: Any) -> Optional[Path]: + self.requests.append(kwargs) + return self.answer + + @property + def filters(self) -> List[FileFilter]: + assert self.requests, "A destination was expected to be asked for" + return list(self.requests[-1]["filters"]) + + +class NSFExportFixture: + """The coordinator over a real setup logic, a recording service and backend, and a recorded screen.""" + + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + *, + operation_active: bool = False, + ) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + self.session_manager = MagicMock() + self.session_manager.get_project_path.return_value = PROJECT_FILE + self.session_manager.get_instrument_path.return_value = INSTRUMENT_DIRECTORY + self.service = FakeNSFExportService() + self.backend = FakeProgramBackend() + self.logic = NSFExportLogic( + self.controller, + self.session_manager, + self.service, + self.backend, + is_operation_active=lambda: operation_active, + ) + + self.window = _WindowRecorder() + self.save_dialog = _SaveDialogRecorder() + self.activity = 0 + + monkeypatch.setattr(nsf_module, "save_file_dialog", self.save_dialog) + + self.coordinator = NSFExportCoordinator( + self.logic, + window=self.window, # type: ignore[arg-type] + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + on_activity_changed=self._on_activity_changed, + ) + + def _on_activity_changed(self) -> None: + self.activity += 1 + + def edit(self, choices: NSFExportChoices) -> None: + self.window.on_choices_changed(choices) + + def browse(self) -> None: + self.window.on_browse() + + def export(self) -> None: + self.window.on_export() + + def close(self) -> None: + self.window.on_close() + + +@pytest.fixture +def nsf(monkeypatch: pytest.MonkeyPatch) -> NSFExportFixture: + return NSFExportFixture(monkeypatch) + + +class TestOfferingTheSetup: + def test_a_project_opens_the_dialog_over_its_setup(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + assert nsf.window.visible + assert nsf.window.view.destination == PROJECT_DIRECTORY / f"{PROJECT_NAME}{EXT_FILE_NSF}" + + def test_a_reconstruction_opens_the_dialog_over_its_slices(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_sample(player_sample(SAMPLE_NAME, (), nes_frequency=NTSC_FREQUENCY)) + + assert nsf.window.visible + assert nsf.window.view.destination == INSTRUMENT_DIRECTORY / f"{SAMPLE_NAME}{EXT_FILE_NSF}" + + def test_opening_claims_the_application(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + assert nsf.coordinator.is_active + assert nsf.activity == 1 + + def test_another_exclusive_operation_leaves_the_dialog_closed(self, monkeypatch: pytest.MonkeyPatch) -> None: + nsf = NSFExportFixture(monkeypatch, operation_active=True) + + nsf.coordinator.open_project() + + assert not nsf.window.visible + assert not nsf.activity + + def test_closing_the_setup_hands_the_application_back(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + nsf.close() + + assert nsf.window.hides == 1 + assert not nsf.coordinator.is_active + assert nsf.activity == 2 + assert not nsf.service.projects + + +class TestTheEditsTheDialogReports: + def test_an_edit_comes_back_to_the_window(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + view = nsf.window.view + + nsf.edit(view.choices.with_channel(ChannelName.NOISE, False, view.offer)) + + assert not nsf.window.view.channel_sounded(ChannelName.NOISE) + + +class TestAskingForTheDestination: + def test_the_file_is_asked_for_from_where_it_stands(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + nsf.browse() + + request = nsf.save_dialog.requests[-1] + assert request["initial_directory"] == PROJECT_DIRECTORY + assert request["default_filename"] == f"{PROJECT_NAME}{EXT_FILE_NSF}" + assert [file_filter.extensions for file_filter in nsf.save_dialog.filters] == [(EXT_FILE_NSF,)] + + def test_a_chosen_file_becomes_the_one_the_export_writes(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + + nsf.browse() + + assert nsf.window.view.destination == CHOSEN + + def test_a_dismissed_dialog_leaves_the_file_alone(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + nsf.save_dialog.answer = None + standing = nsf.window.view.destination + + nsf.browse() + + assert nsf.window.view.destination == standing + + +class TestHandingTheExportOver: + def test_the_dialog_leaves_the_screen_and_the_run_starts(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + destination = nsf.window.view.destination + + nsf.export() + + assert nsf.window.hides == 1 + assert [run.destination for run in nsf.service.projects] == [destination] + assert not nsf.coordinator.is_active + assert nsf.activity == 2 + + def test_the_run_writes_the_program_the_dialog_stood_at(self, nsf: NSFExportFixture) -> None: + nsf.coordinator.open_project() + view = nsf.window.view + nsf.edit(view.choices.with_channel(ChannelName.NOISE, False, view.offer)) + nsf.export() + + assert ChannelName.NOISE not in nsf.backend.program.channels diff --git a/tests/unit/sampletones_application/coordinators/playback/test_failures.py b/tests/unit/sampletones_application/coordinators/playback/test_failures.py new file mode 100644 index 000000000..1eeb5b7f8 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/playback/test_failures.py @@ -0,0 +1,92 @@ +from typing import Final, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +NO_OUTPUT_MESSAGE: Final[str] = "global.dialog.message.no_audio_output" +NO_OUTPUT_TITLE: Final[str] = "global.dialog.title.no_audio_output" +ERROR_MESSAGE: Final[str] = "playback failed" +PLAYING_FAILURE_MESSAGE: Final[str] = "global.dialog.message.audio_playback_error" + + +@pytest.fixture(name="dialogs") +def dialogs_fixture() -> MagicMock: + return MagicMock() + + +@pytest.fixture(name="presenter") +def presenter_fixture(dialogs: MagicMock) -> PlaybackFailurePresenter: + return PlaybackFailurePresenter(dialogs=dialogs, language_manager=LANGUAGE_MANAGER) + + +class TestNoOutput: + """A machine offering no audio output reads as a plain notice, whatever line the caller offers.""" + + @pytest.mark.parametrize("message", [ERROR_MESSAGE, None], ids=["with_a_message", "without_a_message"]) + def test_a_missing_output_reads_as_a_notice( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + message: Optional[str], + ) -> None: + presenter.present(NoOutputDeviceError("no device"), message=message) + + dialogs.show_info.assert_called_once_with( + TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, + LANGUAGE_MANAGER[NO_OUTPUT_MESSAGE], + LANGUAGE_MANAGER[NO_OUTPUT_TITLE], + modal=True, + ) + dialogs.show_error.assert_not_called() + + +class TestOtherFailures: + """Any other failure reads as an error report opening with the caller's line.""" + + @pytest.mark.parametrize( + "exception", + [PlaybackError("stream refused"), OSError("device busy")], + ids=["playback", "io"], + ) + def test_a_failure_reads_as_an_error( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + exception: Exception, + ) -> None: + presenter.present(exception, message=ERROR_MESSAGE) + + dialogs.show_error.assert_called_once_with(exception, ERROR_MESSAGE) + dialogs.show_info.assert_not_called() + + +class TestAFailureWhilePlaying: + """A playback that fails on the thread playing it reads the same whichever source played it.""" + + def test_a_refused_stream_reads_as_an_error_with_the_playback_line( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + ) -> None: + refusal = PlaybackError("Failed to open audio stream: device busy") + + presenter.present_playing_failure(refusal) + + dialogs.show_error.assert_called_once_with(refusal, LANGUAGE_MANAGER[PLAYING_FAILURE_MESSAGE]) + + def test_a_missing_output_still_reads_as_a_notice( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + ) -> None: + presenter.present_playing_failure(NoOutputDeviceError("no device")) + + dialogs.show_info.assert_called_once() + dialogs.show_error.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/playback/test_guard.py b/tests/unit/sampletones_application/coordinators/playback/test_guard.py index 3391ff359..f5873fe87 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_guard.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_guard.py @@ -1,9 +1,10 @@ +from typing import List from unittest.mock import MagicMock import pytest from sampletones_application.coordinators.playback.guard import GuardedPlayer -from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError GUARDED_COMMANDS = ("play", "pause_or_resume") @@ -14,63 +15,74 @@ def player_logic() -> MagicMock: @pytest.fixture -def dialogs() -> MagicMock: +def failures() -> MagicMock: return MagicMock() @pytest.fixture -def guarded_player(player_logic: MagicMock, dialogs: MagicMock) -> GuardedPlayer: - return GuardedPlayer(player_logic, dialogs=dialogs, error_message="playback failed") +def guarded_player(player_logic: MagicMock, failures: MagicMock) -> GuardedPlayer: + return GuardedPlayer(player_logic, failures=failures) class TestGuardedCommands: - """The transport commands that can raise ``PlaybackError`` surface it as a dialog instead of - propagating, so a panel hook or the playback router can invoke them bare.""" + """The transport commands that can raise ``PlaybackError`` hand it to the presenter instead of + propagating, so a panel hook or the playback router can invoke them bare. + + A start that fails at once reads the way a failure on the playing thread does, so one refusal reads + alike however soon the device refused. + """ @pytest.mark.parametrize("command", GUARDED_COMMANDS) def test_delegates_to_the_logic( self, guarded_player: GuardedPlayer, player_logic: MagicMock, - dialogs: MagicMock, + failures: MagicMock, command: str, ) -> None: getattr(guarded_player, command)() getattr(player_logic, command).assert_called_once_with() - dialogs.show_error.assert_not_called() + failures.present_playing_failure.assert_not_called() @pytest.mark.parametrize("command", GUARDED_COMMANDS) - def test_playback_error_becomes_a_dialog( + @pytest.mark.parametrize( + "exception", + [PlaybackError("device unavailable"), NoOutputDeviceError("no device")], + ids=["playback", "no_output"], + ) + def test_a_playback_error_reaches_the_presenter( self, guarded_player: GuardedPlayer, player_logic: MagicMock, - dialogs: MagicMock, + failures: MagicMock, command: str, + exception: PlaybackError, ) -> None: - exception = PlaybackError("device unavailable") getattr(player_logic, command).side_effect = exception getattr(guarded_player, command)() - dialogs.show_error.assert_called_once_with(exception, "playback failed") + failures.present_playing_failure.assert_called_once_with(exception) + +class TestAGuardedRun: + """A command beyond the transport runs under the same boundary the transport commands keep.""" -class TestPassThroughs: - """Stop and the status queries delegate without a guard, matching the logic's own surface.""" + def test_the_command_runs(self, guarded_player: GuardedPlayer, failures: MagicMock) -> None: + ran: List[int] = [] - def test_stop_delegates(self, guarded_player: GuardedPlayer, player_logic: MagicMock) -> None: - guarded_player.stop() + guarded_player.run_guarded(lambda: ran.append(400)) + + assert ran == [400] + failures.present_playing_failure.assert_not_called() + + def test_a_playback_error_reaches_the_presenter(self, guarded_player: GuardedPlayer, failures: MagicMock) -> None: + exception = PlaybackError("device unavailable") - player_logic.stop.assert_called_once_with() + def failing() -> None: + raise exception - def test_queries_reflect_the_logic(self, guarded_player: GuardedPlayer, player_logic: MagicMock) -> None: - player_logic.is_playing.return_value = True - player_logic.is_paused.return_value = False - player_logic.is_engaged.return_value = True - player_logic.is_loaded.return_value = True + guarded_player.run_guarded(failing) - assert guarded_player.is_playing() is True - assert guarded_player.is_paused() is False - assert guarded_player.is_engaged() is True - assert guarded_player.is_loaded() is True + failures.present_playing_failure.assert_called_once_with(exception) diff --git a/tests/unit/sampletones_application/coordinators/tabs/conftest.py b/tests/unit/sampletones_application/coordinators/tabs/conftest.py new file mode 100644 index 000000000..c451c3838 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/tabs/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import scheduling + +__all__ = ["scheduling"] diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index df7e31a30..55e7c3c08 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -7,31 +7,46 @@ from sampletones_application.coordinators.tabs.instructions import ( InstructionsTabCoordinator, ) +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION +from sampletones_application.tags.instructions import TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION +from sampletones_core.library import LibraryState from sampletones_shared.exceptions import LibraryDisplayError +from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) + +__all__ = ["held_frames", "standing_window"] GENERATION_STATUS_TITLE_KEY: Final[str] = "instructions.library.title.generation_status_dialog" REMOVE_LIBRARY_MESSAGE_KEY: Final[str] = "instructions.library.message.remove_library_message" DISPLAY_ERROR_KEY: Final[str] = "instructions.library.message.status_display_error" +EXIT_LIBRARY_MESSAGE_KEY: Final[str] = "global.dialog.message.exit_library_generation_in_progress" +EXIT_LABEL_KEY: Final[str] = "global.dialog.label.exit" +FRAME_CALLBACKS: Final[str] = "sampletones_application.coordinators.tabs.instructions.FrameCallbackManager" -def _coordinator(*, library_exists: bool) -> InstructionsTabCoordinator: +def _coordinator(state: LibraryState) -> InstructionsTabCoordinator: """A coordinator with only the state ``_request_generate_library`` touches, bypassing the constructor.""" coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._library_logic = MagicMock() - coordinator._library_logic.library_available_for_config.return_value = library_exists - coordinator._dialogs = MagicMock() + coordinator._library_logic.config_library_state.return_value = state + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() return coordinator class TestGenerateRequest: - """A library is rarely worth regenerating, so an existing one prompts for confirmation before the - work starts; a missing one generates straight away.""" + """A library this build reads is rarely worth regenerating, so it prompts for confirmation + before the work starts; any other generates straight away.""" def test_existing_library_asks_for_confirmation(self) -> None: - coordinator = _coordinator(library_exists=True) + coordinator = _coordinator(LibraryState.CURRENT) coordinator._request_generate_library() @@ -40,8 +55,9 @@ def test_existing_library_asks_for_confirmation(self) -> None: confirm_action = coordinator._dialogs.show_confirmation.call_args.args[3] assert confirm_action is coordinator._library_logic.request_generation - def test_missing_library_generates_immediately(self) -> None: - coordinator = _coordinator(library_exists=False) + @pytest.mark.parametrize("state", [LibraryState.MISSING, LibraryState.OUTDATED], ids=["missing", "outdated"]) + def test_any_other_library_generates_immediately(self, state: LibraryState) -> None: + coordinator = _coordinator(state) coordinator._request_generate_library() @@ -49,6 +65,32 @@ def test_missing_library_generates_immediately(self) -> None: coordinator._library_logic.request_generation.assert_called_once_with() +class TestALibraryAnotherVersionBuilt: + """Opening a library another version built asks whether to rebuild it, and only the answer + Rebuild does.""" + + def test_the_reader_is_asked_before_the_rebuild(self) -> None: + coordinator = _coordinator(LibraryState.OUTDATED) + key = MagicMock() + + with patch(FRAME_CALLBACKS) as frame_callbacks: + coordinator._on_library_outdated(key) + frame_callbacks.set_frame_callback.call_args.args[0]() + + coordinator._library_logic.rebuild_library.assert_not_called() + confirmation = coordinator._dialogs.show_confirmation.call_args + assert confirmation.args[0] == TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION + + def test_rebuild_rebuilds_the_library_opened(self) -> None: + coordinator = _coordinator(LibraryState.OUTDATED) + key = MagicMock() + + coordinator._confirm_rebuild(key) + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._library_logic.rebuild_library.assert_called_once_with(key) + + def _generation_coordinator( *, converter_visible: bool, @@ -57,7 +99,7 @@ def _generation_coordinator( heavy constructor.""" coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._is_converter_visible = lambda: converter_visible - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() coordinator._ttl_generation_status = GENERATION_STATUS_TITLE_KEY return coordinator @@ -89,7 +131,7 @@ def _remove_library_coordinator( coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._library_logic = MagicMock() coordinator._library_logic.current_library_key = current_library_key - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._instruction_player_logic = MagicMock() coordinator._on_audio_state_changed = MagicMock() coordinator._close_instruction = MagicMock() @@ -265,7 +307,7 @@ def _loaded_coordinator(*, display_error: Exception) -> InstructionsTabCoordinat coordinator._spectrum_panel = MagicMock() coordinator._instruction_player_logic = MagicMock() coordinator._instruction_details_logic = MagicMock() - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() coordinator._on_audio_state_changed = MagicMock() return coordinator @@ -292,3 +334,58 @@ def test_unexpected_error_propagates(self) -> None: coordinator._on_instruction_loaded(MagicMock()) coordinator._dialogs.show_error.assert_not_called() + + +class TestTheExitAsksAboutALibraryBeingBuilt: + """Exiting stops a library being built, so the reader is asked first.""" + + def _coordinator(self, *, generating: bool) -> InstructionsTabCoordinator: + coordinator = _coordinator(LibraryState.CURRENT) + coordinator._library_logic.is_library_generating.return_value = generating + return coordinator + + def test_an_idle_library_lets_the_exit_go_on(self) -> None: + coordinator = self._coordinator(generating=False) + proceed = MagicMock() + decline = MagicMock() + + coordinator.guard_exit(proceed, decline) + + proceed.assert_called_once_with() + decline.assert_not_called() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_question_waits_while_another_window_stands(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(generating=True) + + coordinator.guard_exit(MagicMock(), MagicMock()) + + coordinator._dialogs.show_confirmation.assert_not_called() + standing_window.leave() + coordinator._dialogs.show_confirmation.assert_called_once() + + def test_a_library_finished_meanwhile_lets_the_exit_go_on(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(generating=True) + proceed = MagicMock() + coordinator.guard_exit(proceed, MagicMock()) + + coordinator._library_logic.is_library_generating.return_value = False + standing_window.leave() + + proceed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_library_being_built_asks_first(self) -> None: + coordinator = self._coordinator(generating=True) + proceed = MagicMock() + decline = MagicMock() + + coordinator.guard_exit(proceed, decline) + + proceed.assert_not_called() + decline.assert_not_called() + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION + assert args[1] == EXIT_LIBRARY_MESSAGE_KEY + assert kwargs["ok_label"] == EXIT_LABEL_KEY + assert_the_answers_reach(confirm=args[3], cancel=kwargs["on_cancel"], proceed=proceed, decline=decline) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index c1abf3a98..92b24000a 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -1,105 +1,247 @@ +from dataclasses import dataclass from pathlib import Path -from typing import Final -from unittest.mock import MagicMock +from typing import Callable, Final, Tuple +from unittest.mock import MagicMock, patch +import pytest + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.coordinators.tabs.main import MainTabCoordinator -from sampletones_application.logic.main.converter import ConversionSuccess +from sampletones_application.logic.main.converter.run import ConversionSuccess +from sampletones_application.logic.main.sources.scan import FolderScan +from sampletones_application.services.folder_scan.service import FolderScanService +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, TAG_MAIN_CONVERTER_DIALOG_LOAD, - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, + TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, ) +from sampletones_core.constants.enums import ChannelName +from tests.suite.application import settled +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions +from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) + +__all__ = ["held_frames", "standing_window"] -CONVERTER_RUNNING_MESSAGE_KEY: Final[str] = "main.explorer.message.converter_running_msg" -CONVERTER_RUNNING_TITLE_KEY: Final[str] = "main.explorer.title.converter_running_dialog" +CONVERSION_RUNNING_MESSAGE_KEY: Final[str] = "main.converter.message.conversion_running" +CONVERSION_RUNNING_TITLE_KEY: Final[str] = "main.converter.title.conversion_running_dialog" LOAD_FILE_MESSAGE_KEY: Final[str] = "main.converter.message.load_file_prompt" LOAD_BUTTON_KEY: Final[str] = "main.converter.label.load_button" OPEN_BUTTON_KEY: Final[str] = "main.converter.label.open_button" CLOSE_BUTTON_KEY: Final[str] = "main.converter.label.close_button" STOP_BUTTON_KEY: Final[str] = "main.converter.label.stop_button" CONTINUE_BUTTON_KEY: Final[str] = "main.converter.label.continue_button" - - -def _coordinator(*, operation_active: bool) -> MainTabCoordinator: - """A coordinator with only the state the reconstruct guards touch, bypassing the heavy - constructor.""" +NOTHING_BELOW_KEY: Final[str] = "main.converter.message.scan_nothing_below" +EXIT_CONVERSION_MESSAGE_KEY: Final[str] = "global.dialog.message.exit_conversion_in_progress" +EXIT_LABEL_KEY: Final[str] = "global.dialog.label.exit" +MAIN_MODULE: Final[str] = "sampletones_application.coordinators.tabs.main" + + +def _hooks(*, operation_active: bool) -> MainTabHooks: + """The tab's outward collaborator, every hook of which a test can read what reached it.""" + return MainTabHooks( + is_operation_active=lambda: operation_active, + on_busy_state_changed=MagicMock(), + on_reconstruct_listed=MagicMock(), + on_load_reconstruction=MagicMock(), + on_load_library=MagicMock(), + on_load_file=MagicMock(), + on_load_directory=MagicMock(), + on_canceled=MagicMock(), + on_refresh_trees=MagicMock(), + on_prepare_library=MagicMock(), + ) + + +def _coordinator(*, operation_active: bool, converting: bool = False) -> MainTabCoordinator: + """A coordinator with only the state the doors to the list touch, bypassing the heavy + constructor. + + ``converting`` is the converter's own run holding the list, and ``operation_active`` the busy + authority, which a conversion and every other exclusive operation answer.""" coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._is_operation_active = lambda: operation_active - coordinator._dialogs = MagicMock() + coordinator._hooks = _hooks(operation_active=operation_active or converting) + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() - coordinator._on_reconstruct_file = MagicMock() - coordinator._on_reconstruct_directory = MagicMock() + coordinator._session_manager = MagicMock() + coordinator._converter_logic = MagicMock() + coordinator._converter_logic.live = not converting + coordinator._converter_logic.mixes = False + coordinator._converter_logic.gathered_paths = () + coordinator._folder_scan = MagicMock() + coordinator._scan_window = MagicMock() return coordinator -class TestConverterRunningNotice: - """The busy-authority guard at the intent entry point: an active exclusive operation raises - the converter-running notice and reports the caller must decline; an idle authority stays - silent so the caller proceeds.""" - - def test_active_operation_notifies_and_reports_true(self) -> None: +def _refused(coordinator: MainTabCoordinator) -> bool: + """Whether the one notice saying the run holds the list was shown, and nothing else.""" + coordinator._dialogs.show_info.assert_called_once_with( + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, + CONVERSION_RUNNING_MESSAGE_KEY, + CONVERSION_RUNNING_TITLE_KEY, + ) + return True + + +Door = Callable[[MainTabCoordinator, Path], None] +Reached = Callable[[MainTabCoordinator], bool] + + +def _menu_reconstruct_file(coordinator: MainTabCoordinator, path: Path) -> None: + with patch(f"{MAIN_MODULE}.open_file_dialog", return_value=path): + coordinator.reconstruct_file_dialog() + + +def _menu_reconstruct_directory(coordinator: MainTabCoordinator, path: Path) -> None: + with patch(f"{MAIN_MODULE}.select_directory_dialog", return_value=path): + coordinator.reconstruct_directory_dialog() + + +class TestEveryDoorToTheList(BaseTestSuite): + """Every door that lists recordings meets one rule: the list refuses changes only while the + converter's own run holds it, with one notice. + + Listing starts nothing, so a library generation, a render or an export leaves the list open, + and the busy authority keeps Convert greyed until it ends. Ctrl-click and a double-click reach + the same door Add as stem and Add folder do, since they gather too. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + door: Door + reached: Reached + + test_cases = ( + TestCase( + label="Reconstruct file... in the menu", + door=_menu_reconstruct_file, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, + ), + TestCase( + label="Reconstruct folder... in the menu", + door=_menu_reconstruct_directory, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, + ), + TestCase( + label="Reconstruct file in the browser", + door=MainTabCoordinator.request_reconstruct_file, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, + ), + TestCase( + label="Reconstruct directory in the browser", + door=MainTabCoordinator.request_reconstruct_directory, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, + ), + TestCase( + label="Add as stem, Ctrl-click or double-click on a recording", + door=MainTabCoordinator._on_file_add_requested, + reached=lambda coordinator: coordinator._converter_logic.gather_recordings.called, + ), + TestCase( + label="Add folder or Ctrl-click on a folder", + door=MainTabCoordinator._on_directory_add_requested, + reached=lambda coordinator: coordinator._folder_scan.start.called, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_run_holding_the_list_refuses_it_with_one_notice(self, test_case: TestCase) -> None: + coordinator = _coordinator(operation_active=True, converting=True) + + test_case.door(coordinator, Path("/audio/take.wav")) + + assert (_refused(coordinator), test_case.reached(coordinator)) == (True, False) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_another_operation_leaves_the_list_open(self, test_case: TestCase) -> None: + """A library generation holds the busy authority, and the list takes the recording all the same.""" coordinator = _coordinator(operation_active=True) - assert coordinator._notify_converter_running() is True + test_case.door(coordinator, Path("/audio/take.wav")) - coordinator._dialogs.show_info.assert_called_once_with( - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, - CONVERTER_RUNNING_MESSAGE_KEY, - CONVERTER_RUNNING_TITLE_KEY, - ) + assert (test_case.reached(coordinator), coordinator._dialogs.show_info.called) == (True, False) - def test_idle_reports_false_silently(self) -> None: - coordinator = _coordinator(operation_active=False) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_each_attempt_shows_the_notice_again(self, test_case: TestCase) -> None: + coordinator = _coordinator(operation_active=True, converting=True) - assert coordinator._notify_converter_running() is False + test_case.door(coordinator, Path("/audio/take.wav")) + test_case.door(coordinator, Path("/audio/take.wav")) - coordinator._dialogs.show_info.assert_not_called() + assert coordinator._dialogs.show_info.call_count == 2 -class TestReconstructGuards: - """Reconstruction and conversion share the exclusive worker pool, so the reconstruct intents - decline while an operation runs and delegate to the wired callbacks when idle.""" +class TestAGestureLandingAfterARunStarted: + """A gesture that lands later meets the rule again where it lands, since a run may have started in + between: a folder read coming back, and a question answered.""" - def test_file_request_declines_while_an_operation_is_active(self) -> None: - coordinator = _coordinator(operation_active=True) + def test_a_folder_read_for_add_folder_lists_nothing(self) -> None: + coordinator = _coordinator(operation_active=True, converting=True) - coordinator._request_reconstruct_file(Path("/audio/sample.wav")) + coordinator._gather_read(Path("/audio"), (Path("/audio/take.wav"),)) - coordinator._on_reconstruct_file.assert_not_called() - coordinator._dialogs.show_info.assert_called_once() + assert (_refused(coordinator), coordinator._converter_logic.gather_folder.called) == (True, False) + coordinator._scan_window.close.assert_called_once_with() - def test_file_request_delegates_when_idle(self) -> None: + def test_a_folder_read_for_a_reconstruct_lists_nothing(self) -> None: + coordinator = _coordinator(operation_active=True, converting=True) + + coordinator._take_up_read(Path("/audio"), (Path("/audio/take.wav"),)) + + assert (_refused(coordinator), coordinator._converter_logic.take_up_folder.called) == (True, False) + coordinator._scan_window.close.assert_called_once_with() + + def test_replacing_the_mix_answered_after_a_run_started_lists_nothing(self) -> None: coordinator = _coordinator(operation_active=False) - filepath = Path("/audio/sample.wav") + coordinator._converter_logic.mixes = True + coordinator._converter_logic.gathered_paths = (Path("/audio/a.wav"),) + coordinator.request_reconstruct_file(Path("/audio/b.wav")) + coordinator._converter_logic.live = False - coordinator._request_reconstruct_file(filepath) + coordinator._dialogs.show_confirmation.call_args.args[3]() - coordinator._on_reconstruct_file.assert_called_once_with(filepath) - coordinator._dialogs.show_info.assert_not_called() + assert (_refused(coordinator), coordinator._hooks.on_reconstruct_listed.called) == (True, False) - def test_directory_request_declines_while_an_operation_is_active(self) -> None: - coordinator = _coordinator(operation_active=True) + def test_a_mix_picked_after_a_run_started_changes_nothing(self) -> None: + coordinator = _stems_coordinator(mixes=True, folder_rows=_rows_holding(MAX_STEM_SOURCES, 1)) + coordinator._gather_read(Path("/audio"), tuple(Path(f"/audio/{index}.wav") for index in range(9))) + coordinator._converter_logic.live = False + _rows, _room, answer = coordinator._stem_selection_window.open.call_args.args + + answer((Path("/audio/0.wav"),)) - coordinator._request_reconstruct_directory(Path("/audio")) + assert (_refused(coordinator), coordinator._converter_logic.mix_only.called) == (True, False) - coordinator._on_reconstruct_directory.assert_not_called() - coordinator._dialogs.show_info.assert_called_once() - def test_directory_request_delegates_when_idle(self) -> None: +class TestTheChannelKeys: + """A channel key reaches the recording picked out on the terms the list's own keys keep.""" + + @pytest.mark.parametrize("keys_active", [True, False], ids=["open", "put-away"]) + def test_a_key_reaches_the_pick_only_while_the_list_answers_keys(self, keys_active: bool) -> None: coordinator = _coordinator(operation_active=False) - directory = Path("/audio") + coordinator._converter_panel = MagicMock(keys_active=keys_active) - coordinator._request_reconstruct_directory(directory) + coordinator.toggle_channel(ChannelName.TRIANGLE) - coordinator._on_reconstruct_directory.assert_called_once_with(directory) - coordinator._dialogs.show_info.assert_not_called() + assert coordinator._converter_logic.toggle_channel.called is keys_active def _success_coordinator() -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._dialogs = MagicMock() - coordinator._on_refresh_trees = MagicMock() + coordinator._dialogs = dialogs_on_the_line() + coordinator._hooks = _hooks(operation_active=False) coordinator._converter_logic = MagicMock() coordinator._language_manager = FakeLanguageManager() return coordinator @@ -113,9 +255,9 @@ 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._hooks.on_refresh_trees.assert_called_once_with() coordinator._dialogs.show_confirmation.assert_called_once() args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_MAIN_CONVERTER_DIALOG_LOAD @@ -126,10 +268,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 @@ -137,12 +280,12 @@ def test_directory_success_offers_to_open_without_a_path(self) -> None: class TestCancelConfirmation: - """Cancelling is destructive, so the panel's cancel intent asks for confirmation before the + """Canceling is destructive, so the panel's cancel intent asks for confirmation before the conversion is actually stopped.""" def test_cancel_request_confirms_before_stopping(self) -> None: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._converter_logic = MagicMock() coordinator._language_manager = FakeLanguageManager() @@ -155,3 +298,426 @@ 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" +SCAN_FAILED_KEY: Final[str] = "main.converter.message.scan_failed" +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, + mixes: bool = True, + gathered: Tuple[Path, ...] = (), + folder_rows: Tuple[MagicMock, ...] = (), + gathered_rows: Tuple[MagicMock, ...] = (), + room: int = MAX_STEM_SOURCES, + ceiling: int = MAX_STEM_SOURCES, +) -> MainTabCoordinator: + coordinator = MainTabCoordinator.__new__(MainTabCoordinator) + coordinator._hooks = _hooks(operation_active=operation_active) + coordinator._dialogs = dialogs_on_the_line() + coordinator._language_manager = FakeLanguageManager() + coordinator._session_manager = MagicMock() + coordinator._converter_logic = MagicMock() + coordinator._converter_logic.live = True + coordinator._converter_logic.mixes = mixes + coordinator._converter_logic.gathered_paths = gathered + coordinator._converter_logic.source_count = len(gathered) + coordinator._converter_logic.room_for_sources = room + coordinator._converter_logic.mix_ceiling = ceiling + coordinator._converter_logic.list_fits_a_mix = len(gathered) <= ceiling + coordinator._converter_logic.rows_offered.return_value = folder_rows + coordinator._converter_logic.gathered_rows = gathered_rows + coordinator._stem_selection_window = MagicMock() + coordinator._scan_window = MagicMock() + coordinator._repaint_priority = 0 + coordinator._folder_scan = FolderScan(FolderScanService(priority=coordinator._repaint_priority)) + coordinator._folder_scan.on_started = coordinator._scan_window.open + coordinator._folder_scan.on_progress = coordinator._scan_window.report + coordinator._folder_scan.on_stopped = coordinator._scan_window.close + coordinator._folder_scan.on_failed = coordinator._on_scan_failed + return coordinator + + +def _folder_of(tmp_path: Path, count: int) -> Path: + """A folder holding that many recordings, which the reading below it finds.""" + root = tmp_path / "takes" + root.mkdir(exist_ok=True) + for index in range(count): + (root / f"take_{index:02d}.wav").touch() + + return root + + +def _add_folder(coordinator: MainTabCoordinator, root: Path) -> None: + """Asks for the folder and waits for the reading, the way a reader does. + + The walk's reports reach the coordinator through the queue the render loop drains, so the case + drains it once the walk has ended. + """ + settled(lambda: coordinator._on_directory_add_requested(root)) + + +class TestOutputSwitch: + """A mix reaches a fixed number of recordings, so a longer list is put to the reader first.""" + + def test_turning_to_a_mix_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(mixes=False) + + coordinator._request_output(OutputKind.MIXED) + + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.MIXED) + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_turning_away_from_a_mix_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(gathered=tuple(Path(f"/audio/{index}.wav") for index in range(20))) + + coordinator._request_output(OutputKind.PER_RECORDING) + + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.PER_RECORDING) + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_list_longer_than_a_mix_holds_asks_which_to_mix(self) -> None: + gathered = tuple(Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES + 2)) + listed = _rows_holding(*[1] * len(gathered)) + coordinator = _stems_coordinator(mixes=False, gathered=gathered, gathered_rows=listed) + + coordinator._request_output(OutputKind.MIXED) + + coordinator._converter_logic.set_output.assert_not_called() + rows, room, answer = coordinator._stem_selection_window.open.call_args.args + assert rows == listed + assert room == MAX_STEM_SOURCES + answer([gathered[0]]) + coordinator._converter_logic.mix_only.assert_called_once_with([gathered[0]]) + + def test_a_list_a_mix_holds_takes_effect_at_once(self) -> None: + gathered = tuple(Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES)) + coordinator = _stems_coordinator(mixes=False, gathered=gathered) + + coordinator._request_output(OutputKind.MIXED) + + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.MIXED) + coordinator._stem_selection_window.open.assert_not_called() + + +class TestDirectoryAdd: + """Ctrl-clicking a folder gathers it, standing for the recordings found below it.""" + + def test_a_folder_joins_the_setup(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) + + _add_folder(coordinator, root) + + gathered, found = coordinator._converter_logic.gather_folder.call_args.args + assert gathered == root + assert {path.name for path in found} == {"take_00.wav", "take_01.wav"} + + def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: + """The reading is what knows what a folder holds, so the answer arrives when it comes back + and the setup stands as it was.""" + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 0) + + _add_folder(coordinator, root) + + coordinator._converter_logic.gather_folder.assert_not_called() + assert coordinator._dialogs.show_info.call_args.args[1] == NOTHING_BELOW_KEY + + @requires_folder_permissions + def test_a_folder_it_may_not_open_says_so_and_adds_nothing(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) + + with held_at(root, LOCKED_FOLDER): + _add_folder(coordinator, root) + + coordinator._converter_logic.gather_folder.assert_not_called() + failure, message = coordinator._dialogs.show_error.call_args.args + assert isinstance(failure, PermissionError) + assert message == SCAN_FAILED_KEY + + +class TestFileAdd: + """A recording added from the browser's menu joins the setup, whichever run it names.""" + + 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.gather_recordings.assert_called_once_with([recording]) + + +OVERWRITE_TARGET_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_target_prompt" +OVERWRITE_TARGET_BUTTON_KEY: Final[str] = "main.converter.label.overwrite_target_button" +OVERWRITE_TARGETS_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_targets_prompt" +OVERWRITE_TARGETS_TITLE_KEY: Final[str] = "main.converter.title.overwrite_targets_dialog" + + +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_several_standing_reconstructions_are_all_put_to_the_reader(self, tmp_path: Path) -> None: + """Confirming writes over every one of them, so the prompt speaks for all of them.""" + coordinator = _stems_coordinator() + targets = tuple(tmp_path / name for name in ("one.stn", "two.stn", "three.stn")) + + coordinator._confirm_overwriting_target(targets) + + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[1] == OVERWRITE_TARGETS_PROMPT_KEY + assert args[2] == OVERWRITE_TARGETS_TITLE_KEY + assert kwargs["path"] is None + + 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 TestReconstructListsWhatItNames: + """A Reconstruct lists what it names to convert one apiece, asking first only about a mix it replaces.""" + + def _coordinator(self, *, mixes: bool, gathered: Tuple[Path, ...] = ()) -> MainTabCoordinator: + coordinator = _stems_coordinator(mixes=mixes, gathered=gathered) + coordinator._folder_scan = MagicMock() + return coordinator + + @pytest.mark.parametrize("gathered", [(), (Path("/audio/a.wav"),)]) + def test_a_list_writing_one_apiece_takes_it_straight_away(self, tmp_path: Path, gathered: Tuple[Path, ...]) -> None: + coordinator = self._coordinator(mixes=False, gathered=gathered) + + coordinator.request_reconstruct_file(tmp_path / "a.wav") + + coordinator._converter_logic.take_up_recording.assert_called_once_with(tmp_path / "a.wav") + coordinator._hooks.on_reconstruct_listed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_recording_listed_leaves_its_folder_for_the_next_reconstruct_dialog(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=False) + + coordinator.request_reconstruct_file(tmp_path / "a.wav") + + coordinator._session_manager.set_audio_input_path.assert_called_once_with(tmp_path) + + def test_a_folder_listed_is_left_for_the_next_reconstruct_dialog(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=False) + + coordinator.request_reconstruct_directory(tmp_path) + + coordinator._session_manager.set_audio_input_path.assert_called_once_with(tmp_path) + + def test_an_empty_mix_gives_way_straight_away(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=True) + + coordinator.request_reconstruct_directory(tmp_path) + + coordinator._folder_scan.start.assert_called_once_with(tmp_path, coordinator._take_up_read) + coordinator._hooks.on_reconstruct_listed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_mix_holding_recordings_is_asked_about_first(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) + + coordinator.request_reconstruct_file(tmp_path / "b.wav") + coordinator.request_reconstruct_directory(tmp_path) + + coordinator._hooks.on_reconstruct_listed.assert_not_called() + prompts = [call.args[1] for call in coordinator._dialogs.show_confirmation.call_args_list] + assert prompts == [DISCARD_STEMS_PROMPT_KEY, DISCARD_STEMS_PROMPT_KEY] + + def test_confirming_lists_what_was_named(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) + + coordinator.request_reconstruct_directory(tmp_path) + coordinator._dialogs.show_confirmation.call_args.args[3]() + + coordinator._folder_scan.start.assert_called_once_with(tmp_path, coordinator._take_up_read) + + def test_declining_keeps_the_mix(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) + + coordinator.request_reconstruct_directory(tmp_path) + coordinator._dialogs.show_confirmation.call_args.kwargs["on_cancel"]() + + coordinator._converter_logic.set_output.assert_not_called() + coordinator._hooks.on_reconstruct_listed.assert_not_called() + + +class TestTakingUpWhatAReconstructNamed: + """A recording is listed at once; a folder is read first and listed once the reading is done.""" + + def test_a_recording_is_listed(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + + coordinator._take_up_path(tmp_path / "take.wav") + + coordinator._converter_logic.take_up_recording.assert_called_once_with(tmp_path / "take.wav") + coordinator._converter_logic.start_conversion.assert_not_called() + + def test_a_folder_is_listed_with_what_it_holds(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) + + settled(lambda: coordinator._take_up_path(root)) + + listed, found = coordinator._converter_logic.take_up_folder.call_args.args + assert listed == root + assert {path.name for path in found} == {"take_00.wav", "take_01.wav"} + coordinator._converter_logic.start_conversion.assert_not_called() + + def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 0) + + settled(lambda: coordinator._take_up_path(root)) + + coordinator._converter_logic.take_up_folder.assert_not_called() + assert coordinator._dialogs.show_info.call_args.args[1] == NOTHING_BELOW_KEY + + +def _rows_holding(*counts: int) -> Tuple[MagicMock, ...]: + """Rows standing for that many recordings each, which is what the room is counted against.""" + return tuple(MagicMock(recordings=tuple(MagicMock() for _ in range(count))) for count in counts) + + +class TestGatheringAFolder: + """A mix reaches a fixed number of recordings, so a folder overflowing it asks rather than gathers.""" + + def test_a_run_writing_one_apiece_gathers_whatever_it_holds(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False, folder_rows=_rows_holding(MAX_STEM_SOURCES + 5)) + + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES + 5)) + + coordinator._converter_logic.gather_folder.assert_called_once() + coordinator._stem_selection_window.open.assert_not_called() + + def test_a_folder_a_mix_still_holds_is_gathered(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=True, folder_rows=_rows_holding(MAX_STEM_SOURCES)) + + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES)) + + coordinator._converter_logic.gather_folder.assert_called_once() + coordinator._stem_selection_window.open.assert_not_called() + + def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> None: + rows = _rows_holding(MAX_STEM_SOURCES, 1) + coordinator = _stems_coordinator(mixes=True, folder_rows=rows) + + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES + 1)) + + coordinator._converter_logic.gather_folder.assert_not_called() + offered, room, answer = coordinator._stem_selection_window.open.call_args.args + assert offered == coordinator._converter_logic.gathered_rows + rows + assert room == MAX_STEM_SOURCES + answer([Path("/audio/take.wav")]) + coordinator._converter_logic.mix_only.assert_called_once_with([Path("/audio/take.wav")]) + + def test_a_full_mix_is_offered_beside_what_the_folder_holds(self, tmp_path: Path) -> None: + """A mix with no room left is answerable: letting one go is what makes room for another.""" + rows = _rows_holding(1) + coordinator = _stems_coordinator( + mixes=True, + folder_rows=rows, + gathered_rows=_rows_holding(*[1] * MAX_STEM_SOURCES), + room=0, + ) + + _add_folder(coordinator, _folder_of(tmp_path, 1)) + + offered, room, _answer = coordinator._stem_selection_window.open.call_args.args + assert offered == coordinator._converter_logic.gathered_rows + rows + assert room == MAX_STEM_SOURCES + + def test_the_reading_is_put_on_screen(self, tmp_path: Path) -> None: + """A folder of thousands takes seconds to read, so the reader is shown what they wait for.""" + coordinator = _stems_coordinator(mixes=False, folder_rows=_rows_holding(1)) + root = _folder_of(tmp_path, 1) + + _add_folder(coordinator, root) + + coordinator._scan_window.open.assert_called_once_with(root) + + +class TestTheExitAsksAboutARunningConversion: + """Exiting stops a running conversion, so the reader is asked first.""" + + def _coordinator(self, *, active: bool) -> MainTabCoordinator: + coordinator = _coordinator(operation_active=False) + coordinator._converter_logic.is_active = active + return coordinator + + def test_an_idle_converter_lets_the_exit_go_on(self) -> None: + coordinator = self._coordinator(active=False) + proceed = MagicMock() + decline = MagicMock() + + coordinator.guard_exit(proceed, decline) + + proceed.assert_called_once_with() + decline.assert_not_called() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_question_waits_while_another_window_stands(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(active=True) + + coordinator.guard_exit(MagicMock(), MagicMock()) + + coordinator._dialogs.show_confirmation.assert_not_called() + standing_window.leave() + coordinator._dialogs.show_confirmation.assert_called_once() + + def test_a_conversion_ending_meanwhile_lets_the_exit_go_on(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(active=True) + proceed = MagicMock() + coordinator.guard_exit(proceed, MagicMock()) + + coordinator._converter_logic.is_active = False + standing_window.leave() + + proceed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_running_conversion_asks_first(self) -> None: + coordinator = self._coordinator(active=True) + proceed = MagicMock() + decline = MagicMock() + + coordinator.guard_exit(proceed, decline) + + proceed.assert_not_called() + decline.assert_not_called() + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION + assert args[1] == EXIT_CONVERSION_MESSAGE_KEY + assert kwargs["ok_label"] == EXIT_LABEL_KEY + assert_the_answers_reach(confirm=args[3], cancel=kwargs["on_cancel"], proceed=proceed, decline=decline) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py index b23318406..40f0830d7 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py @@ -1,19 +1,46 @@ from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, List, Optional, Tuple from unittest.mock import MagicMock import pytest 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.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.editor import InstrumentEditor +from sampletones_application.logic.reconstruction.instruments import ( + ReconstructionInstrumentsLogic, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import RateChange, StemRemovalRequest +from sampletones_application.logic.shared.renders import RenderCache from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_application.view_model.reconstruction.instruments import ( + ReconstructionInstrumentsViewModel, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import SILENT_VOLUME +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.exports.format import ExportFormat +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -23,7 +50,18 @@ LoadReconstructionError, UnhandledReconstructionError, ) +from tests.conftest import RENDER_BUDGET +from tests.suite.history.wiring import wired_history from tests.suite.language import FakeLanguageManager +from tests.suite.regeneration import HeldRegeneration +from tests.suite.stems import ( + SHARED_CHANNEL, + SHARED_OWNERS, + SOLE_CHANNEL, + STEM_B_ID, + regenerated, + taking_turns, +) FILE_NOT_FOUND_KEY: Final[str] = "reconstructions.browser.message.file_not_found" LOAD_ERROR_KEY: Final[str] = "reconstructions.browser.message.load_error" @@ -36,6 +74,12 @@ REMOVE_DIRECTORY_MESSAGE_KEY: Final[str] = "reconstructions.browser.message.remove_directory_message" TEXTS: Final[Dict[str, str]] = {INCOMPATIBLE_VERSION_KEY: "got {} expected {}"} +HISTORY_BUDGET: Final[int] = 16 +TYPED_VOLUME: Final[Tuple[int, ...]] = (6, 6) +RETIMED_FREQUENCY: Final[int] = 50 +OPEN_VOICE_ID: Final[str] = "lead-id" + +__all__ = ["taking_turns"] @pytest.fixture @@ -184,12 +228,55 @@ def removal_coordinator() -> ReconstructionTabCoordinator: instance._browser_logic = MagicMock() instance._browser_panel = MagicMock() instance._reconstruction_manager = MagicMock() + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_instruments_logic = MagicMock() instance._language_manager = FakeLanguageManager(TEXTS) instance._lbl_remove = "Remove" instance._msg_load_error = LOAD_ERROR_KEY return instance +class TestTheTabAsksForItsDocumentToChange: + """A removal the reader confirms and a rate the reader types reach the document as steps of its own.""" + + @pytest.fixture + def requests(self) -> MagicMock: + return MagicMock() + + @pytest.fixture + def coordinator(self, requests: MagicMock) -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._dialogs = MagicMock() + instance._language_manager = FakeLanguageManager(TEXTS) + instance._lbl_remove = "Remove" + instance._reconstruction_stems_panel = MagicMock() + instance._on_rewrite_requested = requests + return instance + + def test_a_confirmed_removal_asks_for_the_recording_to_leave( + self, + coordinator: ReconstructionTabCoordinator, + requests: MagicMock, + ) -> None: + row = coordinator._reconstruction_stems_panel.stems_list.row.return_value + row.name = "b" + + coordinator._request_remove_stem(STEM_B_ID) + requests.assert_not_called() + coordinator._dialogs.show_confirmation.call_args.kwargs["on_confirm"]() + + requests.assert_called_once_with(StemRemovalRequest(stem_id=STEM_B_ID, stem_name="b")) + + def test_a_typed_rate_asks_for_the_document_to_be_re_timed( + self, + coordinator: ReconstructionTabCoordinator, + requests: MagicMock, + ) -> None: + coordinator._request_rate_change(RETIMED_FREQUENCY) + + requests.assert_called_once_with(RateChange(nes_frequency=RETIMED_FREQUENCY)) + + class TestRemoveTreeEntries: def test_request_remove_reconstruction_prompts_confirmation( self, @@ -211,15 +298,53 @@ def test_removing_open_reconstruction_detaches_and_marks_it_dirty( removal_coordinator: ReconstructionTabCoordinator, ) -> None: path = Path("tone.strec") - removal_coordinator._reconstruction_manager.filepath = path + removal_coordinator._reconstruction_manager.is_backed_by.return_value = True removal_coordinator._remove_reconstruction(path) + removal_coordinator._reconstruction_manager.is_backed_by.assert_called_once_with(path) removal_coordinator._reconstruction_manager.detach_current_reconstruction.assert_called_once_with() removal_coordinator._reconstruction_manager.mark_updated.assert_called_once_with() removal_coordinator._browser_logic.remove_path.assert_called_once_with(path) removal_coordinator._browser_panel.refresh.assert_called_once_with() + def test_the_open_document_is_shown_without_the_file_it_lost( + self, + removal_coordinator: ReconstructionTabCoordinator, + ) -> None: + """The Source card stops naming a file that is gone.""" + removal_coordinator._reconstruction_manager.is_backed_by.return_value = True + + removal_coordinator._remove_reconstruction(Path("tone.strec")) + + removal_coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with( + refit_waveform=False + ) + + def test_a_file_that_stays_keeps_the_open_document_on_it( + self, + removal_coordinator: ReconstructionTabCoordinator, + ) -> None: + removal_coordinator._reconstruction_manager.is_backed_by.return_value = True + removal_coordinator._browser_logic.remove_path.side_effect = OSError("busy") + + removal_coordinator._remove_reconstruction(Path("tone.strec")) + + removal_coordinator._reconstruction_manager.detach_current_reconstruction.assert_not_called() + removal_coordinator._reconstruction_manager.mark_updated.assert_not_called() + removal_coordinator._dialogs.show_error.assert_called_once() + + def test_removing_another_file_leaves_the_open_document_as_it_is( + self, + removal_coordinator: ReconstructionTabCoordinator, + ) -> None: + removal_coordinator._reconstruction_manager.is_backed_by.return_value = False + + removal_coordinator._remove_reconstruction(Path("other.strec")) + + removal_coordinator._reconstruction_manager.detach_current_reconstruction.assert_not_called() + removal_coordinator._browser_logic.remove_path.assert_called_once_with(Path("other.strec")) + def test_removing_open_directory_detaches_loaded_reconstruction_inside_it( self, removal_coordinator: ReconstructionTabCoordinator, @@ -261,6 +386,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_an_instrument_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,8 +441,9 @@ 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, + skipped_rows=NO_SKIPPED_ROWS, ) ) @@ -285,8 +457,9 @@ 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), + skipped_rows=NO_SKIPPED_ROWS, ) ) @@ -303,12 +476,13 @@ 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, instruments=3, ), + skipped_rows=NO_SKIPPED_ROWS, ) ) @@ -324,9 +498,317 @@ 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, + skipped_rows=NO_SKIPPED_ROWS, ) ) assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_success + + +class TestUpdateReconstructionRefitsTheWaveformOnRequest: + """A retune moves the audio's own length, so the caller that knows this asks the waveform to + re-fit; an ordinary edit leaves the reader's view where it was, as it always has.""" + + @staticmethod + def _coordinator() -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_instruments_logic = MagicMock() + return instance + + def test_a_retune_is_forwarded_to_the_panel_logic(self) -> None: + coordinator = self._coordinator() + + coordinator.update_reconstruction(refit_waveform=True) + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=True) + + def test_an_ordinary_call_asks_for_no_refit(self) -> None: + coordinator = self._coordinator() + + coordinator.update_reconstruction() + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_redraw_asks_for_no_refit(self) -> None: + coordinator = self._coordinator() + + coordinator.redraw_reconstruction(refit_waveform=False) + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_redraw_at_another_rate_is_forwarded_to_the_panel_logic(self) -> None: + """An undo or a replacement can bring a document timed at another rate, which spans another length.""" + coordinator = self._coordinator() + + coordinator.redraw_reconstruction(refit_waveform=True) + + coordinator._reconstruction_panel_logic.update_reconstruction.assert_called_once_with(refit_waveform=True) + + +class TestTheInstrumentsPanelDrawsTheDocument: + """A document rewritten outside the instruments panel is drawn as it stands, and one the panel's + own edit rebuilt keeps the envelopes the panel draws.""" + + @pytest.fixture + def reconstruction(self, taking_turns: Reconstruction) -> Reconstruction: + return taking_turns + + @pytest.fixture + def reconstruction_manager( + self, + reconstruction: Reconstruction, + scheduling: SchedulingBehavior, + ) -> ReconstructionManager: + manager = ReconstructionManager(scheduling=scheduling, renders=RenderCache(budget_bytes=RENDER_BUDGET)) + manager.load_reconstruction_object(reconstruction, name="lead", voice_id=OPEN_VOICE_ID) + return manager + + @pytest.fixture + def instruments_logic( + self, + reconstruction_manager: ReconstructionManager, + ) -> ReconstructionInstrumentsLogic: + """The panel's logic over the editor the application builds, reading the open document.""" + controller = ProjectController(ProjectManager()) + editor = InstrumentEditor( + reconstruction_manager, + controller, + wired_history(controller, budget=HISTORY_BUDGET, strict=True), + lambda _voice_id, _feature_key: (), + ) + return ReconstructionInstrumentsLogic( + editor, + ReconstructionRewrites(reconstruction_manager, HeldRegeneration()), + ) + + @pytest.fixture + def drawn( + self, + instruments_logic: ReconstructionInstrumentsLogic, + ) -> List[Optional[ChannelEnvelopesViewModel]]: + """Every set of envelopes the panel is handed to draw.""" + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = drawn.append + return drawn + + @pytest.fixture + def coordinator( + self, + instruments_logic: ReconstructionInstrumentsLogic, + ) -> ReconstructionTabCoordinator: + instance = object.__new__(ReconstructionTabCoordinator) + instance._reconstruction_panel_logic = MagicMock() + instance._reconstruction_instruments_logic = instruments_logic + return instance + + def test_a_removal_draws_the_document_it_leaves( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + + coordinator.redraw_reconstruction(refit_waveform=False) + + assert drawn == [reconstruction_manager.current_features] + + def test_a_removal_draws_the_frames_it_released_as_rests( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + + coordinator.redraw_reconstruction(refit_waveform=False) + + envelopes = drawn[-1] + assert envelopes is not None + assert envelopes[SHARED_CHANNEL].volume.items[SHARED_OWNERS.index(STEM_B_ID)] == SILENT_VOLUME + + def test_a_removal_draws_a_channel_it_emptied_standing_by( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + reconstruction_manager.apply_edited(without_stem(reconstruction, STEM_B_ID)) + + coordinator.redraw_reconstruction(refit_waveform=False) + + envelopes = drawn[-1] + assert envelopes is not None + assert not envelopes[SOLE_CHANNEL].has_frames + + def test_a_regeneration_leaves_a_field_being_typed_in_alone( + self, + coordinator: ReconstructionTabCoordinator, + reconstruction_manager: ReconstructionManager, + reconstruction: Reconstruction, + drawn: List[Optional[ChannelEnvelopesViewModel]], + ) -> None: + """The regenerated document carries what the reader typed, so the panel keeps drawing it.""" + envelopes = reconstruction_manager.current_features + assert envelopes is not None + typed = envelopes[SHARED_CHANNEL].with_envelope(FeatureKey.VOLUME, Envelope[int](items=TYPED_VOLUME)) + reconstruction_manager.apply_edited(regenerated(reconstruction, SHARED_CHANNEL, typed)) + + coordinator.update_reconstruction() + + assert drawn == [] + + +class TestTheInstrumentsPanelFollowsTheInstrument: + """The panel keeps the instrument it edits while the project holds it, and draws a restore of it.""" + + @pytest.fixture + def project_controller(self) -> ProjectController: + return ProjectController(ProjectManager()) + + @pytest.fixture + def history(self, project_controller: ProjectController) -> HistoryManager: + return wired_history(project_controller, budget=HISTORY_BUDGET, strict=True) + + @pytest.fixture + def instrument_id(self, project_controller: ProjectController, history: HistoryManager) -> str: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + return project_controller.add_instrument(new_instrument("lead")).id + + @pytest.fixture + def editor(self, project_controller: ProjectController, history: HistoryManager) -> InstrumentEditor: + reconstruction_manager = MagicMock(spec=ReconstructionManager) + reconstruction_manager.current_features = None + return InstrumentEditor( + reconstruction_manager, + project_controller, + history, + lambda _voice_id, _feature_key: (), + ) + + @pytest.fixture + def views(self) -> List[ReconstructionInstrumentsViewModel]: + """Every view the panel is handed to draw.""" + return [] + + @pytest.fixture + def coordinator( + self, + editor: InstrumentEditor, + views: List[ReconstructionInstrumentsViewModel], + ) -> ReconstructionTabCoordinator: + instruments_logic = ReconstructionInstrumentsLogic( + editor, ReconstructionRewrites(MagicMock(), HeldRegeneration()) + ) + instruments_logic.on_view_changed = views.append + instance = object.__new__(ReconstructionTabCoordinator) + instance._instrument_editor = editor + instance._reconstruction_instruments_logic = instruments_logic + instance._reconstruction_panel_logic = MagicMock() + return instance + + def test_a_restore_keeping_the_instrument_draws_it( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + editor.edit_instrument(instrument_id) + + coordinator.follow_instrument(restored=True) + + assert len(views) == 1 + assert views[0].instrument is not None + assert editor.holds_instrument + + def test_another_change_keeping_the_instrument_leaves_the_panel_as_drawn( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + """A keystroke in the tracker changes the project, and the panel keeps what the reader writes.""" + editor.edit_instrument(instrument_id) + + coordinator.follow_instrument(restored=False) + + assert views == [] + assert editor.holds_instrument + + @pytest.mark.parametrize("restored", (True, False), ids=("restored", "edited")) + def test_an_instrument_the_project_lost_leaves_the_panel_empty( + self, + restored: bool, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + project_controller: ProjectController, + history: HistoryManager, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + editor.edit_instrument(instrument_id) + with history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(instrument_id) + + coordinator.follow_instrument(restored=restored) + + assert not editor.holds_instrument + assert len(views) == 1 + assert views[0].instrument is None + coordinator._reconstruction_panel_logic.close_reconstruction.assert_called_once_with() + + def test_a_panel_holding_no_instrument_follows_nothing( + self, + coordinator: ReconstructionTabCoordinator, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + coordinator.follow_instrument(restored=True) + + assert views == [] + + def test_closing_the_instrument_draws_the_panel_empty( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + editor.edit_instrument(instrument_id) + + coordinator.close_instrument() + + assert not editor.holds_instrument + assert len(views) == 1 + assert views[0].instrument is None + + def test_closing_the_instrument_empties_the_waveform_card( + self, + coordinator: ReconstructionTabCoordinator, + editor: InstrumentEditor, + instrument_id: str, + ) -> None: + """The card draws the instrument's own audio, so the tab empties it the way a closed reconstruction does.""" + editor.edit_instrument(instrument_id) + + coordinator.close_instrument() + + coordinator._reconstruction_panel_logic.close_reconstruction.assert_called_once_with() + + def test_closing_with_no_instrument_draws_nothing( + self, + coordinator: ReconstructionTabCoordinator, + views: List[ReconstructionInstrumentsViewModel], + ) -> None: + """A reconstruction the panel draws stays drawn, since the close reaches an instrument alone.""" + coordinator.close_instrument() + + assert views == [] + coordinator._reconstruction_panel_logic.close_reconstruction.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index b0e7d7783..f83461be0 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,49 +1,46 @@ from datetime import UTC, datetime from pathlib import Path -from typing import Dict, Final, List -from unittest.mock import MagicMock +from typing import Callable, Dict, Final, List, Optional +from unittest.mock import MagicMock, patch 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.failures import PlaybackFailurePresenter 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 from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.sequencer.channels import ( - ALL_CHANNELS, - SequencerChannelsLogic, +from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic +from sampletones_application.logic.sequencer.history_detail import ( + SequencerHistoryDetail, ) -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 ( - 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 +48,484 @@ 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 ALL_CHANNELS, ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.famitracker.voice import ImportedVoice, InstrumentOmission +from sampletones_core.project.patterns.pitch import Step 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, + NoOutputDeviceError, + PlaybackError, +) +from tests.suite.clipboard import FakeTextClipboard +from tests.suite.gates import HeldGate, held_gate +from tests.suite.history.wiring import wired_history 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" + +__all__ = ["held_gate"] 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, synthesizer), + 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(), + ) + + +PICKED_INSTRUMENT: Final[VoiceSelection] = VoiceSelection( + voice_id="pad-id", + position=2, + name="pad", + kind=VoiceKind.INSTRUMENT, +) +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() + + +POOL_GESTURES: Final[Dict[str, Callable[[SequencerVoices], None]]] = { + "new instrument": SequencerVoices.add_instrument, + "add sample from file": SequencerVoices.add_sample_from_file, + "import instrument": SequencerVoices.import_instrument, +} + + +@pytest.fixture +def pool_voices() -> SequencerVoices: + """The pool gestures over an open project holding no voice yet.""" + project_controller = MagicMock() + project_controller.is_open = True + project_controller.voice_count = 0 + voices_logic = MagicMock() + voices_logic.read_instrument.return_value = _imported() + return _voices(voices_logic, project_controller, MagicMock(), MagicMock()) + + +class TestAVoiceComesIntoAnOpenProjectAlone: + """Every way a voice comes in asks for an open project as it starts, wherever the gesture came from. + + The menus grey these doors without a project, and a key bound to one reaches it all the same. + """ + + @pytest.mark.parametrize("gesture", POOL_GESTURES.values(), ids=POOL_GESTURES.keys()) + def test_with_no_project_nothing_is_asked_for_and_nothing_added( + self, + pool_voices: SequencerVoices, + located_file: List[Dict[str, object]], + gesture: Callable[[SequencerVoices], None], + ) -> None: + pool_voices._project_controller.is_open = False + + gesture(pool_voices) + + assert located_file == [] + pool_voices._voices_logic.add_new_instrument.assert_not_called() + pool_voices._voices_logic.add_instrument.assert_not_called() + pool_voices._import_reconstruction.assert_not_called() + pool_voices._history.transaction.assert_not_called() + pool_voices._dialogs.show_info.assert_called_once() + + def test_with_a_project_a_new_instrument_joins_the_pool(self, pool_voices: SequencerVoices) -> None: + pool_voices.add_instrument() + + pool_voices._voices_logic.add_new_instrument.assert_called_once() + pool_voices._dialogs.show_info.assert_not_called() + + def test_with_a_project_a_sample_is_asked_for_and_brought_in( + self, + pool_voices: SequencerVoices, + located_file: List[Dict[str, object]], + ) -> None: + pool_voices.add_sample_from_file() + + assert len(located_file) == 1 + pool_voices._import_reconstruction.assert_called_once_with(INSTRUMENT_FILE) + + +@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 +537,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 +559,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 +633,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,10 +653,33 @@ 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 +class TestASongThatFailsWhilePlaying: + """A song that fails on the thread playing it reads the way every playback failing there reads.""" + + def test_a_refused_stream_is_presented_as_a_failure_while_playing(self) -> None: + coordinator = object.__new__(SequencerTabCoordinator) + coordinator._playback_failures = MagicMock(spec=PlaybackFailurePresenter) + refusal = PlaybackError("Failed to open audio stream: device busy") + + coordinator._on_player_error(refusal) + + coordinator._playback_failures.present_playing_failure.assert_called_once_with(refusal) + + class TestFollowMode: @pytest.mark.parametrize("mode", list(FollowMode), ids=str) def test_the_sounding_frame_is_shown_while_the_mode_follows_patterns( @@ -353,7 +741,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 +758,334 @@ 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_a_picked_instrument_replaces_nothing( + self, + replace_coordinator: SequencerReconstructions, + ) -> None: + replace_coordinator._voices_panel.selection = PICKED_INSTRUMENT + + replace_coordinator.replace_from_file(Path("kick_02.stn")) + + replace_coordinator._browser_logic.load_reconstruction.assert_not_called() + replace_coordinator._voices_logic.rename_voice.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,105 +1094,130 @@ 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( + def test_replacement_is_announced_after_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 + """An editor holding the sample open by its id shows the reconstruction in the form the project keeps.""" + reconstruction = replace_coordinator._browser_logic.load_reconstruction.return_value order = MagicMock() order.attach_mock(replace_coordinator._on_sample_reconstruction_replaced, "announce") 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"] + assert [call[0] for call in order.mock_calls] == ["replace", "announce"] + replace_coordinator._browser_logic.replace_reconstruction.assert_called_once_with("bass-id", reconstruction) replace_coordinator._on_sample_reconstruction_replaced.assert_called_once_with( "bass-id", - reconstruction, + replace_coordinator._browser_logic.replace_reconstruction.return_value, ) 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 + + def test_label_is_absent_while_an_instrument_is_picked( + self, + replace_coordinator: SequencerReconstructions, + ) -> None: + """A reconstruction takes the place of a sample alone, so a picked instrument offers no replacement.""" + replace_coordinator._voices_panel.selection = PICKED_INSTRUMENT + + assert replace_coordinator.replace_target_label() is None @pytest.fixture -def history_coordinator() -> SequencerTabCoordinator: - """A coordinator with the two collaborators an undoable gesture reaches. +def history_coordinator(held_gate: HeldGate) -> SequencerTabCoordinator: + """A coordinator whose history is a mock, so a test reads what a delegation asked of it. - The 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. + The edits of the open reconstruction hold every gesture until the case releases the gate. """ instance = object.__new__(SequencerTabCoordinator) instance._history = MagicMock() - instance._project_controller = ProjectController(ProjectManager()) + instance._after_edits = held_gate + instance._reconstructions = 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. + """ + return SequencerHistoryRecorder( + MagicMock(), + ProjectController(ProjectManager()), + MagicMock(), + language_manager=MagicMock(), + ) + + @pytest.fixture def wired_history_coordinator( monkeypatch: pytest.MonkeyPatch, @@ -805,17 +1226,17 @@ def wired_history_coordinator( A real manager observes a real controller, and every project replacement — including the ones undo/redo drive — routes back through - ``_on_project_replaced``, exactly as ``_wire_callbacks`` sets it up. The + ``realign_with_project``, exactly as the application's fan-out reaches it. The channels logic is real too, since the handler decides its lifetime. The panel-refreshing ``refresh`` is stubbed since no GUI subtree exists here. """ instance = object.__new__(SequencerTabCoordinator) controller = ProjectController(ProjectManager()) - history = HistoryManager(controller, budget=10, strict=True) - controller.on_mutation = history.handle_mutation - controller.on_project_replaced = instance._on_project_replaced + history = wired_history(controller, budget=10, strict=True) + controller.on_project_replaced = instance.realign_with_project instance._project_controller = controller instance._history = history + instance._after_edits = lambda gesture: gesture() instance._sequencer_channels_logic = SequencerChannelsLogic() instance._sequencer_channels_logic.on_channels_changed = lambda _: None monkeypatch.setattr(instance, "refresh", MagicMock()) @@ -826,7 +1247,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 @@ -834,7 +1255,7 @@ def test_project_replacement_reseeds_history( controller.set_tempo(150) controller.replace_project( - snapshot_project(controller.project), + controller.project.snapshot(), clean=False, ) @@ -843,7 +1264,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 +1278,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 @@ -877,47 +1298,68 @@ class TestChannelMuteLifetime: """The mute set spans history navigation and starts fresh on a document transition. Both arrive as the controller's single ``on_project_replaced`` signal, so these pin the - distinction ``_on_project_replaced`` draws from ``HistoryManager.is_restoring``. + distinction ``realign_with_project`` draws from ``HistoryManager.is_restoring``. """ def test_undo_keeps_the_mute_set( 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_a_rolled_back_gesture_keeps_the_mute_set_and_the_stack( + self, + wired_history_coordinator: SequencerReconstructions, + ) -> None: + """A failed gesture reinstalls the state it started from the way an undo does.""" + coordinator = wired_history_coordinator + controller = coordinator._project_controller + history = coordinator._history + channels = coordinator._sequencer_channels_logic + with history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + channels.toggle(ChannelName.TRIANGLE) + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_SPEED): + controller.set_speed(4) + raise RuntimeError("the gesture failed") + + assert channels.active_channels == ALL_CHANNELS - {ChannelName.TRIANGLE} + assert [entry.action for entry in history.entries] == [HistoryAction.INITIAL, HistoryAction.SET_TEMPO] + assert (controller.project.settings.tempo, history.cursor) == (170, 1) 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 +1367,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 +1382,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 +1410,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 +1464,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 +1496,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 +1529,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 +1562,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 +1577,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,47 +1612,123 @@ 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() class TestHistoryDelegation: - def test_undo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator) -> None: + """Undo, redo and a jump reach the history once the edits of the open reconstruction before them have landed.""" + + def test_undo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator, held_gate: HeldGate) -> None: history_coordinator.undo() + history_coordinator._history.undo.assert_not_called() + + held_gate.release() history_coordinator._history.undo.assert_called_once_with() - def test_redo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator) -> None: + def test_redo_delegates_to_history(self, history_coordinator: SequencerTabCoordinator, held_gate: HeldGate) -> None: history_coordinator.redo() + history_coordinator._history.redo.assert_not_called() + + held_gate.release() history_coordinator._history.redo.assert_called_once_with() - def test_jump_delegates_to_history(self, history_coordinator: SequencerTabCoordinator) -> None: + def test_jump_delegates_to_history(self, history_coordinator: SequencerTabCoordinator, held_gate: HeldGate) -> None: history_coordinator.jump_to_history(3) + history_coordinator._history.jump_to.assert_not_called() + + held_gate.release() history_coordinator._history.jump_to.assert_called_once_with(3) + def test_replacing_a_sample_waits_for_the_edits_before_it( + self, + history_coordinator: SequencerTabCoordinator, + held_gate: HeldGate, + ) -> None: + """The sample may be the one open on the Reconstructions tab.""" + path = Path("incoming.stn") + history_coordinator.replace_reconstruction(path) + history_coordinator._reconstructions.replace_from_file.assert_not_called() + + held_gate.release() + + history_coordinator._reconstructions.replace_from_file.assert_called_once_with(path) + + def test_a_voice_gesture_reading_the_open_sample_waits_for_the_edits_before_it( + self, + history_coordinator: SequencerTabCoordinator, + held_gate: HeldGate, + ) -> None: + """A copy, an export or an instrument taken from the sample reads what the reader drew on it.""" + for name in ( + "_sequencer_voices_logic", + "_sequencer_voices_panel", + "_recorder", + "_history_detail", + "_voices", + "_instrument_exports", + ): + setattr(history_coordinator, name, MagicMock()) + duplicate = history_coordinator._recorder.undoable.return_value + with patch.object(history_coordinator, "add_instrument_from_channel") as instrument_from_channel: + history_coordinator._wire_voices_callbacks() + panel = history_coordinator._sequencer_voices_panel + panel.on_duplicate_requested("lead") + panel.on_export_instrument_requested("lead", ChannelName.PULSE1) + panel.on_instrument_from_channel_requested("lead", ChannelName.TRIANGLE) + duplicate.assert_not_called() + history_coordinator._instrument_exports.request_voice.assert_not_called() + instrument_from_channel.assert_not_called() + + held_gate.release() + + duplicate.assert_called_once_with("lead") + history_coordinator._instrument_exports.request_voice.assert_called_once_with("lead", ChannelName.PULSE1) + instrument_from_channel.assert_called_once_with("lead", ChannelName.TRIANGLE) + + def test_a_new_nes_frequency_waits_for_the_edits_before_it( + self, + history_coordinator: SequencerTabCoordinator, + held_gate: HeldGate, + ) -> None: + """The rate re-times the sample open on the Reconstructions tab, so it follows the edits made there.""" + history_coordinator._sequencer_module_panel = MagicMock() + history_coordinator._recorder = MagicMock() + history_coordinator._sequencer_tracker_logic = MagicMock() + history_coordinator._history_detail = MagicMock() + with patch.object(history_coordinator, "_request_nes_frequency_change") as request: + history_coordinator._wire_module_callbacks() + history_coordinator._sequencer_module_panel.on_nes_frequency(50) + request.assert_not_called() + + held_gate.release() + + request.assert_called_once_with(50) + class TestUndoableWrapper: - def test_wrapped_call_runs_inside_a_transaction(self, 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 +1737,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 +1757,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 +1776,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 +1787,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 +1795,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 @@ -1325,8 +1839,7 @@ def exposure_coordinator() -> SequencerTabCoordinator: instance._song_player_logic = MagicMock() instance._guarded_player = GuardedPlayer( instance._song_player_logic, - dialogs=MagicMock(), - error_message="playback failed", + failures=MagicMock(), ) return instance @@ -1339,31 +1852,86 @@ def test_player_returns_the_guarded_wrapper( assert isinstance(exposure_coordinator.player, GuardedPlayer) +@pytest.fixture(name="failures") +def failures_fixture() -> MagicMock: + return MagicMock() + + +@pytest.fixture(name="refusing_coordinator") +def refusing_coordinator_fixture(failures: MagicMock) -> SequencerTabCoordinator: + """A coordinator whose song player is refused by a machine offering no output device.""" + instance = object.__new__(SequencerTabCoordinator) + instance._song_player_logic = MagicMock() + instance._song_player_logic.play_from.side_effect = NoOutputDeviceError("no device") + instance._song_player_logic.is_playing.return_value = False + instance._sequencer_tracker_logic = MagicMock() + instance._frames = SequencerFrames( + MagicMock(), + instance._sequencer_tracker_logic, + instance._song_player_logic, + MagicMock(), + MagicMock(), + ) + instance._guarded_player = GuardedPlayer( + instance._song_player_logic, + failures=failures, + ) + return instance + + +class TestPlayingFromAPlaceWithNoOutput: + """Every way to play the song from a place reaches the presenter with the refusal, the way Play does.""" + + def test_play_from_this_frame_reaches_the_presenter( + self, + refusing_coordinator: SequencerTabCoordinator, + failures: MagicMock, + ) -> None: + refusing_coordinator.play_from_current_frame() + + failures.present_playing_failure.assert_called_once() + assert isinstance(failures.present_playing_failure.call_args.args[0], NoOutputDeviceError) + + def test_play_from_a_row_reaches_the_presenter( + self, + refusing_coordinator: SequencerTabCoordinator, + failures: MagicMock, + ) -> None: + refusing_coordinator._on_tracker_play_from_row(4) + + failures.present_playing_failure.assert_called_once() + assert isinstance(failures.present_playing_failure.call_args.args[0], NoOutputDeviceError) + + def test_play_from_an_order_frame_reaches_the_presenter( + self, + refusing_coordinator: SequencerTabCoordinator, + failures: MagicMock, + ) -> None: + refusing_coordinator._play_from_frame(1) + + failures.present_playing_failure.assert_called_once() + assert isinstance(failures.present_playing_failure.call_args.args[0], NoOutputDeviceError) + + 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, ) -class FakeTextClipboard: - """The desktop's clipboard, held in memory so a test reads what a copy put there.""" - - def __init__(self) -> None: - self.text: str = "" - - def read(self) -> str: - return self.text - - 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 @@ -1377,24 +1945,26 @@ def block_coordinator() -> SequencerTabCoordinator: """ instance = object.__new__(SequencerTabCoordinator) controller = ProjectController(ProjectManager()) - history = HistoryManager(controller, budget=10, strict=True) - controller.on_mutation = history.handle_mutation + history = wired_history(controller, budget=10, strict=True) controller.new() history.reset() 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, + history, + text_clipboard=FakeTextClipboard(), + ) instance._history_detail = SequencerHistoryDetail( instance._sequencer_tracker_logic, MagicMock(), @@ -1406,15 +1976,24 @@ 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, Step(value=transpose), None) + + +def _break_song_changes(coordinator: SequencerTabCoordinator) -> None: + """Has every song change fail as the views hear of it, the way a view rebuild that breaks would.""" + + def broken() -> None: + raise RuntimeError("the view broke") + + coordinator._project_controller.on_song_changed = broken class TestBlockCopy: @@ -1426,15 +2005,15 @@ 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, - transpose=5, + ChannelName.PULSE1, + pitch=Step(value=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 + assert block.pitches[(0, 1)] == Step(value=5) def test_a_copy_leaves_the_history_stack_as_it_stands( self, @@ -1445,7 +2024,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 +2042,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 block.pitches[(0, 1)] == Step(value=5) + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).pitch is None def test_a_cut_records_one_entry( self, @@ -1481,6 +2060,21 @@ def test_a_cut_records_one_entry( assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK + def test_a_cut_that_fails_leaves_both_clipboards_and_the_grid_as_they_stood( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + _break_song_changes(coordinator) + + with pytest.raises(RuntimeError): + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + assert coordinator._blocks._clipboard.tracker_block is None + assert _text_clipboard(coordinator).text == "" + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 0).pitch == Step(value=5) + def test_a_delete_empties_the_region_in_one_entry( self, block_coordinator: SequencerTabCoordinator, @@ -1491,7 +2085,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).pitch is None assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK @@ -1501,12 +2095,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).pitch == Step(value=5) assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK @@ -1523,7 +2117,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,10 +2131,24 @@ 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 + def test_a_cut_that_fails_leaves_both_clipboards_and_the_order_as_they_stood( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _break_song_changes(coordinator) + + with pytest.raises(RuntimeError): + coordinator._sequencer_order_panel.on_cut_block(PULSE1_FRAME) + + assert coordinator._blocks._clipboard.order_block is None + assert _text_clipboard(coordinator).text == "" + assert coordinator._sequencer_order_logic.entry(ChannelName.PULSE1, 0) == 0 + def test_a_delete_silences_the_region_in_one_entry( self, block_coordinator: SequencerTabCoordinator, @@ -1550,7 +2158,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 +2171,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 +2192,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 +2207,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 +2217,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 +2230,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 +2243,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).pitch == Step(value=9) def test_unrelated_text_leaves_the_copied_block_in_hand( self, @@ -1648,12 +2256,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).pitch == Step(value=5) def test_a_truncated_block_leaves_the_copied_block_in_hand( self, @@ -1661,12 +2269,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).pitch == Step(value=5) def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( self, @@ -1674,23 +2282,149 @@ 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( + def test_a_paste_offers_itself_on_the_text_the_clipboard_answered_with( self, block_coordinator: SequencerTabCoordinator, ) -> None: """The menu asks the same question the paste does, so it offers what the next press reaches.""" coordinator = block_coordinator + _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + assert not coordinator._blocks.can_paste_tracker() + + coordinator._sequencer_tracker_panel.refresh_paste_block(lambda: None) + + assert coordinator._blocks.can_paste_tracker() + assert not coordinator._blocks.can_paste_order() + + +class TestPasteAwaitsTheClipboard: + """The application holding the clipboard hands its text over in its own time, and a paste waits.""" + + def test_a_paste_writes_once_the_clipboard_answers( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + clipboard.hold_answers() + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).pitch is None + assert len(coordinator._history.entries) == recorded + + clipboard.answer() + + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).pitch == Step(value=9) + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + + def test_a_paste_writes_the_text_standing_when_the_clipboard_answers( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """What another application copies while the answer is on its way is what the paste lands.""" + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n03") + clipboard.hold_answers() + + coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=0)) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n05") + clipboard.answer() + + assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 0) == 5 + + def test_a_menu_s_offer_follows_the_answer_it_asked_for( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n03") + clipboard.hold_answers() + answered: List[bool] = [] + + coordinator._sequencer_order_panel.refresh_paste_block(lambda: answered.append(True)) + + assert not answered + assert not coordinator._blocks.can_paste_order() + + clipboard.answer() + + assert answered == [True] + assert coordinator._blocks.can_paste_order() + + def test_a_paste_the_clipboard_answers_nothing_to_writes_nothing( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """What the clipboard holds stays unknown, so the block copied here is not written in its place.""" + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + _place_transpose(coordinator, 5) + coordinator._blocks.copy_tracker(PULSE1_CELL) + clipboard.hold_answers() + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) + clipboard.silence() + + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).pitch is None + assert len(coordinator._history.entries) == recorded + + def test_a_menu_the_clipboard_answers_nothing_to_keeps_its_last_answer( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n03") + coordinator._sequencer_order_panel.refresh_paste_block(lambda: None) + clipboard.hold_answers() + answered: List[bool] = [] + + coordinator._sequencer_order_panel.refresh_paste_block(lambda: answered.append(True)) + clipboard.silence() + + assert answered == [True] + assert coordinator._blocks.can_paste_order() + + +class TestACellTakingFocus: + """A cell picked in the grid or the order takes the keyboard and leaves the voices' mark standing.""" + + def test_a_tracker_cell_keeps_the_marked_voice(self, history_coordinator: SequencerTabCoordinator) -> None: + history_coordinator._sequencer_order_panel = MagicMock() + history_coordinator._sequencer_voices_panel = MagicMock() + + history_coordinator._on_tracker_cell_focused() + + history_coordinator._sequencer_order_panel.deselect_cell.assert_called_once_with() + history_coordinator._sequencer_voices_panel.blur.assert_called_once_with() + history_coordinator._sequencer_voices_panel.deselect.assert_not_called() + + def test_an_order_cell_keeps_the_marked_voice(self, history_coordinator: SequencerTabCoordinator) -> None: + history_coordinator._sequencer_tracker_panel = MagicMock() + history_coordinator._sequencer_voices_panel = MagicMock() + + history_coordinator._on_order_cell_focused() - assert not coordinator._can_paste_tracker_block() + history_coordinator._sequencer_tracker_panel.deselect_cell.assert_called_once_with() + history_coordinator._sequencer_voices_panel.blur.assert_called_once_with() + history_coordinator._sequencer_voices_panel.deselect.assert_not_called() - coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + def test_the_grid_reads_the_mark_from_the_voices_list(self, history_coordinator: SequencerTabCoordinator) -> None: + history_coordinator._sequencer_voices_panel = MagicMock() - assert coordinator._can_paste_tracker_block() - assert not coordinator._can_paste_order_block() + assert history_coordinator._marked_voice() is history_coordinator._sequencer_voices_panel.selection diff --git a/tests/unit/sampletones_application/coordinators/test_config.py b/tests/unit/sampletones_application/coordinators/test_config.py index ace3c6c59..4ea2bd458 100644 --- a/tests/unit/sampletones_application/coordinators/test_config.py +++ b/tests/unit/sampletones_application/coordinators/test_config.py @@ -68,7 +68,7 @@ def test_recovered_outcome_shows_recovery_dialog( config_manager = _manager_with( ConfigRecovered( source_version="1.0.0", - dropped=(("generation", "drive"), ("obsolete_field",)), + dropped=(("generation", "reset_phase"), ("obsolete_field",)), ), config_path=config_path, ) @@ -80,7 +80,7 @@ def test_recovered_outcome_shows_recovery_dialog( kwargs = coordinator._dialogs.show_config_recovery.call_args.kwargs assert kwargs["config_path"] == config_path assert kwargs["source_version"] == "1.0.0" - assert set(kwargs["properties"]) == {"generation.drive", "obsolete_field"} + assert set(kwargs["properties"]) == {"generation.reset_phase", "obsolete_field"} coordinator._dialogs.show_error.assert_not_called() @pytest.mark.parametrize("case", reason_cases, ids=lambda case: case.label) diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py index 3776d0c2f..39e760d83 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -13,6 +13,7 @@ DisplaySettingsViewModel, WindowMode, ) +from sampletones_application.viewport import WindowPlacement from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution from sampletones_shared.types.callback import VoidCallback @@ -21,9 +22,14 @@ WIDESCREEN = Resolution(width=1600, height=900) DEFAULT_RESOLUTION = Resolution(width=1280, height=800) +HAND_SIZED = Resolution(width=1300, height=820) COUNTDOWN_SECONDS = 10.0 +OPENING_X = 120 +OPENING_Y = 90 +FULLSCREEN_PLACEMENT = WindowPlacement(x=0, y=0, resolution=Resolution(width=1920, height=1080)) + BEHAVIOR = DisplayBehavior( resolutions=( Resolution(width=1024, height=768), @@ -76,6 +82,7 @@ def __init__(self) -> None: self.vsync = True self.max_fps = 60 self.borderless = False + self.show_frame_rate = True self.fullscreen = False self.writes: List[Tuple[str, Any]] = [] @@ -95,6 +102,10 @@ def set_borderless(self, borderless: bool) -> None: self.writes.append(("borderless", borderless)) self.borderless = borderless + def set_show_frame_rate(self, show_frame_rate: bool) -> None: + self.writes.append(("show_frame_rate", show_frame_rate)) + self.show_frame_rate = show_frame_rate + class _ViewportRecorder: def __init__(self) -> None: @@ -102,6 +113,7 @@ def __init__(self) -> None: DEFAULT_RESOLUTION.width, DEFAULT_RESOLUTION.height, ) + self.position: Tuple[int, int] = (OPENING_X, OPENING_Y) self.fullscreen_toggles = 0 self.calls: List[Tuple[str, Any]] = [] @@ -109,6 +121,17 @@ def __init__(self) -> None: def monitor_area(self) -> MonitorArea: return MonitorArea(x=0, y=0, width=1920, height=1080, usable_ratio=0.9) + @property + def placement(self) -> WindowPlacement: + width, height = self.resolution + x, y = self.position + return WindowPlacement(x=x, y=y, resolution=Resolution(width=width, height=height)) + + def place(self, placement: WindowPlacement) -> None: + self.calls.append(("place", placement)) + self.position = (placement.x, placement.y) + self.resolution = (placement.resolution.width, placement.resolution.height) + def set_resolution(self, width: int, height: int) -> None: self.calls.append(("resolution", (width, height))) self.resolution = (width, height) @@ -120,8 +143,15 @@ def set_vsync(self, vsync: bool) -> None: self.calls.append(("vsync", vsync)) def toggle_fullscreen(self) -> None: + """Answers the reads DearPyGui gives before a frame: the window keeps the fullscreen place. + + Entering fullscreen is read once a frame is drawn, and leaving it is read only on the next + frame, so a toggle back with no frame between leaves the fullscreen place standing. + """ self.fullscreen_toggles += 1 self.calls.append(("fullscreen", self.fullscreen_toggles)) + self.position = (FULLSCREEN_PLACEMENT.x, FULLSCREEN_PLACEMENT.y) + self.resolution = (FULLSCREEN_PLACEMENT.resolution.width, FULLSCREEN_PLACEMENT.resolution.height) class _FrameLimiterRecorder: @@ -132,6 +162,14 @@ def set_max_fps(self, max_fps: int) -> None: self.rates.append(max_fps) +class _FrameRateReadingRecorder: + def __init__(self) -> None: + self.shown: List[bool] = [] + + def show_frame_rate(self, shown: bool) -> None: + self.shown.append(shown) + + class _WindowRecorder: """Stands in for the dialog window, with the modal hand-off collapsed to a direct call. @@ -211,6 +249,7 @@ def __init__(self) -> None: self.session = _SessionRecorder() self.viewport = _ViewportRecorder() self.frame_limiter = _FrameLimiterRecorder() + self.frame_rate_reading = _FrameRateReadingRecorder() self.palette_source = _PaletteSourceRecorder() self.window = _WindowRecorder() self.countdown = _CountdownRecorder() @@ -221,6 +260,7 @@ def __init__(self) -> None: self.frame_limiter, self.palette_source, _PaletteCatalogRecorder(), + frame_rate_reading=self.frame_rate_reading, window=self.window, countdown=self.countdown, behavior=BEHAVIOR, @@ -250,6 +290,9 @@ def revert(self) -> None: def elapse(self, seconds: float) -> None: self.coordinator.tick(seconds) + def end_the_run(self) -> None: + self.coordinator.cleanup() + @property def settings(self) -> DisplaySettings: return self.window.settings @@ -269,6 +312,7 @@ def test_the_dialog_shows_the_settings_in_force(self, harness: Harness) -> None: window=WindowMode(resolution=DEFAULT_RESOLUTION, borderless=False, fullscreen=False), vsync=True, frame_rate=60, + show_frame_rate=True, ) def test_only_the_sizes_the_monitor_leaves_room_for_are_offered(self, harness: Harness) -> None: @@ -294,6 +338,16 @@ def test_vsync_reaches_the_viewport_the_moment_it_is_switched(self, harness: Har assert ("vsync", False) in harness.viewport.calls + def test_the_frame_rate_reading_leaves_the_bar_the_moment_it_is_switched_off(self, harness: Harness) -> None: + harness.change(harness.settings.with_show_frame_rate(False)) + + assert harness.frame_rate_reading.shown == [False] + + def test_a_switch_left_as_it_was_reaches_no_reading(self, harness: Harness) -> None: + harness.change(harness.settings.with_vsync(False)) + + assert harness.frame_rate_reading.shown == [] + def test_a_size_reaches_the_viewport_the_moment_it_is_picked(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) @@ -331,6 +385,7 @@ def test_confirming_writes_every_setting_to_the_session(self, harness: Harness) "vsync": True, "max_fps": 120, "borderless": False, + "show_frame_rate": True, } def test_confirming_closes_the_dialog(self, harness: Harness) -> None: @@ -351,13 +406,13 @@ def test_confirming_while_the_clock_runs_keeps_the_change_and_stops_the_clock( class TestCancel: - def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: + def test_canceling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: harness.cancel() assert harness.dialogs.confirmations == [] assert not harness.window.visible - def test_cancelling_a_changed_dialog_asks_first(self, harness: Harness) -> None: + def test_canceling_a_changed_dialog_asks_first(self, harness: Harness) -> None: harness.change(harness.settings.with_palette(DARK)) harness.cancel() @@ -391,11 +446,19 @@ def test_discarding_puts_back_the_window_mode_the_dialog_opened_with(self, harne harness.cancel() harness.dialogs.confirm() - assert harness.viewport.calls[-1] == ( - "resolution", - (DEFAULT_RESOLUTION.width, DEFAULT_RESOLUTION.height), + assert harness.viewport.placement == WindowPlacement( + x=OPENING_X, + y=OPENING_Y, + resolution=DEFAULT_RESOLUTION, ) + def test_discarding_puts_the_frame_rate_reading_back_on_the_bar(self, harness: Harness) -> None: + harness.change(harness.settings.with_show_frame_rate(False)) + harness.cancel() + harness.dialogs.confirm() + + assert harness.frame_rate_reading.shown == [False, True] + def test_discarding_writes_nothing_to_the_session(self, harness: Harness) -> None: harness.change(harness.settings.with_vsync(False)) harness.cancel() @@ -404,6 +467,88 @@ def test_discarding_writes_nothing_to_the_session(self, harness: Harness) -> Non assert harness.session.writes == [] +class TestCancelingAHandSizedWindow: + """A window sitting at a size of its own goes back to that exact size and place on Cancel and when the + countdown puts a change back. + + The dialog shows the offered size nearest the window's own, and a discard puts back the opening + place and size the run ending while the dialog stands puts back. The countdown puts back the window + as it stood before the change it guards. A dialog opened in fullscreen goes back to fullscreen and is + placed nowhere. + """ + + OPENING_PLACEMENT = WindowPlacement(x=OPENING_X, y=OPENING_Y, resolution=HAND_SIZED) + + @pytest.fixture(name="hand_sized") + def hand_sized_fixture(self) -> Harness: + """The dialog opened over a window the user sized by hand, between the sizes on offer.""" + harness = Harness() + harness.viewport.resolution = (HAND_SIZED.width, HAND_SIZED.height) + harness.open() + return harness + + def test_the_dialog_shows_the_offered_size_nearest_the_window(self, hand_sized: Harness) -> None: + assert hand_sized.settings.window.resolution == DEFAULT_RESOLUTION + + def test_discarding_a_new_size_puts_back_the_window_s_own(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.keep() + hand_sized.cancel() + hand_sized.dialogs.confirm() + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + assert not hand_sized.window.visible + + def test_canceling_an_untouched_dialog_leaves_the_window_s_own_size(self, hand_sized: Harness) -> None: + hand_sized.cancel() + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + + def test_discarding_fullscreen_puts_back_the_window_s_own_place(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_fullscreen(True))) + hand_sized.keep() + hand_sized.cancel() + hand_sized.dialogs.confirm() + + assert hand_sized.viewport.fullscreen_toggles == 2 + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + + def test_the_clock_running_out_puts_back_the_window_s_own(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.elapse(COUNTDOWN_SECONDS) + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + assert hand_sized.window.visible + + def test_reverting_puts_back_the_window_s_own(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.revert() + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + + def test_a_kept_size_is_what_a_later_clock_puts_back(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.keep() + kept = hand_sized.viewport.placement + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_borderless(True))) + hand_sized.elapse(COUNTDOWN_SECONDS) + + assert hand_sized.viewport.placement == kept + assert kept.resolution == WIDESCREEN + + def test_a_dialog_opened_in_fullscreen_goes_back_to_fullscreen(self) -> None: + harness = Harness() + harness.session.fullscreen = True + harness.open() + harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(False))) + harness.keep() + harness.cancel() + harness.dialogs.confirm() + + assert harness.viewport.fullscreen_toggles == 2 + assert all(name != "place" for name, _ in harness.viewport.calls) + + class TestCountdown: def test_a_window_mode_change_starts_the_clock(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) @@ -504,13 +649,14 @@ def test_keeping_stops_the_clock_and_leaves_the_change_standing(self, harness: H assert not harness.countdown.visible assert harness.settings.window.borderless is True - def test_a_kept_change_is_still_undone_by_cancelling(self, harness: Harness) -> None: + def test_a_kept_change_is_still_undone_by_canceling(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) harness.keep() + made_before_canceling = len(harness.viewport.calls) harness.cancel() harness.dialogs.confirm() - assert harness.viewport.calls[-1] == ("borderless", False) + assert ("borderless", False) in harness.viewport.calls[made_before_canceling:] def test_reverting_by_hand_puts_the_window_mode_back_at_once(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) @@ -541,3 +687,79 @@ def test_editing_a_closed_dialog_is_refused(self, harness: Harness) -> None: with pytest.raises(SystemError): harness.change(settings) + + +class TestARunEndingWhileTheDialogStands: + """A run that ends while the dialog stands puts back the display it opened with, as Cancel does. + + The session keeps the opening values until OK, and leaving records the window as it stands, so + the window returns to its opening place and size first. A change kept on the countdown is + undone the same way, and a dialog already closed leaves the display as it is. + """ + + OPENING_PLACEMENT = WindowPlacement(x=OPENING_X, y=OPENING_Y, resolution=DEFAULT_RESOLUTION) + + def test_a_size_kept_on_the_countdown_goes_back(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) + harness.keep() + + harness.end_the_run() + + assert harness.viewport.placement == self.OPENING_PLACEMENT + + def test_fullscreen_kept_on_the_countdown_goes_back_to_the_opening_place(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(True))) + harness.keep() + + harness.end_the_run() + + assert harness.viewport.fullscreen_toggles == 2 + assert harness.viewport.placement == self.OPENING_PLACEMENT + + def test_a_dialog_opened_in_fullscreen_goes_back_to_fullscreen(self) -> None: + harness = Harness() + harness.session.fullscreen = True + harness.open() + harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(False))) + harness.keep() + + harness.end_the_run() + + assert harness.viewport.fullscreen_toggles == 2 + assert all(name != "place" for name, _ in harness.viewport.calls) + + def test_the_palette_goes_back_and_nothing_is_written(self, harness: Harness) -> None: + harness.change(harness.settings.with_palette(DARK)) + + harness.end_the_run() + + assert harness.palette_source.activated == [DARK, STUDIO] + assert harness.session.writes == [] + assert not harness.window.visible + + def test_a_countdown_running_at_the_end_is_stopped(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) + + harness.end_the_run() + + assert not harness.countdown.visible + assert harness.viewport.calls[-1] == ("place", self.OPENING_PLACEMENT) + + def test_a_confirmed_change_stays(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) + harness.keep() + harness.commit() + calls = list(harness.viewport.calls) + + harness.end_the_run() + + assert harness.viewport.calls == calls + assert harness.viewport.resolution == (WIDESCREEN.width, WIDESCREEN.height) + + def test_a_dialog_never_opened_leaves_the_display_alone(self) -> None: + harness = Harness() + + harness.end_the_run() + + assert harness.viewport.calls == [] + assert harness.palette_source.activated == [] diff --git a/tests/unit/sampletones_application/coordinators/test_failures.py b/tests/unit/sampletones_application/coordinators/test_failures.py new file mode 100644 index 000000000..cb4cf7ff9 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/test_failures.py @@ -0,0 +1,27 @@ +from typing import Final +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.failures import UnhandledFailurePresenter +from sampletones_application.paths import LANG_EN + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +UNEXPECTED_FAILURE: Final[str] = "global.dialog.message.unexpected_failure" + + +@pytest.fixture(name="dialogs") +def dialogs_fixture() -> MagicMock: + return MagicMock() + + +class TestAnUnhandledFailure: + """A failure nothing recovered from reads as a report opening with the line that the action stopped.""" + + def test_it_reads_as_a_failure_report(self, dialogs: MagicMock) -> None: + failure = RuntimeError("it went wrong") + + UnhandledFailurePresenter(dialogs=dialogs, language_manager=LANGUAGE_MANAGER).present(failure) + + dialogs.show_failure_report.assert_called_once_with(failure, LANGUAGE_MANAGER[UNEXPECTED_FAILURE]) diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py index 56ee13d14..4aa92485d 100644 --- a/tests/unit/sampletones_application/coordinators/test_keybindings.py +++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py @@ -2,10 +2,16 @@ import pytest +from sampletones_application.categories.elements.settings import KeybindingActionElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.paths import LANG_EN -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KEY_LIST_JOINER, + KeyCombination, + display_combinations, +) from sampletones_application.utils.gui.shortcuts.ids import ( EDITABLE_SHORTCUT_CATEGORIES, ShortcutId, @@ -27,7 +33,32 @@ UNDO_COMBINATION: Final[str] = "Ctrl+Z" REDO_COMBINATION: Final[str] = "Ctrl+Y" FREE_COMBINATION: Final[str] = "Ctrl+Alt+B" +SECOND_FREE_COMBINATION: Final[str] = "Ctrl+Alt+N" +COMMA_COMBINATION: Final[str] = "Ctrl+," UNREADABLE_COMBINATION: Final[str] = "Ctrl+Nonsense" +FREE_KEYS: Final[str] = KEY_LIST_JOINER.join((FREE_COMBINATION, SECOND_FREE_COMBINATION)) +NO_KEYS: Final[str] = "" +TWO_HOLDERS: Final[Tuple[ShortcutId, ...]] = (ShortcutId.SAVE_PROJECT, ShortcutId.OPEN_PROJECT) + + +def _action_label(shortcut_id: ShortcutId) -> str: + """The name the editor lists an action under, read from the language file.""" + return LanguageManager(LANG_EN)[ + Page.SETTINGS, + Panel.KEYBINDINGS, + TextType.LABEL, + KeybindingActionElements[shortcut_id.name], + ] + + +def _unbound_action() -> str: + """An action the shipped scheme gives no keys.""" + scheme = shipped_scheme() + return next( + shortcut_id.value + for shortcut_id in ShortcutId + if shortcut_id.category in EDITABLE_SHORTCUT_CATEGORIES and not scheme.shortcut(shortcut_id).combinations() + ) class _SessionRecorder: @@ -61,6 +92,7 @@ class _WindowRecorder: def __init__(self) -> None: self.view_models: List[KeybindingsViewModel] = [] self.visible = False + self.listening = False self.on_scheme_selected: Any = None self.on_action_selected: Any = None self.on_combination_typed: Any = None @@ -84,6 +116,10 @@ def yield_to(self, raise_modal: VoidCallback) -> None: def resume(self) -> None: self.visible = True + def listen_again(self) -> None: + self.visible = True + self.listening = True + def hide(self) -> None: self.visible = False @@ -168,10 +204,10 @@ def harness_fixture() -> Harness: class TestOpening: def test_the_dialog_shows_the_keys_in_force(self, harness: Harness) -> None: - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION def test_an_unbound_action_is_listed_carrying_no_keys(self, harness: Harness) -> None: - assert harness.row(ABOUT_DIALOG).combination == "" + assert harness.row(ABOUT_DIALOG).keys == "" def test_every_editable_scope_is_listed(self, harness: Harness) -> None: assert tuple(group.category for group in harness.window.view_model.groups) == tuple( @@ -187,9 +223,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 @@ -199,7 +235,7 @@ def test_the_stored_preference_reaches_the_dialog(self) -> None: harness.session.shortcut_overrides = {SAVE_PROJECT: FREE_COMBINATION} harness.open() - assert harness.row(SAVE_PROJECT).combination == FREE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == FREE_COMBINATION class TestSelection: @@ -207,12 +243,12 @@ def test_selecting_an_action_puts_its_keys_in_the_entry_box(self, harness: Harne harness.select(SAVE_PROJECT) assert harness.window.view_model.selected == SAVE_PROJECT - assert harness.window.view_model.combination == SAVE_COMBINATION + assert harness.window.view_model.keys == SAVE_COMBINATION def test_an_unbound_action_leaves_the_entry_box_empty(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) - assert harness.window.view_model.combination == "" + assert harness.window.view_model.keys == "" def test_a_combination_arriving_with_nothing_selected_is_refused(self, harness: Harness) -> None: with pytest.raises(SystemError): @@ -224,26 +260,26 @@ def test_a_written_combination_reaches_the_action(self, harness: Harness) -> Non harness.select(ABOUT_DIALOG) harness.type_combination(FREE_COMBINATION) - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_a_written_combination_reads_back_the_way_it_is_displayed(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.type_combination("shift+ctrl+alt+b") - assert harness.row(ABOUT_DIALOG).combination == "Ctrl+Alt+Shift+B" + assert harness.row(ABOUT_DIALOG).keys == "Ctrl+Alt+Shift+B" def test_a_captured_press_reaches_the_action(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.capture(FREE_COMBINATION) - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_a_combination_naming_no_key_is_reported_and_the_keys_stand(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) harness.type_combination(UNREADABLE_COMBINATION) assert UNREADABLE_COMBINATION in harness.window.view_model.message - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION def test_a_later_assignment_clears_the_message(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) @@ -266,7 +302,7 @@ def test_assigning_keys_another_action_holds_asks_first(self, harness: Harness) harness.type_combination(SAVE_COMBINATION) assert len(harness.dialogs.confirmations) == 1 - assert harness.row(ABOUT_DIALOG).combination == "" + assert harness.row(ABOUT_DIALOG).keys == "" def test_the_prompt_names_the_action_the_keys_are_taken_from(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) @@ -285,8 +321,8 @@ def test_confirming_takes_the_keys_and_leaves_the_holder_unbound(self, harness: harness.type_combination(SAVE_COMBINATION) harness.dialogs.confirm() - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION - assert harness.row(SAVE_PROJECT).combination == "" + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == "" assert harness.window.visible def test_declining_leaves_both_actions_on_the_keys_they_had(self, harness: Harness) -> None: @@ -294,25 +330,59 @@ def test_declining_leaves_both_actions_on_the_keys_they_had(self, harness: Harne harness.type_combination(SAVE_COMBINATION) harness.dialogs.decline() - assert harness.row(ABOUT_DIALOG).combination == "" - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == "" + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION assert harness.window.visible - def test_an_alias_another_action_answers_is_taken_the_same_way(self, harness: Harness) -> None: - """Redo answers Ctrl+Shift+Z beside its own keys, which an assignment takes with them.""" + def test_declining_a_pressed_combination_brings_its_row_back_listening(self, harness: Harness) -> None: + """The reader declined the keys they pressed, so the row waits for the press they meant.""" harness.select(ABOUT_DIALOG) - harness.type_combination("Ctrl+Shift+Z") + harness.capture(SAVE_COMBINATION) + harness.dialogs.decline() + + assert harness.window.visible + assert harness.window.listening + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION + + def test_declining_a_written_combination_brings_the_dialog_back_listening_for_nothing( + self, + harness: Harness, + ) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(SAVE_COMBINATION) + harness.dialogs.decline() + + assert harness.window.visible + assert not harness.window.listening + + def test_confirming_a_pressed_combination_brings_the_dialog_back_listening_for_nothing( + self, + harness: Harness, + ) -> None: + harness.select(ABOUT_DIALOG) + harness.capture(SAVE_COMBINATION) harness.dialogs.confirm() - assert harness.row(ABOUT_DIALOG).combination == "Ctrl+Shift+Z" - assert harness.row(ShortcutId.REDO.value).combination == "" + assert harness.window.visible + assert not harness.window.listening + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION + + def test_an_alias_another_action_answers_is_taken_alone(self, harness: Harness) -> None: + """Redo answers an alias beside its main key, and taking the alias leaves Redo its main key.""" + redo = shipped_scheme().shortcut(ShortcutId.REDO) + harness.select(ABOUT_DIALOG) + harness.type_combination(redo.aliases[0].display()) + harness.dialogs.confirm() + + assert harness.row(ABOUT_DIALOG).keys == redo.aliases[0].display() + assert harness.row(REDO).keys == redo.display() def test_the_keys_an_action_already_answers_are_assigned_without_asking(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) harness.type_combination(SAVE_COMBINATION) assert harness.dialogs.confirmations == [] - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION class TestClear: @@ -320,7 +390,7 @@ def test_clearing_leaves_the_action_unbound(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) harness.clear() - assert harness.row(SAVE_PROJECT).combination == "" + assert harness.row(SAVE_PROJECT).keys == "" def test_the_keys_a_cleared_action_held_are_free_to_take(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) @@ -329,7 +399,7 @@ def test_the_keys_a_cleared_action_held_are_free_to_take(self, harness: Harness) harness.type_combination(SAVE_COMBINATION) assert harness.dialogs.confirmations == [] - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION class TestReset: @@ -347,7 +417,7 @@ def test_confirming_puts_every_action_back_on_the_shipped_keys(self, harness: Ha harness.reset() harness.dialogs.confirm() - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION assert harness.window.visible def test_declining_leaves_the_edits_standing(self, harness: Harness) -> None: @@ -356,7 +426,7 @@ def test_declining_leaves_the_edits_standing(self, harness: Harness) -> None: harness.reset() harness.dialogs.decline() - assert harness.row(SAVE_PROJECT).combination == FREE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == FREE_COMBINATION def test_a_reset_stores_no_overrides(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) @@ -411,18 +481,18 @@ def test_a_stored_preference_reopens_on_the_keys_it_stored(self, harness: Harnes harness.commit() harness.open() - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION - assert harness.row(SAVE_PROJECT).combination == "" + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == "" class TestCancel: - def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: + def test_canceling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: harness.cancel() assert harness.dialogs.confirmations == [] assert not harness.window.visible - def test_cancelling_an_edited_dialog_asks_first(self, harness: Harness) -> None: + def test_canceling_an_edited_dialog_asks_first(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.type_combination(FREE_COMBINATION) harness.cancel() @@ -437,7 +507,7 @@ def test_keeping_the_edit_brings_the_dialog_back(self, harness: Harness) -> None harness.dialogs.decline() assert harness.window.visible - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_discarding_leaves_the_keys_in_force_alone(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) @@ -463,7 +533,7 @@ def test_choosing_the_scheme_already_open_leaves_the_edits_standing(self, harnes harness.type_combination(FREE_COMBINATION) harness.select_scheme(shipped_scheme().name) - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_an_unknown_scheme_falls_back_to_the_one_the_build_defaults_to(self, harness: Harness) -> None: harness.select_scheme("nonexistent") @@ -484,8 +554,8 @@ def traded_fixture(self, harness: Harness) -> Harness: return harness def test_each_action_arrives_at_the_keys_the_other_left(self, traded: Harness) -> None: - assert traded.row(UNDO).combination == REDO_COMBINATION - assert traded.row(REDO).combination == UNDO_COMBINATION + assert traded.row(UNDO).keys == REDO_COMBINATION + assert traded.row(REDO).keys == UNDO_COMBINATION def test_the_traded_keys_reach_the_scheme_put_in_force(self, traded: Harness) -> None: traded.commit() @@ -497,5 +567,103 @@ def test_a_stored_trade_reopens_on_the_keys_it_stored(self, traded: Harness) -> traded.commit() traded.open() - assert traded.row(UNDO).combination == REDO_COMBINATION - assert traded.row(REDO).combination == UNDO_COMBINATION + assert traded.row(UNDO).keys == REDO_COMBINATION + assert traded.row(REDO).keys == UNDO_COMBINATION + + +class TestEveryKey: + """An action answers a list of keys, its main key first, and the editor keeps every one of them.""" + + def test_a_typed_list_becomes_every_key_of_the_action(self, harness: Harness) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(FREE_KEYS) + + assert harness.row(ABOUT_DIALOG).keys == FREE_KEYS + + def test_a_pressed_key_leads_and_the_other_keys_stay(self, harness: Harness) -> None: + redo = shipped_scheme().shortcut(ShortcutId.REDO) + harness.select(REDO) + harness.capture(FREE_COMBINATION) + + assert harness.row(REDO).keys == display_combinations( + (KeyCombination.parse(FREE_COMBINATION), *redo.combinations()) + ) + + def test_pressing_an_own_alias_moves_it_to_the_front_without_asking(self, harness: Harness) -> None: + redo = shipped_scheme().shortcut(ShortcutId.REDO) + harness.select(REDO) + harness.capture(redo.aliases[0].display()) + + assert harness.dialogs.confirmations == [] + assert harness.row(REDO).keys == display_combinations((*redo.aliases, redo.combinations()[0])) + + def test_a_typed_list_taking_keys_from_two_actions_asks_once_naming_both(self, harness: Harness) -> None: + scheme = shipped_scheme() + taken = display_combinations(scheme.shortcut(shortcut_id).combinations()[0] for shortcut_id in TWO_HOLDERS) + harness.select(ABOUT_DIALOG) + harness.type_combination(taken) + + assert len(harness.dialogs.confirmations) == 1 + assert all( + _action_label(shortcut_id) in harness.dialogs.confirmations[-1]["message"] for shortcut_id in TWO_HOLDERS + ) + + def test_confirming_a_list_taken_from_two_actions_moves_both_keys(self, harness: Harness) -> None: + scheme = shipped_scheme() + taken = display_combinations(scheme.shortcut(shortcut_id).combinations()[0] for shortcut_id in TWO_HOLDERS) + harness.select(ABOUT_DIALOG) + harness.type_combination(taken) + harness.dialogs.confirm() + + assert harness.row(ABOUT_DIALOG).keys == taken + assert all(harness.row(shortcut_id.value).keys == "" for shortcut_id in TWO_HOLDERS) + + def test_clearing_unbinds_every_key(self, harness: Harness) -> None: + harness.select(REDO) + harness.clear() + + assert harness.row(REDO).keys == "" + + def test_an_empty_entry_unbinds_a_bound_action(self, harness: Harness) -> None: + harness.select(SAVE_PROJECT) + harness.type_combination(NO_KEYS) + harness.commit() + + assert dict(harness.session.writes)["overrides"] == {SAVE_PROJECT: None} + + def test_an_empty_entry_for_an_unbound_action_leaves_nothing_to_discard(self, harness: Harness) -> None: + harness.select(_unbound_action()) + harness.type_combination(NO_KEYS) + harness.cancel() + + assert (harness.dialogs.confirmations, harness.window.visible) == ([], False) + + def test_clearing_an_unbound_action_leaves_nothing_to_discard(self, harness: Harness) -> None: + harness.select(_unbound_action()) + harness.clear() + harness.cancel() + + assert (harness.dialogs.confirmations, harness.window.visible) == ([], False) + + def test_a_typed_comma_key_reaches_the_action(self, harness: Harness) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(COMMA_COMBINATION) + + assert (harness.window.view_model.message, harness.row(ABOUT_DIALOG).keys) == ( + "", + KeyCombination.parse(COMMA_COMBINATION).display(), + ) + + def test_a_list_is_stored_as_written(self, harness: Harness) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(FREE_KEYS) + harness.commit() + + assert dict(harness.session.writes)["overrides"] == {ABOUT_DIALOG: FREE_KEYS} + + def test_a_list_naming_no_key_in_one_place_is_reported_and_the_keys_stand(self, harness: Harness) -> None: + harness.select(SAVE_PROJECT) + harness.type_combination(KEY_LIST_JOINER.join((FREE_COMBINATION, UNREADABLE_COMBINATION))) + + assert UNREADABLE_COMBINATION in harness.window.view_model.message + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index e811e956b..056170386 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -1,10 +1,27 @@ from dataclasses import dataclass from pathlib import Path +from typing import Callable, Final, Optional from unittest.mock import MagicMock import pytest +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.categories.skipped import MAX_REPORTED_ROWS +from sampletones_application.coordinators import project as project_module from sampletones_application.coordinators.project import ProjectCoordinator +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.paths import LANG_EN +from sampletones_application.services.export.kind import ExportKind +from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.utils.callbacks.gates import Gate +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.exports.format import ExportFormat +from sampletones_core.project import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings from sampletones_shared.exceptions import ( IncompatibleProjectVersionError, IncorrectReconstructionDataError, @@ -13,8 +30,22 @@ NotAValidArchiveError, UnhandledProjectError, ) +from sampletones_shared.paths.extensions import EXT_FILE_MODULE from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import held_frames +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) +from tests.suite.silent_rows import MISSING_VOICE_ID, SILENT_CHANNEL + +__all__ = ["held_frames", "standing_window"] + +ORIGINAL_TITLE: Final[str] = "As saved" +EDITED_TITLE: Final[str] = "As edited" @pytest.fixture @@ -24,25 +55,90 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), MagicMock(), MagicMock(), - tracker_backends={}, - dialogs=MagicMock(), + export_backends={}, + format_setups={}, + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), ) -class TestProjectRestoreSuccess: - def test_loads_and_keeps_session_pointer( +class TestAProjectOpenedAtStart: + """A project a run starts on stands for its file, as one opened by hand does. + + The session remembers the file, and Save writes there at once. A project the run never opened + asks where to save it. + """ + + @pytest.fixture(name="save_dialog") + def save_dialog_fixture(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + dialog = MagicMock(return_value=None) + monkeypatch.setattr(project_module, "save_file_dialog", dialog) + return dialog + + @pytest.fixture(name="saved_project") + def saved_project_fixture(self, tmp_path: Path) -> Path: + """A project file on disk, titled so a save can be told from the file as it was.""" + path = tmp_path / "song.stp" + manager = ProjectManager() + manager.current.info.title = ORIGINAL_TITLE + manager.save(path) + return path + + @pytest.fixture(name="starting") + def starting_fixture(self, tmp_path: Path) -> ProjectCoordinator: + """A coordinator over a real project manager, its session remembering nothing yet.""" + project_manager = ProjectManager() + session_manager = MagicMock() + session_manager.current_project = None + session_manager.get_project_path.return_value = tmp_path + return ProjectCoordinator( + ProjectController(project_manager), + project_manager, + session_manager, + MagicMock(), + export_backends={}, + format_setups={}, + dialogs=dialogs_on_the_line(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + ) + + def test_the_session_remembers_its_file(self, starting: ProjectCoordinator, saved_project: Path) -> None: + starting.load_project_safely(saved_project) + + starting._session_manager.set_current_project.assert_called_once_with(saved_project) + + def test_save_writes_its_file_without_asking( self, - project_coordinator: ProjectCoordinator, + starting: ProjectCoordinator, + saved_project: Path, + save_dialog: MagicMock, ) -> None: - path = Path("song.stp") + starting.load_project_safely(saved_project) + starting._project_controller.set_title(EDITED_TITLE) - project_coordinator.load_project_safely(path) + assert starting.save() is SaveOutcome.WRITTEN - project_coordinator._project_controller.load.assert_called_once_with(path) - project_coordinator._session_manager.set_current_project.assert_not_called() + save_dialog.assert_not_called() + assert ProjectContainer.load(saved_project).info.title == EDITED_TITLE + + def test_a_new_project_asks_where_to_save( + self, + starting: ProjectCoordinator, + saved_project: Path, + save_dialog: MagicMock, + ) -> None: + starting.load_project_safely(saved_project) + starting.new_project() + + assert starting.save() is SaveOutcome.CALLED_OFF + + save_dialog.assert_called_once() + starting._session_manager.set_current_project.assert_called_with(None) + assert ProjectContainer.load(saved_project).info.title == ORIGINAL_TITLE class TestProjectRestoreAbsorbsFailures(BaseTestSuite): @@ -152,3 +248,569 @@ def test_manual_load_shows_error_dialog( project_coordinator._dialogs.show_error.assert_called_once_with(test_case.failure) project_coordinator._session_manager.set_current_project.assert_not_called() + + +class TestAFormatWithASetupOpensIt: + """A format asking for its own choices opens its setup where the save dialog would, and every + other format asks for the destination alone.""" + + @pytest.fixture + def setup(self) -> MagicMock: + return MagicMock() + + @pytest.fixture + def save_dialog(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + dialog = MagicMock(return_value=None) + monkeypatch.setattr(project_module, "save_file_dialog", dialog) + return dialog + + @pytest.fixture + def coordinator(self, setup: MagicMock) -> ProjectCoordinator: + backend = MagicMock() + backend.extension.return_value = EXT_FILE_MODULE + return ProjectCoordinator( + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + export_backends={ExportFormat.FAMITRACKER: backend}, + format_setups={ExportFormat.NSF: setup}, + dialogs=dialogs_on_the_line(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + ) + + def test_a_format_with_a_setup_opens_it( + self, + coordinator: ProjectCoordinator, + setup: MagicMock, + save_dialog: MagicMock, + ) -> None: + coordinator.export_project_dialog(ExportFormat.NSF) + + setup.open_project.assert_called_once_with() + save_dialog.assert_not_called() + + def test_a_format_asking_for_the_file_alone_opens_the_save_dialog( + self, + coordinator: ProjectCoordinator, + setup: MagicMock, + save_dialog: MagicMock, + ) -> None: + coordinator.export_project_dialog(ExportFormat.FAMITRACKER) + + save_dialog.assert_called_once() + setup.open_project.assert_not_called() + + def test_a_closed_project_opens_nothing( + self, + coordinator: ProjectCoordinator, + setup: MagicMock, + save_dialog: MagicMock, + ) -> None: + coordinator._project_controller.is_open = False + + coordinator.export_project_dialog(ExportFormat.NSF) + + setup.open_project.assert_not_called() + save_dialog.assert_not_called() + + +@pytest.fixture(name="coordinator") +def coordinator_fixture() -> ProjectCoordinator: + """A coordinator reporting a finished export, in the shipped language.""" + project_manager = MagicMock() + project_manager.current = Project.create(title="Demo", author="Tester", settings=ProjectSettings()) + return ProjectCoordinator( + MagicMock(), + project_manager, + MagicMock(), + MagicMock(), + export_backends={}, + format_setups={}, + dialogs=dialogs_on_the_line(), + language_manager=LanguageManager(LANG_EN), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + ) + + +def announced(coordinator: ProjectCoordinator) -> str: + """The message the dialog announcing an export shows.""" + message: str = coordinator._dialogs.show_info.call_args.args[1] + return message + + +class TestAWrittenProjectReportsTheRowsLeftSilent: + """A project holding rows that name a voice with no instrument still exports, and the dialog + announcing it lists those rows where the reader finds them in the tracker.""" + + @staticmethod + def _success(skipped_rows: tuple[SkippedRow, ...]) -> ExportSuccess: + return ExportSuccess( + kind=ExportKind.PROJECT, + filepath=Path("song.ftm"), + export_format=ExportFormat.FAMITRACKER, + truncation=None, + skipped_rows=skipped_rows, + ) + + @staticmethod + def _row(index: int) -> SkippedRow: + return SkippedRow( + voice_id=MISSING_VOICE_ID, + channel=SILENT_CHANNEL, + order_position=3, + row_index=index, + reason=SkipReason.NO_INSTRUMENT, + ) + + def test_a_project_with_an_instrument_for_every_row_announces_the_export_alone( + self, + coordinator: ProjectCoordinator, + ) -> None: + coordinator._on_export_result(self._success(NO_SKIPPED_ROWS)) + + assert announced(coordinator) == LanguageManager(LANG_EN)["global.dialog.message.project_exported_successfully"] + + def test_the_rows_follow_the_announcement( + self, + coordinator: ProjectCoordinator, + ) -> None: + coordinator._on_export_result(self._success((self._row(26),))) + + lines = announced(coordinator).splitlines() + assert lines[0] == "FamiTracker module exported successfully." + assert lines[-1].endswith("Frame 03, Pulse 1, row 1A: ..") + + def test_a_long_list_is_cut_where_the_dialog_can_hold_it( + self, + coordinator: ProjectCoordinator, + ) -> None: + left_out = 4 + rows = tuple(self._row(index) for index in range(MAX_REPORTED_ROWS + left_out)) + + coordinator._on_export_result(self._success(rows)) + + assert announced(coordinator).endswith(f"and {left_out} more") + + +class TestAWrittenProjectReportsTheInstrumentsShortened: + """A format stores a bounded number of values per dimension, so the dialog announcing a + project export says how many instruments it shortened.""" + + @staticmethod + def _success( + truncation: Optional[EnvelopeTruncation], + skipped_rows: tuple[SkippedRow, ...], + ) -> ExportSuccess: + return ExportSuccess( + kind=ExportKind.PROJECT, + filepath=Path("song.btp"), + export_format=ExportFormat.BITPHASE, + truncation=truncation, + skipped_rows=skipped_rows, + ) + + def test_a_shortened_project_export_names_what_it_left_out( + self, + coordinator: ProjectCoordinator, + ) -> None: + truncation = EnvelopeTruncation(frames=512, source_frames=600, instruments=3) + + coordinator._on_export_result(self._success(truncation, NO_SKIPPED_ROWS)) + + paragraphs = announced(coordinator).split("\n\n") + assert paragraphs == [ + LanguageManager(LANG_EN)["global.dialog.message.bitphase_project_exported_successfully"], + LanguageManager(LANG_EN)["global.dialog.template.export_truncated"].format( + frames=truncation.frames, + source_frames=truncation.source_frames, + instruments=truncation.instruments, + ), + ] + + def test_a_whole_project_export_adds_no_notice( + self, + coordinator: ProjectCoordinator, + ) -> None: + coordinator._on_export_result(self._success(None, NO_SKIPPED_ROWS)) + + assert ( + announced(coordinator) + == LanguageManager(LANG_EN)["global.dialog.message.bitphase_project_exported_successfully"] + ) + + def test_the_instruments_shortened_follow_the_rows_left_silent( + self, + coordinator: ProjectCoordinator, + ) -> None: + silent = SkippedRow( + voice_id=MISSING_VOICE_ID, + channel=SILENT_CHANNEL, + order_position=0, + row_index=0, + reason=SkipReason.NO_INSTRUMENT, + ) + + truncation = EnvelopeTruncation(frames=512, source_frames=600, instruments=1) + + coordinator._on_export_result(self._success(truncation, (silent,))) + + paragraphs = announced(coordinator).split("\n\n") + assert paragraphs[1].startswith(LanguageManager(LANG_EN)["global.dialog.message.export_skipped_rows"]) + assert paragraphs[2] == LanguageManager(LANG_EN)["global.dialog.template.export_truncated"].format( + frames=truncation.frames, + source_frames=truncation.source_frames, + instruments=truncation.instruments, + ) + + +class TestTheSaveAPromptWaitsOn: + """A save prompt goes on, asks again or stands aside according to what the save came to.""" + + @pytest.fixture(name="save_dialog") + def save_dialog_fixture(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + dialog = MagicMock(return_value=None) + monkeypatch.setattr(project_module, "save_file_dialog", dialog) + return dialog + + @pytest.fixture(name="saving") + def saving_fixture( + self, + project_coordinator: ProjectCoordinator, + tmp_path: Path, + ) -> ProjectCoordinator: + project_coordinator._session_manager.get_project_path.return_value = tmp_path + return project_coordinator + + def test_a_project_with_a_file_is_written_there( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + filepath = tmp_path / "song.stp" + saving._project_manager.path = filepath + + assert saving.save() is SaveOutcome.WRITTEN + saving._project_controller.save.assert_called_once_with(filepath) + + def test_a_file_dialog_closed_without_a_name_calls_the_save_off( + self, + saving: ProjectCoordinator, + save_dialog: MagicMock, + ) -> None: + saving._project_manager.path = None + + assert saving.save() is SaveOutcome.CALLED_OFF + saving._project_controller.save.assert_not_called() + + def test_a_name_chosen_in_the_file_dialog_is_written( + self, + saving: ProjectCoordinator, + save_dialog: MagicMock, + tmp_path: Path, + ) -> None: + filepath = tmp_path / "song.stp" + save_dialog.return_value = filepath + saving._project_manager.path = None + + assert saving.save() is SaveOutcome.WRITTEN + saving._project_controller.save.assert_called_once_with(filepath) + + def test_a_write_that_fails_shows_its_error( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + failure = OSError("disk full") + saving._project_manager.path = tmp_path / "song.stp" + saving._project_controller.save.side_effect = failure + + assert saving.save() is SaveOutcome.FAILED + assert saving._dialogs.show_error.call_args.args[0] is failure + + def test_a_save_asked_for_by_itself_says_it_landed( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + saving._project_manager.path = tmp_path / "song.stp" + + saving.save() + + saving._dialogs.show_info.assert_called_once() + + def test_a_save_a_prompt_asked_for_goes_on_without_a_word( + self, + saving: ProjectCoordinator, + tmp_path: Path, + ) -> None: + """What the prompt guards opens next, so it opens alone.""" + filepath = tmp_path / "song.stp" + saving._project_manager.path = filepath + + assert saving._write_project() is SaveOutcome.WRITTEN + saving._project_controller.save.assert_called_once_with(filepath) + saving._dialogs.show_info.assert_not_called() + + def test_every_save_prompt_waits_on_the_quiet_save(self, saving: ProjectCoordinator) -> None: + saving._project_controller.is_open = True + saving._project_controller.is_dirty = True + + saving.guard_close(MagicMock(), MagicMock()) + saving.guard_new(MagicMock(), MagicMock()) + + for prompt in saving._dialogs.show_save_confirmation.call_args_list: + assert prompt.kwargs["on_save"] == saving._write_project + + +class TestTheExitAsksAboutTheProject: + """Exiting with unsaved project changes asks to save them first, the answer lets the exit go on, and + Cancel turns it away.""" + + def test_a_saved_project_lets_the_exit_go_on(self, project_coordinator: ProjectCoordinator) -> None: + project_coordinator._project_controller.is_dirty = False + proceed = MagicMock() + decline = MagicMock() + + project_coordinator.guard_exit(proceed, decline) + + proceed.assert_called_once_with() + decline.assert_not_called() + project_coordinator._dialogs.show_save_confirmation.assert_not_called() + + def test_an_unsaved_project_asks_to_save_first(self, project_coordinator: ProjectCoordinator) -> None: + project_coordinator._project_controller.is_dirty = True + proceed = MagicMock() + decline = MagicMock() + + project_coordinator.guard_exit(proceed, decline) + + proceed.assert_not_called() + decline.assert_not_called() + prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_save"] == project_coordinator._write_project + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) + + +class TestReplacingOrClosingTheProject: + """New, Open and Close ask before the open project goes, and Cancel turns the request away. + + An unsaved project is offered a save, an open project holding no changes is still asked about, and a + request with no project open goes on at once, or, for Close, is turned away. + """ + + @pytest.fixture(name="proceed") + def proceed_fixture(self) -> MagicMock: + return MagicMock() + + @pytest.fixture(name="decline") + def decline_fixture(self) -> MagicMock: + return MagicMock() + + @pytest.mark.parametrize("guard", ["guard_new", "guard_open"]) + def test_no_project_open_goes_on_at_once( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + guard: str, + ) -> None: + project_coordinator._project_controller.is_open = False + + {"guard_new": project_coordinator.guard_new, "guard_open": project_coordinator.guard_open}[guard]( + proceed, + decline, + ) + + proceed.assert_called_once_with() + decline.assert_not_called() + + @pytest.mark.parametrize("guard", ["guard_new", "guard_open", "guard_close"]) + def test_an_unsaved_project_asks_and_cancel_turns_the_request_away( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + guard: str, + ) -> None: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = True + + { + "guard_new": project_coordinator.guard_new, + "guard_open": project_coordinator.guard_open, + "guard_close": project_coordinator.guard_close, + }[guard](proceed, decline) + + proceed.assert_not_called() + prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) + + @pytest.mark.parametrize("guard", ["guard_new", "guard_open"]) + def test_a_saved_project_is_asked_about_and_cancel_turns_the_request_away( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + guard: str, + ) -> None: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = False + + {"guard_new": project_coordinator.guard_new, "guard_open": project_coordinator.guard_open}[guard]( + proceed, + decline, + ) + + proceed.assert_not_called() + prompt = project_coordinator._dialogs.show_confirmation.call_args.kwargs + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) + + def test_a_saved_project_closes_at_once( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + ) -> None: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = False + + project_coordinator.guard_close(proceed, decline) + + proceed.assert_called_once_with() + decline.assert_not_called() + + def test_closing_with_no_project_open_is_turned_away( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + ) -> None: + project_coordinator._project_controller.is_open = False + + project_coordinator.guard_close(proceed, decline) + + proceed.assert_not_called() + decline.assert_called_once_with() + project_coordinator._dialogs.show_save_confirmation.assert_not_called() + + +def _close_the_project(controller: MagicMock) -> None: + controller.is_open = False + controller.is_dirty = False + + +def _save_the_project(controller: MagicMock) -> None: + controller.is_dirty = False + + +class TestAQuestionAboutTheProjectWaitsForTheScreen(BaseTestSuite): + """A guard asks about the open project once the screen is free for its question, reading the project then. + + A project another conversation closed or saved meanwhile lets the request through with no question. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + guard: Callable[[ProjectCoordinator], Gate] + settle: Callable[[MagicMock], None] + + test_cases = ( + TestCase(label="new", guard=lambda coordinator: coordinator.guard_new, settle=_close_the_project), + TestCase(label="open", guard=lambda coordinator: coordinator.guard_open, settle=_close_the_project), + TestCase(label="close", guard=lambda coordinator: coordinator.guard_close, settle=_save_the_project), + TestCase(label="exit", guard=lambda coordinator: coordinator.guard_exit, settle=_save_the_project), + ) + + @pytest.fixture(name="unsaved") + def unsaved_fixture(self, project_coordinator: ProjectCoordinator) -> ProjectCoordinator: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = True + return project_coordinator + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_waits_while_another_window_stands( + self, + test_case: TestCase, + unsaved: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + + test_case.guard(unsaved)(proceed, decline) + + unsaved._dialogs.show_save_confirmation.assert_not_called() + proceed.assert_not_called() + decline.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_asks_once_the_window_leaves( + self, + test_case: TestCase, + unsaved: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + standing_window.leave() + + prompt = unsaved._dialogs.show_save_confirmation.call_args.kwargs + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_project_settled_meanwhile_goes_on_with_no_question( + self, + test_case: TestCase, + unsaved: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + test_case.settle(unsaved._project_controller) + standing_window.leave() + + proceed.assert_called_once_with() + decline.assert_not_called() + unsaved._dialogs.show_save_confirmation.assert_not_called() + unsaved._dialogs.show_confirmation.assert_not_called() + + def test_a_saved_project_lets_the_exit_go_on_while_another_window_stands( + self, + project_coordinator: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + """Leaving while a dialog stands goes on at once when there is nothing to ask about.""" + project_coordinator._project_controller.is_dirty = False + proceed = MagicMock() + + project_coordinator.guard_exit(proceed, MagicMock()) + + proceed.assert_called_once_with() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index efe251588..d400af1e0 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -1,23 +1,183 @@ from dataclasses import dataclass +from enum import Enum +from functools import partial from pathlib import Path -from typing import List, Optional -from unittest.mock import MagicMock +from typing import Callable, Final, List, Optional, Tuple +from unittest.mock import MagicMock, call, patch import pytest +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.coordinators import reconstruction as reconstruction_module from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.coordinators.tabs.reconstruction import ( + ReconstructionTabCoordinator, +) +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.manager import HistoryManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.reconstruction.edit import ChannelEdit, ReconstructionEdit, Retune, StemRemoval from sampletones_application.logic.reconstruction.manager import ReconstructionManager -from sampletones_application.services.regeneration import RegeneratedInstrument +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ( + ChannelChange, + RateChange, + StemRemovalRequest, +) +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceSuccess -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED +from sampletones_application.utils.callbacks.gates import Gate, pass_gates +from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import SILENT_VOLUME +from sampletones_core.exporters import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_shared.exceptions import ( InvalidMetadataError, InvalidReconstructionValuesError, ) -from tests.conftest import ReconstructionFactory +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT +from sampletones_shared.types.callback import VoidCallback +from tests.conftest import RENDER_BUDGET, ReconstructionFactory +from tests.suite.application import HeldQueue, held_queue, scheduling, synchronous_executor from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import held_frames +from tests.suite.history.wiring import wired_history +from tests.suite.language import FakeLanguageManager +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) +from tests.suite.stems import ( + SHARED_CHANNEL, + SHARED_OWNERS, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + TAKING_TURNS_PITCH, + TAKING_TURNS_VOLUME, + taking_turns, + taking_turns_file, +) + +__all__ = [ + "held_frames", + "held_queue", + "scheduling", + "standing_window", + "synchronous_executor", + "taking_turns", + "taking_turns_file", +] + +OPEN_VOICE_ID: Final[str] = "lead-id" +HISTORY_BUDGET: Final[int] = 16 +EDITED_VOLUME: Final[Tuple[int, ...]] = (5, 5) +LATER_VOLUME: Final[Tuple[int, ...]] = (3, 3) +SOLE_VOLUME: Final[Tuple[int, ...]] = (3,) +ARPEGGIO: Final[Tuple[int, ...]] = (0, 3) +RETUNED_FREQUENCY: Final[int] = 50 +REMOVED_NAME: Final[str] = "b" + + +def _volumes(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + """The volume each frame of a channel plays, as the document holds it.""" + return [instruction.volume for instruction in reconstruction.instructions[channel_name]] + + +def _sounding_volumes(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + """The volume of each frame a channel sounds, leaving out the release an envelope ends on.""" + return [instruction.volume for instruction in reconstruction.instructions[channel_name] if instruction.on] + + +def _sounding_pitches(reconstruction: Reconstruction, channel_name: ChannelName) -> List[int]: + return [instruction.pitch for instruction in reconstruction.instructions[channel_name] if instruction.on] + + +def _move( + coordinator: ReconstructionCoordinator, + channel_name: ChannelName, + feature_key: FeatureKey, + items: Tuple[int, ...], +) -> None: + """The reader moving one dimension of a channel on the instruments panel.""" + coordinator.request_rewrite( + ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: Envelope[int](items=items)}, + initial_pitch=None, + ) + ) + + +def _remove(coordinator: ReconstructionCoordinator, stem_id: int) -> None: + """The reader confirming the removal of a recording on the stems card.""" + coordinator.request_rewrite(StemRemovalRequest(stem_id=stem_id, stem_name=REMOVED_NAME)) + + +def _retime(coordinator: ReconstructionCoordinator, nes_frequency: int) -> None: + """The reader typing another rate into the Audio source panel.""" + coordinator.request_rewrite(RateChange(nes_frequency=nes_frequency)) + + +def _undo(coordinator: ReconstructionCoordinator, history: HistoryManager) -> None: + """The reader pressing Undo, which the application holds until the edits before it land.""" + coordinator.after_edits(history.undo) + + +def _load_by_hand( + coordinator: ReconstructionCoordinator, + filepath: Path, + decline: VoidCallback, +) -> None: + """Asks to open ``filepath`` the way the reconstruction menu does, past the question it raises.""" + pass_gates((coordinator.guard_load,), partial(coordinator.open, filepath), decline) + + +def _load_converted( + coordinator: ReconstructionCoordinator, + filepath: Path, + decline: VoidCallback, +) -> None: + """Asks to load what a conversion wrote the way the Converter's Load does, past the question it raises.""" + pass_gates((partial(coordinator.guard_load_converted, filepath),), partial(coordinator.load, filepath), decline) + + +def _edit_voice( + coordinator: ReconstructionCoordinator, + voice_id: str, + decline: VoidCallback, +) -> None: + """Asks to edit a voice of the project the way the Sequencer does, past the question it raises.""" + pass_gates((coordinator.guard_edit_voice,), partial(coordinator.open_project_voice, voice_id), decline) + + +@pytest.fixture +def decline() -> MagicMock: + """What a request the gates turn away runs, recording that it ran.""" + return MagicMock() + + +def _save(coordinator: ReconstructionCoordinator) -> None: + """The reader pressing Save reconstruction, which the application holds until the edits before it land.""" + coordinator.after_edits(coordinator.save) + + +class VoiceKind(Enum): + SAMPLE = "sample" + INSTRUMENT = "instrument" @pytest.fixture @@ -27,12 +187,13 @@ def reconstruction_coordinator() -> ReconstructionCoordinator: MagicMock(), MagicMock(), MagicMock(), - dialogs=MagicMock(), + MagicMock(), + MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), on_reconstruction_updated=MagicMock(), - is_reconstruction_embedded=MagicMock(return_value=False), ) @@ -46,14 +207,16 @@ def _gating_coordinator( MagicMock(), MagicMock(), MagicMock(), - dialogs=MagicMock(), + MagicMock(), + MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), on_reconstruction_updated=MagicMock(), - is_reconstruction_embedded=lambda: embedded, ) coordinator._reconstruction_manager.session.unsaved_changes = unsaved + coordinator._reconstruction_manager.is_project_sample = embedded coordinator.set_reconstructions_tab(MagicMock()) return coordinator @@ -113,46 +276,170 @@ def test_restore_clears_session_pointer( ) -class TestRegenerationApplyOrdering: - def test_history_hook_sees_prior_reconstruction_identity( - self, - reconstruction_factory: ReconstructionFactory, - ) -> None: - """Pins the hook-before-apply order in ``_on_updated``. +class TestAnEditIsRecordedBeforeItIsShown: + """Pins the hook-before-apply order in ``apply_edit``, which every landing edit takes. + + The hook records the edit against the project, so the history holds it by the time the open + document rebinds to the new object and the tab shows it. + """ + + @pytest.fixture + def observed(self) -> List[Tuple[Optional[Reconstruction], Reconstruction]]: + """The document open as each edit is recorded, beside the reconstruction the edit brings.""" + return [] - The hook locates the owning project sample by identity against the prior - reconstruction, so it must observe the manager before the document rebinds - to the regenerated object. - """ - manager = ReconstructionManager(scheduling=MagicMock()) - prior = reconstruction_factory() - manager.load_reconstruction_object(prior, name="lead") - observed: List[Optional[Reconstruction]] = [] + @pytest.fixture + def recording_coordinator( + self, + reconstruction_manager: ReconstructionManager, + rewrites: ReconstructionRewrites, + observed: List[Tuple[Optional[Reconstruction], Reconstruction]], + held_queue: HeldQueue, + taking_turns: Reconstruction, + ) -> ReconstructionCoordinator: + reconstruction_manager.load_reconstruction_object(taking_turns, name="lead", voice_id=OPEN_VOICE_ID) coordinator = ReconstructionCoordinator( - manager, + reconstruction_manager, MagicMock(), + rewrites, MagicMock(), MagicMock(), - dialogs=MagicMock(), + MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), - on_reconstruction_updated=lambda _outcome: observed.append(manager.reconstruction), - is_reconstruction_embedded=lambda: False, - ) - coordinator.set_reconstructions_tab(MagicMock()) - regenerated = reconstruction_factory() - outcome = RegeneratedInstrument( - reconstruction=regenerated, - generator_name=GeneratorName.PULSE1, - feature_key=FeatureKey.VOLUME, + on_reconstruction_updated=lambda edit: observed.append( + (reconstruction_manager.reconstruction, edit.reconstruction) + ), ) + coordinator.set_reconstructions_tab(MagicMock(spec=ReconstructionTabCoordinator)) + return coordinator + + def test_a_regenerated_instrument_is_recorded_against_the_document_it_leaves( + self, + recording_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + observed: List[Tuple[Optional[Reconstruction], Reconstruction]], + held_queue: HeldQueue, + taking_turns: Reconstruction, + ) -> None: + _move(recording_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + held_queue.drain() - coordinator._on_regeneration_result(ServiceSuccess(value=outcome)) + assert len(observed) == 1 + prior, edited = observed[0] + assert prior is taking_turns + assert reconstruction_manager.reconstruction is edited + + def test_a_removed_recording_travels_the_same_path_as_a_regenerated_instrument( + self, + recording_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + observed: List[Tuple[Optional[Reconstruction], Reconstruction]], + taking_turns: Reconstruction, + ) -> None: + """Every edit of the open document is applied alike, so the history sees them alike.""" + _remove(recording_coordinator, STEM_B_ID) assert len(observed) == 1 - assert observed[0] is prior - assert manager.reconstruction is regenerated + prior, remaining = observed[0] + assert prior is taking_turns + assert reconstruction_manager.reconstruction is remaining + + +class TestAnEditRedrawsWhatItRewrote: + """The instruments panel keeps what its own edit drew, and draws afresh what a removal rewrote.""" + + @pytest.fixture + def tab(self, reconstruction_coordinator: ReconstructionCoordinator) -> MagicMock: + tab = MagicMock(spec=ReconstructionTabCoordinator) + reconstruction_coordinator.set_reconstructions_tab(tab) + return tab + + def test_a_regenerated_instrument_keeps_the_envelopes_the_panel_draws( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """The regeneration carries what the reader typed, so a field being typed in keeps its text.""" + reconstruction_coordinator.apply_edit( + ChannelEdit( + reconstruction=reconstruction_factory(), + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + + tab.update_reconstruction.assert_called_once_with() + tab.redraw_reconstruction.assert_not_called() + + def test_a_retune_keeps_the_envelopes_and_refits_the_waveform( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A retune carries every instruction over, and its audio spans another length.""" + reconstruction_coordinator.apply_edit( + Retune( + reconstruction=reconstruction_factory(), + nes_frequency=RETUNED_FREQUENCY, + ) + ) + + tab.update_reconstruction.assert_called_once_with(refit_waveform=True) + tab.redraw_reconstruction.assert_not_called() + + def test_a_removed_recording_redraws_the_instruments_panel( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A removal releases frames the panel drew as sounding, so the panel draws the document it leaves.""" + reconstruction_coordinator.apply_edit( + StemRemoval( + reconstruction=reconstruction_factory(), + stem_name="kick", + ) + ) + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + tab.update_reconstruction.assert_not_called() + + @pytest.mark.parametrize("edit_kind", ("channel", "retune", "removal")) + def test_every_edit_leaves_the_document_unsaved( + self, + edit_kind: str, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + edits = { + "channel": ChannelEdit( + reconstruction=reconstruction_factory(), + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ), + "retune": Retune(reconstruction=reconstruction_factory(), nes_frequency=RETUNED_FREQUENCY), + "removal": StemRemoval(reconstruction=reconstruction_factory(), stem_name="kick"), + } + + reconstruction_coordinator.apply_edit(edits[edit_kind]) + + reconstruction_coordinator._reconstruction_manager.mark_updated.assert_called_once_with() + + def test_a_change_that_will_never_land_redraws_the_open_document( + self, + reconstruction_coordinator: ReconstructionCoordinator, + tab: MagicMock, + ) -> None: + """The panel drew the change, so it draws the document as it stands once the change is let go.""" + reconstruction_coordinator._rewrites.on_dropped() + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) class TestReconstructionRestorePropagatesUnexpected: @@ -168,6 +455,88 @@ def test_runtime_error_propagates( reconstruction_coordinator._session_manager.set_current_reconstruction.assert_not_called() +def _asked(coordinator: ReconstructionCoordinator) -> bool: + """Whether the coordinator put a question about the open document to the reader.""" + return bool(coordinator._dialogs.show_save_confirmation.called or coordinator._dialogs.show_confirmation.called) + + +class TestAQuestionAboutTheDocumentWaitsForTheScreen(BaseTestSuite): + """A guard asks about the open document once the screen is free for its question, reading it then. + + A document saved or closed meanwhile lets the request through with no question. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + guard: Callable[[ReconstructionCoordinator], Gate] + + test_cases = ( + TestCase(label="load", guard=lambda coordinator: coordinator.guard_load), + TestCase( + label="load_converted", + guard=lambda coordinator: partial(coordinator.guard_load_converted, Path("written.stn")), + ), + TestCase(label="close", guard=lambda coordinator: coordinator.guard_close), + TestCase(label="exit", guard=lambda coordinator: coordinator.guard_exit), + TestCase(label="edit_voice", guard=lambda coordinator: coordinator.guard_edit_voice), + ) + + @pytest.fixture(name="unsaved") + def unsaved_fixture(self) -> ReconstructionCoordinator: + return _gating_coordinator(unsaved=True, embedded=False) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_waits_while_another_window_stands( + self, + test_case: TestCase, + unsaved: ReconstructionCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + + test_case.guard(unsaved)(proceed, decline) + + assert not _asked(unsaved) + proceed.assert_not_called() + decline.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_asks_once_the_window_leaves( + self, + test_case: TestCase, + unsaved: ReconstructionCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + standing_window.leave() + + assert _asked(unsaved) + proceed.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_document_saved_meanwhile_goes_on_with_no_question( + self, + test_case: TestCase, + unsaved: ReconstructionCoordinator, + standing_window: StandingWindow, + ) -> None: + """Save on a question asked about a document closed meanwhile would ask the same question again.""" + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + unsaved._reconstruction_manager.session.unsaved_changes = False + standing_window.leave() + + proceed.assert_called_once_with() + decline.assert_not_called() + assert not _asked(unsaved) + + class TestSaveConfirmationGating(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): @@ -220,7 +589,7 @@ def test_close_prompts_only_for_standalone_unsaved( embedded=test_case.embedded, ) - coordinator.close_with_confirmation() + coordinator.guard_close(coordinator.close, MagicMock()) if test_case.expects_prompt: coordinator._dialogs.show_save_confirmation.assert_called_once() @@ -237,6 +606,7 @@ def test_close_prompts_only_for_standalone_unsaved( def test_load_prompts_only_for_standalone_unsaved( self, test_case: TestCase, + decline: MagicMock, ) -> None: coordinator = _gating_coordinator( unsaved=test_case.unsaved, @@ -244,7 +614,7 @@ def test_load_prompts_only_for_standalone_unsaved( ) path = Path("lead.stn") - coordinator.load_with_confirmation(path) + _load_by_hand(coordinator, path, decline) if test_case.expects_prompt: coordinator._dialogs.show_save_confirmation.assert_called_once() @@ -252,3 +622,1326 @@ def test_load_prompts_only_for_standalone_unsaved( else: coordinator._dialogs.show_save_confirmation.assert_not_called() coordinator._reconstructions_tab.load_reconstruction.assert_called_once_with(path) + decline.assert_not_called() + + +@pytest.fixture +def project_manager() -> ProjectManager: + return ProjectManager() + + +@pytest.fixture +def project_controller(project_manager: ProjectManager) -> ProjectController: + return ProjectController(project_manager) + + +@pytest.fixture +def history(project_controller: ProjectController) -> HistoryManager: + """A strict history, so an edit that reaches the project outside a transaction is reported.""" + return wired_history(project_controller, budget=HISTORY_BUDGET, strict=True) + + +@pytest.fixture +def reconstruction_manager(scheduling: SchedulingBehavior) -> ReconstructionManager: + return ReconstructionManager(scheduling=scheduling, renders=RenderCache(budget_bytes=RENDER_BUDGET)) + + +@pytest.fixture +def tab() -> MagicMock: + return MagicMock(spec=ReconstructionTabCoordinator) + + +@pytest.fixture +def rewrites(reconstruction_manager: ReconstructionManager) -> ReconstructionRewrites: + """The steps of the open document, rebuilt by the real regeneration on a synchronous worker.""" + return ReconstructionRewrites(reconstruction_manager, RegenerationService()) + + +@pytest.fixture +def following_coordinator( + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + project_manager: ProjectManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + held_queue: HeldQueue, +) -> ReconstructionCoordinator: + """The coordinator on a real project, history and document, wired the way the application wires it. + + The application writes an edit of a project sample back into the project as one history entry, + follows every project state change, and fans a replaced project out to the sequencer, which + reseeds the history, before the coordinator follows it. The fixture repeats that wiring, and a + new project stands open. + """ + + def write_back(edit: ReconstructionEdit) -> None: + voice_id = reconstruction_manager.voice_id + if voice_id is None: + return + + with history.transaction( + edit.history_action, + coalesce=edit.coalesce_key(voice_id), + ): + project_controller.replace_sample_reconstruction(voice_id, edit.reconstruction) + + coordinator = ReconstructionCoordinator( + reconstruction_manager, + MagicMock(), + rewrites, + MagicMock(), + project_controller, + history, + dialogs=dialogs_on_the_line(), + language_manager=FakeLanguageManager({}), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=write_back, + ) + coordinator.set_reconstructions_tab(tab) + + def realign() -> None: + history.reset() + coordinator.follow_replaced_project() + + project_manager.session.on_state_changed = coordinator.follow_project + project_controller.on_project_replaced = realign + project_controller.new() + return coordinator + + +@pytest.fixture +def open_sample( + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, +) -> Sample: + """A sample added to the project and opened on the tab, with the calls opening it cleared.""" + with history.transaction(HistoryAction.ADD_SAMPLE): + sample = project_controller.add_sample(reconstruction_factory(), "lead") + _edit_voice(following_coordinator, sample.id, decline) + held_queue.drain() + tab.reset_mock() + return sample + + +@pytest.fixture +def standalone_path( + reconstruction_manager: ReconstructionManager, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, +) -> Path: + """A reconstruction file opened on the tab, standing apart from the project.""" + path = tmp_path / "lead.stn" + reconstruction_factory().save(path) + reconstruction_manager.load_reconstruction(path) + return path + + +def _retimed(reconstruction: Reconstruction) -> Reconstruction: + """The same reconstruction timed at another NES frequency, which spans another length.""" + return reconstruction.model_copy( + update={"config": reconstruction.config.with_library(nes_frequency=reconstruction.config.nes_frequency // 2)} + ) + + +class TestTheTabFollowsTheVoiceItShows: + """The tab knows the voice it shows by its id, so a restore reaches it and a replaced project lets it go.""" + + def test_an_undo_keeping_the_sample_shows_the_reconstruction_it_restores( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + original = open_sample.reconstruction + following_coordinator.apply_edit( + ChannelEdit( + reconstruction=reconstruction_factory(), + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + tab.reset_mock() + + history.undo() + + assert reconstruction_manager.reconstruction is original + assert reconstruction_manager.voice_id == open_sample.id + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_an_undo_across_a_rate_change_refits_the_waveform( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + _retime(following_coordinator, open_sample.reconstruction.config.nes_frequency // 2) + tab.reset_mock() + + history.undo() + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=True) + + def test_an_undo_leaving_the_reconstruction_as_it_was_redraws_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + """A restore shares every reconstruction it kept, so the document it already shows stands.""" + with history.transaction(HistoryAction.SET_TEMPO): + project_controller.set_tempo(150) + tab.reset_mock() + + history.undo() + + assert reconstruction_manager.reconstruction is open_sample.reconstruction + tab.redraw_reconstruction.assert_not_called() + tab.update_reconstruction.assert_not_called() + + def test_an_undo_taking_the_sample_out_closes_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + history: HistoryManager, + open_sample: Sample, + ) -> None: + history.undo() + + assert reconstruction_manager.current_reconstruction is None + assert reconstruction_manager.voice_id is None + + def test_a_redo_bringing_the_sample_back_leaves_the_tab_empty( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + ) -> None: + """A voice that comes back is the reader's to open again.""" + history.undo() + + history.redo() + + assert project_controller.project.voice(open_sample.id) is not None + assert reconstruction_manager.current_reconstruction is None + + def test_a_redo_taking_the_sample_out_closes_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + decline: MagicMock, + ) -> None: + with history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(open_sample.id) + history.undo() + _edit_voice(following_coordinator, open_sample.id, decline) + + history.redo() + + assert reconstruction_manager.current_reconstruction is None + + def test_removing_the_sample_closes_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + ) -> None: + with history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(open_sample.id) + + assert reconstruction_manager.current_reconstruction is None + + def test_a_removal_that_fails_keeps_the_sample_open( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + """The removal is rolled back after the project was stamped, so the tab still shows the sample it brings back.""" + with pytest.raises(RuntimeError), history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(open_sample.id) + raise RuntimeError("the removal broke") + + assert reconstruction_manager.voice_id == open_sample.id + assert reconstruction_manager.current_reconstruction is not None + tab.close_reconstruction.assert_not_called() + + def test_an_edit_writing_the_project_first_leaves_the_document_to_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """The write-back stamps the project while the document still holds what the edit started from.""" + edited = reconstruction_factory() + + following_coordinator.apply_edit( + ChannelEdit( + reconstruction=edited, + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + + assert reconstruction_manager.reconstruction is edited + tab.redraw_reconstruction.assert_not_called() + tab.update_reconstruction.assert_called_once_with() + + def test_a_new_project_lets_the_sample_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + open_sample: Sample, + ) -> None: + project_controller.new() + + assert reconstruction_manager.current_reconstruction is None + + def test_closing_the_project_lets_the_sample_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + open_sample: Sample, + ) -> None: + project_controller.close() + + assert reconstruction_manager.current_reconstruction is None + + def test_reopening_the_saved_project_lets_the_sample_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + open_sample: Sample, + tmp_path: Path, + ) -> None: + """The file keeps each voice's id, so the reopened project names the voice the tab showed.""" + path = tmp_path / f"song{EXT_FILE_PROJECT}" + project_controller.save(path) + + project_controller.load(path) + + assert isinstance(project_controller.project.voice(open_sample.id), Sample) + assert reconstruction_manager.current_reconstruction is None + + def test_a_standalone_document_outlasts_a_new_and_a_closed_project( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + standalone_path: Path, + ) -> None: + project_controller.new() + project_controller.close() + + assert reconstruction_manager.filepath == standalone_path + + def test_a_restore_asks_the_instrument_to_follow_as_restored( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + ) -> None: + with history.transaction(HistoryAction.SET_TEMPO): + project_controller.set_tempo(150) + tab.reset_mock() + + history.undo() + + tab.follow_instrument.assert_called_with(restored=True) + + def test_an_edit_asks_the_instrument_to_follow_as_it_stands( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + ) -> None: + tab.reset_mock() + + with history.transaction(HistoryAction.SET_TEMPO): + project_controller.set_tempo(150) + + tab.follow_instrument.assert_called_once_with(restored=False) + + def test_a_replaced_project_closes_the_instrument( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + tab: MagicMock, + ) -> None: + tab.reset_mock() + + project_controller.close() + + tab.close_instrument.assert_called_once_with() + + +class TestOpeningAProjectVoice(BaseTestSuite): + """Edit on a voice puts it in front of the tab, asking first where that would lose unsaved work.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + kind: VoiceKind + + test_cases = ( + TestCase(label="sample", kind=VoiceKind.SAMPLE), + TestCase(label="instrument", kind=VoiceKind.INSTRUMENT), + ) + + @staticmethod + def _add_voice( + kind: VoiceKind, + project_controller: ProjectController, + history: HistoryManager, + reconstruction_factory: ReconstructionFactory, + ) -> str: + match kind: + case VoiceKind.SAMPLE: + with history.transaction(HistoryAction.ADD_SAMPLE): + return project_controller.add_sample(reconstruction_factory(), "lead").id + case VoiceKind.INSTRUMENT: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + return project_controller.add_instrument(new_instrument("lead")).id + + def test_a_sample_opens_as_the_document_it_is( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, + ) -> None: + voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) + sample = project_controller.project.voice(voice_id) + assert isinstance(sample, Sample) + + _edit_voice(following_coordinator, voice_id, decline) + + assert reconstruction_manager.voice_id == voice_id + assert reconstruction_manager.reconstruction is sample.reconstruction + tab.release_instrument.assert_called_once_with() + decline.assert_not_called() + + def test_an_instrument_opens_in_the_editor_on_its_tab( + self, + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, + ) -> None: + voice_id = self._add_voice(VoiceKind.INSTRUMENT, project_controller, history, reconstruction_factory) + + _edit_voice(following_coordinator, voice_id, decline) + + tab.edit_instrument.assert_called_once_with(voice_id) + following_coordinator._on_tab_switch.assert_called_once_with(Tab.RECONSTRUCTIONS) + decline.assert_not_called() + + def test_an_unknown_voice_opens_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + decline: MagicMock, + ) -> None: + _edit_voice(following_coordinator, "gone", decline) + + assert reconstruction_manager.current_reconstruction is None + tab.edit_instrument.assert_not_called() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_an_unsaved_standalone_document_is_offered_a_save_first( + self, + test_case: TestCase, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + standalone_path: Path, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, + ) -> None: + voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) + reconstruction_manager.mark_updated() + + _edit_voice(following_coordinator, voice_id, decline) + + following_coordinator._dialogs.show_save_confirmation.assert_called_once() + assert reconstruction_manager.filepath == standalone_path + tab.edit_instrument.assert_not_called() + decline.assert_not_called() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_answer_opens_the_voice( + self, + test_case: TestCase, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + standalone_path: Path, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, + ) -> None: + voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) + reconstruction_manager.mark_updated() + _edit_voice(following_coordinator, voice_id, decline) + + following_coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() + + match test_case.kind: + case VoiceKind.SAMPLE: + assert reconstruction_manager.voice_id == voice_id + case VoiceKind.INSTRUMENT: + tab.edit_instrument.assert_called_once_with(voice_id) + decline.assert_not_called() + + def test_an_edited_project_sample_opens_another_voice_at_once( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + open_sample: Sample, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, + ) -> None: + """A project sample's edits belong to the project, so putting it away loses nothing.""" + voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) + reconstruction_manager.mark_updated() + + _edit_voice(following_coordinator, voice_id, decline) + + following_coordinator._dialogs.show_save_confirmation.assert_not_called() + assert reconstruction_manager.voice_id == voice_id + decline.assert_not_called() + + +class TestAnOutsideRewriteOfTheOpenSample: + """A sample replaced or retuned from the sequencer shows on the tab only where the tab has it open.""" + + def test_a_replaced_sample_shows_its_new_reconstruction( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + incoming = reconstruction_factory() + + following_coordinator.replace_sample(open_sample.id, incoming) + + assert reconstruction_manager.reconstruction is incoming + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_replacement_at_another_rate_refits_the_waveform( + self, + following_coordinator: ReconstructionCoordinator, + open_sample: Sample, + tab: MagicMock, + ) -> None: + following_coordinator.replace_sample(open_sample.id, _retimed(open_sample.reconstruction)) + + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=True) + + def test_a_replaced_sample_the_tab_holds_no_longer_leaves_the_document_alone( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + following_coordinator.replace_sample("another-id", reconstruction_factory()) + + assert reconstruction_manager.reconstruction is open_sample.reconstruction + tab.redraw_reconstruction.assert_not_called() + + def test_a_replacement_with_nothing_open_opens_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + reconstruction_factory: ReconstructionFactory, + ) -> None: + following_coordinator.replace_sample(OPEN_VOICE_ID, reconstruction_factory()) + + assert reconstruction_manager.current_reconstruction is None + tab.redraw_reconstruction.assert_not_called() + + def test_a_retuned_sample_shows_its_new_length( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + open_sample: Sample, + tab: MagicMock, + ) -> None: + """A retune carries every envelope over, so the panel keeps what it draws.""" + half = open_sample.reconstruction.config.nes_frequency // 2 + + _retime(following_coordinator, half) + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert reconstruction.config.nes_frequency == half + tab.update_reconstruction.assert_called_once_with(refit_waveform=True) + tab.redraw_reconstruction.assert_not_called() + + def test_a_retune_with_nothing_open_opens_nothing( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + ) -> None: + _retime(following_coordinator, RETUNED_FREQUENCY) + + assert reconstruction_manager.current_reconstruction is None + tab.update_reconstruction.assert_not_called() + + +class TestTheSaveAPromptWaitsOn: + """A save prompt goes on, asks again or stands aside according to what the save came to.""" + + def test_a_document_with_a_file_is_written( + self, + reconstruction_coordinator: ReconstructionCoordinator, + ) -> None: + reconstruction_coordinator._reconstruction_manager.save_reconstruction.return_value = True + + assert reconstruction_coordinator.save() is SaveOutcome.WRITTEN + + def test_a_project_sample_has_nothing_to_write_to( + self, + reconstruction_coordinator: ReconstructionCoordinator, + ) -> None: + manager = reconstruction_coordinator._reconstruction_manager + manager.is_file_backed = False + manager.is_project_sample = True + manager.save_reconstruction.return_value = False + + assert reconstruction_coordinator.save() is SaveOutcome.CALLED_OFF + + def test_a_document_whose_file_was_taken_away_asks_where_to_go( + self, + reconstruction_coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """The save a prompt waits on writes it where the reader picks, and goes on without a word.""" + chosen = tmp_path / "lead.stn" + monkeypatch.setattr(reconstruction_module, "save_file_dialog", MagicMock(return_value=chosen)) + manager = self._fileless(reconstruction_coordinator) + + assert reconstruction_coordinator.save() is SaveOutcome.WRITTEN + manager.save_reconstruction_as.assert_called_once_with(chosen) + reconstruction_coordinator._dialogs.show_info.assert_not_called() + + def test_a_file_dialog_closed_without_a_name_calls_the_save_off( + self, + reconstruction_coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(reconstruction_module, "save_file_dialog", MagicMock(return_value=None)) + manager = self._fileless(reconstruction_coordinator) + + assert reconstruction_coordinator.save() is SaveOutcome.CALLED_OFF + manager.save_reconstruction_as.assert_not_called() + + def test_save_as_asked_for_by_itself_says_it_landed( + self, + reconstruction_coordinator: ReconstructionCoordinator, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr(reconstruction_module, "save_file_dialog", MagicMock(return_value=tmp_path / "lead.stn")) + reconstruction_coordinator.set_reconstructions_tab(MagicMock()) + + assert reconstruction_coordinator.save_as_dialog() is SaveOutcome.WRITTEN + reconstruction_coordinator._dialogs.show_info.assert_called_once() + + @staticmethod + def _fileless(coordinator: ReconstructionCoordinator) -> MagicMock: + """A standalone document with no file of its own, the way removing its file leaves it.""" + coordinator.set_reconstructions_tab(MagicMock()) + manager: MagicMock = coordinator._reconstruction_manager + manager.is_file_backed = False + manager.is_project_sample = False + manager.current_reconstruction.filepath = None + manager.current_reconstruction.name = "lead" + return manager + + def test_a_write_that_fails_shows_its_error( + self, + reconstruction_coordinator: ReconstructionCoordinator, + ) -> None: + failure = OSError("disk full") + reconstruction_coordinator._reconstruction_manager.save_reconstruction.side_effect = failure + + assert reconstruction_coordinator.save() is SaveOutcome.FAILED + assert reconstruction_coordinator._dialogs.show_error.call_args.args[0] is failure + + +NO_PROMPT: Final[str] = "none" +SAVE_PROMPT: Final[str] = "save" +REPLACED_PROMPT: Final[str] = "replaced" +REPLACED_MESSAGE_KEY: Final[str] = "global.dialog.message.load_replaced_reconstruction" +DISCARD_LABEL_KEY: Final[str] = "global.dialog.label.discard" + + +class TestLoadingAConversion(BaseTestSuite): + """Loading what a conversion wrote asks first about unsaved changes. A conversion that wrote over + the open document's own file offers to discard the changes or keep them, since a save would write + the old document over the new one.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + unsaved: bool + embedded: bool + same_file: bool + + test_cases = ( + TestCase(label="saved_another_file", unsaved=False, embedded=False, same_file=False, expected=NO_PROMPT), + TestCase(label="saved_its_own_file", unsaved=False, embedded=False, same_file=True, expected=NO_PROMPT), + TestCase(label="unsaved_another_file", unsaved=True, embedded=False, same_file=False, expected=SAVE_PROMPT), + TestCase(label="unsaved_its_own_file", unsaved=True, embedded=False, same_file=True, expected=REPLACED_PROMPT), + TestCase(label="project_sample_another_file", unsaved=True, embedded=True, same_file=False, expected=NO_PROMPT), + TestCase(label="project_sample_its_own_file", unsaved=True, embedded=True, same_file=True, expected=NO_PROMPT), + ) + + @staticmethod + def _coordinator( + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> ReconstructionCoordinator: + """A coordinator over the document in ``open.stn``, opened from its file or as a project sample, unsaved or saved as the case says.""" + opened = tmp_path / "open.stn" + reconstruction_factory().save(opened) + manager = ReconstructionManager(scheduling=MagicMock(), renders=RenderCache(budget_bytes=RENDER_BUDGET)) + coordinator = ReconstructionCoordinator( + manager, + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + MagicMock(), + dialogs=dialogs_on_the_line(), + language_manager=FakeLanguageManager(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + on_reconstruction_updated=MagicMock(), + ) + coordinator.set_reconstructions_tab(MagicMock()) + if test_case.embedded: + manager.load_reconstruction_object( + Reconstruction.load(opened), + name=opened.stem, + voice_id=OPEN_VOICE_ID, + ) + else: + manager.load_reconstruction(opened) + if test_case.unsaved: + manager.mark_updated() + + return coordinator + + @staticmethod + def _converted(test_case: TestCase, tmp_path: Path) -> Path: + """What the conversion wrote: the open file spelled another way, or a file of its own.""" + if not test_case.same_file: + return tmp_path / "converted.stn" + + (tmp_path / "folder").mkdir() + return tmp_path / "folder" / ".." / "open.stn" + + @staticmethod + def _asked(coordinator: ReconstructionCoordinator) -> str: + dialogs = coordinator._dialogs + if dialogs.show_save_confirmation.called: + return SAVE_PROMPT + + if dialogs.show_confirmation.called: + assert dialogs.show_confirmation.call_args.kwargs["tag"] == TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED + return REPLACED_PROMPT + + return NO_PROMPT + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_question_put_before_loading( + self, + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + decline: MagicMock, + ) -> None: + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) + + assert self._asked(coordinator) == test_case.expected + decline.assert_not_called() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_going_on_loads_what_the_conversion_wrote( + self, + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + decline: MagicMock, + ) -> None: + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + converted = self._converted(test_case, tmp_path) + + _load_converted(coordinator, converted, decline) + asked = self._asked(coordinator) + if asked == SAVE_PROMPT: + coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() + elif asked == REPLACED_PROMPT: + coordinator._dialogs.show_confirmation.call_args.kwargs["on_confirm"]() + + coordinator._tab.load_reconstruction.assert_called_once_with(converted) + decline.assert_not_called() + + @pytest.mark.parametrize( + "test_case", + [test_case for test_case in test_cases if test_case.expected != NO_PROMPT], + ids=lambda test_case: test_case.label, + ) + def test_nothing_loads_before_the_answer( + self, + test_case: TestCase, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + decline: MagicMock, + ) -> None: + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) + + coordinator._tab.load_reconstruction.assert_not_called() + assert coordinator.is_unsaved() + decline.assert_not_called() + + def test_a_replaced_file_offers_to_discard_the_changes( + self, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + decline: MagicMock, + ) -> None: + """Saving would write the old document over the conversion, so the prompt offers no Save.""" + test_case = next(test_case for test_case in self.test_cases if test_case.expected == REPLACED_PROMPT) + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) + + prompt = coordinator._dialogs.show_confirmation.call_args.kwargs + assert prompt["message"] == REPLACED_MESSAGE_KEY + assert prompt["ok_label"] == DISCARD_LABEL_KEY + coordinator._dialogs.show_save_confirmation.assert_not_called() + + def test_the_save_prompt_saves_the_open_document( + self, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + decline: MagicMock, + ) -> None: + test_case = next(test_case for test_case in self.test_cases if test_case.expected == SAVE_PROMPT) + coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) + + outcome = coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_save"]() + + assert outcome is SaveOutcome.WRITTEN + assert not coordinator.is_unsaved() + + +class TestTheExitAsksAboutTheReconstruction(BaseTestSuite): + """Exiting with unsaved changes in a reconstruction of its own asks to save them first. A project + sample's changes belong to the project, which the exit asks about already.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + unsaved: bool + embedded: bool + + test_cases = ( + TestCase(label="standalone_unsaved_asks", unsaved=True, embedded=False, expected=True), + TestCase(label="standalone_saved_goes_on", unsaved=False, embedded=False, expected=False), + TestCase(label="project_sample_unsaved_goes_on", unsaved=True, embedded=True, expected=False), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_exit_asks_only_about_a_standalone_document(self, test_case: TestCase) -> None: + coordinator = _gating_coordinator(unsaved=test_case.unsaved, embedded=test_case.embedded) + proceed = MagicMock() + decline = MagicMock() + + coordinator.guard_exit(proceed, decline) + + assert coordinator._dialogs.show_save_confirmation.called is test_case.expected + assert proceed.called is not test_case.expected + decline.assert_not_called() + + def test_the_answer_lets_the_exit_go_on_and_cancel_turns_it_away(self) -> None: + coordinator = _gating_coordinator(unsaved=True, embedded=False) + proceed = MagicMock() + decline = MagicMock() + + coordinator.guard_exit(proceed, decline) + + prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_save"] == coordinator.save + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) + + @pytest.mark.parametrize("guard", ["guard_load", "guard_close"]) + def test_cancel_on_a_load_or_a_close_turns_the_request_away(self, guard: str) -> None: + coordinator = _gating_coordinator(unsaved=True, embedded=False) + proceed = MagicMock() + decline = MagicMock() + + {"guard_load": coordinator.guard_load, "guard_close": coordinator.guard_close}[guard](proceed, decline) + + prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) + + +@pytest.fixture +def turns_sample( + following_coordinator: ReconstructionCoordinator, + project_controller: ProjectController, + history: HistoryManager, + tab: MagicMock, + held_queue: HeldQueue, + taking_turns: Reconstruction, + decline: MagicMock, +) -> Sample: + """The two-recording document added to the project and opened on the tab.""" + with history.transaction(HistoryAction.ADD_SAMPLE): + sample = project_controller.add_sample(taking_turns, "turns") + _edit_voice(following_coordinator, sample.id, decline) + held_queue.drain() + tab.reset_mock() + return sample + + +@pytest.fixture +def turns_path( + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + tab: MagicMock, + held_queue: HeldQueue, + taking_turns_file: Path, +) -> Path: + """The two-recording document opened from its file, with both recordings loaded beside it.""" + reconstruction_manager.load_reconstruction(taking_turns_file) + held_queue.drain() + tab.reset_mock() + return taking_turns_file + + +class TestTheDocumentChangesOneStepAtATime: + """The tab changes its open document one step at a time, in the order the reader made the changes. + + Each step is built from the document the previous step left, a result whose document has since + been put away is dropped, and the gestures that read or put away the whole document wait for the + edits before them. + """ + + def test_a_removal_after_an_edit_keeps_the_edit_and_takes_the_recording_out( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _remove(following_coordinator, STEM_B_ID) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert STEM_B_ID not in reconstruction.stems_data.config.entries_by_id + assert _volumes(reconstruction, SHARED_CHANNEL)[SHARED_OWNERS.index(STEM_A_ID)] == EDITED_VOLUME[0] + + def test_a_removal_after_an_edit_keeps_the_audio_of_the_recording_that_stays( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _remove(following_coordinator, STEM_B_ID) + held_queue.drain() + + data = reconstruction_manager.current_reconstruction + assert data is not None + assert len(data.stem_audios) == 1 + + def test_loading_another_document_mid_flight_leaves_it_as_it_was( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + other = tmp_path / "other.stn" + reconstruction_factory().save(other) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + reconstruction_manager.load_reconstruction(other) + loaded = reconstruction_manager.reconstruction + held_queue.drain() + + assert reconstruction_manager.reconstruction is loaded + assert not following_coordinator.is_unsaved() + + def test_opening_another_voice_mid_flight_leaves_it_as_it_was( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + held_queue: HeldQueue, + turns_sample: Sample, + reconstruction_factory: ReconstructionFactory, + decline: MagicMock, + ) -> None: + with history.transaction(HistoryAction.ADD_SAMPLE): + other = project_controller.add_sample(reconstruction_factory(), "other") + original = other.reconstruction + entries = len(history.entries) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _edit_voice(following_coordinator, other.id, decline) + held_queue.drain() + + sample = project_controller.project.voice(other.id) + assert isinstance(sample, Sample) + assert sample.reconstruction is original + assert reconstruction_manager.reconstruction is original + assert len(history.entries) == entries + + def test_closing_mid_flight_leaves_nothing_unsaved( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.guard_close(following_coordinator.close, MagicMock()) + held_queue.drain() + + assert reconstruction_manager.current_reconstruction is None + assert not following_coordinator.is_unsaved() + + def test_two_dimensions_of_one_channel_both_land( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + assert _sounding_pitches(reconstruction, SHARED_CHANNEL) == [TAKING_TURNS_PITCH + offset for offset in ARPEGGIO] + + def test_two_channels_both_land( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + assert _sounding_volumes(reconstruction, SOLE_CHANNEL) == list(SOLE_VOLUME) + + def test_a_rate_change_after_an_edit_keeps_the_edit_at_the_new_rate( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _retime(following_coordinator, RETUNED_FREQUENCY) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert reconstruction.config.nes_frequency == RETUNED_FREQUENCY + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_an_undo_right_after_an_edit_undoes_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + original = turns_sample.reconstruction + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _undo(following_coordinator, history) + held_queue.drain() + + assert reconstruction_manager.voice_id == turns_sample.id + assert reconstruction_manager.reconstruction is original + assert project_controller.project.voice(turns_sample.id) is not None + history.redo() + held_queue.drain() + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_save_right_after_an_edit_writes_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _save(following_coordinator) + held_queue.drain() + + assert _sounding_volumes(Reconstruction.load(turns_path), SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_save_as_right_after_an_edit_writes_the_edit( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_path: Path, + tmp_path: Path, + ) -> None: + copy = tmp_path / "copy.stn" + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.after_edits(lambda: reconstruction_manager.save_reconstruction_as(copy)) + held_queue.drain() + + assert _sounding_volumes(Reconstruction.load(copy), SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_result_meeting_a_copy_put_in_its_place_is_let_go( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + tab: MagicMock, + turns_path: Path, + tmp_path: Path, + ) -> None: + """A Save As adopting its copy while a rebuild runs leaves the copy as written, and the panel redrawn.""" + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + reconstruction_manager.save_reconstruction_as(tmp_path / "copy.stn") + copy = reconstruction_manager.reconstruction + held_queue.drain() + + assert reconstruction_manager.reconstruction is copy + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_an_edit_drawn_before_a_waiting_removal_is_refused( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + """The panel drew the edit on frames the removal is about to release, so it would bring them back.""" + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _remove(following_coordinator, STEM_B_ID) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert STEM_B_ID not in reconstruction.stems_data.config.entries_by_id + assert _volumes(reconstruction, SHARED_CHANNEL) == [EDITED_VOLUME[0], SILENT_VOLUME] + + def test_an_edit_refused_behind_a_waiting_gesture_is_redrawn_away( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + tab: MagicMock, + turns_sample: Sample, + ) -> None: + """Once the line empties, the panel draws the document, which the refused edit never reached.""" + gesture = MagicMock() + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + following_coordinator.after_edits(gesture) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + gesture.assert_called_once_with() + tab.redraw_reconstruction.assert_called_once_with(refit_waveform=False) + + def test_a_gesture_waits_for_the_edits_before_it( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + read: List[Optional[Reconstruction]] = [] + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + + following_coordinator.after_edits(lambda: read.append(reconstruction_manager.reconstruction)) + assert read == [] + held_queue.drain() + + assert len(read) == 1 + assert read[0] is not None + assert _sounding_volumes(read[0], SHARED_CHANNEL) == list(EDITED_VOLUME) + + def test_a_gesture_with_nothing_on_its_way_runs_at_once( + self, + following_coordinator: ReconstructionCoordinator, + turns_sample: Sample, + ) -> None: + gesture = MagicMock() + + following_coordinator.after_edits(gesture) + + gesture.assert_called_once_with() + + def test_a_failed_rebuild_is_shown_and_the_next_step_still_runs( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + failure = RuntimeError("synthesis failed") + failing = MagicMock() + failing.from_features.side_effect = failure + exporters = {**CHANNEL_TO_EXPORTER_MAP, SHARED_CHANNEL: failing} + with patch("sampletones_application.services.regeneration.service.CHANNEL_TO_EXPORTER_MAP", exporters): + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert following_coordinator._dialogs.show_error.call_args.args[0] is failure + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == [TAKING_TURNS_VOLUME] * len(SHARED_OWNERS) + assert _sounding_volumes(reconstruction, SOLE_CHANNEL) == list(SOLE_VOLUME) + + def test_the_waveform_stays_faded_until_every_step_has_landed( + self, + following_coordinator: ReconstructionCoordinator, + held_queue: HeldQueue, + tab: MagicMock, + turns_sample: Sample, + ) -> None: + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + _move(following_coordinator, SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME) + assert tab.set_reconstruction_dimmed.call_args_list == [call(True)] + + held_queue.drain() + + assert tab.set_reconstruction_dimmed.call_args_list == [call(True), call(False)] + + def test_a_drag_collapses_into_the_place_it_ended( + self, + following_coordinator: ReconstructionCoordinator, + reconstruction_manager: ReconstructionManager, + project_controller: ProjectController, + history: HistoryManager, + held_queue: HeldQueue, + turns_sample: Sample, + ) -> None: + """A rebuild runs while the drag moves on, so the positions it passes through merge behind it.""" + entries = len(history.entries) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, (4, 4)) + _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + held_queue.drain() + + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert _sounding_volumes(reconstruction, SHARED_CHANNEL) == list(EDITED_VOLUME) + assert len(history.entries) == entries + 1 diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py index 8e642d443..0eff5756e 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, ) @@ -21,7 +21,6 @@ ) from sampletones_core.audio.writers import AudioFormat from sampletones_core.configs import Config -from sampletones_shared.types.callback import VoidCallback from tests.suite.language import FakeLanguageManager from tests.suite.render import FakeRenderService @@ -90,11 +89,7 @@ def filters(self) -> List[FileFilter]: class RenderFixture: - """The coordinator over a real render logic, a recording service, and a recorded screen. - - The frame the report waits for is taken as passing when a test asks for it, so the hand-off - from the window to the dialog that reports an outcome is walked one step at a time. - """ + """The coordinator over a real render logic, a recording service, and a recorded screen.""" def __init__( self, @@ -121,14 +116,8 @@ def __init__( self.dialogs = _DialogsRecorder() self.save_dialog = _SaveDialogRecorder() self.activity = 0 - self.pending: List[VoidCallback] = [] monkeypatch.setattr(render_module, "save_file_dialog", self.save_dialog) - monkeypatch.setattr( - render_module.FrameCallbackManager, - "set_frame_callback", - lambda callback, frame_count=1: self.pending.append(callback), - ) self.coordinator = SongRenderCoordinator( self.logic, @@ -159,13 +148,6 @@ def stop(self) -> None: def close(self) -> None: self.window.on_close() - def advance_frame(self) -> None: - """Runs what was waiting for the frame the window left the screen in.""" - pending = self.pending - self.pending = [] - for callback in pending: - callback() - @pytest.fixture def render(monkeypatch: pytest.MonkeyPatch) -> RenderFixture: @@ -292,7 +274,6 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture render.start() render.service.emit(ServiceSuccess(value=CHOSEN)) - render.advance_frame() assert render.dialogs.paths == [ { @@ -302,22 +283,12 @@ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture } ] - def test_the_report_waits_for_the_screen_the_window_left(self, render: RenderFixture) -> None: - render.open() - render.start() - - render.service.emit(ServiceSuccess(value=CHOSEN)) - - assert render.window.hides == 1 - assert not render.dialogs.paths - def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: render.open() render.start() failure = OSError("no room on the device") render.service.emit(ServiceError(exception=failure)) - render.advance_frame() assert render.dialogs.errors == [ { @@ -331,8 +302,7 @@ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) - render.start() render.stop() - render.service.emit(ServiceCancelled()) - render.advance_frame() + render.service.emit(ServiceCanceled()) assert render.window.hides == 1 assert not render.dialogs.paths @@ -343,9 +313,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/layout/test_about_dialog.py b/tests/unit/sampletones_application/layout/test_about_dialog.py index 99296ccaa..53c05f2ae 100644 --- a/tests/unit/sampletones_application/layout/test_about_dialog.py +++ b/tests/unit/sampletones_application/layout/test_about_dialog.py @@ -1,7 +1,13 @@ from sampletones_application.layout.general.dialogs.about import AboutDialogLayout +from sampletones_application.layout.primitives import DialogGeometry class TestTheRoomTheTextTakes: def test_the_text_wraps_in_what_the_mark_leaves(self) -> None: - layout = AboutDialogLayout(width=480, height=210, logo=72, padding=40) + layout = AboutDialogLayout( + window=DialogGeometry(width=480, height=210), + logo=72, + padding=40, + ) + assert layout.text_wrap == 368 diff --git a/tests/unit/sampletones_application/layout/test_fonts.py b/tests/unit/sampletones_application/layout/test_fonts.py index 451768a97..2d17402a9 100644 --- a/tests/unit/sampletones_application/layout/test_fonts.py +++ b/tests/unit/sampletones_application/layout/test_fonts.py @@ -2,16 +2,16 @@ from sampletones_application.layout.fonts import FontScale, FontsLayout, Step, Typeface -SANS: Final[FontScale] = FontScale(small=11, medium=12, large=13, title=14) -MONO: Final[FontScale] = FontScale(small=21, medium=22, large=23, title=24) -ICON: Final[FontScale] = FontScale(small=31, medium=32, large=33, title=34) +SANS: Final[FontScale] = FontScale(tiny=10, small=11, medium=12, large=13, title=14) +MONO: Final[FontScale] = FontScale(tiny=20, small=21, medium=22, large=23, title=24) +ICON: Final[FontScale] = FontScale(tiny=30, small=31, medium=32, large=33, title=34) class TestTheSizeLadder: """Every rung a font asks for answers with a size, on the typeface asking for it.""" def test_every_rung_answers_with_the_size_the_scale_states(self) -> None: - assert [SANS.step(step) for step in Step] == [11, 12, 13, 14] + assert [SANS.step(step) for step in Step] == [10, 11, 12, 13, 14] def test_a_typeface_answers_from_a_ladder_of_its_own(self) -> None: layout = FontsLayout(scale=1, sans=SANS, mono=MONO, icon=ICON) diff --git a/tests/unit/sampletones_application/layout/test_primitives.py b/tests/unit/sampletones_application/layout/test_primitives.py new file mode 100644 index 000000000..c4695a0b2 --- /dev/null +++ b/tests/unit/sampletones_application/layout/test_primitives.py @@ -0,0 +1,36 @@ +from typing import Final + +from sampletones_application.layout.primitives import DEARPYGUI_MAXIMUM_WINDOW_SIZE, DialogGeometry + +STATED_HEIGHT: Final[int] = 210 +STATED_WIDTH: Final[int] = 480 + + +class TestTheSizeADialogIsHeldTo: + """A dialog reads at the width it states and grows down to hold what it holds. + + A window free to widen to its content, holding content that measures itself against the + window's width, hands each a little more every frame until the screen stops it. + """ + + def test_the_width_a_dialog_opens_at_is_the_width_it_stops_at(self) -> None: + geometry = DialogGeometry(width=STATED_WIDTH, height=STATED_HEIGHT) + + assert geometry.minimum_size[0] == geometry.maximum_size[0] == STATED_WIDTH + + def test_a_dialog_stating_no_height_holds_its_width_all_the_same(self) -> None: + geometry = DialogGeometry(width=STATED_WIDTH) + + assert geometry.minimum_size[0] == geometry.maximum_size[0] == STATED_WIDTH + + def test_the_height_a_dialog_opens_at_is_the_least_it_takes(self) -> None: + geometry = DialogGeometry(width=STATED_WIDTH, height=STATED_HEIGHT) + + assert geometry.minimum_size[1] == STATED_HEIGHT + assert geometry.maximum_size[1] == DEARPYGUI_MAXIMUM_WINDOW_SIZE + + def test_a_dialog_stating_no_height_starts_from_its_content(self) -> None: + geometry = DialogGeometry(width=STATED_WIDTH) + + assert geometry.minimum_size[1] == 0 + assert geometry.maximum_size[1] == DEARPYGUI_MAXIMUM_WINDOW_SIZE 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..126d6c352 --- /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 ExportBackends +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 ExportBackends.build().by_format.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/nsf/__init__.py b/tests/unit/sampletones_application/logic/export/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/export/nsf/source/__init__.py b/tests/unit/sampletones_application/logic/export/nsf/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/export/nsf/source/test_sample.py b/tests/unit/sampletones_application/logic/export/nsf/source/test_sample.py new file mode 100644 index 000000000..e2647320b --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/nsf/source/test_sample.py @@ -0,0 +1,18 @@ +from typing import Final + +import pytest + +from sampletones_application.logic.export.nsf.source.sample import SampleSource +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME +from tests.suite.player import player_sample + +SAMPLE_NAME: Final[str] = "Amen" +NTSC_FREQUENCY: Final[int] = 60 + + +class TestAReconstructionHasNoFrames: + def test_asking_where_a_frame_starts_is_refused(self) -> None: + source = SampleSource(request=player_sample(SAMPLE_NAME, (), nes_frequency=NTSC_FREQUENCY)) + + with pytest.raises(ValueError): + source.frame_tick(FIRST_FRAME) diff --git a/tests/unit/sampletones_application/logic/export/nsf/test_logic.py b/tests/unit/sampletones_application/logic/export/nsf/test_logic.py new file mode 100644 index 000000000..ca756e704 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/nsf/test_logic.py @@ -0,0 +1,298 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.export.nsf.logic import NSFExportLogic +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exports.request import SampleExport +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme, offered_schemes +from sampletones_player.export.program import NSFProgram +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.nsf import PROGRAM_TITLE, FakeNSFExportService, FakeProgramBackend +from tests.suite.player import PLAYER_REFERENCE_PITCH, player_features, player_instrument, player_sample + +PROJECT_NAME: Final[str] = "chiptune" +SAMPLE_NAME: Final[str] = "Amen" +EXTRA_FRAMES: Final[int] = 3 +LOOP_FRAME: Final[int] = 2 +NTSC_FREQUENCY: Final[int] = 60 +SHORT_FRAMES: Final[int] = 8 +LONG_FRAMES: Final[int] = 24 +CHOSEN_DESTINATION: Final[Path] = Path("/home/user/programs/take one.nsf") + + +def two_slice_sample() -> SampleExport: + """A reconstruction sounding a short lead and a longer bass, neither of them repeating.""" + return player_sample( + SAMPLE_NAME, + ( + player_instrument( + "lead", + ChannelName.PULSE1, + player_features(SHORT_FRAMES, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + player_instrument( + "bass", + ChannelName.TRIANGLE, + player_features(LONG_FRAMES, PLAYER_REFERENCE_PITCH, duty_cycle=False), + nes_frequency=NTSC_FREQUENCY, + loop=False, + ), + ), + nes_frequency=NTSC_FREQUENCY, + ) + + +class NSFExportFixture: + """An NSF export setup over a real project, with a service and a backend that record the run.""" + + def __init__(self, directory: Path, *, operation_active: bool = False) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + for _ in range(EXTRA_FRAMES): + self.controller.append_frame() + + self.directory = directory + self.session_manager = MagicMock() + self.session_manager.get_project_path.return_value = directory + self.session_manager.get_instrument_path.return_value = directory + self.service = FakeNSFExportService() + self.backend = FakeProgramBackend() + self.views: List[NSFExportViewModel] = [] + self.destinations: List[Path] = [] + self.logic = NSFExportLogic( + self.controller, + self.session_manager, + self.service, + self.backend, + is_operation_active=lambda: operation_active, + ) + self.logic.on_view_changed = self.views.append + self.logic.on_choose_destination = self.destinations.append + + @property + def view(self) -> NSFExportViewModel: + assert self.views, "A view was expected to be emitted" + return self.views[-1] + + def edit(self, choices: NSFExportChoices) -> None: + self.logic.apply(choices) + + +@pytest.fixture +def export(tmp_path: Path) -> NSFExportFixture: + return NSFExportFixture(tmp_path) + + +class TestOfferingTheProject: + def test_the_dialog_opens_on_a_file_named_after_the_project(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + assert export.view.destination == export.directory / f"{PROJECT_NAME}{EXT_FILE_NSF}" + + def test_the_dialog_opens_on_the_program_the_project_is_written_as(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + program = NSFProgram.for_project(export.controller.project) + assert export.view.choices == NSFExportChoices.initial(program, export.view.offer) + + def test_every_channel_frame_and_scheme_is_offered(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + offer = export.view.offer + assert offer.channels == tuple(ChannelName.items()) + assert offer.frame_count == export.controller.order_length + assert offer.schemes == offered_schemes(seeded=True) + + def test_the_length_is_the_whole_orders(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + project = export.controller.project + assert export.view.ticks == SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick( + project.song.order_length() + ) + assert export.view.nes_frequency == project.settings.nes_frequency + + def test_the_setup_occupies_the_application_from_the_dialog_opening(self, export: NSFExportFixture) -> None: + assert not export.logic.is_active + + assert export.logic.open_project() + + assert export.logic.is_active + + def test_another_exclusive_operation_holds_the_dialog_closed(self, tmp_path: Path) -> None: + export = NSFExportFixture(tmp_path, operation_active=True) + + assert not export.logic.open_project() + assert not export.logic.is_active + assert not export.views + + def test_closing_releases_the_application(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.close() + + assert not export.logic.is_active + + +class TestOfferingASample: + def test_the_channels_are_the_ones_the_slices_sound_on(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + + assert export.view.channels == (ChannelName.PULSE1, ChannelName.TRIANGLE) + assert NSFRepeat.FROM_FRAME not in export.view.repeats + assert export.view.schemes == offered_schemes(seeded=False) + + def test_the_dialog_opens_on_a_file_named_after_the_reconstruction(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + + assert export.view.destination == export.directory / f"{SAMPLE_NAME}{EXT_FILE_NSF}" + + def test_the_song_lasts_as_long_as_the_longest_sounding_slice(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + assert export.view.ticks == LONG_FRAMES + + export.edit(export.view.choices.with_channel(ChannelName.TRIANGLE, False, export.view.offer)) + + assert export.view.ticks == SHORT_FRAMES + + +class TestTheDestination: + def test_the_destination_is_asked_for_from_where_it_stands(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.request_destination() + + assert export.destinations == [export.view.destination] + + def test_a_chosen_destination_is_the_one_written(self, export: NSFExportFixture) -> None: + export.logic.open_project() + export.logic.set_destination(CHOSEN_DESTINATION) + + export.logic.start() + + assert export.service.projects[0].destination == CHOSEN_DESTINATION + + +class TestHandingTheProjectOver: + def test_the_service_writes_the_project_through_the_chosen_backend(self, export: NSFExportFixture) -> None: + export.logic.open_project() + destination = export.view.destination + + assert export.logic.start() + + run = export.service.projects[0] + assert run.destination == destination + assert run.backend is export.backend.chosen[0] + assert run.request == export.controller.export_request + + def test_the_program_carries_the_choices(self, export: NSFExportFixture) -> None: + export.logic.open_project() + offer = export.view.offer + choices = ( + export.view.choices.with_title(PROGRAM_TITLE) + .with_channel(ChannelName.NOISE, False, offer) + .with_repeat(NSFRepeat.FROM_FRAME, offer) + .with_loop_frame(LOOP_FRAME, offer) + .with_scheme(CompressionScheme.RUNS, offer) + ) + export.edit(choices) + + export.logic.start() + + program = export.backend.program + assert program.information == choices.information + assert program.channels == frozenset(ChannelName.items()) - {ChannelName.NOISE} + assert program.loop_tick == SongTiming.from_project( + export.controller.project, bounds=SONG_TICK_BOUNDS + ).frame_tick(LOOP_FRAME) + assert program.scheme == CompressionScheme.RUNS + + def test_handing_over_closes_the_setup(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.start() + + assert not export.logic.is_active + + def test_a_program_sounding_nothing_stays_in_the_setup(self, export: NSFExportFixture) -> None: + export.logic.open_project() + choices = export.view.choices + for channel in ChannelName.items(): + choices = choices.with_channel(channel, False, export.view.offer) + export.edit(choices) + + assert not export.logic.start() + + assert not export.service.projects + assert export.logic.is_active + + def test_the_project_folder_stays_where_the_project_left_it(self, export: NSFExportFixture) -> None: + export.logic.open_project() + + export.logic.start() + + export.session_manager.set_project_path.assert_not_called() + + def test_an_export_starts_from_the_open_setup_alone(self, export: NSFExportFixture) -> None: + export.logic.open_project() + export.logic.start() + + assert not export.logic.start() + + assert len(export.service.projects) == 1 + + +class TestTheRepeatLandsOnTheSongsTicks(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + repeat: NSFRepeat + expected: Optional[int] + + test_cases = ( + TestCase(label="once", repeat=NSFRepeat.ONCE, expected=None), + TestCase(label="from_start", repeat=NSFRepeat.FROM_START, expected=SONG_START), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_sample_repeats_where_the_choice_says(self, test_case: TestCase, tmp_path: Path) -> None: + export = NSFExportFixture(tmp_path) + export.logic.open_sample(two_slice_sample()) + export.edit(export.view.choices.with_repeat(test_case.repeat, export.view.offer)) + + export.logic.start() + + assert export.backend.program.loop_tick == test_case.expected + + +class TestHandingTheSampleOver: + def test_the_service_writes_the_slices(self, export: NSFExportFixture) -> None: + sample = two_slice_sample() + export.logic.open_sample(sample) + + export.logic.start() + + assert export.service.samples[0].request == sample + + def test_the_next_instrument_export_opens_where_this_one_was_written(self, export: NSFExportFixture) -> None: + export.logic.open_sample(two_slice_sample()) + export.logic.set_destination(CHOSEN_DESTINATION) + + export.logic.start() + + export.session_manager.set_instrument_path.assert_called_once_with(CHOSEN_DESTINATION.parent) diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py new file mode 100644 index 000000000..6d0552e62 --- /dev/null +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -0,0 +1,307 @@ +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.exporters.skipped import NO_SKIPPED_ROWS +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" +CANCELING_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, + canceling_label=CANCELING_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, + skipped_rows=NO_SKIPPED_ROWS, + ) + + +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_traveling_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_traveling_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 == CANCELING_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 == CANCELING_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/conftest.py b/tests/unit/sampletones_application/logic/history/conftest.py index 3d433bd58..a94d260b3 100644 --- a/tests/unit/sampletones_application/logic/history/conftest.py +++ b/tests/unit/sampletones_application/logic/history/conftest.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass +from pathlib import Path from typing import Final, Protocol, Tuple import pytest @@ -5,8 +7,12 @@ 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 tests.suite.history.audit import HistoryAudit, immediately +from tests.suite.history.projects import every_part_file +from tests.suite.history.wiring import wired_history DEFAULT_HISTORY_BUDGET: Final[int] = 10 +AUDIT_BUDGET: Final[int] = 20 class HistoryFactory(Protocol): @@ -18,6 +24,19 @@ def __call__( ) -> Tuple[ProjectController, HistoryManager]: ... +@dataclass(frozen=True) +class AuditedHistory: + """A strict history over the every-part project, with the audit holding it to its rules.""" + + controller: ProjectController + history: HistoryManager + audit: HistoryAudit + + +class AuditFactory(Protocol): + def __call__(self, *, budget: int = AUDIT_BUDGET) -> AuditedHistory: ... + + @pytest.fixture def project_controller() -> ProjectController: return ProjectController(ProjectManager()) @@ -31,10 +50,36 @@ def build( budget: int = DEFAULT_HISTORY_BUDGET, ) -> Tuple[ProjectController, HistoryManager]: controller = ProjectController(ProjectManager()) - history = HistoryManager(controller, budget=budget, strict=strict) - controller.on_mutation = history.handle_mutation + history = wired_history(controller, budget=budget, strict=strict) controller.new() history.reset() return controller, history return build + + +@pytest.fixture +def audit_factory(every_part_file: Path) -> AuditFactory: + """Opens the every-part project under a strict history of ``budget`` entries and audits it.""" + + def build(*, budget: int = AUDIT_BUDGET) -> AuditedHistory: + controller = ProjectController(ProjectManager()) + history = wired_history(controller, budget=budget, strict=True) + controller.load(every_part_file) + history.reset() + audit = HistoryAudit( + controller, + history, + budget=budget, + doors=history, + settle=immediately, + observers=(), + ) + return AuditedHistory(controller=controller, history=history, audit=audit) + + return build + + +@pytest.fixture +def audited(audit_factory: AuditFactory) -> AuditedHistory: + return audit_factory() 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_audit.py b/tests/unit/sampletones_application/logic/history/test_audit.py new file mode 100644 index 000000000..29c9b78d2 --- /dev/null +++ b/tests/unit/sampletones_application/logic/history/test_audit.py @@ -0,0 +1,248 @@ +from pathlib import Path +from typing import Final + +import pytest + +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_core.constants.enums import ChannelName +from sampletones_core.project import Project +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from tests.suite.history.audit import HistoryAudit, StackModel, immediately +from tests.suite.history.projects import FIRST_PATTERN, LEAD, PAD +from tests.unit.sampletones_application.logic.history.conftest import AUDIT_BUDGET, AuditedHistory + +TEMPO_KEY: Final[CoalesceKey] = ("tempo",) +FIRST_ROW: Final[int] = 0 +PLANTED_VOLUME: Final[int] = 1 +PLANTED_PITCH: Final[int] = 30 +UNHEARD_SAVE: Final[str] = "unheard.stp" + + +def _raise_tempo(audited: AuditedHistory) -> None: + def gesture() -> None: + with audited.history.transaction(HistoryAction.SET_TEMPO, coalesce=TEMPO_KEY): + audited.controller.set_tempo(audited.controller.project.settings.tempo + 1) + + audited.audit.perform(gesture, action=HistoryAction.SET_TEMPO, target=TEMPO_KEY) + + +def _speed_up(audited: AuditedHistory) -> None: + with audited.history.transaction(HistoryAction.SET_SPEED): + audited.controller.set_speed(audited.controller.project.settings.speed + 1) + + +def _speed_up_audited(audited: AuditedHistory) -> None: + audited.audit.perform(lambda: _speed_up(audited), action=HistoryAction.SET_SPEED, target=None) + + +def _retitle_audited(audited: AuditedHistory) -> None: + def gesture() -> None: + with audited.history.transaction(HistoryAction.EDIT_PROJECT_PROPERTIES): + audited.controller.set_title(f"{audited.controller.project.info.title}!") + + audited.audit.perform(gesture, action=HistoryAction.EDIT_PROJECT_PROPERTIES, target=None) + + +def _sample( + project: Project, + name: str, +) -> Sample: + return next(voice for voice in project.voices if isinstance(voice, Sample) and voice.name == name) + + +def _instrument( + project: Project, + name: str, +) -> Instrument: + return next(voice for voice in project.voices if isinstance(voice, Instrument) and voice.name == name) + + +def _baseline(audited: AuditedHistory) -> Project: + return audited.history.entries[0].project + + +class OvershootingDoors: + """Doors whose undo lands on the first entry, as a history restoring the wrong entry would.""" + + def __init__(self, history: HistoryManager) -> None: + self._history = history + + def undo(self) -> None: + self._history.jump_to(0) + + def redo(self) -> None: + self._history.redo() + + def jump_to(self, index: int) -> None: + self._history.jump_to(index) + + +class TestACleanHistoryPasses: + def test_a_walk_over_recorded_gestures_holds(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + _speed_up_audited(audited) + _retitle_audited(audited) + + audited.audit.walk() + + assert audited.audit.model.cursor == 3 + + def test_a_run_of_one_target_holds(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + _raise_tempo(audited) + + audited.audit.walk() + + assert len(audited.history.entries) == 2 + + +class TestPlantedFaults: + """Each fault the history must never make, planted by hand, fails the audit's next check.""" + + def test_a_row_changed_inside_an_entry(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + row = _baseline(audited).song.channels[ChannelName.PULSE1].get_row(FIRST_PATTERN, FIRST_ROW) + + object.__setattr__(row, "volume", PLANTED_VOLUME) + + with pytest.raises(AssertionError, match="Entry 0"): + audited.audit.check() + + def test_a_shared_stream_changed_in_place(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + reconstruction = _sample(audited.controller.project, LEAD).reconstruction + stream = reconstruction.instructions_data[0] + + object.__setattr__(stream, "initial_pitch", PLANTED_PITCH) + + with pytest.raises(AssertionError, match="Entry 0"): + audited.audit.check() + + def test_an_envelope_changed_inside_an_entry(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + instrument = _instrument(_baseline(audited), PAD) + + object.__setattr__(instrument.envelopes, "volume", instrument.envelopes.arpeggio) + + with pytest.raises(AssertionError, match="Entry 0"): + audited.audit.check() + + def test_a_restore_installing_another_entry(self, audited: AuditedHistory) -> None: + overshooting = AuditedHistory( + controller=audited.controller, + history=audited.history, + audit=HistoryAudit( + audited.controller, + audited.history, + budget=AUDIT_BUDGET, + doors=OvershootingDoors(audited.history), + settle=immediately, + observers=(), + ), + ) + _raise_tempo(overshooting) + _speed_up_audited(overshooting) + + with pytest.raises(AssertionError, match="cursor stands at 0"): + overshooting.audit.undo() + + def test_a_live_project_another_entry_put_in_place(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + + audited.controller.replace_project(_baseline(audited), clean=False) + + with pytest.raises(AssertionError, match="live project differs"): + audited.audit.check() + + def test_a_gesture_recorded_under_another_action(self, audited: AuditedHistory) -> None: + with pytest.raises(AssertionError, match="holds"): + audited.audit.perform( + lambda: _speed_up(audited), + action=HistoryAction.SET_TEMPO, + target=None, + ) + + def test_a_gesture_that_records_nothing(self, audited: AuditedHistory) -> None: + with pytest.raises(AssertionError, match="left the project as it stood"): + audited.audit.perform(lambda: None, action=HistoryAction.SET_TEMPO, target=None) + + def test_a_stale_reconstruction_view(self, audited: AuditedHistory) -> None: + reconstruction = _sample(audited.controller.project, LEAD).reconstruction + pitches = dict(reconstruction.initial_pitches) + pitches[ChannelName.PULSE1] = PLANTED_PITCH + + vars(reconstruction)["initial_pitches"] = pitches + + with pytest.raises(AssertionError, match="stale initial_pitches"): + audited.audit.check() + + def test_stale_instrument_frames(self, audited: AuditedHistory) -> None: + instrument = _instrument(audited.controller.project, PAD) + frames = instrument.instructions(ChannelName.PULSE1) + + vars(instrument)["_instructions"] = {channel: frames[:1] for channel in ChannelName.items()} + + with pytest.raises(AssertionError, match="stale pulse1 frames"): + audited.audit.check() + + def test_a_voices_collection_that_lost_its_ids(self, audited: AuditedHistory) -> None: + _raise_tempo(audited) + baseline = _baseline(audited) + + baseline.voices = baseline.voices.copy() # type: ignore[assignment] + + with pytest.raises(AssertionError, match="answers"): + audited.audit.check() + + def test_a_save_the_history_never_heard_of( + self, + audited: AuditedHistory, + tmp_path: Path, + ) -> None: + _raise_tempo(audited) + audited.controller.on_saved = None + + audited.controller.save(tmp_path / UNHEARD_SAVE) + + with pytest.raises(AssertionError, match="reads clean"): + audited.audit.check() + + +class TestTheModel: + """The model's own rules, read against cases worked out by hand.""" + + def test_a_commit_below_the_save_point_drops_it(self) -> None: + model = StackModel(budget=10) + model.reset("baseline", clean=True) + model.commit(HistoryAction.SET_TEMPO, None, "tempo") + model.save() + model.undo() + + model.commit(HistoryAction.SET_SPEED, None, "speed") + + assert model.saved is None + assert [entry.fingerprint for entry in model.entries] == ["baseline", "speed"] + + def test_eviction_takes_the_save_point_along(self) -> None: + model = StackModel(budget=2) + model.reset("baseline", clean=True) + + model.commit(HistoryAction.SET_TEMPO, None, "tempo") + model.commit(HistoryAction.SET_SPEED, None, "speed") + + assert model.saved is None + assert model.cursor == 1 + + def test_a_restore_ends_a_run(self) -> None: + model = StackModel(budget=10) + model.reset("baseline", clean=True) + model.commit(HistoryAction.SET_TEMPO, TEMPO_KEY, "first") + model.undo() + model.redo() + + model.commit(HistoryAction.SET_TEMPO, TEMPO_KEY, "second") + + assert [entry.fingerprint for entry in model.entries] == ["baseline", "first", "second"] diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index a1f08ffdd..6add7f66d 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -1,4 +1,5 @@ -from typing import List +import copy +from typing import Final, FrozenSet, List import pytest @@ -9,12 +10,39 @@ fingerprint_project, ) from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_core.reconstructions import Reconstruction -from sampletones_shared.utils.serialization import hash_model +from sampletones_shared.utils.hashing import hash_model from tests.conftest import ReconstructionFactory +from tests.suite.history.audit import fresh_fingerprint +from tests.suite.history.perturbation import perturbed_project, project_leaves +from tests.suite.history.projects import every_part_project from tests.unit.sampletones_application.logic.history.conftest import HistoryFactory +REACHED_LABELS: Final[FrozenSet[str]] = frozenset( + { + "Metadata.version", + "ProjectInfo.title", + "ProjectInfo.modified", + "ProjectSettings.tempo", + "Song.rows_per_pattern", + "Song.order[]{}", + "Row.volume", + "Note.value", + "Step.value", + "NoteOn.voice_id", + "Sample.name", + "Reconstruction.coefficient", + "PulseInstruction.volume", + "NoiseInstruction.period", + "InstructionsItem.initial_pitch", + "ChannelAssignment.stem_ids[]", + "StemsData.scale", + "StemSettings.channels[]", + "Instrument.name", + "Instrument.initial_period", + } +) + class CountingHash: def __init__(self) -> None: @@ -34,7 +62,7 @@ def test_fingerprint_stable_across_snapshot( project_controller.add_sample(reconstruction_factory(), name="lead") original = fingerprint_project(project_controller.project, reconstruction_hash=hash_model) - snapshot = snapshot_project(project_controller.project) + snapshot = project_controller.project.snapshot() assert fingerprint_project(snapshot, reconstruction_hash=hash_model) == original @@ -52,6 +80,41 @@ def test_fingerprint_changes_with_state( assert fingerprint_project(project_controller.project, reconstruction_hash=hash_model) != before +class TestFingerprintCompleteness: + """A change to any field a project holds reaches the fingerprint, so the history audit is blind nowhere.""" + + def test_every_field_reaches_the_fingerprint(self) -> None: + project = every_part_project() + baseline = fresh_fingerprint(project) + + blind = [ + leaf.label + for leaf in project_leaves(project) + if fresh_fingerprint(perturbed_project(project, leaf)) == baseline + ] + + assert blind == [] + + def test_the_walk_reaches_every_kind_of_part(self) -> None: + labels = {leaf.label for leaf in project_leaves(every_part_project())} + + assert REACHED_LABELS <= labels + + def test_an_independent_copy_keeps_the_fingerprint(self) -> None: + project = every_part_project() + + assert fresh_fingerprint(copy.deepcopy(project)) == fresh_fingerprint(project) + + def test_mappings_in_another_order_keep_the_fingerprint(self) -> None: + project = every_part_project() + baseline = fresh_fingerprint(project) + + project.song.order = [dict(reversed(list(frame.items()))) for frame in project.song.order] + project.song.channels = dict(reversed(list(project.song.channels.items()))) + + assert fresh_fingerprint(project) == baseline + + class TestHashCache: def test_hash_computed_once_per_reconstruction_object( self, @@ -88,9 +151,8 @@ def test_prune_drops_hashes_for_discarded_reconstructions( ) -> None: counting = CountingHash() cache = ReconstructionHashCache(reconstruction_hash=counting) - kept = reconstruction_factory() + kept = project_controller.add_sample(reconstruction_factory(), name="kept").reconstruction discarded = reconstruction_factory() - project_controller.add_sample(kept, name="kept") cache.hash(kept) cache.hash(discarded) @@ -146,7 +208,7 @@ def test_restore_raises_on_mutated_snapshot_shared_state( with history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) - sample.reconstruction.coefficient = sample.reconstruction.coefficient + 1.0 + object.__setattr__(sample.reconstruction, "coefficient", sample.reconstruction.coefficient + 1.0) with pytest.raises(HistoryIntegrityError): history.undo() @@ -160,8 +222,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/history/test_manager.py b/tests/unit/sampletones_application/logic/history/test_manager.py index dc6cc06f8..169c37d1e 100644 --- a/tests/unit/sampletones_application/logic/history/test_manager.py +++ b/tests/unit/sampletones_application/logic/history/test_manager.py @@ -108,7 +108,11 @@ def test_batched_edit_commits_one_entry( history.undo() assert controller.project.settings.tempo == original - def test_exception_inside_transaction_commits_partial_gesture( + +class TestRollback: + """A gesture lands whole or not at all: one that raises leaves the project and the stack as they were.""" + + def test_a_failed_gesture_restores_the_project_and_records_nothing( self, history_factory: HistoryFactory, ) -> None: @@ -116,14 +120,233 @@ def test_exception_inside_transaction_commits_partial_gesture( original = controller.project.settings.tempo with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): - controller.set_tempo(150) + controller.set_tempo(170) raise RuntimeError("boom") + assert controller.project.settings.tempo == original + assert len(history.entries) == 1 + assert history.can_undo is False + + def test_a_failure_in_an_inner_scope_rolls_back_the_whole_gesture( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + original = (controller.project.settings.tempo, controller.project.settings.speed) + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.ADD_SAMPLE): + controller.set_tempo(170) + with history.transaction(HistoryAction.SET_SPEED): + controller.set_speed(4) + raise RuntimeError("boom") + + assert (controller.project.settings.tempo, controller.project.settings.speed) == original + assert len(history.entries) == 1 + + def test_an_inner_failure_the_gesture_recovers_from_commits( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + + with history.transaction(HistoryAction.ADD_SAMPLE): + controller.set_tempo(170) + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_SPEED): + raise RuntimeError("recovered") + + assert controller.project.settings.tempo == 170 assert len(history.entries) == 2 - assert controller.project.settings.tempo == 150 - history.undo() - assert controller.project.settings.tempo == original + def test_a_gesture_returning_early_commits( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + + def gesture() -> None: + with history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + return + + gesture() + + assert controller.project.settings.tempo == 170 + assert len(history.entries) == 2 + + def test_a_failure_before_any_mutation_reinstalls_nothing( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + replaced: List[bool] = [] + controller.on_project_replaced = lambda: replaced.append(True) + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): + raise RuntimeError("boom") + + assert replaced == [] + + def test_a_coalescing_run_goes_on_past_a_rollback( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + key = ("tempo",) + with history.transaction(HistoryAction.SET_TEMPO, coalesce=key): + controller.set_tempo(170) + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO, coalesce=key): + controller.set_tempo(155) + raise RuntimeError("boom") + with history.transaction(HistoryAction.SET_TEMPO, coalesce=key): + controller.set_tempo(160) + + assert len(history.entries) == 2 + assert controller.project.settings.tempo == 160 + + def test_a_rollback_at_the_save_point_reads_clean( + self, + history_factory: HistoryFactory, + tmp_path: Path, + ) -> None: + controller, history = history_factory() + controller.on_saved = history.mark_saved + controller.save(tmp_path / "song.zip") + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + raise RuntimeError("boom") + + assert controller.is_dirty is False + + def test_a_project_replaced_midway_rolls_nothing_back( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(150) + controller.new() + history.reset() + controller.set_tempo(160) + raise RuntimeError("boom") + + assert controller.project.settings.tempo == 160 + assert len(history.entries) == 1 + + +class TestLandingEffects: + """What a gesture does outside the project happens once the gesture lands, and goes with one rolled back.""" + + def test_an_effect_runs_once_the_gesture_lands_after_its_entry( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + entries_seen: List[int] = [] + + with history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + history.after_landing(lambda: entries_seen.append(len(history.entries))) + assert entries_seen == [] + + assert entries_seen == [2] + + def test_an_effect_runs_at_once_between_gestures( + self, + history_factory: HistoryFactory, + ) -> None: + _, history = history_factory() + ran: List[str] = [] + + history.after_landing(lambda: ran.append("effect")) + + assert ran == ["effect"] + + def test_a_failed_gesture_drops_its_effects( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + ran: List[str] = [] + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + history.after_landing(lambda: ran.append("effect")) + raise RuntimeError("boom") + + assert ran == [] + + def test_a_gesture_failing_before_any_mutation_drops_its_effects( + self, + history_factory: HistoryFactory, + ) -> None: + _, history = history_factory() + ran: List[str] = [] + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): + history.after_landing(lambda: ran.append("effect")) + raise RuntimeError("boom") + + assert ran == [] + + def test_an_inner_scope_effect_waits_for_the_outermost( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + ran: List[str] = [] + + with history.transaction(HistoryAction.ADD_SAMPLE): + with history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + history.after_landing(lambda: ran.append("effect")) + assert ran == [] + + assert ran == ["effect"] + + def test_a_gesture_changing_nothing_runs_its_effects( + self, + history_factory: HistoryFactory, + ) -> None: + _, history = history_factory() + ran: List[str] = [] + + with history.transaction(HistoryAction.CUT_BLOCK): + history.after_landing(lambda: ran.append("effect")) + + assert ran == ["effect"] + assert len(history.entries) == 1 + + def test_effects_run_in_the_order_they_were_handed_in( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + ran: List[str] = [] + + with history.transaction(HistoryAction.SET_TEMPO): + history.after_landing(lambda: ran.append("first")) + controller.set_tempo(170) + history.after_landing(lambda: ran.append("second")) + + assert ran == ["first", "second"] + + def test_an_effect_handed_in_by_a_rollback_runs_at_once( + self, + history_factory: HistoryFactory, + ) -> None: + """The reinstall runs between gestures, so what it gives rise to follows the project it puts back.""" + controller, history = history_factory() + ran: List[str] = [] + controller.on_project_replaced = lambda: history.after_landing(lambda: ran.append("effect")) + + with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO): + controller.set_tempo(170) + raise RuntimeError("boom") + + assert ran == ["effect"] class TestCoalescing: @@ -364,7 +587,6 @@ def test_save_hook_marks_the_current_cursor( tmp_path: Path, ) -> None: controller, history = history_factory() - controller.on_saved = history.mark_saved with history.transaction(HistoryAction.SET_TEMPO): controller.set_tempo(150) diff --git a/tests/unit/sampletones_application/logic/history/test_rules.py b/tests/unit/sampletones_application/logic/history/test_rules.py new file mode 100644 index 000000000..fd02d7962 --- /dev/null +++ b/tests/unit/sampletones_application/logic/history/test_rules.py @@ -0,0 +1,450 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, ClassVar, Dict, Final, Optional, Protocol, Sequence, Tuple, Union + +import pytest + +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.history.transaction import CoalesceKey +from sampletones_application.logic.project.controller import ProjectController +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.unit.sampletones_application.logic.history.conftest import AuditedHistory, AuditFactory + +TEMPO_KEY: Final[CoalesceKey] = ("tempo",) +OTHER_KEY: Final[CoalesceKey] = ("other",) +SAVED_NAME: Final[str] = "saved.stp" +TIGHT_BUDGET: Final[int] = 3 +ROOMY_BUDGET: Final[int] = 20 + +INITIAL: Final = HistoryAction.INITIAL +TEMPO: Final = HistoryAction.SET_TEMPO +SPEED: Final = HistoryAction.SET_SPEED +PROPERTIES: Final = HistoryAction.EDIT_PROJECT_PROPERTIES + + +def _raise_tempo(controller: ProjectController) -> None: + controller.set_tempo(controller.project.settings.tempo + 1) + + +def _toggle_speed(controller: ProjectController) -> None: + controller.set_speed(controller.project.settings.speed % 2 + 5) + + +def _retitle(controller: ProjectController) -> None: + controller.set_title(f"{controller.project.info.title}!") + + +EDITS: Final[Dict[HistoryAction, Callable[[ProjectController], None]]] = { + TEMPO: _raise_tempo, + SPEED: _toggle_speed, + PROPERTIES: _retitle, +} + + +class Step(Protocol): + def run( + self, + audited: AuditedHistory, + saved: Path, + ) -> None: ... + + +@dataclass(frozen=True) +class Commit: + """One gesture of ``action`` on the target ``key`` names, checked by the audit.""" + + action: HistoryAction + key: Optional[CoalesceKey] + + def run( + self, + audited: AuditedHistory, + saved: Path, + ) -> None: + def gesture() -> None: + with audited.history.transaction(self.action, coalesce=self.key): + EDITS[self.action](audited.controller) + + audited.audit.perform(gesture, action=self.action, target=self.key) + + +@dataclass(frozen=True) +class Undo: + def run( + self, + audited: AuditedHistory, + saved: Path, + ) -> None: + audited.audit.undo() + + +@dataclass(frozen=True) +class Redo: + def run( + self, + audited: AuditedHistory, + saved: Path, + ) -> None: + audited.audit.redo() + + +@dataclass(frozen=True) +class Jump: + index: int + + def run( + self, + audited: AuditedHistory, + saved: Path, + ) -> None: + audited.audit.jump_to(self.index) + + +@dataclass(frozen=True) +class Save: + def run( + self, + audited: AuditedHistory, + saved: Path, + ) -> None: + audited.audit.save(lambda: audited.controller.save(saved)) + + +AnyStep = Union[Commit, Undo, Redo, Jump, Save] + + +def _walk( + audit_factory: AuditFactory, + tmp_path: Path, + steps: Sequence[AnyStep], + *, + budget: int, +) -> AuditedHistory: + audited = audit_factory(budget=budget) + for step in steps: + step.run(audited, tmp_path / SAVED_NAME) + + return audited + + +def _assert_stack( + audited: AuditedHistory, + *, + actions: Tuple[HistoryAction, ...], + cursor: int, + dirty: bool, +) -> None: + assert tuple(entry.action for entry in audited.history.entries) == actions + assert audited.history.cursor == cursor + assert audited.controller.is_dirty is dirty + + +class TestCoalescing(BaseTestSuite): + """Consecutive gestures of one action on one target make one entry, until something ends the run.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + steps: Tuple[AnyStep, ...] + expected: Tuple[HistoryAction, ...] + cursor: int + + test_cases: ClassVar[Sequence[TestCase]] = [ + TestCase( + label="one target twice runs together", + steps=(Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO), + cursor=1, + ), + TestCase( + label="one target three times runs together", + steps=(Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO), + cursor=1, + ), + TestCase( + label="two targets stay apart", + steps=(Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, OTHER_KEY)), + expected=(INITIAL, TEMPO, TEMPO), + cursor=2, + ), + TestCase( + label="two actions on one target stay apart", + steps=(Commit(TEMPO, TEMPO_KEY), Commit(SPEED, TEMPO_KEY)), + expected=(INITIAL, TEMPO, SPEED), + cursor=2, + ), + TestCase( + label="a gesture naming no target never runs", + steps=(Commit(PROPERTIES, None), Commit(PROPERTIES, None)), + expected=(INITIAL, PROPERTIES, PROPERTIES), + cursor=2, + ), + TestCase( + label="another gesture between ends the run", + steps=(Commit(TEMPO, TEMPO_KEY), Commit(SPEED, OTHER_KEY), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO, SPEED, TEMPO), + cursor=3, + ), + TestCase( + label="an undo and a redo between end the run", + steps=(Commit(TEMPO, TEMPO_KEY), Undo(), Redo(), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO, TEMPO), + cursor=2, + ), + TestCase( + label="a jump away and back ends the run", + steps=(Commit(TEMPO, TEMPO_KEY), Jump(0), Jump(1), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO, TEMPO), + cursor=2, + ), + TestCase( + label="a jump onto the cursor keeps the run", + steps=(Commit(TEMPO, TEMPO_KEY), Jump(1), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO), + cursor=1, + ), + TestCase( + label="an undo at the start keeps nothing from running", + steps=(Undo(), Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO), + cursor=1, + ), + TestCase( + label="a save between ends the run", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO, TEMPO), + cursor=2, + ), + TestCase( + label="a run starts again past the save point", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, TEMPO_KEY)), + expected=(INITIAL, TEMPO, TEMPO), + cursor=2, + ), + ] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_entries( + self, + test_case: TestCase, + audit_factory: AuditFactory, + tmp_path: Path, + ) -> None: + audited = _walk(audit_factory, tmp_path, test_case.steps, budget=ROOMY_BUDGET) + + assert tuple(entry.action for entry in audited.history.entries) == test_case.expected + assert audited.history.cursor == test_case.cursor + + +class TestSavePoint(BaseTestSuite): + """The entry last saved reads clean whenever the cursor stands on it, until it leaves the stack.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + steps: Tuple[AnyStep, ...] + budget: int + expected: Tuple[HistoryAction, ...] + cursor: int + dirty: bool + + test_cases: ClassVar[Sequence[TestCase]] = [ + TestCase( + label="an opened project starts clean", + steps=(), + budget=ROOMY_BUDGET, + expected=(INITIAL,), + cursor=0, + dirty=False, + ), + TestCase( + label="an edit leaves the save point", + steps=(Commit(TEMPO, TEMPO_KEY),), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO), + cursor=1, + dirty=True, + ), + TestCase( + label="an undo back onto the opened state is clean", + steps=(Commit(TEMPO, TEMPO_KEY), Undo()), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO), + cursor=0, + dirty=False, + ), + TestCase( + label="an undo past a save is dirty", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Undo()), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO), + cursor=0, + dirty=True, + ), + TestCase( + label="a redo back onto a save is clean", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Undo(), Redo()), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO), + cursor=1, + dirty=False, + ), + TestCase( + label="a jump onto a save is clean", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Commit(SPEED, OTHER_KEY), Jump(0), Jump(1)), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO, SPEED), + cursor=1, + dirty=False, + ), + TestCase( + label="a commit over the saved branch leaves nothing clean", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Undo(), Commit(SPEED, OTHER_KEY), Undo()), + budget=ROOMY_BUDGET, + expected=(INITIAL, SPEED), + cursor=0, + dirty=True, + ), + TestCase( + label="a run past the save point keeps the saved entry", + steps=(Commit(TEMPO, TEMPO_KEY), Save(), Commit(TEMPO, TEMPO_KEY), Commit(TEMPO, TEMPO_KEY), Undo()), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO, TEMPO), + cursor=1, + dirty=False, + ), + TestCase( + label="eviction moves the save point with its entry", + steps=( + Commit(TEMPO, None), + Save(), + Commit(SPEED, None), + Commit(PROPERTIES, None), + Undo(), + Undo(), + ), + budget=TIGHT_BUDGET, + expected=(TEMPO, SPEED, PROPERTIES), + cursor=0, + dirty=False, + ), + TestCase( + label="eviction of the saved entry leaves nothing clean", + steps=( + Commit(TEMPO, None), + Commit(SPEED, None), + Commit(PROPERTIES, None), + Undo(), + Undo(), + ), + budget=TIGHT_BUDGET, + expected=(TEMPO, SPEED, PROPERTIES), + cursor=0, + dirty=True, + ), + ] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_dirty_state( + self, + test_case: TestCase, + audit_factory: AuditFactory, + tmp_path: Path, + ) -> None: + audited = _walk(audit_factory, tmp_path, test_case.steps, budget=test_case.budget) + + _assert_stack( + audited, + actions=test_case.expected, + cursor=test_case.cursor, + dirty=test_case.dirty, + ) + + +class TestStackShape(BaseTestSuite): + """A commit drops the redo branch, and the budget drops the oldest entries.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + steps: Tuple[AnyStep, ...] + budget: int + expected: Tuple[HistoryAction, ...] + cursor: int + + test_cases: ClassVar[Sequence[TestCase]] = [ + TestCase( + label="a commit after two undos drops both redone entries", + steps=( + Commit(TEMPO, None), + Commit(SPEED, None), + Commit(PROPERTIES, None), + Undo(), + Undo(), + Commit(SPEED, None), + ), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO, SPEED), + cursor=2, + ), + TestCase( + label="a commit after a jump to the start keeps only the baseline", + steps=(Commit(TEMPO, None), Commit(SPEED, None), Jump(0), Commit(PROPERTIES, None)), + budget=ROOMY_BUDGET, + expected=(INITIAL, PROPERTIES), + cursor=1, + ), + TestCase( + label="undos and redos alone keep every entry", + steps=(Commit(TEMPO, None), Commit(SPEED, None), Undo(), Undo(), Redo(), Redo(), Redo()), + budget=ROOMY_BUDGET, + expected=(INITIAL, TEMPO, SPEED), + cursor=2, + ), + TestCase( + label="the budget drops the oldest entries", + steps=(Commit(TEMPO, None), Commit(SPEED, None), Commit(PROPERTIES, None), Commit(TEMPO, None)), + budget=TIGHT_BUDGET, + expected=(SPEED, PROPERTIES, TEMPO), + cursor=2, + ), + TestCase( + label="a stack at its budget keeps every entry", + steps=(Commit(TEMPO, None), Commit(SPEED, None)), + budget=TIGHT_BUDGET, + expected=(INITIAL, TEMPO, SPEED), + cursor=2, + ), + TestCase( + label="a run at the budget replaces the top and drops nothing", + steps=(Commit(TEMPO, None), Commit(SPEED, TEMPO_KEY), Commit(SPEED, TEMPO_KEY)), + budget=TIGHT_BUDGET, + expected=(INITIAL, TEMPO, SPEED), + cursor=2, + ), + ] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_entries( + self, + test_case: TestCase, + audit_factory: AuditFactory, + tmp_path: Path, + ) -> None: + audited = _walk(audit_factory, tmp_path, test_case.steps, budget=test_case.budget) + + assert tuple(entry.action for entry in audited.history.entries) == test_case.expected + assert audited.history.cursor == test_case.cursor + + def test_every_entry_the_budget_keeps_restores_its_state( + self, + audit_factory: AuditFactory, + tmp_path: Path, + ) -> None: + audited = _walk( + audit_factory, + tmp_path, + (Commit(TEMPO, None), Commit(SPEED, None), Commit(PROPERTIES, None), Commit(TEMPO, None)), + budget=TIGHT_BUDGET, + ) + + audited.audit.walk() diff --git a/tests/unit/sampletones_application/logic/instruction/conftest.py b/tests/unit/sampletones_application/logic/instruction/conftest.py new file mode 100644 index 000000000..f10810f7f --- /dev/null +++ b/tests/unit/sampletones_application/logic/instruction/conftest.py @@ -0,0 +1,20 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_application.config.managers.config import ConfigManager +from tests.suite.application import held_queue +from tests.suite.library import aim_library_directory + +LIBRARIES: Final[str] = "libraries" + +__all__ = ["held_queue"] + + +@pytest.fixture +def config_manager(tmp_path: Path) -> ConfigManager: + """A configuration keeping its libraries in the case's own directory.""" + manager = ConfigManager(tmp_path / "config.json") + aim_library_directory(manager, tmp_path / LIBRARIES) + return manager 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..7a9b98056 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -1,13 +1,29 @@ import threading -from typing import Dict, Final +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, Final, List, Optional from unittest.mock import MagicMock, patch import pytest from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.instruction import library_manager as library_manager_module from sampletones_application.logic.instruction.library import LibraryLogic +from sampletones_application.logic.instruction.library_manager import ( + InstructionsLibraryManager, +) from sampletones_application.paths import LANG_EN +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.instruction.library import ( + LibraryPanelViewModel, +) +from sampletones_core.configs import Config, InstructionsLibraryConfig +from sampletones_core.constants.enums import GeneratorName, SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.library import InstructionLibraryData, InstructionLibraryKey, LibraryState from sampletones_core.parallelization import TaskStatus +from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION from sampletones_shared.exceptions import ( DeserializationError, IncompatibleLibraryDataVersionError, @@ -17,7 +33,12 @@ LoadLibraryError, UnhandledLibraryError, ) +from tests.suite.application import HeldQueue +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.files import requires_symlinks from tests.suite.language import FakeLanguageManager +from tests.suite.library import LINKED_LIBRARIES, OTHER_LIBRARIES, WrittenLibrary, aim_library_directory LOAD_ERROR_KEY: Final[str] = "instructions.library.message.status_load_error" FILE_NOT_FOUND_KEY: Final[str] = "instructions.library.message.status_file_not_found" @@ -27,17 +48,25 @@ 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" +GENERATED_INSTRUCTIONS: Final[int] = 8 +EARLIER_VERSION: Final[str] = "2.0" +OTHER_GAMMA: Final[int] = 50 +OTHER_TUNING: Final[float] = 432.0 +EARLIER_WINDOW_SIZE: Final[int] = 13579 +DERIVED_WINDOW_SIZE: Final[int] = 0 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.", "instructions.library.template.library_exists_template": "{} exists.", + "instructions.library.template.library_outdated_template": "{} was built by another version.", + "instructions.library.label.rebuild_library_button": "Rebuild", "instructions.library.template.library_not_exists_template": "{} doesn't exist.", } @@ -51,6 +80,40 @@ def _logic(*, operation_active: bool) -> LibraryLogic: return logic +def _preparing_logic(state: LibraryState) -> LibraryLogic: + """A library logic whose configuration names a library standing as ``state``, bypassing the + heavy constructor.""" + logic = LibraryLogic.__new__(LibraryLogic) + logic._config_manager = MagicMock() + logic._library_manager = MagicMock() + logic._library_manager.library_state.return_value = state + logic.generate_library = MagicMock() + return logic + + +class TestPreparingTheLibraryForAConversion(BaseTestSuite): + """A conversion uses a library this build reads as it stands; any other is generated.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + state: LibraryState + expected: bool + + test_cases = ( + TestCase(label="a library this build reads", state=LibraryState.CURRENT, expected=False), + TestCase(label="a library another version built", state=LibraryState.OUTDATED, expected=True), + TestCase(label="no library", state=LibraryState.MISSING, expected=True), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_whether_it_is_generated(self, test_case: TestCase) -> None: + logic = _preparing_logic(test_case.state) + + logic.prepare_library() + + assert logic.generate_library.called is test_case.expected + + class TestRequestGeneration: """The user-initiated Generate path yields to an in-flight exclusive operation, so a standalone library generation cannot run alongside a conversion. A conversion's own preparatory generation @@ -177,11 +240,11 @@ def _generation_logic(*, generating: bool = True) -> LibraryLogic: """A library logic with only the state the generation emits touch, bypassing the heavy constructor.""" logic = LibraryLogic.__new__(LibraryLogic) - logic._status_lock = threading.Lock() logic._config_manager = MagicMock() logic._library_manager = MagicMock() logic._library_manager.is_generating.return_value = generating logic._library_manager.is_library_loaded.return_value = False + logic._library_manager.library_state.return_value = LibraryState.MISSING logic._language_manager = FakeLanguageManager(TEXTS) logic.on_view_changed = MagicMock() return logic @@ -191,13 +254,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() @@ -227,7 +290,6 @@ def test_update_status_yields_during_generation(self) -> None: def test_update_status_repaints_the_idle_state(self) -> None: logic = _generation_logic(generating=False) - logic._library_manager.library_exists_for_key.return_value = False with patch( "sampletones_application.logic.instruction.library.get_display_name_from_key", @@ -253,9 +315,22 @@ def test_update_status_reports_a_loaded_library(self) -> None: assert view_model.status_text == "lib loaded." assert view_model.generate_button_label == "Regenerate" + def test_update_status_reports_a_library_another_version_built(self) -> None: + logic = _generation_logic(generating=False) + logic._library_manager.library_state.return_value = LibraryState.OUTDATED + + with patch( + "sampletones_application.logic.instruction.library.get_display_name_from_key", + return_value="lib", + ): + logic.update_status() + + view_model = logic.on_view_changed.call_args.args[0] + assert view_model.status_text == "lib was built by another version." + def test_update_status_reports_an_existing_unloaded_library(self) -> None: logic = _generation_logic(generating=False) - logic._library_manager.library_exists_for_key.return_value = True + logic._library_manager.library_state.return_value = LibraryState.CURRENT with patch( "sampletones_application.logic.instruction.library.get_display_name_from_key", @@ -268,11 +343,643 @@ 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 TreeLock: + """The lock the catalog's tree shares with a generation, counted the way the tree counts it. + + Its release reaches ``on_released`` through the render loop's queue, as the tree's lock state does. + """ + + def __init__(self) -> None: + self.holders = 0 + self.on_released: Optional[Callable[[], None]] = None + + def lock(self) -> None: + self.holders += 1 + + def unlock(self) -> None: + was_held = self.holders > 0 + self.holders = max(0, self.holders - 1) + if was_held and self.holders == 0 and self.on_released is not None: + CallbackQueue.add(self.on_released) + + def locked(self) -> bool: + return self.holders > 0 + + +class Creator: + """A library creator whose workers have finished, holding the reports it was handed.""" + + total_instructions = GENERATED_INSTRUCTIONS + completed_instructions = GENERATED_INSTRUCTIONS + + def __init__(self, _config: Any, _window: Any) -> None: + self.callbacks: Dict[str, Callable[..., None]] = {} + + def set_callbacks(self, **callbacks: Callable[..., None]) -> None: + self.callbacks.update(callbacks) + + def start(self) -> None: + pass + + def shutdown(self) -> None: + pass + + +@dataclass +class Catalog: + """A library logic over a real catalog, with what it hands the tab written down.""" + + logic: LibraryLogic + manager: InstructionsLibraryManager + config_manager: ConfigManager + queue: HeldQueue + lock: TreeLock + rebuilds_under_lock: List[bool] = field(default_factory=list) + views: List[LibraryPanelViewModel] = field(default_factory=list) + outdated: List[InstructionLibraryKey] = field(default_factory=list) + missing: List[Path] = field(default_factory=list) + errors: List[Exception] = field(default_factory=list) + shown: List[Any] = field(default_factory=list) + + @property + def creator(self) -> Creator: + creator = self.manager.creator + assert isinstance(creator, Creator) + return creator + + def start_generation(self) -> None: + """Ask for a generation and take the start report its creator sends.""" + self.logic.generate_library() + self.creator.callbacks["on_start"]() + self.queue.drain() + + def write_library(self) -> None: + """Report the library complete from a thread of its own, as a creator's monitor does.""" + worker = threading.Thread( + target=self.creator.callbacks["on_completed"], + args=((self.config_manager.key, WrittenLibrary()),), + ) + worker.start() + worker.join() + + +@pytest.fixture +def catalog( + config_manager: ConfigManager, + held_queue: HeldQueue, + monkeypatch: pytest.MonkeyPatch, +) -> Catalog: + monkeypatch.setattr(library_manager_module, "InstructionsLibraryCreator", Creator) + manager = InstructionsLibraryManager(config_manager, language_manager=MagicMock()) + logic = LibraryLogic( + config_manager, + manager, + language_manager=FakeLanguageManager(TEXTS), + is_operation_active=lambda: False, + ) + lock = TreeLock() + logic.configure_lock(lock.lock, lock.unlock, lock.locked) + lock.on_released = logic.reload_remembered_library + config_manager.add_config_change_callback(logic.follow_config) + catalog = Catalog(logic=logic, manager=manager, config_manager=config_manager, queue=held_queue, lock=lock) + logic.on_rebuild_tree_needed = lambda: catalog.rebuilds_under_lock.append(lock.locked()) + logic.on_view_changed = catalog.views.append + logic.on_apply_library_config = config_manager.apply_library_config + logic.on_library_outdated = catalog.outdated.append + logic.on_load_file_not_found = lambda path, message: catalog.missing.append(path) + logic.on_load_error = lambda exception, message: catalog.errors.append(exception) + logic.on_instruction_loaded = catalog.shown.append + return catalog + + +def _other_settings(catalog: Catalog) -> InstructionsLibraryConfig: + """Library settings the catalog's configuration differs from, its tuning included.""" + return catalog.config_manager.config.library.model_copy( + update={ + "spectrum_method": SpectrumMethod.FFT, + "transformation_gamma": OTHER_GAMMA, + "a4_frequency": OTHER_TUNING, + } + ) + + +def _write_library( + directory: Path, + library_config: InstructionsLibraryConfig, + library_data_version: str, + window_size: int = DERIVED_WINDOW_SIZE, +) -> InstructionLibraryKey: + """Writes an empty library built for ``library_config`` under ``directory``, stated at + ``library_data_version``, its window ``window_size`` samples long where that is given and the one + this build derives otherwise.""" + key = InstructionLibraryKey.create(library_config, Window.from_config(library_config, custom_size=window_size)) + library = InstructionLibraryData.create(Config().model_copy(update={"library": library_config}), {}) + stated = library.metadata.model_copy(update={"library_data_version": library_data_version}) + directory.mkdir(parents=True, exist_ok=True) + library.model_copy(update={"metadata": stated}).save(directory / key.filename) + return key + + +def _opened_library(catalog: Catalog) -> InstructionLibraryKey: + """Writes the library the configuration names into the catalog's folder and opens it as Load does.""" + key = _write_library( + catalog.manager.library_directory, + catalog.config_manager.config.library, + SAMPLETONES_LIBRARY_DATA_VERSION, + ) + catalog.logic.load_library_and_set_current(key) + return key + + +def _rebuild(catalog: Catalog) -> None: + """Takes the start report the generation a rebuild asked for sends.""" + catalog.creator.callbacks["on_start"]() + catalog.queue.drain() + + +def _complete(catalog: Catalog) -> None: + catalog.write_library() + + +def _cancel(catalog: Catalog) -> None: + catalog.creator.callbacks["on_progress"](TaskStatus.CANCELED, MagicMock()) + catalog.creator.callbacks["on_canceled"]() + + +def _fail(catalog: Catalog) -> None: + catalog.creator.callbacks["on_error"](RuntimeError("generation failed")) + + +ENDINGS: Final = pytest.mark.parametrize( + "ending", + [_complete, _cancel, _fail], + ids=["completed", "canceled", "failed"], +) + + +class TestAGenerationClosing: + """A generation reports from the creator's threads, and the render loop takes each report up: + the close lets the tree lock go, then reads the catalog the library was written into.""" + + def test_the_lock_is_taken_when_the_generation_is_asked_for(self, catalog: Catalog) -> None: + catalog.logic.generate_library() + + assert (catalog.manager.is_generating(), catalog.lock.locked()) == (True, True) + + def test_the_close_waits_for_the_render_loop(self, catalog: Catalog) -> None: + catalog.start_generation() + + catalog.write_library() + + assert (catalog.manager.is_generating(), catalog.lock.locked(), catalog.rebuilds_under_lock) == ( + True, + True, + [], + ) + + def test_the_tree_is_rebuilt_once_the_lock_is_let_go(self, catalog: Catalog) -> None: + catalog.start_generation() + catalog.write_library() + + catalog.queue.drain() + + assert catalog.rebuilds_under_lock == [False] + + def test_the_library_written_reads_as_loaded(self, catalog: Catalog) -> None: + catalog.start_generation() + catalog.write_library() + + catalog.queue.drain() + + assert catalog.manager.is_library_loaded(catalog.config_manager.key) is True + assert catalog.views[-1].generate_button_label == "Regenerate" + + @ENDINGS + def test_every_ending_paints_the_idle_controls(self, catalog: Catalog, ending: Any) -> None: + catalog.start_generation() + + ending(catalog) + catalog.queue.drain() + + assert (catalog.manager.is_generating(), catalog.views[-1].is_generating, catalog.lock.locked()) == ( + False, + False, + False, + ) + + +class TestTheCatalogFollowingTheConfiguration: + """A configuration change naming another library directory roots the catalog there, and any + other change repaints the status over the libraries already loaded.""" + + def test_another_directory_is_read_afresh(self, catalog: Catalog, tmp_path: Path) -> None: + other = tmp_path / OTHER_LIBRARIES + + aim_library_directory(catalog.config_manager, other) + + assert (catalog.manager.library_directory, catalog.rebuilds_under_lock) == (other, [False]) + + def test_the_same_directory_repaints_over_what_is_loaded(self, catalog: Catalog) -> None: + catalog.manager._catalog.library.save_data(catalog.config_manager.key, WrittenLibrary()) + + aim_library_directory(catalog.config_manager, catalog.config_manager.get_library_directory()) + + assert catalog.rebuilds_under_lock == [] + assert catalog.views[-1].generate_button_label == "Regenerate" + + def test_a_folder_pointed_away_from_and_back_loads_its_library_again( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The folder left lets its library go, and the way back loads the library it had open.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + away = catalog.views[-1].generate_button_label + + aim_library_directory(catalog.config_manager, ours) + + assert (away, catalog.manager.is_library_loaded(key), catalog.views[-1].generate_button_label) == ( + "Generate", + True, + "Regenerate", + ) + + def test_a_library_the_folder_only_took_up_stays_unloaded_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A start takes up the library the configuration names without loading it, so the reader never + opened it and the way back leaves it as it stood.""" + ours = catalog.config_manager.get_library_directory() + key = _write_library(ours, catalog.config_manager.config.library, SAMPLETONES_LIBRARY_DATA_VERSION) + catalog.logic.refresh_libraries(load_if_needed=False) + taken_up = (catalog.manager.current_library_key, catalog.manager.is_library_loaded(key)) + + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + aim_library_directory(catalog.config_manager, ours) + + assert (taken_up, catalog.manager.is_library_loaded(key), catalog.views[-1].generate_button_label) == ( + (key, False), + False, + "Generate", + ) + + def test_a_library_never_opened_whose_file_left_while_away_goes_unreported( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + ours = catalog.config_manager.get_library_directory() + key = _write_library(ours, catalog.config_manager.config.library, SAMPLETONES_LIBRARY_DATA_VERSION) + catalog.logic.refresh_libraries(load_if_needed=False) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + (ours / key.filename).unlink() + + aim_library_directory(catalog.config_manager, ours) + + assert (catalog.missing, catalog.errors) == ([], []) + + @requires_symlinks + def test_a_link_to_the_same_folder_repaints_over_what_is_loaded( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A link names the folder the catalog stands at, so the catalog keeps what it loaded.""" + key = _opened_library(catalog) + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(catalog.config_manager.get_library_directory(), target_is_directory=True) + rebuilds = len(catalog.rebuilds_under_lock) + + aim_library_directory(catalog.config_manager, link) + + assert (len(catalog.rebuilds_under_lock), catalog.manager.is_library_loaded(key)) == (rebuilds, True) + assert catalog.views[-1].generate_button_label == "Regenerate" + + @requires_symlinks + def test_a_notice_names_the_folder_by_the_link_the_reader_chose( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + key = _opened_library(catalog) + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(catalog.config_manager.get_library_directory(), target_is_directory=True) + aim_library_directory(catalog.config_manager, link) + (link / key.filename).unlink() + + catalog.logic.load_library_and_set_current(key) + + assert catalog.missing == [link / key.filename] + + @requires_symlinks + def test_a_library_generated_across_a_respelling_lands_loaded_under_the_new_spelling( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A link names the folder the generation started in, so the generation's library stays in memory, + and its path goes through the link.""" + ours = catalog.config_manager.get_library_directory() + ours.mkdir(parents=True) + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(ours, target_is_directory=True) + catalog.start_generation() + + aim_library_directory(catalog.config_manager, link) + catalog.write_library() + catalog.queue.drain() + + key = catalog.config_manager.key + assert (catalog.manager.is_library_loaded(key), catalog.manager.get_path(key)) == ( + True, + link / key.filename, + ) + + def test_a_library_whose_file_left_while_away_is_reported_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The way back loads the library as a Load does, so a file gone meanwhile is reported, once, and + the folder's choice goes with it.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + path = catalog.manager.get_path(key) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + path.unlink() + + aim_library_directory(catalog.config_manager, ours) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + aim_library_directory(catalog.config_manager, ours) + + assert (catalog.missing, catalog.manager.is_library_loaded(key), catalog.manager.current_library_key) == ( + [path], + False, + None, + ) + + def test_a_library_another_build_rewrote_while_away_asks_to_be_rebuilt_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The way back opens the library as a Load does, so a file another version wrote meanwhile asks + for a rebuild, and the folder's choice goes with it.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + _write_library(ours, catalog.config_manager.config.library, EARLIER_VERSION) + + aim_library_directory(catalog.config_manager, ours) + + assert ( + catalog.outdated, + catalog.errors, + catalog.manager.is_library_loaded(key), + catalog.manager.current_library_key, + ) == ([key], [], False, None) + + def test_the_way_back_keeps_the_settings_the_reader_left_with( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The way back brings the folder's library back as it stood, and the settings stay as the reader + set them while away.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + settings = _other_settings(catalog) + catalog.config_manager.apply_library_config( + InstructionLibraryKey.create(settings, Window.from_config(settings)), + settings, + ) + + aim_library_directory(catalog.config_manager, ours) + + assert (catalog.manager.is_library_loaded(key), catalog.config_manager.config.library) == (True, settings) + + def test_a_library_generated_while_away_is_written_held_nowhere_and_loaded_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + ours = catalog.config_manager.get_library_directory() + started_in = catalog.manager._catalog + catalog.start_generation() + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + + catalog.write_library() + catalog.queue.drain() + held_while_away = dict(started_in.library.data) + aim_library_directory(catalog.config_manager, ours) + + assert ( + (ours / catalog.config_manager.key.filename).exists(), + held_while_away, + catalog.manager.is_library_loaded(catalog.config_manager.key), + ) == (True, {}, True) + + def test_a_way_back_during_a_generation_loads_once_the_tree_stands_free( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A generation holds the tree, so the way back waits for it before loading the folder's library.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + catalog.start_generation() + + aim_library_directory(catalog.config_manager, ours) + during = catalog.manager.is_library_loaded(key) + catalog.write_library() + catalog.queue.drain() + + assert (during, catalog.manager.is_library_loaded(key)) == (False, True) + + @ENDINGS + def test_a_change_during_a_generation_stands_whatever_the_generation_came_to( + self, + catalog: Catalog, + ending: Any, + tmp_path: Path, + ) -> None: + other = tmp_path / OTHER_LIBRARIES + catalog.start_generation() + + aim_library_directory(catalog.config_manager, other) + ending(catalog) + catalog.queue.drain() + + assert catalog.manager.library_directory == catalog.config_manager.get_library_directory() == other + + def test_a_library_generated_across_a_change_lands_where_the_generation_started( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + started_in = catalog.config_manager.get_library_directory() + catalog.start_generation() + + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + catalog.write_library() + catalog.queue.drain() + + assert (started_in / catalog.config_manager.key.filename).exists() + assert catalog.manager.library_state(catalog.config_manager.key) is LibraryState.MISSING + + +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] + + +class TestOpeningALibrary: + """Opening a library loads one this build reads and brings its settings along; a library + another version built is put to the reader first.""" + + def test_a_library_another_version_built_asks_before_anything_changes(self, catalog: Catalog) -> None: + settings = catalog.config_manager.config + key = _write_library(catalog.manager.library_directory, _other_settings(catalog), EARLIER_VERSION) + + catalog.logic.load_library_and_set_current(key) + + assert (catalog.outdated, catalog.config_manager.config, catalog.manager.is_library_loaded(key)) == ( + [key], + settings, + False, + ) + + def test_a_library_this_build_reads_brings_its_settings_tuning_included(self, catalog: Catalog) -> None: + library_config = _other_settings(catalog) + key = _write_library(catalog.manager.library_directory, library_config, SAMPLETONES_LIBRARY_DATA_VERSION) + + catalog.logic.load_library_and_set_current(key) + + assert (catalog.config_manager.config.library, catalog.config_manager.key) == (library_config, key) + + def test_a_file_opened_by_path_standing_as_another_version_built_it_asks(self, catalog: Catalog) -> None: + key = _write_library(catalog.manager.library_directory, _other_settings(catalog), EARLIER_VERSION) + + catalog.logic.load_library_file(catalog.manager.get_path(key)) + + assert catalog.outdated == [key] + + def test_a_file_outside_the_directory_with_no_copy_inside_is_reported_missing( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + settings = catalog.config_manager.config + key = _write_library(tmp_path / OTHER_LIBRARIES, _other_settings(catalog), SAMPLETONES_LIBRARY_DATA_VERSION) + + catalog.logic.load_library_file(tmp_path / OTHER_LIBRARIES / key.filename) + + assert (catalog.missing, catalog.config_manager.config) == ([catalog.manager.get_path(key)], settings) + + def test_a_generator_of_a_library_another_version_built_shows_nothing(self, catalog: Catalog) -> None: + key = _write_library(catalog.manager.library_directory, _other_settings(catalog), EARLIER_VERSION) + + catalog.logic.load_library_generator(key, GeneratorName.PULSE) + + assert (catalog.outdated, catalog.shown) == ([key], []) + + +class TestRebuildingALibrary: + """A rebuild takes the settings the library was built for and generates it in its place.""" + + def test_the_settings_it_was_built_for_are_applied_and_the_generation_starts(self, catalog: Catalog) -> None: + library_config = _other_settings(catalog) + key = _write_library(catalog.manager.library_directory, library_config, EARLIER_VERSION) + + catalog.logic.rebuild_library(key) + + assert (catalog.config_manager.key, catalog.config_manager.config.library, catalog.manager.is_generating()) == ( + key, + library_config, + True, + ) + + def test_a_library_rebuilt_under_another_name_leaves_only_the_new_file(self, catalog: Catalog) -> None: + """A library an earlier build wrote for another window carries that window in its name, and the + rebuild writes the library this build derives in its place.""" + directory = catalog.manager.library_directory + key = _write_library(directory, _other_settings(catalog), EARLIER_VERSION, EARLIER_WINDOW_SIZE) + replaced = catalog.manager.get_path(key) + + catalog.logic.rebuild_library(key) + _rebuild(catalog) + catalog.write_library() + catalog.queue.drain() + + assert (replaced.exists(), catalog.manager.get_path(catalog.config_manager.key).exists()) == (False, True) + + def test_a_library_rebuilt_under_its_own_name_stays(self, catalog: Catalog) -> None: + key = _write_library(catalog.manager.library_directory, _other_settings(catalog), EARLIER_VERSION) + + catalog.logic.rebuild_library(key) + _rebuild(catalog) + catalog.write_library() + catalog.queue.drain() + + assert (catalog.config_manager.key, catalog.manager.library_state(key)) == (key, LibraryState.CURRENT) + + @pytest.mark.parametrize("ending", [_cancel, _fail], ids=["canceled", "failed"]) + def test_a_rebuild_that_ends_unwritten_keeps_the_file_through_later_generations( + self, + catalog: Catalog, + ending: Any, + ) -> None: + directory = catalog.manager.library_directory + key = _write_library(directory, _other_settings(catalog), EARLIER_VERSION, EARLIER_WINDOW_SIZE) + replaced = catalog.manager.get_path(key) + + catalog.logic.rebuild_library(key) + _rebuild(catalog) + ending(catalog) + catalog.queue.drain() + catalog.start_generation() + catalog.write_library() + catalog.queue.drain() + + assert replaced.exists() + + def test_a_rebuild_during_another_operation_changes_nothing(self, catalog: Catalog) -> None: + settings = catalog.config_manager.config + key = _write_library(catalog.manager.library_directory, _other_settings(catalog), EARLIER_VERSION) + catalog.logic._is_operation_active = lambda: True + + catalog.logic.rebuild_library(key) + + assert (catalog.config_manager.config, catalog.manager.is_generating()) == (settings, False) + + +class TestRefreshingOverALibraryAnotherVersionBuilt: + def test_it_is_reported_and_left_unloaded(self, catalog: Catalog) -> None: + key = _write_library( + catalog.manager.library_directory, + catalog.config_manager.config.library, + EARLIER_VERSION, + ) + + with patch( + "sampletones_application.logic.instruction.library.get_display_name_from_key", + return_value="lib", + ): + catalog.logic.refresh_libraries() + + assert ( + catalog.manager.is_library_loaded(key), + catalog.views[-1].status_text, + catalog.views[-1].generate_button_label, + ) == (False, "lib was built by another version.", "Rebuild") diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py index dddedc18b..eb3b51f14 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py @@ -1,4 +1,6 @@ +import shutil from pathlib import Path +from typing import List from unittest.mock import MagicMock import pytest @@ -7,23 +9,26 @@ from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) +from sampletones_application.logic.instruction.readiness import LibraryReadiness from sampletones_application.view_model.main.updates import AdvancedSettingsUpdate -from sampletones_core.library import InstructionLibraryKey - - -@pytest.fixture -def config_manager(tmp_path: Path) -> ConfigManager: - return ConfigManager(tmp_path / "config.json") +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.library import InstructionLibraryKey, LibraryState +from sampletones_core.structures.tree import LibraryNode +from tests.suite.compatibility import LIBRARY_VERSION, archived +from tests.suite.files import requires_symlinks +from tests.suite.library import ( + LINKED_LIBRARIES, + OTHER_LIBRARIES, + WrittenLibrary, + aim_library_directory, + write_empty_library, +) @pytest.fixture -def library_manager( - config_manager: ConfigManager, - tmp_path: Path, -) -> InstructionsLibraryManager: - manager = InstructionsLibraryManager(config_manager, language_manager=MagicMock()) - manager.set_library_directory(tmp_path / "libraries") - return manager +def library_manager(config_manager: ConfigManager) -> InstructionsLibraryManager: + return InstructionsLibraryManager(config_manager, language_manager=MagicMock()) def _set_transformation_gamma(config_manager: ConfigManager, gamma: int) -> None: @@ -42,22 +47,23 @@ def _set_transformation_gamma(config_manager: ConfigManager, gamma: int) -> None def _create_library_file( library_manager: InstructionsLibraryManager, key: InstructionLibraryKey, +) -> None: + write_empty_library(library_manager.get_path(key)) + + +def _create_earlier_library_file( + library_manager: InstructionsLibraryManager, + key: InstructionLibraryKey, ) -> None: path = library_manager.get_path(key) path.parent.mkdir(parents=True, exist_ok=True) - path.touch() - + shutil.copyfile(archived(ObjectKind.LIBRARY, LIBRARY_VERSION), path) -class TestConversionLibraryReadiness: - """Guards the contract the conversion flow relies on. - Conversion reconstructs from the library identified by the *current* configuration, so - library readiness must be judged by the configuration's key, never by the key of whatever - library happens to be loaded. Conflating the two caused the loaded library's parameters to - be reapplied over the user's freshly changed settings (see the convert/reconstruct flow). - """ +class TestTheLibraryAConfigurationNames: + """A library is judged by the key it is asked about, whatever library the catalog last took up.""" - def test_availability_tracks_current_config_not_loaded_key( + def test_the_library_of_settings_changed_since_is_missing( self, config_manager: ConfigManager, library_manager: InstructionsLibraryManager, @@ -69,19 +75,244 @@ def test_availability_tracks_current_config_not_loaded_key( _set_transformation_gamma(config_manager, 100) - assert library_manager.is_library_available_for_config() is False - assert library_manager.does_library_exist() is True - assert config_manager.config.library.transformation_gamma == 100 + assert ( + library_manager.library_state(config_manager.key), + library_manager.library_state(previous_key), + library_manager.current_library_key, + ) == (LibraryState.MISSING, LibraryState.CURRENT, previous_key) + + def test_a_library_another_version_built_is_left_unsynced( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + _create_earlier_library_file(library_manager, config_manager.key) + + assert ( + library_manager.library_state(config_manager.key), + library_manager.sync_with_config_key(config_manager.key), + ) == (LibraryState.OUTDATED, None) + - def test_availability_true_when_current_config_library_exists( +class TestTheLibrariesTheCatalogLists: + def test_a_library_another_version_built_is_marked_and_holds_no_generators( self, config_manager: ConfigManager, library_manager: InstructionsLibraryManager, ) -> None: + """A library another version built opens only to be rebuilt, so it lists nothing to load.""" + _set_transformation_gamma(config_manager, 50) + earlier_key = config_manager.key + _create_earlier_library_file(library_manager, earlier_key) _set_transformation_gamma(config_manager, 100) _create_library_file(library_manager, config_manager.key) - assert library_manager.is_library_available_for_config() is True + library_manager.gather_available_libraries() + library_manager.rebuild_tree() + + root = library_manager.tree.get_root() + assert root is not None + rows = { + node.library_key: (node.outdated, len(node.children)) + for node in root.children + if isinstance(node, LibraryNode) + } + assert rows == {earlier_key: (True, 0), config_manager.key: (False, len(GeneratorName))} + + +class TestTheDirectoryTheCatalogStandsAt: + """The folder the catalog stands at keeps the libraries it loaded. A folder left lets them go and keeps + the one it took up, so the reader coming back finds that choice again.""" + + @staticmethod + def _loaded_here(library_manager: InstructionsLibraryManager, key: InstructionLibraryKey) -> None: + """Writes the library ``key`` names into the directory the catalog stands at, and loads it.""" + _create_library_file(library_manager, key) + library_manager.load_library(key) + + def test_the_directory_it_stands_at_keeps_what_it_loaded( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + self._loaded_here(library_manager, config_manager.key) + + moved = library_manager.set_library_directory(config_manager.get_library_directory()) + + assert (moved, library_manager.is_library_loaded(config_manager.key)) == (False, True) + + def test_another_directory_starts_with_nothing_loaded_or_taken_up( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + self._loaded_here(library_manager, config_manager.key) + other = tmp_path / OTHER_LIBRARIES + + moved = library_manager.set_library_directory(other) + + assert ( + moved, + library_manager.library_directory, + library_manager.is_library_loaded(config_manager.key), + library_manager.current_library_key, + ) == (True, other, False, None) + + def test_pointing_away_lets_the_data_go_and_keeps_the_choice( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + """A folder left holds no library in memory, and the reader coming back finds its choice standing + unloaded, for the catalog's logic to load again.""" + ours = config_manager.get_library_directory() + self._loaded_here(library_manager, config_manager.key) + left = library_manager._catalog + + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.gather_available_libraries() + held_while_away = dict(left.library.data) + library_manager.set_library_directory(ours) + library_manager.gather_available_libraries() + + assert ( + held_while_away, + library_manager.is_library_loaded(config_manager.key), + library_manager.current_library_key, + ) == ({}, False, config_manager.key) + + def test_the_way_back_hands_over_the_library_loaded_when_the_reader_left_once( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + ours = config_manager.get_library_directory() + self._loaded_here(library_manager, config_manager.key) + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.set_library_directory(ours) + + assert (library_manager.take_released_library(), library_manager.take_released_library()) == ( + config_manager.key, + None, + ) + + def test_a_library_taken_up_unloaded_leaves_nothing_to_load_on_the_way_back( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + """The configuration's library is taken up at a start without loading, so the reader never opened + it and a way back has nothing to load again.""" + ours = config_manager.get_library_directory() + _create_library_file(library_manager, config_manager.key) + library_manager.sync_with_config_key(config_manager.key) + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.set_library_directory(ours) + + assert (library_manager.current_library_key, library_manager.take_released_library()) == ( + config_manager.key, + None, + ) + + def test_what_the_other_directory_took_up_stays_with_it( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + ours = config_manager.get_library_directory() + other = tmp_path / OTHER_LIBRARIES + library_manager.set_library_directory(other) + self._loaded_here(library_manager, config_manager.key) + + library_manager.set_library_directory(ours) + + assert (library_manager.is_library_loaded(config_manager.key), library_manager.current_library_key) == ( + False, + None, + ) + library_manager.set_library_directory(other) + assert library_manager.current_library_key == config_manager.key + + def test_a_library_whose_file_left_while_away_is_let_go( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + ours = config_manager.get_library_directory() + self._loaded_here(library_manager, config_manager.key) + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.gather_available_libraries() + (ours / config_manager.key.filename).unlink() + + library_manager.set_library_directory(ours) + library_manager.gather_available_libraries() + + assert library_manager.is_library_loaded(config_manager.key) is False + + +class TestAnotherSpellingOfTheFolder: + """Two spellings of one folder name one catalog, so pointing the catalog at another spelling keeps + what the folder loaded and moves nowhere. The folder goes by the spelling given last.""" + + @staticmethod + def _respelled_keeps_what_it_loaded( + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + spelling: Path, + ) -> None: + _create_library_file(library_manager, config_manager.key) + library_manager.load_library(config_manager.key) + + moved = library_manager.set_library_directory(spelling) + + assert ( + moved, + library_manager.is_library_loaded(config_manager.key), + library_manager.library_directory, + library_manager.get_path(config_manager.key), + ) == (False, True, spelling, spelling / config_manager.key.filename) + + def test_a_detour_through_the_parent( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + folder = config_manager.get_library_directory() + detour = folder.parent / ".." / folder.parent.name / folder.name + + self._respelled_keeps_what_it_loaded(config_manager, library_manager, detour) + + @requires_symlinks + def test_a_link_to_the_folder( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(config_manager.get_library_directory(), target_is_directory=True) + + self._respelled_keeps_what_it_loaded(config_manager, library_manager, link) + + @requires_symlinks + def test_a_folder_configured_through_a_link_goes_by_the_link_from_the_start( + self, + config_manager: ConfigManager, + tmp_path: Path, + ) -> None: + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(config_manager.get_library_directory(), target_is_directory=True) + aim_library_directory(config_manager, link) + + library_manager = InstructionsLibraryManager(config_manager, language_manager=MagicMock()) + + assert library_manager.get_path(config_manager.key) == link / config_manager.key.filename class TestCompleteGeneration: @@ -96,15 +327,15 @@ def test_file_error_reports_and_reraises( self, library_manager: InstructionsLibraryManager, ) -> None: - library_manager._library = MagicMock() - library_manager._library.save_data.side_effect = PermissionError("save failed") + catalog = MagicMock() + catalog.library.write_data.side_effect = PermissionError("save failed") error_callback = MagicMock() completed_callback = MagicMock() library_manager.on_generation_error = error_callback library_manager.on_generation_completed = completed_callback with pytest.raises(PermissionError): - library_manager._complete_generation((MagicMock(), MagicMock())) + library_manager._complete_generation(catalog, (MagicMock(), MagicMock())) error_callback.assert_called_once() completed_callback.assert_not_called() @@ -113,13 +344,13 @@ def test_unexpected_error_propagates_directly( self, library_manager: InstructionsLibraryManager, ) -> None: - library_manager._library = MagicMock() - library_manager._library.save_data.side_effect = RuntimeError("unexpected") + catalog = MagicMock() + catalog.library.write_data.side_effect = RuntimeError("unexpected") error_callback = MagicMock() library_manager.on_generation_error = error_callback with pytest.raises(RuntimeError): - library_manager._complete_generation((MagicMock(), MagicMock())) + library_manager._complete_generation(catalog, (MagicMock(), MagicMock())) error_callback.assert_not_called() @@ -127,12 +358,123 @@ def test_successful_save_sets_current_key_and_completes( self, library_manager: InstructionsLibraryManager, ) -> None: - library_manager._library = MagicMock() + library_manager._catalog.library = MagicMock() completed_callback = MagicMock() library_manager.on_generation_completed = completed_callback key = MagicMock() - library_manager._complete_generation((key, MagicMock())) + library_manager._complete_generation(library_manager._catalog, (key, MagicMock())) - assert library_manager._current_library_key is key + assert library_manager.current_library_key is key completed_callback.assert_called_once() + + def test_a_library_lands_where_its_generation_started( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + started_in = library_manager._catalog + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + + library_manager._complete_generation(started_in, (config_manager.key, WrittenLibrary())) + + assert started_in.library.get_path(config_manager.key).exists() + assert (library_manager.library_state(config_manager.key), library_manager.current_library_key) == ( + LibraryState.MISSING, + None, + ) + + def test_a_library_generated_while_away_is_written_and_chosen_and_held_nowhere( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + """The folder the generation started in is left, so it keeps the library's file and its choice and + no data, and the reader coming back finds the library chosen there.""" + ours = config_manager.get_library_directory() + started_in = library_manager._catalog + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + + library_manager._complete_generation(started_in, (config_manager.key, WrittenLibrary())) + library_manager.set_library_directory(ours) + + assert ( + started_in.library.get_path(config_manager.key).exists(), + started_in.library.data, + library_manager.current_library_key, + ) == (True, {}, config_manager.key) + + +class TestTheLibraryAConversionWaitsFor: + """A conversion waits out a generation, then takes the library or gives the request up.""" + + def test_a_generation_under_way_is_waited_out( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + _create_library_file(library_manager, config_manager.key) + library_manager._creator = CreatorEndingItsRun(library_manager) + + readiness = library_manager.library_readiness(config_manager.get_library_directory(), config_manager.key) + + assert readiness == LibraryReadiness.PREPARING + + def test_a_library_on_disk_with_no_generation_is_ready( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + _create_library_file(library_manager, config_manager.key) + + readiness = library_manager.library_readiness(config_manager.get_library_directory(), config_manager.key) + + assert readiness == LibraryReadiness.READY + + def test_a_library_another_version_built_with_no_generation_is_missing( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + _create_earlier_library_file(library_manager, config_manager.key) + + readiness = library_manager.library_readiness(config_manager.get_library_directory(), config_manager.key) + + assert readiness == LibraryReadiness.MISSING + + def test_a_library_missing_with_no_generation_is_missing( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + readiness = library_manager.library_readiness(config_manager.get_library_directory(), config_manager.key) + + assert readiness == LibraryReadiness.MISSING + + +class CreatorEndingItsRun: + """A creator that notes whether its generation still read as in progress while it ended.""" + + def __init__(self, manager: InstructionsLibraryManager) -> None: + self._manager = manager + self.generating_while_ending: List[bool] = [] + + def shutdown(self) -> None: + self.generating_while_ending.append(self._manager.is_generating()) + + +class TestReleasingTheCreator: + """A generation reads as in progress until its creator has ended the pool it ran on.""" + + def test_the_generation_reads_as_in_progress_until_the_creator_has_ended( + self, + library_manager: InstructionsLibraryManager, + ) -> None: + creator = CreatorEndingItsRun(library_manager) + library_manager._creator = creator + + library_manager.release_creator() + + assert (creator.generating_while_ending, library_manager.is_generating()) == ([True], False) diff --git a/tests/unit/sampletones_application/logic/main/converter/__init__.py b/tests/unit/sampletones_application/logic/main/converter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/main/converter/test_derive.py b/tests/unit/sampletones_application/logic/main/converter/test_derive.py new file mode 100644 index 000000000..079ba1c86 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_derive.py @@ -0,0 +1,165 @@ +from pathlib import Path +from typing import List, Sequence, Tuple + +from sampletones_application.logic.main.converter.derive import derive_conversion_setup +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import recording + + +def _path(name: str) -> Path: + return Path(f"/audio/{name}.wav") + + +def _gathered( + *levels: Sequence[str], + holding: Sequence[ChannelName] = (ChannelName.PULSE1,), +) -> Tuple[SourceList, MixLevels]: + """A list and the levels naming it, every recording holding the same channels.""" + sources = SourceList() + for level in levels: + for name in level: + sources = sources.add_recording(recording(str(_path(name)), holding)) + + return sources, MixLevels.of([[_path(name) for name in level] for level in levels]) + + +class TestWhatEachRecordingBringsToTheSetup(BaseTestSuite): + def test_a_recording_carries_the_channels_its_own_row_holds(self) -> None: + sources, levels = _gathered(["lead"], holding=[ChannelName.PULSE1, ChannelName.PULSE2]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.channels == (ChannelName.PULSE1, ChannelName.PULSE2) + + def test_a_bend_the_recording_carries_reaches_the_entry(self) -> None: + sources = SourceList().add_recording( + recording(str(_path("lead")), [ChannelName.TRIANGLE], [ChannelName.TRIANGLE]) + ) + levels = MixLevels.of([[_path("lead")]]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.bends == (ChannelName.TRIANGLE,) + + +class TestTheSetupTheLevelsAmountTo(BaseTestSuite): + def test_a_recordings_position_is_its_stem_id(self) -> None: + sources, levels = _gathered(["a", "b"]) + + setup = derive_conversion_setup( + sources, + levels, + 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: + sources, levels = _gathered(["a", "c"], ["b"]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.hierarchy.levels == ((0, 1), (2,)) + + def test_the_mix_lists_the_recordings_in_entry_order(self) -> None: + sources, levels = _gathered(["a"], ["b"]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert setup.sources == (_path("a"), _path("b")) + + def test_the_mode_travels_with_the_setup(self) -> None: + sources, levels = _gathered(["a"]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert setup.stems.hierarchy.mode == HierarchyMode.ROUND_ROBIN + + def test_each_recording_carries_the_count_its_own_row_holds(self) -> None: + """A count belongs to the recording, so the entry it becomes states what its row said.""" + sources = SourceList().add_recording( + recording(str(_path("lead")), [ChannelName.PULSE1, ChannelName.PULSE2], channel_cap=1) + ) + levels = MixLevels.of([[_path("lead")]]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.channel_cap == 1 + + +class TestARecordingThatTakesNoPart(BaseTestSuite): + """A recording left holding no channel the run enables reaches neither the mix nor the entries.""" + + def _silent_beside(self, names: List[str]) -> Tuple[SourceList, MixLevels]: + sources = SourceList() + for name in names: + channels = [] if name == "silent" else [ChannelName.PULSE1] + sources = sources.add_recording(recording(str(_path(name)), channels)) + + return sources, MixLevels.of([[_path(name)] for name in names]) + + def test_it_reaches_neither_the_mix_nor_the_entries(self) -> None: + sources = SourceList() + sources = sources.add_recording(recording(str(_path("a")), [ChannelName.PULSE1])) + sources = sources.add_recording(recording(str(_path("silent")), [])) + sources = sources.add_recording(recording(str(_path("b")), [ChannelName.PULSE1])) + levels = MixLevels.of([[_path("a"), _path("silent")], [_path("b")]]) + + setup = derive_conversion_setup( + sources, + levels, + 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: + sources, levels = self._silent_beside(["silent", "b"]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.hierarchy.levels == ((0,),) + + def test_a_path_the_list_never_gathered_takes_no_part(self) -> None: + sources, levels = _gathered(["a"]) + levels = MixLevels.of([[_path("a"), _path("stranger")]]) + + setup = derive_conversion_setup( + sources, + levels, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.sources == (_path("a"),) diff --git a/tests/unit/sampletones_application/logic/main/converter/test_destination.py b/tests/unit/sampletones_application/logic/main/converter/test_destination.py new file mode 100644 index 000000000..430b33c28 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Final + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter import BatchConversion, BatchEntry, GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite + +_PULSE: Final[StemsConfig] = StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])) +_TRIANGLE: Final[StemsConfig] = StemsConfig.single_entry(StemSettings.covering([ChannelName.TRIANGLE])) + + +class TestWhereARunWrites(BaseTestSuite): + """The destination names what the run's plan names, and a run with no plan names nothing new.""" + + def test_a_completed_run_names_what_it_wrote(self) -> None: + written = Path("/reconstructions/mixed.stn") + + assert Destination.unset().writing_to(written).output_path == written + + def test_a_run_with_nobody_taking_part_stands_where_it_was(self) -> None: + destination = Destination.unset().writing_to(Path("/reconstructions/earlier.stn")) + + assert destination.aimed_at(Config(), None) == destination + + def test_a_mix_names_the_file_its_plan_writes(self, tmp_path: Path) -> None: + config = Config() + plan = GroupConversion(sources=(tmp_path / "a.wav", tmp_path / "b.wav"), stems=_PULSE) + + assert Destination.unset().aimed_at(config, plan).output_path == plan.destination(config) + + def test_a_batch_names_the_folder_holding_what_its_plan_writes(self, tmp_path: Path) -> None: + config = Config() + plan = BatchConversion( + entries=( + BatchEntry(source=tmp_path / "lead.wav", stems=_PULSE, base_directory=None), + BatchEntry(source=tmp_path / "bass.wav", stems=_TRIANGLE, base_directory=None), + ) + ) + + output_path = Destination.unset().aimed_at(config, plan).output_path + + assert output_path == plan.destination(config) + assert all(output_path in job.output_path.parents for job in plan.jobs(config)) diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py new file mode 100644 index 000000000..2daad7abc --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -0,0 +1,178 @@ +from pathlib import Path +from typing import FrozenSet, List + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + + +def _mixed(*names: str) -> Gathering: + """A setup a mix converts: the recordings gathered, and the order they pick in.""" + gathering = Gathering.empty() + for name in names: + gathering = gathering.mixing(recording(f"/audio/{name}.wav")) + + return gathering + + +def _listed(*names: str) -> Gathering: + """A setup a per-recording run converts: the recordings gathered, no order among them.""" + gathering = Gathering.empty() + for name in names: + gathering = gathering.listing(recording(f"/audio/{name}.wav")) + + return gathering + + +def _names(gathering: Gathering) -> List[str]: + return [path.stem for path in gathering.paths] + + +def _mixed_names(gathering: Gathering) -> List[str]: + return [path.stem for path in gathering.levels.paths] + + +class TestGatheringRecordings(BaseTestSuite): + def test_a_recording_stands_in_the_list_and_on_a_level(self) -> None: + gathering = _mixed("bass") + + assert _names(gathering) == ["bass"] + assert gathering.recording(Path("/audio/bass.wav")) is not None + + def test_recordings_pick_in_the_order_they_were_mixed(self) -> None: + assert _names(_mixed("bass", "lead")) == ["bass", "lead"] + + def test_a_recording_already_gathered_keeps_what_it_holds(self) -> None: + gathering = _mixed("bass").written( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset({ChannelName.NOISE}), + ) + + gathering = gathering.mixing(recording("/audio/bass.wav", [ChannelName.PULSE1])) + + settled = gathering.recording(Path("/audio/bass.wav")) + assert settled is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + + def test_a_recording_leaves_both_sides_of_the_setup(self) -> None: + gathering = _mixed("bass", "lead").remove(SourceKey.recording(Path("/audio/bass.wav"))) + + assert _names(gathering) == ["lead"] + assert _mixed_names(gathering) == ["lead"] + assert gathering.recording(Path("/audio/bass.wav")) is None + + +class TestTheCeilingAMixHoldsTo(BaseTestSuite): + """A mix reaches as many recordings as the assignment has room to mix, whatever gathers them.""" + + def test_an_empty_setup_has_room_for_the_whole_ceiling(self) -> None: + assert Gathering.empty().room == MAX_STEM_SOURCES + + def test_a_recording_arriving_at_a_full_setup_reaches_neither_side(self) -> None: + gathering = _mixed(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]) + + gathering = gathering.mixing(recording("/audio/one_more.wav")) + + assert gathering.count == MAX_STEM_SOURCES + assert gathering.recording(Path("/audio/one_more.wav")) is None + + def test_a_list_within_the_ceiling_fits_one_mix(self) -> None: + assert _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]).fits_a_mix is True + + def test_a_list_past_the_ceiling_does_not(self) -> None: + """Turning to a mix is what asks this, so a longer list is what the reader is asked about.""" + assert _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES + 1)]).fits_a_mix is False + + +class TestSettlingOneRecording(BaseTestSuite): + def test_a_slot_settles_on_the_recording_named(self) -> None: + gathering = _mixed("bass", "lead").written( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset({ChannelName.NOISE}), + ) + + settled = gathering.recording(Path("/audio/bass.wav")) + untouched = gathering.recording(Path("/audio/lead.wav")) + assert settled is not None and untouched is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + assert untouched.settings.channel_set == {ChannelName.PULSE1} + + def test_settling_a_recording_the_setup_never_gathered_changes_nothing(self) -> None: + gathering = _mixed("bass") + + assert gathering.written(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset()) == gathering + + +class TestTheListAPerRecordingRunConverts(BaseTestSuite): + """A run writing one reconstruction apiece converts whatever the list holds, unbounded.""" + + def test_a_recording_joins_the_list_without_joining_a_mix(self) -> None: + gathering = _listed("bass") + + assert _names(gathering) == ["bass"] + assert _mixed_names(gathering) == [] + + def test_the_list_takes_more_than_a_mix_could_hold(self) -> None: + gathering = _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES + 3)]) + + assert gathering.count == MAX_STEM_SOURCES + 3 + + def test_a_folder_stands_for_the_recordings_below_it(self) -> None: + gathering = Gathering.empty().listing_folder( + folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + ) + + assert gathering.count == 2 + assert gathering.row_count == 1 + assert gathering.folder_root_of(Path("/audio/a.wav")) == Path("/audio") + + def test_a_folder_goes_with_everything_it_stands_for(self) -> None: + gathering = Gathering.empty().listing_folder(folder("/audio", [recording("/audio/a.wav")])) + + gathering = gathering.remove(SourceKey.folder(Path("/audio"))) + + assert gathering.count == 0 + + +class TestTurningToAMix(BaseTestSuite): + """A mix converts loose recordings and holds a fixed number of them.""" + + def test_the_recordings_picked_stand_alone_and_in_order(self) -> None: + held = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + gathering = Gathering.empty().listing_folder(held) + + gathering = gathering.mixing_only((recording("/audio/b.wav"),)) + + assert _names(gathering) == ["b"] + assert _mixed_names(gathering) == ["b"] + assert gathering.folder_root_of(Path("/audio/b.wav")) is None + + def test_a_recording_keeps_the_settings_it_is_named_with(self) -> None: + gathering = Gathering.empty().listing(recording("/audio/a.wav")) + + named = recording("/audio/a.wav", [ChannelName.NOISE]) + settled = gathering.mixing_only((named,)).recording(Path("/audio/a.wav")) + + assert settled is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + + def test_a_recording_the_list_never_gathered_joins_it(self) -> None: + """The mix and the list are one thing, so naming a recording is what brings it in.""" + gathering = _listed("a").mixing_only((recording("/audio/a.wav"), recording("/audio/stranger.wav"))) + + assert _names(gathering) == ["a", "stranger"] + assert _mixed_names(gathering) == ["a", "stranger"] + + +class TestTurningAwayFromAMix(BaseTestSuite): + def test_the_list_stands_and_the_picking_order_goes(self) -> None: + gathering = _mixed("bass", "lead").unmixed() + + assert _names(gathering) == ["bass", "lead"] + assert _mixed_names(gathering) == [] diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py new file mode 100644 index 000000000..bdc96fc5c --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -0,0 +1,1447 @@ +from pathlib import Path +from typing import Any, Callable, FrozenSet, List, Optional, Tuple +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SettingsField, SourceKind +from sampletones_application.logic.instruction.readiness import LibraryReadiness +from sampletones_application.logic.main.converter.logic import ConverterLogic +from sampletones_application.logic.main.converter.run import ConversionSuccess +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult +from sampletones_application.services.result import ServiceError, ServiceProgress, ServiceSuccess +from sampletones_application.view_model.main.converter import ( + ACTIVE_PHASES, + ConversionPhase, + ConverterViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.library import LibraryState +from sampletones_core.reconstructions.converter import ConversionJob, GroupConversion +from sampletones_core.reconstructions.converter.paths import get_audio_files +from tests.suite.base import BaseTestSuite +from tests.suite.language import FakeLanguageManager +from tests.unit.sampletones_application.logic.main.converter.texts import TEXTS + +SCHEDULING: str = "sampletones_application.logic.main.converter.logic.CallbackQueue.add" +LOUD_DRIVE: float = 2.0 + + +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}) + + +def _config_manager_writing_under(reconstructions_directory: Path) -> MagicMock: + """The configuration manager a converter reads where a run writes from.""" + config_manager = MagicMock() + config_manager.config = _config_writing_under(reconstructions_directory) + config_manager.get_reconstructions_directory.return_value = reconstructions_directory + return config_manager + + +@pytest.fixture +def session_manager(tmp_path: Path) -> SessionManager: + """A session writing under the test's own directory, so the joining settings round-trip.""" + return SessionManager(UserProfile(config=tmp_path / "config.json", state=tmp_path / "state.yaml")) + + +@pytest.fixture +def service() -> MagicMock: + """The conversion service the converter drives, which reports back through its subscription.""" + service = MagicMock() + service.is_running.return_value = False + return service + + +@pytest.fixture +def converter_logic( + tmp_path: Path, + session_manager: SessionManager, + service: MagicMock, +) -> 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 = _config_manager_writing_under(reconstructions_directory) + scheduling = MagicMock( + priorities=MagicMock(schedule=0), + delays=MagicMock(schedule=0, cancel=0), + ) + logic = ConverterLogic( + config_manager, + session_manager, + service, + scheduling=scheduling, + language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] + is_operation_active=lambda: logic.is_active, + ) + logic.on_view_changed = MagicMock() + logic.prepare_library = MagicMock() + logic.library_readiness = lambda directory, key: LibraryReadiness.PREPARING + return logic + + +def _view(converter_logic: ConverterLogic) -> ConverterViewModel: + """The panel's last reading of the converter.""" + view_model: ConverterViewModel = converter_logic.on_view_changed.call_args.args[0] + return view_model + + +def _phase(converter_logic: ConverterLogic) -> ConversionPhase: + return _view(converter_logic).phase + + +def _reports(service: MagicMock, result: ConversionResult) -> None: + """Hands the converter a result the conversion service would report to it.""" + handler: Callable[[ConversionResult], None] = service.subscribe.call_args.args[0] + handler(result) + + +def _mixing(converter_logic: ConverterLogic, *names: str) -> None: + """Gathers recordings into a mix, which is the run several recordings amount to.""" + converter_logic.set_output(OutputKind.MIXED) + converter_logic.gather_recordings([Path(f"/audio/{name}.wav") for name in names]) + + +def _listed(converter_logic: ConverterLogic, *names: str) -> None: + """Gathers recordings into a run writing one reconstruction apiece.""" + converter_logic.gather_recordings([Path(f"/audio/{name}.wav") for name in names]) + + +def _card_channels(converter_logic: ConverterLogic) -> FrozenSet[ChannelName]: + """The channels the settings card reads as held by every recording it inspects.""" + return frozenset( + channel.channel for channel in converter_logic.source_settings_view.channels if channel.use is Agreement.ALL + ) + + +def _item_of(job: ConversionJob) -> ConversionItem: + """The item the conversion service reports for ``job`` while the run works on it.""" + return ConversionItem(source=job.sources[0], output_path=job.output_path) + + +def _started_plan(converter_logic: ConverterLogic, service: MagicMock) -> GroupConversion: + """The plan the converter hands the service once the library it waits for is ready.""" + converter_logic.library_readiness = lambda directory, key: LibraryReadiness.READY + with patch(SCHEDULING): + converter_logic.start_conversion(confirmed=True) + + plan: GroupConversion = service.start.call_args.args[1] + return plan + + +class TestCancelDuringLibraryGeneration(BaseTestSuite): + """The converter requests a library when none exists and waits for it. Canceling during that + wait must abort the pending conversion and stop the in-flight generation.""" + + def test_cancel_while_waiting_cancels_generation_and_finishes( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + cancel_generation = MagicMock() + on_canceled = MagicMock() + converter_logic.cancel_library_generation = cancel_generation + converter_logic.on_canceled = on_canceled + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + assert _phase(converter_logic) == ConversionPhase.WAITING + + converter_logic.cancel() + + cancel_generation.assert_called_once() + on_canceled.assert_called_once() + assert _phase(converter_logic) == ConversionPhase.CANCELED + + def test_wait_loop_aborts_once_no_longer_waiting( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING) as scheduled: + converter_logic.start_conversion() + converter_logic.cancel() + scheduled.reset_mock() + + converter_logic._wait_for_library_and_start() + + service.start.assert_not_called() + scheduled.assert_not_called() + + def test_wait_poll_does_not_emit_a_zero_progress_view( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """While waiting, the bar reflects the library-generation progress. A re-poll that finds the + library still missing must only re-queue itself, never emit its own view: emitting one would + carry ``progress=0.0`` and momentarily reset the bar.""" + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING) as scheduled: + converter_logic.start_conversion() + converter_logic.on_view_changed.reset_mock() + scheduled.reset_mock() + + converter_logic._wait_for_library_and_start() + + converter_logic.on_view_changed.assert_not_called() + scheduled.assert_called_once() + + def test_a_library_missing_once_the_generation_ends_gives_the_request_up( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + """A generation that failed or was canceled elsewhere leaves nothing to wait for.""" + on_canceled = MagicMock() + converter_logic.on_canceled = on_canceled + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + converter_logic.library_readiness = lambda directory, key: LibraryReadiness.MISSING + + converter_logic._wait_for_library_and_start() + + service.start.assert_not_called() + on_canceled.assert_called_once_with() + assert _phase(converter_logic) == ConversionPhase.CANCELED + + +class TestNoChannelsGuard(BaseTestSuite): + """A gathered recording holding no channel reconstructs nothing, so the run must not start.""" + + def test_no_generators_notifies_and_does_not_start( + self, + converter_logic: ConverterLogic, + ) -> None: + _listed(converter_logic, "a") + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) + on_no_generators = MagicMock() + converter_logic.on_no_generators = on_no_generators + + converter_logic.start_conversion() + + on_no_generators.assert_called_once() + converter_logic.prepare_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + +class TestNothingToConvertGuard(BaseTestSuite): + """A converter aimed at nothing has no plan to run, so a request leaves it where it stands.""" + + def test_a_request_with_nothing_picked_starts_nothing( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic.emit_initial_view() + + converter_logic.start_conversion() + + converter_logic.prepare_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + def test_a_mix_runs_without_a_recording_ever_being_picked( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + """A mix converts the recordings it gathered, so nothing about the browser's selection gates it.""" + _mixing(converter_logic, "a", "b") + + plan = _started_plan(converter_logic, service) + + assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + +class TestOverwriteGuard(BaseTestSuite): + """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: + """Gathers ``path`` and answers where its run would write.""" + converter_logic.gather_recordings([path]) + return _view(converter_logic).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(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_called_once_with((target,)) + converter_logic.prepare_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + def _target_alone(self, converter_logic: ConverterLogic, source: Path) -> Path: + """Where a run over this recording alone would write, leaving the setup as it was found.""" + target = self._aimed_at(converter_logic, source) + converter_logic.remove_source(source) + return target + + def test_every_standing_target_is_named_to_the_reader( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """A recording the reader named is written whenever the run goes, so all of them are named. + + The answer covers each one it is given, which is what makes the prompt worth reading. + """ + sources = [] + targets = [] + for name in ("one.wav", "two.wav", "three.wav"): + source = tmp_path / name + source.touch() + sources.append(source) + targets.append(self._target_alone(converter_logic, source)) + + for target in targets: + self._standing(target) + + converter_logic.gather_recordings(sources) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_called_once_with(tuple(targets)) + assert _phase(converter_logic) == 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(SCHEDULING): + converter_logic.start_conversion(confirmed=True) + + on_target_exists.assert_not_called() + converter_logic.prepare_library.assert_called_once() + assert _phase(converter_logic) == 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(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.WAITING + + def test_a_folder_starts_without_asking( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """A recording gathered from a folder is never written over, so a standing file stops nothing.""" + sources = tmp_path / "sources" + sources.mkdir() + (sources / "song.wav").touch() + converter_logic.gather_folder(sources, get_audio_files(sources, sort=True)) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.WAITING + + +class TestStartConversionGate(BaseTestSuite): + """A conversion refuses to start while another exclusive operation is active, so two heavy + processes cannot run at once.""" + + def test_refuses_when_an_operation_is_active( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + converter_logic._is_operation_active = lambda: True + + converter_logic.start_conversion() + + service.start.assert_not_called() + converter_logic.prepare_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + def test_proceeds_when_nothing_is_active( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + + converter_logic.prepare_library.assert_called_once() + assert _phase(converter_logic) == ConversionPhase.WAITING + + +class TestWhatTheSetupNamesItselfBy(BaseTestSuite): + """A setup holding one row is that row, which is what a reader converting one file reads.""" + + def test_one_recording_names_itself( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = _aimed_at_a_recording(converter_logic, tmp_path) + + view_model = _view(converter_logic) + + assert (view_model.input_path, view_model.is_file) == (source, True) + + def test_one_folder_names_the_tree_it_mirrors( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + sources = tmp_path / "sources" + sources.mkdir() + (sources / "song.wav").touch() + + converter_logic.gather_folder(sources, get_audio_files(sources, sort=True)) + + view_model = _view(converter_logic) + assert (view_model.input_path, view_model.is_file) == (sources, False) + + def test_several_rows_name_none_of_them(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "a", "b") + + assert _view(converter_logic).input_path is None + + +class TestWhatACompletedConversionLeaves(BaseTestSuite): + """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_the_reconstructions_that_were_written( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + on_success = MagicMock() + converter_logic.on_success = on_success + written = (Path("/reconstructions/kick.stn"),) + + _reports(service, ServiceSuccess(value=written)) + + assert _phase(converter_logic) == ConversionPhase.COMPLETED + on_success.assert_called_once_with(ConversionSuccess(written=written)) + + def test_one_written_reconstruction_becomes_the_displayed_output( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + written = (Path("/reconstructions/kick.stn"),) + + _reports(service, ServiceSuccess(value=written)) + + assert _view(converter_logic).output_path == written[0] + + def test_a_batch_loads_the_folder_and_one_file_loads_itself( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + converter_logic.on_load_file = MagicMock() + converter_logic.on_load_directory = MagicMock() + + _reports(service, ServiceSuccess(value=(Path("/reconstructions/kick.stn"),))) + converter_logic.handle_load_request() + converter_logic.on_load_file.assert_called_once_with(Path("/reconstructions/kick.stn")) + + _reports( + service, + ServiceSuccess(value=(Path("/reconstructions/kick.stn"), Path("/reconstructions/snare.stn"))), + ) + converter_logic.handle_load_request() + converter_logic.on_load_directory.assert_called_once_with() + + +class TestTheLineARunOfOneShows(BaseTestSuite): + """A run with one job names the reconstruction that job writes, whatever the setup is named after.""" + + def test_a_folder_left_with_one_job_names_its_recording( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + """A rerun over a folder writes only the recordings still to be written, so a folder left with + one names that recording, and the folder its destination names stays out of the line.""" + loops = tmp_path / "loops" + loops.mkdir() + for name in ("bass.wav", "lead.wav"): + (loops / name).touch() + converter_logic.gather_folder(loops, get_audio_files(loops, sort=True)) + plan = _started_plan(converter_logic, service) + config: Config = service.start.call_args.args[0] + written, still_to_write = plan.jobs(config) + written.output_path.parent.mkdir(parents=True) + written.output_path.touch() + (job,) = plan.jobs(config) + + _reports(service, ServiceProgress(completed=0, total=1, current_item=_item_of(job))) + + assert (job, _view(converter_logic).status_text) == ( + still_to_write, + TEXTS["main.converter.template.single_progress_template"].format(job.output_path.stem), + ) + + +class TestFailureReturnsToIdle(BaseTestSuite): + """With no Close button, a failure reports through ``on_error`` and schedules its own return to + idle so the panel never strands on the failed phase.""" + + def test_failure_schedules_return_to_idle_and_reports( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + converter_logic.on_error = MagicMock() + + with patch(SCHEDULING) as scheduled: + _reports(service, ServiceError(exception=RuntimeError("boom"))) + + assert _phase(converter_logic) == ConversionPhase.FAILED + scheduled.assert_called_once() + assert scheduled.call_args.args[0] == converter_logic.close + converter_logic.on_error.assert_called_once() + + +class TestActivePhases(BaseTestSuite): + """``is_active`` reports a conversion occupying resources for every non-idle, non-terminal phase — + covering the WAITING preparation that runs before the service starts.""" + + def test_a_requested_conversion_is_active_before_the_service_takes_it( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + + assert _phase(converter_logic) in ACTIVE_PHASES + assert converter_logic.is_active is True + + def test_a_settled_conversion_holds_nothing( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _reports(service, ServiceSuccess(value=(Path("/reconstructions/kick.stn"),))) + + assert converter_logic.is_active is False + + def test_a_converter_that_has_run_nothing_is_idle(self, converter_logic: ConverterLogic) -> None: + converter_logic.emit_initial_view() + + assert (_phase(converter_logic), converter_logic.is_active) == (ConversionPhase.IDLE, False) + + +class TestTheSetupARunHolds(BaseTestSuite): + """A conversion converts what it was started from, so the setup rests until the run settles.""" + + @staticmethod + def _waiting(converter_logic: ConverterLogic, tmp_path: Path) -> Path: + """A conversion standing on one gathered recording, which is what a gesture then meets.""" + source = _aimed_at_a_recording(converter_logic, tmp_path) + with patch(SCHEDULING): + converter_logic.start_conversion(confirmed=True) + + assert _phase(converter_logic) == ConversionPhase.WAITING + return source + + @staticmethod + def _channels_of(converter_logic: ConverterLogic, path: Path) -> FrozenSet[ChannelName]: + """The channels the setup holds on ``path``, read from the setup rather than from a view. + + A run under way emits no view of the setup, so a case watching the setup through the run + reads the rows the converter answers with. + """ + held = [row.channels for row in converter_logic.gathered_rows if row.path == path] + assert len(held) == 1 + return held[0] + + def test_a_channel_key_leaves_the_run_converting_what_it_started_on( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = _aimed_at_a_recording(converter_logic, tmp_path) + converter_logic.select_row(source, SourceKind.RECORDING) + self._waiting(converter_logic, tmp_path) + held = self._channels_of(converter_logic, source) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert self._channels_of(converter_logic, source) == held + + def test_a_recording_stays_gathered_through_the_run( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = self._waiting(converter_logic, tmp_path) + + converter_logic.remove_source(source) + + assert converter_logic.gathered_paths == (source,) + + def test_the_output_switch_rests_through_the_run( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + self._waiting(converter_logic, tmp_path) + + converter_logic.set_output(OutputKind.MIXED) + + assert converter_logic.mixes is False + + def test_a_reconstruct_request_leaves_the_gathered_recordings_alone( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """A **Reconstruct** lists what it names, and the list stands inert while the run holds it.""" + source = self._waiting(converter_logic, tmp_path) + other = tmp_path / "take.wav" + other.touch() + + converter_logic.take_up_recording(other) + converter_logic.take_up_folder(tmp_path, [other]) + + assert converter_logic.gathered_paths == (source,) + + def test_the_row_a_reader_inspects_rests_with_the_list( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """The list stands inert through a run, so the row the card names stays the row drawn picked.""" + source = _aimed_at_a_recording(converter_logic, tmp_path) + converter_logic.select_row(source, SourceKind.RECORDING) + self._waiting(converter_logic, tmp_path) + + converter_logic.clear_selection() + + assert (converter_logic.source_settings_view.subject is not None, _view(converter_logic).selected_key) == ( + True, + str(source), + ) + + def test_a_waiting_run_begins_from_the_settings_it_was_asked_with( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + self._waiting(converter_logic, tmp_path) + asked_with = converter_logic._config_manager.config + generation = asked_with.generation.model_copy(update={"reset_phase": not asked_with.generation.reset_phase}) + converter_logic._config_manager.config = asked_with.model_copy(update={"generation": generation}) + converter_logic.library_readiness = lambda directory, key: LibraryReadiness.READY + + converter_logic._wait_for_library_and_start() + + assert service.start.call_args.args[0].generation.reset_phase == asked_with.generation.reset_phase + + def test_a_waiting_run_looks_for_its_library_where_it_was_asked_to( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + self._waiting(converter_logic, tmp_path) + asked_for = ( + Path(converter_logic._config_manager.config.general.library_directory), + converter_logic._config_manager.key, + ) + looked_for: List[Tuple[Path, Any]] = [] + converter_logic._config_manager.config = converter_logic._config_manager.config.model_copy( + update={ + "general": converter_logic._config_manager.config.general.model_copy( + update={"library_directory": str(tmp_path / "elsewhere")} + ) + } + ) + + def readiness(directory: Path, key: Any) -> LibraryReadiness: + looked_for.append((directory, key)) + return LibraryReadiness.PREPARING + + converter_logic.library_readiness = readiness + with patch(SCHEDULING): + converter_logic._wait_for_library_and_start() + + assert looked_for == [asked_for] + + def test_a_request_holds_where_its_library_stood_when_asked( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + looked_at: List[Path] = [] + + def library_state(path: Path) -> LibraryState: + looked_at.append(path) + return LibraryState.OUTDATED + + converter_logic.library_state = library_state + self._waiting(converter_logic, tmp_path) + + request = converter_logic._run.request + assert request is not None + assert (request.library_state, looked_at) == ( + LibraryState.OUTDATED, + [request.library_directory / request.library_key.filename], + ) + + +class TestGatheringRecordings(BaseTestSuite): + """The rows a reader gathers, as the panel reads them back.""" + + def _names(self, converter_logic: ConverterLogic) -> List[str]: + return [row.name for row in _view(converter_logic).stem_sources] + + def test_a_gathered_recording_becomes_a_row(self, converter_logic: ConverterLogic) -> None: + converter_logic.gather_recordings([Path("/audio/bass.wav")]) + converter_logic.gather_recordings([Path("/audio/lead.wav")]) + + assert self._names(converter_logic) == ["bass", "lead"] + + def test_adding_a_listed_recording_leaves_the_list_as_it_is(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "bass", "lead") + converter_logic.isolate_source(Path("/audio/lead.wav")) + + converter_logic.gather_recordings([Path("/audio/lead.wav")]) + + rows = _view(converter_logic).stem_sources + assert [row.name for row in rows] == ["bass", "lead"] + assert [row.level for row in rows] == [0, 1] + + def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES + 3)]) + + assert converter_logic.source_count == MAX_STEM_SOURCES + assert converter_logic.room_for_sources == 0 + + def test_removing_a_recording_takes_it_out(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.remove_source(Path("/audio/a.wav")) + + assert self._names(converter_logic) == ["b"] + + def test_turning_to_a_mix_carries_the_picked_file_in( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = _aimed_at_a_recording(converter_logic, tmp_path) + + converter_logic.set_output(OutputKind.MIXED) + + assert self._names(converter_logic) == [source.stem] + + def test_turning_away_from_a_mix_keeps_every_recording( + self, + converter_logic: ConverterLogic, + ) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.set_output(OutputKind.PER_RECORDING) + + assert converter_logic.source_count == 2 + + def test_a_per_recording_run_takes_more_than_a_mix_could_hold( + self, + converter_logic: ConverterLogic, + ) -> None: + _listed(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES + 3)]) + + assert converter_logic.source_count == MAX_STEM_SOURCES + 3 + + def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.move_source_to_new_level(Path("/audio/b.wav"), 0) + + rows = _view(converter_logic).stem_sources + assert [(row.path.name, row.level, row.level_count) for row in rows] == [ + ("b.wav", 0, 2), + ("a.wav", 1, 2), + ] + + +class TestWhatAReconstructLists(BaseTestSuite): + """A Reconstruct lists what it names for a run writing one reconstruction apiece, and starts nothing. + + The reader sees the recordings and their settings before converting, so the run is theirs to start. + """ + + def test_a_recording_joins_the_list_beside_what_it_holds( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _listed(converter_logic, "bass") + + converter_logic.take_up_recording(Path("/audio/lead.wav")) + + assert converter_logic.gathered_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav")) + assert converter_logic.mixes is False + service.start.assert_not_called() + + def test_a_folder_joins_the_list_as_a_folder( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + root = tmp_path / "album" + root.mkdir() + found = [root / "a.wav", root / "b.wav"] + for recording in found: + recording.touch() + + converter_logic.take_up_folder(root, found) + + assert [row.name for row in _view(converter_logic).stem_sources] == [root.name] + assert converter_logic.gathered_paths == tuple(found) + service.start.assert_not_called() + + def test_a_mix_gives_way_to_a_list_of_what_was_named(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "bass", "lead") + + converter_logic.take_up_recording(Path("/audio/drums.wav")) + + assert converter_logic.mixes is False + assert converter_logic.gathered_paths == (Path("/audio/drums.wav"),) + + +class TestAnsweringWhichRecordingsToMix(BaseTestSuite): + """A mix reaching a fixed number of recordings is put to the reader, and the answer is what + the mix is then built from: what it names joins, and what it leaves out goes.""" + + def _names(self, converter_logic: ConverterLogic) -> List[str]: + return [row.name for row in _view(converter_logic).stem_sources] + + def test_the_answer_names_the_whole_mix(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b", "c") + + converter_logic.mix_only([Path("/audio/a.wav"), Path("/audio/c.wav")]) + + assert self._names(converter_logic) == ["a", "c"] + + def test_a_recording_the_list_never_held_joins_it(self, converter_logic: ConverterLogic) -> None: + """A full mix is answered by letting one go for one a folder offered, in the one gesture.""" + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES)]) + + standing = [Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES - 1)] + converter_logic.mix_only([*standing, Path("/audio/late.wav")]) + + assert self._names(converter_logic)[-1] == "late" + assert converter_logic.source_count == MAX_STEM_SOURCES + + def test_a_recording_standing_keeps_the_channels_it_was_given( + self, + converter_logic: ConverterLogic, + ) -> None: + _mixing(converter_logic, "a", "b") + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE})) + + converter_logic.mix_only([Path("/audio/a.wav")]) + + assert _view(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE}) + + def test_the_run_it_leaves_is_a_mix(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "a", "b") + + converter_logic.mix_only([Path("/audio/a.wav")]) + + assert converter_logic.mixes + + +class TestWhatTheGatheredRecordingsRun(BaseTestSuite): + """What the converter asks the service to run, once a reader has set the mix up.""" + + def test_the_rows_channels_and_levels_reach_the_setup( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a", "b") + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.PULSE1})) + converter_logic.isolate_source(Path("/audio/b.wav")) + + plan = _started_plan(converter_logic, service) + + assert plan.stems.entries[0].settings.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, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) + plan = _started_plan(converter_logic, service) + + 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_the_hierarchy_mode_reaches_the_setup( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a") + + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + assert _started_plan(converter_logic, service).stems.hierarchy.mode == HierarchyMode.STRICT + + def test_the_count_a_recording_holds_reaches_its_entry( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a") + converter_logic.select_row(Path("/audio/a.wav"), SourceKind.RECORDING) + + converter_logic.set_channel_cap(1) + + assert _started_plan(converter_logic, service).stems.entries[0].settings.channel_cap == 1 + + def test_the_configuration_reaches_the_service_with_the_plan( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a") + + _started_plan(converter_logic, service) + + started_config = service.start.call_args.args[0] + assert started_config == converter_logic._config_manager.config + + +class TestAFolderInTheList(BaseTestSuite): + """A folder stands as one row, answering for every recording gathered below it.""" + + def _folder(self, converter_logic: ConverterLogic, tmp_path: Path, names: List[str]) -> Path: + root = tmp_path / "sources" + root.mkdir() + for name in names: + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + return root + + def test_a_folder_draws_one_row_naming_what_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + rows = _view(converter_logic).stem_sources + + assert len(rows) == 1 + assert (rows[0].path, rows[0].holds, rows[0].stands_for_a_folder) == (root, 2, True) + + def test_a_folder_its_recordings_agree_on_reads_as_held( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """The list and the settings card read one folder the same way, since both fold its rows.""" + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + converter_logic.select_row(root, SourceKind.FOLDER) + + row = _view(converter_logic).stem_sources[0] + + assert row.channels == _card_channels(converter_logic) + assert row.partial_channels == frozenset() + + def test_a_folder_its_recordings_differ_on_reads_as_half_held( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.set_source_channels(root / "a.wav", frozenset({ChannelName.PULSE1})) + + row = _view(converter_logic).stem_sources[0] + assert row.channels == frozenset({ChannelName.PULSE1}) + assert ChannelName.TRIANGLE in row.partial_channels + + def test_one_gesture_settles_the_whole_folder( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + converter_logic.set_source_channels(root / "a.wav", frozenset({ChannelName.PULSE1})) + + converter_logic.toggle_folder_channel(root, ChannelName.TRIANGLE) + + row = _view(converter_logic).stem_sources[0] + assert ChannelName.TRIANGLE in row.channels + assert ChannelName.TRIANGLE not in row.partial_channels + + def test_a_folder_every_recording_of_which_holds_it_lets_it_go( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.toggle_folder_channel(root, ChannelName.TRIANGLE) + + row = _view(converter_logic).stem_sources[0] + assert ChannelName.TRIANGLE not in row.channels + assert ChannelName.TRIANGLE not in row.partial_channels + + def test_removing_a_folder_takes_everything_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.remove_folder(root) + + assert _view(converter_logic).stem_sources == () + assert converter_logic.source_count == 0 + + def test_turning_to_a_mix_gives_up_the_folder( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.set_output(OutputKind.MIXED) + + rows = _view(converter_logic).stem_sources + assert [row.stands_for_a_folder for row in rows] == [False, False] + + +class TestABoxPicksItsRow(BaseTestSuite): + """A click on a row's box picks that row, as a click on its name does, so the card follows the box.""" + + @staticmethod + def _folder(converter_logic: ConverterLogic, tmp_path: Path) -> Path: + root = tmp_path / "sources" + root.mkdir() + for name in ("a.wav", "b.wav"): + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + return root + + def test_a_recordings_box_picks_the_recording(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + snare = Path("/audio/snare.wav") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + + converter_logic.set_source_channels(snare, frozenset({ChannelName.PULSE1})) + + subject = converter_logic.source_settings_view.subject + assert subject is not None + assert (_view(converter_logic).selected_key, subject.name, subject.kind) == ( + str(snare), + snare.stem, + SourceKind.RECORDING, + ) + + def test_a_box_with_nothing_picked_picks_its_row(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick") + kick = Path("/audio/kick.wav") + assert converter_logic.source_settings_view.edits_new_recordings is True + + converter_logic.set_source_channels(kick, frozenset({ChannelName.PULSE1})) + + assert converter_logic.source_settings_view.edits_new_recordings is False + assert _view(converter_logic).selected_key == str(kick) + + def test_the_row_picked_before_keeps_its_channels(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + kick = Path("/audio/kick.wav") + converter_logic.select_row(kick, SourceKind.RECORDING) + standing = _card_channels(converter_logic) + + converter_logic.set_source_channels(Path("/audio/snare.wav"), frozenset({ChannelName.NOISE})) + converter_logic.select_row(kick, SourceKind.RECORDING) + + assert _card_channels(converter_logic) == standing + + def test_a_box_inside_a_folder_picks_that_recording( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path) + converter_logic.select_row(root, SourceKind.FOLDER) + + converter_logic.set_source_channels(root / "a.wav", frozenset({ChannelName.PULSE1})) + + assert _view(converter_logic).selected_key == str(root / "a.wav") + assert _card_channels(converter_logic) == frozenset({ChannelName.PULSE1}) + + def test_a_folders_box_picks_the_folder( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path) + converter_logic.select_row(root / "a.wav", SourceKind.RECORDING) + + converter_logic.toggle_folder_channel(root, ChannelName.TRIANGLE) + + subject = converter_logic.source_settings_view.subject + assert subject is not None + assert (_view(converter_logic).selected_key, subject.name, subject.kind) == ( + str(root), + root.name, + SourceKind.FOLDER, + ) + + +class TestTheChannelAKeyReaches(BaseTestSuite): + """A channel's key settles the row a reader picked out, which is the box beside that row.""" + + @staticmethod + def _channels_of(converter_logic: ConverterLogic, path: Path) -> FrozenSet[ChannelName]: + """The channels the row standing for ``path`` reads as holding.""" + held = [row.channels for row in _view(converter_logic).stem_sources if row.path == path] + assert len(held) == 1 + return held[0] + + def test_it_settles_the_recording_picked_out(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + kick = Path("/audio/kick.wav") + converter_logic.select_row(kick, SourceKind.RECORDING) + held = ChannelName.TRIANGLE in self._channels_of(converter_logic, kick) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert (ChannelName.TRIANGLE in self._channels_of(converter_logic, kick)) is not held + + def test_it_leaves_the_rows_it_was_not_pointed_at(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + snare = Path("/audio/snare.wav") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + standing = self._channels_of(converter_logic, snare) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert self._channels_of(converter_logic, snare) == standing + + def test_a_folder_picked_out_carries_it_to_what_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = tmp_path / "sources" + root.mkdir() + for name in ("a.wav", "b.wav"): + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + converter_logic.select_row(root, SourceKind.FOLDER) + held = ChannelName.TRIANGLE in self._channels_of(converter_logic, root) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert (ChannelName.TRIANGLE in self._channels_of(converter_logic, root)) is not held + + def test_nothing_picked_out_leaves_the_list_as_it_stands(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + standing = [row.channels for row in _view(converter_logic).stem_sources] + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert [row.channels for row in _view(converter_logic).stem_sources] == standing + + +class TestWhatTheSettingsCardEdits(BaseTestSuite): + """The card edits every recording the picked row stands for, or what a recording joins with.""" + + @staticmethod + def _drive_of(converter_logic: ConverterLogic, channel_name: ChannelName) -> Optional[float]: + """The drive the card reads on one channel.""" + return next( + channel.drive + for channel in converter_logic.source_settings_view.channels + if channel.channel is channel_name + ) + + def test_a_picked_row_takes_the_drive(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + + converter_logic.set_drive(ChannelName.PULSE1, LOUD_DRIVE) + + assert self._drive_of(converter_logic, ChannelName.PULSE1) == LOUD_DRIVE + + def test_the_rows_beside_it_stand_as_they_were(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + converter_logic.set_drive(ChannelName.PULSE1, LOUD_DRIVE) + + converter_logic.select_row(Path("/audio/snare.wav"), SourceKind.RECORDING) + + assert self._drive_of(converter_logic, ChannelName.PULSE1) == UNIT_DRIVE + + def test_a_folder_hands_the_drive_to_every_recording_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = tmp_path / "sources" + root.mkdir() + for name in ("a.wav", "b.wav"): + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + converter_logic.select_row(root, SourceKind.FOLDER) + converter_logic.set_drive(ChannelName.PULSE1, LOUD_DRIVE) + + converter_logic.select_row(root / "a.wav", SourceKind.RECORDING) + assert self._drive_of(converter_logic, ChannelName.PULSE1) == LOUD_DRIVE + + def test_a_channel_the_recording_leaves_free_takes_no_drive(self, converter_logic: ConverterLogic) -> None: + """A drive belongs to a channel a recording occupies, so a free one is left alone.""" + _listed(converter_logic, "kick") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + converter_logic.toggle_slot(SettingsField.CHANNELS, ChannelName.PULSE1) + standing = converter_logic.source_settings_view + + converter_logic.set_drive(ChannelName.PULSE1, LOUD_DRIVE) + + assert converter_logic.source_settings_view == standing + + def test_with_nothing_picked_the_card_names_what_a_recording_joins_with( + self, + converter_logic: ConverterLogic, + ) -> None: + assert converter_logic.source_settings_view.edits_new_recordings is True + + converter_logic.set_drive(ChannelName.PULSE1, LOUD_DRIVE) + _listed(converter_logic, "kick") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + + assert self._drive_of(converter_logic, ChannelName.PULSE1) == LOUD_DRIVE + + def test_a_box_with_nothing_picked_settles_what_a_recording_joins_with( + self, + converter_logic: ConverterLogic, + ) -> None: + held = ChannelName.PULSE2 in _card_channels(converter_logic) + + converter_logic.toggle_slot(SettingsField.CHANNELS, ChannelName.PULSE2) + + assert (ChannelName.PULSE2 in _card_channels(converter_logic)) is not held + + +class TestTheStemsView(BaseTestSuite): + """What the panel is told about the setup being built.""" + + def test_the_rows_reach_the_view_in_list_order(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b") + + view_model = _view(converter_logic) + + assert [row.name for row in view_model.stem_sources] == ["a", "b"] + assert view_model.mixes is True + assert view_model.has_input is True + + def test_a_row_shows_the_channels_it_may_take(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a") + + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE})) + + assert _view(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE}) + + def test_the_view_states_whether_another_recording_fits(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES)]) + + view_model = _view(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.set_output(OutputKind.MIXED) + + view_model = _view(converter_logic) + + assert view_model.has_input is False + assert view_model.convert_button_enabled is False + + +def _aimed_at_a_recording(converter_logic: ConverterLogic, tmp_path: Path) -> Path: + """Gathers a recording standing on disk, the way a click in the browser does.""" + source = tmp_path / "song.wav" + source.touch() + converter_logic.gather_recordings([source]) + return source + + +class TestAFolderOfFolders(BaseTestSuite): + """A gathered folder stands for every recording below it, however deep the tree goes.""" + + @staticmethod + def _tree(tmp_path: Path) -> Path: + root = tmp_path / "library" + nested = root / "loops" / "drums" + nested.mkdir(parents=True) + (root / "top.wav").touch() + (nested / "deep.wav").touch() + (nested / "notes.txt").write_text("not audio") + return root + + def test_every_recording_below_it_is_gathered( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._tree(tmp_path) + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + + assert {path.name for path in converter_logic.gathered_paths} == {"top.wav", "deep.wav"} + + def test_it_still_stands_as_one_row(self, converter_logic: ConverterLogic, tmp_path: Path) -> None: + root = self._tree(tmp_path) + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + + rows = _view(converter_logic).stem_sources + assert len(rows) == 1 + assert (rows[0].path, rows[0].holds) == (root, 2) + + +class TestTheRunTheSessionCarries(BaseTestSuite): + """The shape of a run is carried between launches, so the converter opens where it was left.""" + + def test_the_output_switch_is_written_down( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + converter_logic.set_output(OutputKind.MIXED) + + assert session_manager.converter_output is OutputKind.MIXED + + def test_what_a_recording_joins_with_is_written_down( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + """With no row picked the card edits the joining settings, which the session carries.""" + converter_logic.set_channel_cap(2) + + assert session_manager.converter_settings.channel_cap == 2 + + def test_the_order_is_written_down( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + assert session_manager.converter_hierarchy_mode is HierarchyMode.STRICT + + def test_a_converter_opens_on_what_the_session_carries( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + service: MagicMock, + tmp_path: Path, + ) -> None: + """The whole point of writing them down: the next launch reads them back.""" + converter_logic.set_output(OutputKind.MIXED) + converter_logic.set_channel_cap(2) + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + reopened = ConverterLogic( + _config_manager_writing_under(tmp_path / "reconstructions"), + session_manager, + service, + scheduling=MagicMock(priorities=MagicMock(schedule=0), delays=MagicMock(schedule=0, cancel=0)), + language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] + is_operation_active=lambda: False, + ) + + assert reopened.mixes is True + assert reopened.source_settings_view.channel_cap == 2 diff --git a/tests/unit/sampletones_application/logic/main/converter/test_messages.py b/tests/unit/sampletones_application/logic/main/converter/test_messages.py new file mode 100644 index 000000000..426663192 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -0,0 +1,162 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_application.services.conversion.result import ( + ConversionItem, + ReconstructionStep, +) +from sampletones_application.services.result import ServiceProgress +from sampletones_application.view_model.main.converter import ConversionPhase +from sampletones_core.reconstructions.stage import ReconstructionStage +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.unit.sampletones_application.logic.main.converter.texts import TEXTS, messages, messages_reading + +FRAMES: Final[int] = 1100 + + +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, + ) + + +def _item(output_name: str, step: Optional[ReconstructionStep] = None) -> ConversionItem: + """The item a run reports while it writes the reconstruction ``output_name`` from a kick drum.""" + return ConversionItem( + source=Path("/audio/kick.wav"), + output_path=Path(f"/reconstructions/{output_name}.stn"), + step=step, + ) + + +def _step(stage: ReconstructionStage, completed: int) -> ReconstructionStep: + return ReconstructionStep(stage=stage, completed=completed, total=FRAMES) + + +class TestProgressText(BaseTestSuite): + """A batch counts the files it has written; a run of one names the reconstruction its job writes. + + The reading states which of the two the run is, and carries a stage where the run has one + reconstruction to name a stage for, so the words follow the unit the run reports in. + """ + + def test_a_batch_counts_its_files(self) -> None: + assert messages().progress_text(_progress(2, 5, item=_item("kick"))) == "Progress: 2/5 files" + + def test_a_single_job_names_the_reconstruction_it_writes(self) -> None: + """The job's file names the line, which differs from the recording it reads.""" + assert messages().progress_text(_progress(0, 1, item=_item("track"))) == "Reconstructing track..." + + def test_the_status_names_the_stage_and_its_counts(self) -> None: + progress = _progress(0, 1, item=_item("kick", _step(ReconstructionStage.MATCHING, 412)), partial=0.35) + + assert messages().progress_text(progress) == "Reconstructing kick... - matching 412/1100" + + +class TestTextAtThePointOfUse(BaseTestSuite): + """Every phrase reads the language file when it is shown, so a language chosen mid-run reaches + the next reading.""" + + def test_a_status_line_reads_the_text_standing_when_shown(self) -> None: + texts = dict(TEXTS) + converter_messages = messages_reading(texts) + texts["main.converter.message.status_idle"] = "Ready" + + assert converter_messages.idle == "Ready" + + def test_a_stage_reads_the_text_standing_when_shown(self) -> None: + texts = dict(TEXTS) + converter_messages = messages_reading(texts) + texts["main.converter.message.stage_matching"] = "comparing" + progress = _progress(0, 1, item=_item("kick", _step(ReconstructionStage.MATCHING, 412))) + + assert converter_messages.progress_text(progress) == "Reconstructing kick... - comparing 412/1100" + + +class TestActionLabel(BaseTestSuite): + """The button says what the run writes. + + One recording names its document, several are counted, and the count reads as a mix or as a run + of its own depending on the output switch, so the switch and the button read as one sentence. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + phase: ConversionPhase + mixes: bool + converted: Tuple[Path, ...] + expected: str + + test_cases = ( + TestCase( + label="nothing_gathered_offers_the_bare_label", + phase=ConversionPhase.IDLE, + mixes=False, + converted=(), + expected=TEXTS["main.converter.label.convert_button"], + ), + TestCase( + label="one_recording_names_its_document", + phase=ConversionPhase.IDLE, + mixes=False, + converted=(Path("/audio/kick.wav"),), + expected=TEXTS["main.converter.template.convert_recording"].format(name="kick"), + ), + TestCase( + label="a_mix_of_one_names_it_too", + phase=ConversionPhase.IDLE, + mixes=True, + converted=(Path("/audio/kick.wav"),), + expected=TEXTS["main.converter.template.convert_recording"].format(name="kick"), + ), + TestCase( + label="several_recordings_are_counted", + phase=ConversionPhase.IDLE, + mixes=False, + converted=(Path("/audio/kick.wav"), Path("/audio/snare.wav"), Path("/audio/hat.wav")), + expected=TEXTS["main.converter.template.convert_recordings"].format(count=3), + ), + TestCase( + label="several_mixed_recordings_read_as_a_mix", + phase=ConversionPhase.IDLE, + mixes=True, + converted=(Path("/audio/kick.wav"), Path("/audio/snare.wav"), Path("/audio/hat.wav")), + expected=TEXTS["main.converter.template.mix_recordings"].format(count=3), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_label_says_what_the_run_writes(self, test_case: TestCase) -> None: + label = messages().action_label( + phase=test_case.phase, + mixes=test_case.mixes, + converted=test_case.converted, + ) + + assert label == test_case.expected + + @pytest.mark.parametrize( + "phase", + [ConversionPhase.WAITING, ConversionPhase.RUNNING, ConversionPhase.CANCELING], + ) + def test_a_conversion_holding_resources_reads_the_cancel_label(self, phase: ConversionPhase) -> None: + label = messages().action_label( + phase=phase, + mixes=False, + converted=(Path("/audio/kick.wav"),), + ) + + assert label == TEXTS["main.converter.label.cancel_button"] diff --git a/tests/unit/sampletones_application/logic/main/converter/test_run.py b/tests/unit/sampletones_application/logic/main/converter/test_run.py new file mode 100644 index 000000000..69d2146c9 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_run.py @@ -0,0 +1,255 @@ +from pathlib import Path +from typing import Callable, List, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.main.converter.run import ( + ConversionRequest, + ConversionRun, + ConversionSuccess, + RunReport, +) +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult +from sampletones_application.services.result import ( + ServiceCanceled, + ServiceError, + ServiceIntermediate, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.view_model.main.converter import ConversionPhase +from sampletones_core.configs import Config +from sampletones_core.library import LibraryState +from sampletones_core.parallelization import TaskProgress +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.converter.texts import messages + +WRITTEN: Tuple[Path, ...] = (Path("/reconstructions/kick.stn"),) + + +def _request(library_state: LibraryState) -> ConversionRequest: + return ConversionRequest( + config=Config(), + plan=MagicMock(), + library_key=MagicMock(), + library_state=library_state, + ) + + +def _library_progress(completed: int, total: int) -> ConversionResult: + """The intermediate result the service reports while the library is being generated.""" + return ServiceIntermediate(data=TaskProgress(completed=completed, total=total)) + + +class Driver: + """A run and the service seam a test reports results through, as the real service would.""" + + def __init__(self) -> None: + self.service = MagicMock() + self.service.is_running.return_value = False + self.reports: List[RunReport] = [] + self.run = ConversionRun(self.service, messages=messages()) + self.run.on_report = self.reports.append + self.run.on_success = MagicMock() + self.run.on_error = MagicMock() + self.run.on_canceled = MagicMock() + + @property + def _handler(self) -> Callable[[ConversionResult], None]: + handler: Callable[[ConversionResult], None] = self.service.subscribe.call_args.args[0] + return handler + + def reports_from_service(self, result: ConversionResult) -> None: + """Hands the run a result the conversion service would report to it.""" + self._handler(result) + + def begin(self) -> None: + request = _request(LibraryState.CURRENT) + self.run.wait(request) + self.run.begin(request) + + +@pytest.fixture +def driver() -> Driver: + return Driver() + + +class TestWhereARunStands(BaseTestSuite): + """A run occupies resources from the moment it is requested until it settles.""" + + def test_a_fresh_run_is_idle(self, driver: Driver) -> None: + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.IDLE, False) + + def test_a_request_waits_for_the_library_it_converts_against(self, driver: Driver) -> None: + driver.run.wait(_request(LibraryState.MISSING)) + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.WAITING, True) + + def test_the_first_progress_puts_the_run_under_way(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.RUNNING, True) + + def test_canceling_holds_resources_until_the_service_answers(self, driver: Driver) -> None: + driver.begin() + + driver.run.cancel() + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.CANCELING, True) + driver.service.cancel.assert_called_once() + + @pytest.mark.parametrize( + ("result", "phase"), + [ + (ServiceSuccess(value=WRITTEN), ConversionPhase.COMPLETED), + (ServiceError(exception=RuntimeError("boom")), ConversionPhase.FAILED), + (ServiceCanceled(), ConversionPhase.CANCELED), + ], + ids=["completed", "failed", "canceled"], + ) + def test_a_settled_run_holds_nothing( + self, + driver: Driver, + result: ConversionResult, + phase: ConversionPhase, + ) -> None: + driver.begin() + + driver.reports_from_service(result) + + assert (driver.run.phase, driver.run.is_active) == (phase, False) + + +class TestWhatARunReports(BaseTestSuite): + def test_a_request_says_it_is_waiting(self, driver: Driver) -> None: + driver.run.wait(_request(LibraryState.MISSING)) + + assert driver.reports[-1].status_text == "main.converter.message.status_waiting" + + def test_progress_names_the_document_its_job_writes(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service( + ServiceProgress( + completed=0, + total=1, + current_item=ConversionItem(source=Path("/audio/kick.wav"), output_path=Path("/out/track.stn")), + ) + ) + + assert driver.reports[-1].status_text == "Reconstructing track..." + + def test_progress_names_the_recording_under_way(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service( + ServiceProgress( + completed=0, + total=2, + current_item=ConversionItem(source=Path("/audio/snare.wav"), output_path=Path("/out/snare.stn")), + ) + ) + + assert driver.reports[-1].input_path == Path("/audio/snare.wav") + + def test_a_run_naming_no_recording_leaves_the_reader_looking_at_their_own(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + + assert driver.reports[-1].input_path is None + + def test_a_canceled_run_keeps_reporting_the_canceling_line(self, driver: Driver) -> None: + driver.begin() + driver.run.cancel() + + driver.reports_from_service(ServiceProgress(completed=1, total=2)) + + assert driver.reports[-1].status_text == "main.converter.message.status_canceling" + assert driver.run.phase == ConversionPhase.CANCELING + + def test_library_progress_moves_the_bar_while_waiting(self, driver: Driver) -> None: + driver.run.wait(_request(LibraryState.MISSING)) + + driver.reports_from_service(ServiceStarted(total=1)) + driver.reports_from_service(_library_progress(completed=3, total=4)) + + assert driver.reports[-1].progress == pytest.approx(0.75) + + @pytest.mark.parametrize( + ("library_state", "status_text"), + [ + (LibraryState.MISSING, "main.converter.message.status_generating_library"), + (LibraryState.OUTDATED, "main.converter.message.status_updating_library"), + ], + ids=["missing", "built by another version"], + ) + def test_library_progress_names_what_the_library_needs( + self, + driver: Driver, + library_state: LibraryState, + status_text: str, + ) -> None: + driver.run.wait(_request(library_state)) + + driver.reports_from_service(_library_progress(completed=1, total=4)) + + assert driver.reports[-1].status_text == status_text + + def test_library_progress_once_the_run_is_under_way_reports_nothing(self, driver: Driver) -> None: + driver.begin() + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + reported = len(driver.reports) + + driver.reports_from_service(_library_progress(completed=3, total=4)) + + assert len(driver.reports) == reported + + +class TestWhatACompletedRunHandsOver(BaseTestSuite): + """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_the_reconstructions_that_were_written(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceSuccess(value=WRITTEN)) + + driver.run.on_success.assert_called_once_with(ConversionSuccess(written=WRITTEN)) + assert driver.run.written == WRITTEN + + def test_a_failure_hands_over_the_exception(self, driver: Driver) -> None: + error = RuntimeError("boom") + driver.begin() + + driver.reports_from_service(ServiceError(exception=error)) + + driver.run.on_error.assert_called_once_with(error) + + def test_a_cancellation_says_so(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceCanceled()) + + driver.run.on_canceled.assert_called_once_with() + + def test_a_request_given_up_before_the_service_took_it_cancels_all_the_same(self, driver: Driver) -> None: + driver.run.wait(_request(LibraryState.MISSING)) + + driver.run.abandon() + + driver.run.on_canceled.assert_called_once_with() + assert driver.run.phase == ConversionPhase.CANCELED + + def test_closing_lets_the_written_reconstructions_go(self, driver: Driver) -> None: + driver.begin() + driver.reports_from_service(ServiceSuccess(value=WRITTEN)) + + driver.run.close() + + assert (driver.run.written, driver.run.phase) == ((), ConversionPhase.IDLE) + driver.service.release.assert_called_once() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_settings.py b/tests/unit/sampletones_application/logic/main/converter/test_settings.py new file mode 100644 index 000000000..e1302846d --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -0,0 +1,44 @@ +from typing import List + +from sampletones_application.constants.output import OutputKind +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite + +TONES: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] + + +def _settings(channels: List[ChannelName]) -> RunSettings: + return RunSettings( + joining=StemSettings.covering(channels), + output=OutputKind.PER_RECORDING, + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + ) + + +class TestWhatARecordingJoinsWith(BaseTestSuite): + def test_the_settings_a_recording_joins_with_are_the_readers_to_name(self) -> None: + joining = StemSettings.covering([ChannelName.NOISE]).with_channel_cap(1) + + settings = _settings(TONES).with_joining(joining) + + assert settings.joining == joining + + def test_naming_them_leaves_the_shape_of_the_run(self) -> None: + settings = _settings(TONES).with_output(OutputKind.MIXED) + + named = settings.with_joining(StemSettings.covering([ChannelName.NOISE])) + + assert (named.output, named.hierarchy_mode) == (settings.output, settings.hierarchy_mode) + + +class TestTheShapeOfTheRun(BaseTestSuite): + def test_the_run_is_named_as_a_mix(self) -> None: + assert _settings(TONES).with_output(OutputKind.MIXED).mixes is True + + def test_the_levels_take_turns_as_the_reader_asked(self) -> None: + settings = _settings(TONES).with_hierarchy_mode(HierarchyMode.STRICT) + + assert settings.hierarchy_mode == HierarchyMode.STRICT diff --git a/tests/unit/sampletones_application/logic/main/converter/test_setup.py b/tests/unit/sampletones_application/logic/main/converter/test_setup.py new file mode 100644 index 000000000..0d9514891 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_setup.py @@ -0,0 +1,134 @@ +from pathlib import Path +from typing import List, Optional + +from sampletones_application.constants.output import OutputKind +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.setup import ( + batch_entries, + conversion_plan, + conversion_setup, + playing_sources, +) +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP, DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels +from sampletones_core.reconstructions.converter import BatchConversion, GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + +JOINING: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] + + +def _recording(path: str, channel_cap: int) -> Recording: + """A gathered recording over the usual channels, sounding at most ``channel_cap`` at once.""" + return recording(path, JOINING, bending_channels(JOINING), channel_cap=channel_cap) + + +def _state( + *, + output: OutputKind, + listed: Optional[List[str]] = None, + mixed: Optional[List[str]] = None, + gathered_folder: Optional[str] = None, + channel_cap: int = ALL_STEMS_CHANNEL_CAP, + hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE, +) -> ConverterState: + gathering = Gathering.empty() + for name in listed or []: + gathering = gathering.listing(_recording(name, channel_cap)) + + for name in mixed or []: + gathering = gathering.mixing(_recording(name, channel_cap)) + + if gathered_folder is not None: + gathering = gathering.listing_folder( + folder( + gathered_folder, + [ + _recording(f"{gathered_folder}/a.wav", channel_cap), + _recording(f"{gathered_folder}/b.wav", channel_cap), + ], + ) + ) + + return ConverterState( + settings=RunSettings( + joining=StemSettings.covering(JOINING), + output=output, + hierarchy_mode=hierarchy_mode, + ), + gathering=gathering, + destination=Destination.unset(), + selected=None, + ) + + +class TestWhatAPerRecordingRunConverts(BaseTestSuite): + """One reconstruction per gathered recording, each under the settings its own row holds.""" + + def test_a_recording_becomes_an_entry_of_its_own(self) -> None: + plan = conversion_plan(_state(output=OutputKind.PER_RECORDING, listed=["/audio/kick.wav"])) + + assert isinstance(plan, BatchConversion) + assert [entry.source for entry in plan.entries] == [Path("/audio/kick.wav")] + + def test_a_recording_the_reader_named_carries_no_folder(self) -> None: + entries = batch_entries(_state(output=OutputKind.PER_RECORDING, listed=["/audio/kick.wav"])) + + assert entries[0].base_directory is None + assert entries[0].is_named is True + + def test_a_recording_gathered_from_a_folder_carries_the_folder_its_tree_mirrors(self) -> None: + entries = batch_entries(_state(output=OutputKind.PER_RECORDING, gathered_folder="/audio")) + + assert [entry.base_directory for entry in entries] == [Path("/audio"), Path("/audio")] + + def test_an_entry_holds_the_settings_the_recording_carries(self) -> None: + entries = batch_entries(_state(output=OutputKind.PER_RECORDING, listed=["/audio/kick.wav"])) + + assert entries[0].stems == StemsConfig.single_entry(StemSettings.covering(JOINING)) + + def test_each_entry_carries_the_count_its_own_recording_holds(self) -> None: + """How many channels one recording sounds at once is that recording's own to state.""" + state = _state(output=OutputKind.PER_RECORDING, listed=["/audio/a.wav", "/audio/b.wav"], channel_cap=1) + + assert [entry.stems.entries[0].settings.channel_cap for entry in batch_entries(state)] == [1, 1] + + def test_a_setup_holding_nothing_names_no_plan(self) -> None: + assert conversion_plan(_state(output=OutputKind.PER_RECORDING)) is None + + +class TestWhatAMixRuns(BaseTestSuite): + def test_a_mix_groups_every_gathered_recording(self) -> None: + plan = conversion_plan(_state(output=OutputKind.MIXED, mixed=["/audio/a.wav", "/audio/b.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_a_mix_with_nobody_taking_part_names_no_plan(self) -> None: + assert conversion_plan(_state(output=OutputKind.MIXED)) is None + + def test_the_levels_take_turns_as_the_reader_asked(self) -> None: + state = _state( + output=OutputKind.MIXED, + mixed=["/audio/a.wav"], + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert conversion_setup(state).stems.hierarchy.mode == HierarchyMode.STRICT + + def test_the_recordings_that_take_part_stand_in_mixing_order(self) -> None: + state = _state(output=OutputKind.MIXED, mixed=["/audio/a.wav", "/audio/b.wav"]) + + assert playing_sources(state) == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + def test_a_per_recording_run_reaches_the_recordings_it_writes(self) -> None: + state = _state(output=OutputKind.PER_RECORDING, listed=["/audio/a.wav", "/audio/b.wav"]) + + assert playing_sources(state) == (Path("/audio/a.wav"), Path("/audio/b.wav")) diff --git a/tests/unit/sampletones_application/logic/main/converter/test_view.py b/tests/unit/sampletones_application/logic/main/converter/test_view.py new file mode 100644 index 000000000..80fbba577 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_view.py @@ -0,0 +1,220 @@ +from pathlib import Path +from typing import Final, Optional + +from sampletones_application.constants.output import OutputKind +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.converter.view import source_settings +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.view_model.main.source import ( + ChannelSettingsViewModel, + SourceSettingsPanelViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE, UNIT_DRIVE +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + +LOUD_DRIVE: Final[float] = 2.0 +JOINING: Final[StemSettings] = StemSettings.covering(list(DEFAULT_CHANNELS)) +FOLDER_ROOT: Final[str] = "/audio/loops" +PICKED: Final[str] = "/audio/kick.wav" + + +def _state( + *recordings: Recording, + selected: Optional[SourceKey] = None, + joining: StemSettings = JOINING, +) -> ConverterState: + """A converter holding these recordings, with ``selected`` inspected.""" + gathering = Gathering.empty() + for item in recordings: + gathering = gathering.listing(item) + + return ConverterState( + settings=RunSettings( + joining=joining, + output=OutputKind.PER_RECORDING, + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + ), + gathering=gathering, + destination=Destination.unset(), + selected=selected, + ) + + +def _folder_state(*recordings: Recording) -> ConverterState: + """A converter holding one folder standing for these recordings, the folder inspected.""" + state = _state(selected=SourceKey.folder(Path(FOLDER_ROOT))) + return state.with_gathering(state.gathering.listing_folder(folder(FOLDER_ROOT, recordings))) + + +def _card(state: ConverterState, *, live: bool = True) -> SourceSettingsPanelViewModel: + return source_settings(state, live=live) + + +def _channel( + view_model: SourceSettingsPanelViewModel, + channel_name: ChannelName, +) -> ChannelSettingsViewModel: + return next(channel for channel in view_model.channels if channel.channel is channel_name) + + +class TestWhatTheCardIsPointedAt(BaseTestSuite): + def test_with_nothing_picked_it_edits_what_a_recording_joins_with(self) -> None: + view_model = _card(_state(recording("/audio/kick.wav"))) + + assert view_model.subject is None + assert view_model.edits_new_recordings is True + + def test_it_reads_the_settings_a_recording_joins_with(self) -> None: + joining = StemSettings.covering([ChannelName.NOISE]).with_channel_cap(1) + + view_model = _card(_state(recording("/audio/kick.wav"), joining=joining)) + + assert _channel(view_model, ChannelName.NOISE).use is Agreement.ALL + assert _channel(view_model, ChannelName.PULSE1).use is Agreement.NONE + assert view_model.channel_cap == 1 + + def test_a_picked_recording_names_the_card(self) -> None: + state = _state( + recording("/audio/kick.wav"), + selected=SourceKey.recording(Path("/audio/kick.wav")), + ) + + subject = _card(state).subject + + assert subject is not None + assert (subject.name, subject.holds) == ("kick", 1) + + def test_a_row_the_list_lets_go_of_falls_back_to_the_joining_settings(self) -> None: + state = _state(selected=SourceKey.recording(Path("/audio/gone.wav"))) + + view_model = _card(state) + + assert view_model.edits_new_recordings is True + assert _channel(view_model, ChannelName.PULSE1).use is Agreement.ALL + + def test_a_conversion_under_way_holds_the_card_still(self) -> None: + assert _card(_state(), live=False).live is False + + +class TestHowOneRecordingReads(BaseTestSuite): + @staticmethod + def _picked(item: Recording) -> SourceSettingsPanelViewModel: + return _card(_state(item, selected=SourceKey.recording(item.path))) + + def test_a_channel_it_occupies_reads_as_held(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.PULSE1])) + + assert _channel(view_model, ChannelName.PULSE1).use is Agreement.ALL + assert _channel(view_model, ChannelName.TRIANGLE).use is Agreement.NONE + + def test_a_channel_it_bends_reads_as_bent(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.TRIANGLE], [ChannelName.TRIANGLE])) + + assert _channel(view_model, ChannelName.TRIANGLE).bend is Agreement.ALL + + def test_the_noise_channel_reads_no_bend(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.NOISE])) + + noise = _channel(view_model, ChannelName.NOISE) + assert (noise.bend, noise.bendable) == (Agreement.NONE, False) + + def test_the_drive_it_gives_a_channel_reads_back(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.PULSE1], drives={ChannelName.PULSE1: LOUD_DRIVE})) + + assert _channel(view_model, ChannelName.PULSE1).drive == LOUD_DRIVE + + def test_a_channel_it_leaves_free_rests_at_the_calibrated_level(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.PULSE1])) + + triangle = _channel(view_model, ChannelName.TRIANGLE) + assert (triangle.drive, triangle.drive_mixed) == (UNIT_DRIVE, False) + + def test_the_count_it_holds_reads_back(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.PULSE1, ChannelName.TRIANGLE], channel_cap=1)) + + assert view_model.channel_cap == 1 + assert view_model.step_agreement(1) is Agreement.ALL + assert view_model.step_agreement(2) is Agreement.NONE + + def test_a_step_past_the_channels_it_occupies_reaches_nothing(self) -> None: + view_model = self._picked(recording(PICKED, [ChannelName.PULSE1, ChannelName.TRIANGLE])) + + assert (view_model.channels_used, view_model.step_reaches(2), view_model.step_reaches(3)) == (2, True, False) + + +class TestHowAFolderReads(BaseTestSuite): + """Recordings that differ read half-lit, the way they do in the list.""" + + def test_a_channel_only_some_of_them_occupy_reads_as_half_held(self) -> None: + view_model = _card( + _folder_state( + recording(f"{FOLDER_ROOT}/a.wav", [ChannelName.PULSE1]), + recording(f"{FOLDER_ROOT}/b.wav", [ChannelName.TRIANGLE]), + ) + ) + + assert _channel(view_model, ChannelName.PULSE1).use is Agreement.SOME + + def test_drives_they_differ_on_read_as_mixed(self) -> None: + view_model = _card( + _folder_state( + recording(f"{FOLDER_ROOT}/a.wav", [ChannelName.PULSE1], drives={ChannelName.PULSE1: LOUD_DRIVE}), + recording(f"{FOLDER_ROOT}/b.wav", [ChannelName.PULSE1]), + ) + ) + + pulse = _channel(view_model, ChannelName.PULSE1) + assert (pulse.drive, pulse.drive_mixed, pulse.drive_shown) == (None, True, UNIT_DRIVE) + + def test_one_drive_they_agree_on_reads_plainly(self) -> None: + view_model = _card( + _folder_state( + recording(f"{FOLDER_ROOT}/a.wav", [ChannelName.PULSE1], drives={ChannelName.PULSE1: LOUD_DRIVE}), + recording(f"{FOLDER_ROOT}/b.wav", [ChannelName.PULSE1], drives={ChannelName.PULSE1: LOUD_DRIVE}), + ) + ) + + assert _channel(view_model, ChannelName.PULSE1).drive == LOUD_DRIVE + + def test_counts_they_differ_on_leave_every_step_half_lit(self) -> None: + view_model = _card( + _folder_state( + recording(f"{FOLDER_ROOT}/a.wav", [ChannelName.PULSE1, ChannelName.TRIANGLE], channel_cap=1), + recording(f"{FOLDER_ROOT}/b.wav", [ChannelName.PULSE1, ChannelName.TRIANGLE], channel_cap=2), + ) + ) + + assert view_model.channel_cap is None + assert view_model.step_agreement(1) is Agreement.SOME + assert view_model.step_agreement(2) is Agreement.SOME + assert view_model.step_agreement(3) is Agreement.NONE + + def test_the_channels_used_is_the_most_any_of_them_occupies(self) -> None: + view_model = _card( + _folder_state( + recording(f"{FOLDER_ROOT}/a.wav", [ChannelName.PULSE1]), + recording(f"{FOLDER_ROOT}/b.wav", [ChannelName.PULSE1, ChannelName.TRIANGLE]), + ) + ) + + assert view_model.channels_used == 2 + + def test_the_folder_names_how_many_recordings_it_stands_for(self) -> None: + view_model = _card( + _folder_state( + recording(f"{FOLDER_ROOT}/a.wav"), + recording(f"{FOLDER_ROOT}/b.wav"), + ) + ) + + subject = view_model.subject + assert subject is not None + assert (subject.holds, subject.stands_for_a_folder) == (2, True) diff --git a/tests/unit/sampletones_application/logic/main/converter/texts.py b/tests/unit/sampletones_application/logic/main/converter/texts.py new file mode 100644 index 000000000..909594af0 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/texts.py @@ -0,0 +1,31 @@ +from typing import Dict, Final, Mapping + +from sampletones_application.logic.main.converter.messages import ConverterMessages +from tests.suite.language import FakeLanguageManager + +TEXTS: Final[Dict[str, str]] = { + "main.converter.label.convert_button": "Convert", + "main.converter.label.cancel_button": "Cancel", + "main.converter.template.convert_recording": "Convert {name}", + "main.converter.template.convert_recordings": "Convert {count} recordings", + "main.converter.template.mix_recordings": "Mix {count} recordings", + "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_gathering": "gathering", + "global.dialog.label.unknown_duration": "?", + "global.dialog.template.time_estimation": "", +} + + +def messages() -> ConverterMessages: + """The converter's phrases over a language manager stating the ones a test reads.""" + return messages_reading(TEXTS) + + +def messages_reading(texts: Mapping[str, str]) -> ConverterMessages: + """The converter's phrases over a language manager answering from ``texts`` as they stand.""" + return ConverterMessages(FakeLanguageManager(texts)) # type: ignore[arg-type] diff --git a/tests/unit/sampletones_application/logic/main/sources/__init__.py b/tests/unit/sampletones_application/logic/main/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/main/sources/factories.py b/tests/unit/sampletones_application/logic/main/sources/factories.py new file mode 100644 index 000000000..029e0091b --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/factories.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Iterable, Mapping, Optional, Sequence + +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def settings( + channels: Sequence[ChannelName] = (ChannelName.PULSE1,), + bends: Sequence[ChannelName] = (), + drives: Optional[Mapping[ChannelName, float]] = None, + channel_cap: int = ALL_STEMS_CHANNEL_CAP, +) -> StemSettings: + return StemSettings( + channels=list(channels), + bends=list(bends), + drives=dict(drives or {}), + channel_cap=channel_cap, + ) + + +def recording( + path: str, + channels: Sequence[ChannelName] = (ChannelName.PULSE1,), + bends: Sequence[ChannelName] = (), + drives: Optional[Mapping[ChannelName, float]] = None, + channel_cap: int = ALL_STEMS_CHANNEL_CAP, +) -> Recording: + return Recording(path=Path(path), settings=settings(channels, bends, drives, channel_cap)) + + +def folder(root: str, recordings: Iterable[Recording]) -> Folder: + return Folder(root=Path(root), recordings=tuple(recordings)) diff --git a/tests/unit/sampletones_application/logic/main/sources/test_levels.py b/tests/unit/sampletones_application/logic/main/sources/test_levels.py new file mode 100644 index 000000000..7a8c88718 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_levels.py @@ -0,0 +1,145 @@ +from pathlib import Path +from typing import List, Sequence + +import pytest + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.logic.main.sources.levels import MixLevels +from tests.suite.base import BaseTestSuite + + +def _path(name: str) -> Path: + return Path(f"/audio/{name}.wav") + + +def _levels(*names: Sequence[str]) -> MixLevels: + return MixLevels.of([[_path(name) for name in level] for level in names]) + + +def _shape(levels: MixLevels) -> List[List[str]]: + return [[path.stem for path in level] for level in levels.levels] + + +class TestGathering(BaseTestSuite): + """The mix 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(MixLevels().add(_path("bass"))) == [["bass"]] + + def test_further_recordings_join_the_first_level(self) -> None: + levels = MixLevels().add(_path("bass")).add(_path("lead")) + assert _shape(levels) == [["bass", "lead"]] + + def test_a_recording_already_gathered_changes_nothing(self) -> None: + assert _shape(_levels(["bass"]).add(_path("bass"))) == [["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_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 TestTheCeilingAMixHolds(BaseTestSuite): + """A mix reaches as many recordings as the assignment has room to mix.""" + + def test_an_empty_mix_has_room_for_the_whole_ceiling(self) -> None: + assert MixLevels().room == MAX_STEM_SOURCES + + def test_room_falls_as_recordings_are_gathered(self) -> None: + assert _levels(["a", "b"]).room == MAX_STEM_SOURCES - 2 + + def test_a_recording_arriving_at_a_full_mix_leaves_it_as_it_stands(self) -> None: + levels = MixLevels() + for index in range(MAX_STEM_SOURCES): + levels = levels.add(_path(f"source{index}")) + + assert levels.count == MAX_STEM_SOURCES + assert levels.add(_path("one_more")).count == MAX_STEM_SOURCES + + +class TestMovesWithinALevel(BaseTestSuite): + """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(BaseTestSuite): + 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(BaseTestSuite): + 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(BaseTestSuite): + """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 diff --git a/tests/unit/sampletones_application/logic/main/sources/test_list.py b/tests/unit/sampletones_application/logic/main/sources/test_list.py new file mode 100644 index 000000000..4f4e5bd38 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -0,0 +1,188 @@ +from pathlib import Path + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.slots import BEND_SLOT, CHANNEL_SLOT +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + + +class TestGatheringRecordings(BaseTestSuite): + def test_a_recording_named_stands_as_a_row_of_its_own(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")) + assert sources.paths == (Path("/audio/a.wav"),) + assert sources.row_count == 1 + + def test_rows_stand_in_the_order_they_were_added(self) -> None: + sources = SourceList().add_recording(recording("/audio/b.wav")).add_recording(recording("/audio/a.wav")) + assert sources.paths == (Path("/audio/b.wav"), Path("/audio/a.wav")) + + def test_a_path_already_standing_leaves_the_list_as_it_is(self) -> None: + first = recording("/audio/a.wav", [ChannelName.PULSE1]) + again = recording("/audio/a.wav", [ChannelName.NOISE]) + sources = SourceList().add_recording(first).add_recording(again) + assert sources.rows == (first,) + + +class TestGatheringAFolder(BaseTestSuite): + def test_a_folder_stands_as_one_row_holding_its_recordings(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered) + + assert sources.row_count == 1 + assert sources.count == 2 + assert sources.paths == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + def test_a_root_already_standing_leaves_the_list_as_it_is(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav")]) + sources = SourceList().add_folder(gathered).add_folder(folder("/audio", [recording("/audio/b.wav")])) + assert sources.count == 1 + + def test_a_loose_recording_the_folder_covers_joins_it(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")) + sources = sources.add_folder(folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")])) + + assert sources.row_count == 1 + assert sources.folder_root_of(Path("/audio/a.wav")) == Path("/audio") + + def test_a_loose_recording_joins_holding_the_settings_it_stood_with(self) -> None: + settled = recording("/audio/a.wav", [ChannelName.NOISE]) + sources = SourceList().add_recording(settled) + sources = sources.add_folder(folder("/audio", [recording("/audio/a.wav", [ChannelName.PULSE1])])) + + gathered = sources.recording(Path("/audio/a.wav")) + assert gathered is not None + assert gathered.settings.channel_set == {ChannelName.NOISE} + + def test_a_recording_another_folder_holds_stays_where_it_is(self) -> None: + sources = SourceList().add_folder(folder("/audio/inner", [recording("/audio/inner/a.wav")])) + sources = sources.add_folder(folder("/audio", [recording("/audio/inner/a.wav"), recording("/audio/b.wav")])) + + assert sources.folder_root_of(Path("/audio/inner/a.wav")) == Path("/audio/inner") + assert sources.folder_root_of(Path("/audio/b.wav")) == Path("/audio") + assert sources.count == 2 + + def test_a_recording_the_reader_named_belongs_to_no_folder(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")) + assert sources.folder_root_of(Path("/audio/a.wav")) is None + + +class TestAFolderStandingForNothing(BaseTestSuite): + """A row names the recordings a run writes, so a folder naming none stays out of the list.""" + + def test_an_empty_folder_leaves_the_list_as_it_is(self) -> None: + sources = SourceList().add_folder(folder("/audio", [])) + + assert sources.rows == () + + def test_a_folder_whose_recordings_another_holds_leaves_it_as_it_is(self) -> None: + held = recording("/audio/takes/a.wav") + sources = SourceList().add_folder(folder("/audio/takes", [held])) + + sources = sources.add_folder(folder("/audio/takes/again", [held])) + + assert sources.row_count == 1 + + +class TestLettingSourcesGo(BaseTestSuite): + def test_a_folder_goes_with_everything_it_holds(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).remove(gathered.key) + assert sources.rows == () + + def test_a_recording_inside_a_folder_goes_from_that_folder(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).remove(SourceKey.recording(Path("/audio/a.wav"))) + + assert sources.row_count == 1 + assert sources.paths == (Path("/audio/b.wav"),) + + def test_a_folder_left_holding_nothing_goes_along_with_its_last_recording(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav")]) + sources = SourceList().add_folder(gathered).remove(SourceKey.recording(Path("/audio/a.wav"))) + assert sources.rows == () + + def test_a_recording_the_reader_named_goes_on_its_own(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")).add_recording(recording("/audio/b.wav")) + sources = sources.remove(SourceKey.recording(Path("/audio/a.wav"))) + assert sources.paths == (Path("/audio/b.wav"),) + + +class TestSettlingWhatARowStandsFor(BaseTestSuite): + def test_a_recording_settles_on_its_own(self) -> None: + row = recording("/audio/a.wav") + sources = SourceList().add_recording(row).settled(row.key, CHANNEL_SLOT, ChannelName.NOISE, True) + + settled = sources.recording(Path("/audio/a.wav")) + assert settled is not None + assert settled.settings.channel_set == {ChannelName.PULSE1, ChannelName.NOISE} + + def test_a_folder_settles_every_recording_it_holds(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).settled(gathered.key, CHANNEL_SLOT, ChannelName.NOISE, True) + + assert all(ChannelName.NOISE in source.settings.channel_set for source in sources.recordings) + + def test_settling_a_folder_leaves_the_rows_beside_it_alone(self) -> None: + loose = recording("/other/a.wav") + gathered = folder("/audio", [recording("/audio/a.wav")]) + sources = SourceList().add_recording(loose).add_folder(gathered) + sources = sources.settled(gathered.key, CHANNEL_SLOT, ChannelName.NOISE, True) + + untouched = sources.recording(Path("/other/a.wav")) + assert untouched is not None + assert untouched.settings.channel_set == {ChannelName.PULSE1} + + +class TestHowAFolderReads(BaseTestSuite): + def test_a_folder_every_recording_of_which_holds_it_reads_as_all(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.ALL + + def test_a_folder_whose_recordings_differ_reads_as_some(self) -> None: + gathered = folder( + "/audio", + [recording("/audio/a.wav", [ChannelName.PULSE1]), recording("/audio/b.wav", [ChannelName.NOISE])], + ) + sources = SourceList().add_folder(gathered) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.SOME + + def test_a_folder_no_recording_of_which_holds_it_reads_as_none(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav", [ChannelName.PULSE1])]) + sources = SourceList().add_folder(gathered) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.NOISE) == Agreement.NONE + + def test_a_row_the_list_has_none_of_reads_as_none(self) -> None: + assert ( + SourceList().agreement(SourceKey.recording(Path("/audio/a.wav")), CHANNEL_SLOT, ChannelName.PULSE1) + == Agreement.NONE + ) + + +class TestOneGestureOnARow(BaseTestSuite): + def test_a_folder_its_recordings_disagree_on_settles_on_all_of_them(self) -> None: + gathered = folder( + "/audio", + [recording("/audio/a.wav", [ChannelName.PULSE1]), recording("/audio/b.wav", [ChannelName.NOISE])], + ) + sources = SourceList().add_folder(gathered).toggled(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.ALL + + def test_a_folder_every_recording_of_which_holds_it_lets_it_go(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).toggled(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.NONE + + def test_a_bend_settles_the_same_way_a_channel_does(self) -> None: + gathered = folder( + "/audio", + [ + recording("/audio/a.wav", [ChannelName.TRIANGLE]), + recording("/audio/b.wav", [ChannelName.TRIANGLE]), + ], + ) + sources = SourceList().add_folder(gathered).toggled(gathered.key, BEND_SLOT, ChannelName.TRIANGLE) + assert sources.agreement(gathered.key, BEND_SLOT, ChannelName.TRIANGLE) == Agreement.ALL diff --git a/tests/unit/sampletones_application/logic/main/sources/test_scan.py b/tests/unit/sampletones_application/logic/main/sources/test_scan.py new file mode 100644 index 000000000..f83a6cc8d --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -0,0 +1,317 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.logic.main.sources.scan import FolderScan, ScanPhase +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) + +MANY: Final[Path] = Path("many") +FEW: Final[Path] = Path("few") +OTHER: Final[Path] = Path("other") +COUNT: Final[int] = 64 +FOUND: Final[Tuple[Path, ...]] = (Path("few/a.wav"), Path("few/b.wav")) + +Answered = List[Tuple[Path, Tuple[Path, ...]]] + + +class ScanServiceStandIn: + """The walk a scan drives, its reports handed back by the case the way the render loop drains them.""" + + def __init__(self) -> None: + self.started: List[FolderScanRequest] = [] + self.stops = 0 + self._handler: Optional[Callable[[FolderScanResult], None]] = None + + def subscribe(self, handler: Callable[[FolderScanResult], None]) -> None: + self._handler = handler + + def start(self, request: FolderScanRequest) -> None: + self.started.append(request) + + def stop(self) -> None: + self.stops += 1 + + @property + def latest(self) -> FolderScanRequest: + return self.started[-1] + + def report(self, result: FolderScanResult) -> None: + assert self._handler is not None + self._handler(result) + + +class Heard: + """What the reader is told: the folder named, the count, the reading given up, and a failure.""" + + def __init__(self, scan: FolderScan) -> None: + self.started: List[Path] = [] + self.counts: List[int] = [] + self.stopped = 0 + self.failures: List[Exception] = [] + scan.on_started = self.started.append + scan.on_progress = self.counts.append + scan.on_stopped = self._stop + scan.on_failed = self.failures.append + + def _stop(self) -> None: + self.stopped += 1 + + +@pytest.fixture(name="service") +def service_fixture() -> ScanServiceStandIn: + return ScanServiceStandIn() + + +@pytest.fixture(name="scan") +def scan_fixture(service: ScanServiceStandIn) -> FolderScan: + return FolderScan(service) + + +@pytest.fixture(name="heard") +def heard_fixture(scan: FolderScan) -> Heard: + return Heard(scan) + + +@pytest.fixture(name="answered") +def answered_fixture() -> Answered: + return [] + + +def answer_into(answered: Answered) -> Callable[[Path, Tuple[Path, ...]], None]: + return lambda root, recordings: answered.append((root, recordings)) + + +class TestAReading: + """A folder asked for is read, and the reader is told what the walk reports about it.""" + + def test_a_folder_asked_for_is_read(self, scan: FolderScan, service: ScanServiceStandIn) -> None: + scan.start(MANY, lambda _root, _found: None) + + assert [request.root for request in service.started] == [MANY] + assert scan.phase is ScanPhase.READING + + def test_the_reader_is_told_the_folder_and_the_count( + self, + scan: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + scan.start(MANY, lambda _root, _found: None) + + service.report(FolderScanStarted(request=service.latest)) + service.report(FolderScanProgress(request=service.latest, count=COUNT)) + + assert heard.started == [MANY] + assert heard.counts == [COUNT] + + def test_what_was_found_reaches_whoever_asked( + self, + scan: FolderScan, + service: ScanServiceStandIn, + answered: Answered, + ) -> None: + scan.start(FEW, answer_into(answered)) + + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) + + assert answered == [(FEW, FOUND)] + assert scan.phase is ScanPhase.IDLE + + def test_each_reading_answers_the_caller_that_asked_for_it( + self, + scan: FolderScan, + service: ScanServiceStandIn, + ) -> None: + """A gathering and a conversion ask the one scan, so a reading answering the other would convert a + folder nobody asked about.""" + gathered: Answered = [] + converted: Answered = [] + scan.start(MANY, answer_into(gathered)) + service.report(FolderScanSuccess(request=service.latest, recordings=())) + + scan.start(FEW, answer_into(converted)) + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) + + assert gathered == [(MANY, ())] + assert converted == [(FEW, FOUND)] + + def test_a_folder_asked_for_while_one_is_read_is_turned_away( + self, + scan: FolderScan, + service: ScanServiceStandIn, + answered: Answered, + ) -> None: + scan.start(MANY, lambda _root, _found: None) + + scan.start(FEW, answer_into(answered)) + service.report(FolderScanSuccess(request=service.latest, recordings=())) + + assert [request.root for request in service.started] == [MANY] + assert answered == [] + + def test_a_failed_reading_is_reported_and_lets_the_next_start( + self, + scan: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + failure = OSError("the tree went away") + scan.start(MANY, lambda _root, _found: None) + + service.report(FolderScanError(request=service.latest, exception=failure)) + scan.start(FEW, lambda _root, _found: None) + + assert heard.failures == [failure] + assert [request.root for request in service.started] == [MANY, FEW] + + +class TestStoppingAReading: + """Stop gives a reading up for the reader at once, while the walk winds down until it is heard to give up. + + A folder asked for in that span is read once the walk has given up, the latest one asked for + taking the place of an earlier one. Reports of the reading let go are set aside. + """ + + @pytest.fixture(name="stopped") + def stopped_fixture(self, scan: FolderScan, service: ScanServiceStandIn, answered: Answered) -> FolderScan: + """A reading of the large folder, stopped and winding down.""" + scan.start(MANY, answer_into(answered)) + service.report(FolderScanStarted(request=service.latest)) + scan.stop() + return scan + + def test_stop_asks_the_walk_to_give_up_once(self, stopped: FolderScan, service: ScanServiceStandIn) -> None: + stopped.stop() + + assert stopped.phase is ScanPhase.WINDING_DOWN + assert service.stops == 1 + + def test_the_count_of_a_stopped_reading_is_set_aside( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + service.report(FolderScanProgress(request=service.latest, count=COUNT)) + + assert heard.counts == [] + + def test_the_walk_giving_up_ends_the_reading( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + service.report(FolderScanCanceled(request=service.latest)) + + assert heard.stopped == 1 + assert stopped.phase is ScanPhase.IDLE + + def test_a_folder_asked_for_meanwhile_waits_for_the_walk( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + ) -> None: + stopped.start(FEW, lambda _root, _found: None) + + assert [request.root for request in service.started] == [MANY] + assert stopped.phase is ScanPhase.WINDING_DOWN + + def test_the_folder_asked_for_meanwhile_is_read_once_the_walk_gives_up( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + ) -> None: + stopped.start(FEW, lambda _root, _found: None) + + service.report(FolderScanCanceled(request=service.latest)) + + assert [request.root for request in service.started] == [MANY, FEW] + assert stopped.phase is ScanPhase.READING + + def test_the_latest_folder_asked_for_is_the_one_read( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + ) -> None: + stopped.start(OTHER, lambda _root, _found: None) + stopped.start(FEW, lambda _root, _found: None) + + service.report(FolderScanCanceled(request=service.latest)) + + assert [request.root for request in service.started] == [MANY, FEW] + + def test_the_folder_read_next_answers_its_own_caller( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + answered: Answered, + ) -> None: + converted: Answered = [] + stopped.start(FEW, answer_into(converted)) + service.report(FolderScanCanceled(request=service.latest)) + + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) + + assert answered == [] + assert converted == [(FEW, FOUND)] + + def test_a_walk_finishing_before_it_heard_stop_answers_nobody( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + answered: Answered, + ) -> None: + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) + + assert answered == [] + assert heard.stopped == 1 + + def test_a_walk_failing_as_it_winds_down_ends_the_way_a_stopped_one_does( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + """The reader gave the reading up, so its failure reads as the stop, and the folder asked for meanwhile is read.""" + stopped.start(FEW, lambda _root, _found: None) + + service.report(FolderScanError(request=service.latest, exception=OSError("the tree went away"))) + + assert heard.failures == [] + assert heard.stopped == 1 + assert [request.root for request in service.started] == [MANY, FEW] + + def test_a_late_report_of_the_reading_let_go_is_set_aside( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + answered: Answered, + ) -> None: + let_go = service.latest + stopped.start(FEW, lambda _root, _found: None) + service.report(FolderScanCanceled(request=let_go)) + + service.report(FolderScanProgress(request=let_go, count=COUNT)) + service.report(FolderScanSuccess(request=let_go, recordings=FOUND)) + + assert heard.counts == [] + assert answered == [] + assert stopped.phase is ScanPhase.READING + + def test_stop_with_nothing_read_asks_nothing(self, scan: FolderScan, service: ScanServiceStandIn) -> None: + scan.stop() + + assert service.stops == 0 + assert scan.phase is ScanPhase.IDLE diff --git a/tests/unit/sampletones_application/logic/main/sources/test_slots.py b/tests/unit/sampletones_application/logic/main/sources/test_slots.py new file mode 100644 index 000000000..68c8f7a22 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_slots.py @@ -0,0 +1,76 @@ +from sampletones_application.logic.main.sources.slots import ( + BEND_SLOT, + CHANNEL_SLOT, + SETTINGS_SLOTS, +) +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import settings + + +class TestWhichChannelsASlotIsOfferedOn(BaseTestSuite): + """A choice reaches a reader on the channels the settings it edits put it to.""" + + def test_a_channel_is_offered_on_every_channel(self) -> None: + held = settings(list(ChannelName.items())) + assert all(CHANNEL_SLOT.offers(held, channel_name) for channel_name in ChannelName.items()) + + def test_a_bend_is_offered_on_the_occupied_channels_that_read_one(self) -> None: + held = settings(list(ChannelName.items())) + assert BEND_SLOT.offered(held) == TONE_CHANNELS + assert not BEND_SLOT.offers(held, ChannelName.NOISE) + + def test_a_bend_reaches_no_channel_the_recording_leaves_alone(self) -> None: + """A bend belongs to a channel the recording occupies, so an empty one offers none.""" + assert BEND_SLOT.offered(settings([])) == frozenset() + + +class TestSettlingTheChannelsARecordingOccupies(BaseTestSuite): + def test_a_channel_settled_on_joins_the_ones_held(self) -> None: + settled = CHANNEL_SLOT.settled(settings(), ChannelName.NOISE, True) + assert settled.channel_set == {ChannelName.PULSE1, ChannelName.NOISE} + + def test_a_channel_settled_off_leaves_the_ones_held(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.NOISE]) + settled = CHANNEL_SLOT.settled(held, ChannelName.NOISE, False) + assert settled.channel_set == {ChannelName.PULSE1} + + def test_the_channels_stand_in_the_order_the_application_names_them(self) -> None: + held = settings([ChannelName.NOISE]) + settled = CHANNEL_SLOT.settled(held, ChannelName.PULSE1, True) + assert settled.channels == (ChannelName.PULSE1, ChannelName.NOISE) + + def test_a_channel_settled_off_takes_the_bend_it_carried(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.TRIANGLE], [ChannelName.TRIANGLE]) + settled = CHANNEL_SLOT.settled(held, ChannelName.TRIANGLE, False) + assert settled.bends == () + + +class TestSettlingTheChannelsARecordingBends(BaseTestSuite): + def test_a_bend_settled_on_a_channel_held_stands(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.TRIANGLE]) + settled = BEND_SLOT.settled(held, ChannelName.TRIANGLE, True) + assert settled.bend_set == {ChannelName.TRIANGLE} + + def test_a_bend_reaches_only_a_channel_the_recording_occupies(self) -> None: + settled = BEND_SLOT.settled(settings([ChannelName.PULSE1]), ChannelName.TRIANGLE, True) + assert settled.bends == () + + def test_a_bend_reaches_only_a_channel_whose_hardware_reads_one(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.NOISE]) + settled = BEND_SLOT.settled(held, ChannelName.NOISE, True) + assert settled.bends == () + + def test_settling_a_bend_leaves_the_channels_as_they_stand(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.TRIANGLE]) + settled = BEND_SLOT.settled(held, ChannelName.TRIANGLE, True) + assert settled.channels == held.channels + + +class TestTheSlotsARecordingOffers(BaseTestSuite): + def test_every_slot_reads_and_settles_the_choice_it_names(self) -> None: + for slot in SETTINGS_SLOTS: + held = settings(list(TONE_CHANNELS)) + settled = slot.settled(held, ChannelName.TRIANGLE, True) + assert slot.holds(settled, ChannelName.TRIANGLE) + assert not slot.holds(slot.settled(settled, ChannelName.TRIANGLE, False), ChannelName.TRIANGLE) diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py deleted file mode 100644 index 4fc8ddac3..000000000 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ /dev/null @@ -1,318 +0,0 @@ -from pathlib import Path -from typing import Dict, Final -from unittest.mock import MagicMock, patch - -import pytest - -from sampletones_application.logic.main.converter import ( - ConversionSuccess, - ConverterLogic, -) -from sampletones_application.view_model.main.converter import ( - ACTIVE_PHASES, - ConversionPhase, - ConverterViewModel, -) -from tests.suite.language import FakeLanguageManager - -TEXTS: Final[Dict[str, str]] = { - "main.converter.label.convert_sample_button": "Convert sample", - "main.converter.label.convert_directory_button": "Convert directory", - "main.converter.label.cancel_button": "Cancel", - "main.converter.template.convert_label_template": "{}: {}", -} - - -@pytest.fixture -def converter_logic() -> ConverterLogic: - config_manager = MagicMock() - config_manager.get_reconstructions_directory.return_value = Path("/tmp/reconstructions") - service = MagicMock() - service.is_running.return_value = False - scheduling = MagicMock( - priorities=MagicMock(schedule=0), - delays=MagicMock(schedule=0, cancel=0), - ) - logic = ConverterLogic( - config_manager, - service, - scheduling=scheduling, - language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] - is_operation_active=lambda: False, - ) - logic.on_view_changed = MagicMock() - logic.generate_library = MagicMock() - logic.is_library_available = lambda: False - return logic - - -class TestCancelDuringLibraryGeneration: - """The converter requests a library when none exists and waits for it. Cancelling during that - wait must abort the pending conversion and stop the in-flight generation.""" - - def test_cancel_while_waiting_cancels_generation_and_finishes( - self, - converter_logic: ConverterLogic, - ) -> None: - cancel_generation = MagicMock() - on_cancelled = MagicMock() - converter_logic.cancel_library_generation = cancel_generation - converter_logic.on_cancelled = on_cancelled - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - assert converter_logic._phase == ConversionPhase.WAITING - - converter_logic.cancel() - - cancel_generation.assert_called_once() - on_cancelled.assert_called_once() - assert converter_logic._phase == ConversionPhase.CANCELLED - - def test_wait_loop_aborts_once_no_longer_waiting( - self, - converter_logic: ConverterLogic, - ) -> None: - with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled: - converter_logic.start_conversion() - converter_logic.cancel() - scheduled.reset_mock() - - converter_logic._wait_for_library_and_start() - - converter_logic._service.start.assert_not_called() - scheduled.assert_not_called() - - def test_wait_poll_does_not_emit_a_zero_progress_view( - self, - converter_logic: ConverterLogic, - ) -> None: - """While waiting, the bar reflects the library-generation progress. A re-poll that finds the - library still missing must only re-queue itself, never emit its own view: emitting one would - carry ``progress=0.0`` and momentarily reset the bar.""" - with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled: - converter_logic.start_conversion() - converter_logic.on_view_changed.reset_mock() - scheduled.reset_mock() - - converter_logic._wait_for_library_and_start() - - converter_logic.on_view_changed.assert_not_called() - scheduled.assert_called_once() - - -class TestNoGeneratorsGuard: - """With no generators 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 = [] - on_no_generators = MagicMock() - converter_logic.on_no_generators = on_no_generators - - converter_logic.start_conversion() - - on_no_generators.assert_called_once() - converter_logic.generate_library.assert_not_called() - assert converter_logic._phase == ConversionPhase.IDLE - - -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.""" - - @pytest.mark.parametrize("phase", sorted(ACTIVE_PHASES, key=str)) - def test_active_during_non_terminal_phases( - self, - converter_logic: ConverterLogic, - phase: ConversionPhase, - ) -> None: - converter_logic._phase = phase - assert converter_logic.is_active is True - - @pytest.mark.parametrize( - "phase", - [ - ConversionPhase.IDLE, - ConversionPhase.COMPLETED, - ConversionPhase.CANCELLED, - ConversionPhase.FAILED, - ], - ) - def test_inactive_when_idle_or_terminal( - self, - converter_logic: ConverterLogic, - phase: ConversionPhase, - ) -> None: - converter_logic._phase = phase - assert converter_logic.is_active is False - - -def _last_view_model(converter_logic: ConverterLogic) -> ConverterViewModel: - return converter_logic.on_view_changed.call_args.args[0] - - -class TestActionLabel: - """The one action button's label is a projection of converter state, composed where the display - strings are resolved (the logic layer) rather than glued together in the panel: it names the - selected input while idle and reads the cancel label once a conversion holds resources. - """ - - def test_idle_file_label_names_the_selected_file( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._is_file = True - converter_logic._input_path = Path("/audio/kick.wav") - - converter_logic.emit_initial_view() - - assert _last_view_model(converter_logic).action_label == "Convert sample: kick.wav" - - def test_idle_directory_label_uses_the_directory_variant( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._is_file = False - converter_logic._input_path = Path("/audio/drums") - - converter_logic.emit_initial_view() - - assert _last_view_model(converter_logic).action_label == "Convert directory: drums" - - def test_idle_without_input_reads_the_bare_convert_label( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._input_path = None - - converter_logic.emit_initial_view() - - assert _last_view_model(converter_logic).action_label == "Convert sample" - - def test_active_conversion_reads_the_cancel_label( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._input_path = Path("/audio/kick.wav") - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - assert _last_view_model(converter_logic).action_label == "Cancel" - - -class TestStartConversionGate: - """A conversion refuses to start while another exclusive operation is active, so two heavy - processes cannot run at once.""" - - def test_refuses_when_an_operation_is_active( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._is_operation_active = lambda: True - - converter_logic.start_conversion() - - converter_logic._service.start.assert_not_called() - converter_logic.generate_library.assert_not_called() - assert converter_logic._phase == ConversionPhase.IDLE - - def test_proceeds_when_nothing_is_active( - self, - converter_logic: ConverterLogic, - ) -> None: - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - converter_logic.generate_library.assert_called_once() - assert converter_logic._phase == ConversionPhase.WAITING - - -class TestAssignPaths: - """``get_output_path``'s contract is the ``OSError`` family: those failures abort the - conversion and report through ``on_error``; a failure outside the contract is a bug and - propagates.""" - - @pytest.mark.parametrize( - "error", - [FileNotFoundError("missing"), OSError("invalid path")], - ids=["missing", "invalid"], - ) - def test_path_failure_reports_error_and_aborts( - self, - converter_logic: ConverterLogic, - error: Exception, - ) -> None: - converter_logic.on_error = MagicMock() - - with patch( - "sampletones_application.logic.main.converter.get_output_path", - side_effect=error, - ): - result = converter_logic._assign_paths( - Path("/tmp/input.wav"), - MagicMock(), - ) - - assert result is False - converter_logic.on_error.assert_called_once_with(error) - - def test_unexpected_failure_propagates( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic.on_error = MagicMock() - - with ( - patch( - "sampletones_application.logic.main.converter.get_output_path", - side_effect=KeyError("drive"), - ), - pytest.raises(KeyError), - ): - converter_logic._assign_paths(Path("/tmp/input.wav"), MagicMock()) - - converter_logic.on_error.assert_not_called() - - -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).""" - - def test_success_carries_input_kind_and_output_path( - 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") - - converter_logic._on_conversion_complete(Path("/reconstructions/kick.rcn")) - - assert converter_logic._phase == ConversionPhase.COMPLETED - on_success.assert_called_once_with( - ConversionSuccess(is_file=True, output_path=Path("/reconstructions/kick.rcn")) - ) - - -class TestFailureReturnsToIdle: - """With no Close button, a failure reports through ``on_error`` and schedules its own return to - idle so the panel never strands on the failed phase.""" - - def test_failure_schedules_return_to_idle_and_reports( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic.on_error = MagicMock() - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled: - converter_logic._on_conversion_error(RuntimeError("boom")) - - assert converter_logic._phase == ConversionPhase.FAILED - scheduled.assert_called_once() - assert scheduled.call_args.args[0] == converter_logic.close - converter_logic.on_error.assert_called_once() diff --git a/tests/unit/sampletones_application/logic/main/test_explorer_manager.py b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py index ae9d18205..722d6c3ab 100644 --- a/tests/unit/sampletones_application/logic/main/test_explorer_manager.py +++ b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py @@ -108,30 +108,19 @@ def test_a_folder_no_session_left_open_stays_folded( assert not manager.is_directory_open(paths[NOTES]) assert rows_below(manager.tree, paths[NOTES]) == [] - def test_a_folder_the_disk_has_lost_is_dropped_at_startup( + def test_a_folder_gone_for_now_stays_remembered( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + """A folder on a drive unplugged for one run comes back open once the drive does.""" paths = write_corpus(tmp_path) gone = tmp_path / "gone" - manager = build_manager(tmp_path, {paths[MUSIC], gone}, monkeypatch) - assert manager.open_directories == {paths[MUSIC]} - - def test_a_file_standing_where_a_folder_was_is_dropped_at_startup( - self, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - write_corpus(tmp_path) - replaced = tmp_path / "replaced" - replaced.touch() - - manager = build_manager(tmp_path, {replaced}, monkeypatch) + manager.refresh_tree() - assert manager.open_directories == set() + assert manager.open_directories >= {paths[MUSIC], gone} class TestReadingApartFromStandingOpen: diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 54dfae4de..972597076 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -1,15 +1,21 @@ 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.patterns.pitch import Step +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,26 +65,38 @@ 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( + def test_add_sample_holds_the_document_detached_and_leaves_the_given_one( self, reconstruction_factory: Callable[[], Reconstruction], ) -> 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 == () + assert reconstruction.audio_filepath + assert sample.reconstruction.instructions_data == reconstruction.instructions_data + + def test_add_sample_holds_a_detached_document_as_it_is( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + controller = _controller() + detached = reconstruction_factory().detached() + + sample = controller.add_sample(detached, name="lead") + + assert sample.reconstruction is detached def test_remove_sample_purges_row_references( self, @@ -87,19 +105,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 +125,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 +147,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 +164,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,30 +185,30 @@ 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( + def test_duplicate_sample_appends_a_copy_sharing_the_document( self, reconstruction_factory: Callable[[], Reconstruction] ) -> None: 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 clone.reconstruction is source.reconstruction + 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 +217,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, @@ -221,9 +233,9 @@ def test_replace_sample_reconstruction_swaps_content_and_keeps_identity( controller.replace_sample_reconstruction(sample.id, replacement) - assert sample.reconstruction is replacement + assert sample.reconstruction.id == replacement.id 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 +244,12 @@ 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 sample.reconstruction.audio_filepath == () + assert replacement.audio_filepath def test_replace_sample_reconstruction_preserves_row_references( self, @@ -245,15 +258,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 +271,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 +282,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 +290,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,52 +302,49 @@ 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, - ), - transpose=0, + command=NoteOn(voice_id=sample.id), + pitch=Step(value=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.transpose == 0 + assert row.command.voice_id == sample.id + assert row.pitch == Step(value=0) assert row.volume == 10 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 +356,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 +371,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 +383,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 +507,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 +612,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, ) @@ -555,12 +623,12 @@ def test_sample_holds_reconstruction_by_reference( self, reconstruction_factory: Callable[[], Reconstruction] ) -> None: controller = _controller() - reconstruction = reconstruction_factory() + reconstruction = reconstruction_factory().detached() 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( + def test_an_edit_reaches_the_project_as_the_document_it_installs( self, reconstruction_factory: Callable[[], Reconstruction] ) -> None: controller = _controller() @@ -575,16 +643,19 @@ def test_in_place_reconstruction_edit_is_visible_through_project( duty_cycle=1, ) ] - reconstruction.update_generator_data( - GeneratorName.PULSE1, + edited = sample.reconstruction.with_channel_data( + ChannelName.PULSE1, new_instructions, - np.zeros(64, dtype=np.float32), 72, (), + heard=reconstruction.recorded_stem_ids, ) + controller.replace_sample_reconstruction(sample.id, edited) - 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 is edited + assert stored.get_channel_instructions(ChannelName.PULSE1) == new_instructions + assert reconstruction.get_channel_instructions(ChannelName.PULSE1) != new_instructions class TestBatch: @@ -592,12 +663,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, @@ -659,6 +730,23 @@ def test_every_mutation_signals_the_history_as_it_lands(self) -> None: assert mutations == ["mutation", "mutation"] + def test_a_mutation_whose_stamp_raises_still_signals_the_history(self) -> None: + """A gesture failing as its mutation is stamped is rolled back over a mutation the history counted.""" + project_manager = ProjectManager() + controller = ProjectController(project_manager) + mutations: List[str] = [] + controller.on_mutation = lambda: mutations.append("mutation") + + def failing_stamp() -> None: + raise RuntimeError("the stamp went wrong") + + project_manager.session.on_state_changed = failing_stamp + + with pytest.raises(RuntimeError): + controller.set_tempo(170) + + assert mutations == ["mutation"] + def test_nested_batches_announce_on_the_outermost_exit(self) -> None: controller = _controller() emitted: List[str] = [] diff --git a/tests/unit/sampletones_application/logic/project/test_manager.py b/tests/unit/sampletones_application/logic/project/test_manager.py index e0af6e437..27d77aac5 100644 --- a/tests/unit/sampletones_application/logic/project/test_manager.py +++ b/tests/unit/sampletones_application/logic/project/test_manager.py @@ -1,9 +1,11 @@ from pathlib import Path +from typing import List, Optional 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_core.project.project import Project from sampletones_shared.exceptions import NotAValidArchiveError from tests.suite.errors import DIRECTORY_READ_ERRORS @@ -11,8 +13,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 +27,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 +44,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: @@ -60,3 +62,124 @@ def test_invalid_archive_raises_load_project_error(self, tmp_path: Path) -> None with pytest.raises(NotAValidArchiveError): ProjectManager().load(path) + + +class TestTheFileAProjectStandsFor: + """The open project stands for the file it was last loaded from or saved to. + + A new or closed project stands for none, a failed load leaves the open project's file as it was, + and each new file is reported once. + """ + + @pytest.fixture(name="reported") + def reported_fixture(self) -> List[Optional[Path]]: + return [] + + @pytest.fixture(name="manager") + def manager_fixture(self, reported: List[Optional[Path]]) -> ProjectManager: + manager = ProjectManager() + manager.on_path_changed = reported.append + return manager + + @pytest.fixture(name="saved_project") + def saved_project_fixture(self, tmp_path: Path) -> Path: + path = tmp_path / "song.stp" + ProjectManager().save(path) + return path + + def test_a_fresh_manager_stands_for_no_file(self, manager: ProjectManager) -> None: + assert manager.path is None + + def test_a_loaded_project_stands_for_its_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + assert manager.path == saved_project + assert reported == [saved_project] + + def test_a_saved_project_stands_for_the_file_it_was_saved_to( + self, + manager: ProjectManager, + saved_project: Path, + tmp_path: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + copy = tmp_path / "copy.stp" + + manager.save(copy) + + assert manager.path == copy + assert reported == [saved_project, copy] + + def test_saving_again_to_its_file_reports_nothing_new( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.save(saved_project) + + assert reported == [saved_project] + + def test_a_new_project_stands_for_no_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.new() + + assert manager.path is None + assert reported == [saved_project, None] + + def test_a_closed_project_stands_for_no_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.close() + + assert manager.path is None + assert reported == [saved_project, None] + + def test_a_failed_load_keeps_the_open_project_s_file( + self, + manager: ProjectManager, + saved_project: Path, + tmp_path: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + broken = tmp_path / "broken.stp" + broken.write_bytes(b"this is not a zip archive") + + with pytest.raises(NotAValidArchiveError): + manager.load(broken) + + assert manager.path == saved_project + assert reported == [saved_project] + + def test_a_history_restore_keeps_the_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.install(Project.create(), clean=False) + + assert manager.path == saved_project + assert reported == [saved_project] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 8a1cae3e5..863255069 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -17,10 +17,19 @@ ) from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_core.structures.tree import ( + ConfigGroupNode, + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.application import synchronous_executor from tests.suite.language import FakeLanguageManager +__all__ = ["synchronous_executor"] + HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" HASH_B: Final[str] = "a1b2c3d4e5f60718293a4b5c6d7e8f90" @@ -37,7 +46,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 +55,7 @@ def config_fields( nf=nes_frequency, sm=spectrum_method, tg=transformation_gamma, - gn=generators, + gn=channels, ch=config_hash, ) @@ -109,6 +118,22 @@ def directory_node(name: str, parent: TreeNode) -> FileSystemNode: ) +def config_group_node(name: str, parent: TreeNode) -> ConfigGroupNode: + """A heading naming a stretch of the configuration text the rows below it carry.""" + return ConfigGroupNode(name, node_type=NodeType.GROUP, parent=parent) + + +def config_variant_node(name: str, parent: TreeNode) -> ConfigNode: + """A reconstruction row labeled by the configuration that produced it, as the sample branch builds it.""" + return ConfigNode( + name, + node_type=NodeType.FILE, + filepath=(RECONSTRUCTIONS / name).with_suffix(EXT_FILE_RECONSTRUCTION), + config=config_fields(), + parent=parent, + ) + + def file_node(name: str, parent: TreeNode) -> FileSystemNode: return FileSystemNode( name, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py index 80c0c9b97..4643fc547 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py @@ -5,6 +5,8 @@ from .conftest import ( child_names, + config_group_node, + config_variant_node, container_root, directory_node, file_node, @@ -68,6 +70,51 @@ def test_a_folded_group_keeps_the_children_it_led_to(self) -> None: assert child_names(directory) == ["song"] +class TestWhatAFoldedNameStates: + def test_a_sample_leaves_the_variant_reading_as_a_plain_name(self) -> None: + root = container_root() + branch = group_node("branch", root) + variant = config_variant_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", branch)) + + collapse_single_child_containers(root) + + assert not variant.states_configuration + + def test_a_chain_of_configuration_headings_still_states_a_configuration(self) -> None: + root = container_root() + branch = group_node("branch", root) + frequencies = config_group_node("44.1 kHz·30 Hz", branch) + variant = config_variant_node("PTN", config_group_node("FFT·γ0", frequencies)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·FFT·γ0·PTN"] + assert variant.states_configuration + + def test_a_source_folder_above_a_folded_variant_leaves_it_plain(self) -> None: + """Deepest first, the sample folds in and then the folder does, and one plain name is enough.""" + root = container_root() + branch = group_node("branch", root) + folder = group_node("Amen Breaks", branch) + variant = config_variant_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", folder)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["Amen Breaks·cw_amen02_165·44.1 kHz·30 Hz·FFT·γ0·PTN"] + assert not variant.states_configuration + + def test_a_variant_no_heading_folded_into_states_its_configuration(self) -> None: + root = container_root() + branch = group_node("branch", root) + sample = sample_node("cw_amen02_165", branch) + variant = config_variant_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample) + config_variant_node("44.1 kHz·60 Hz·FFT·γ0·PTN", sample) + + collapse_single_child_containers(root) + + assert variant.states_configuration + + class TestHeadingsThatStay: def test_a_group_gathering_several_rows_stays(self) -> None: root = container_root() 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/browser/test_stems_reader.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_stems_reader.py new file mode 100644 index 000000000..641dcd471 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_stems_reader.py @@ -0,0 +1,146 @@ +import os +from pathlib import Path +from typing import Final, List, Sequence, Tuple + +import pytest + +from sampletones_application.logic.reconstruction.browser.stems import ( + ReconstructionStemsReader, +) +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.sequencer import sample_reconstruction +from tests.suite.stems import recorded_from + +RECORDINGS: Final[List[str]] = ["Lead Vocals", "Drums", "Bass"] +REWRITTEN: Final[List[str]] = ["Guitar", "Organ", "Bass"] +LATER: Final[float] = 120.0 + + +def write_document(directory: Path, names: Sequence[str]) -> Path: + """Writes a reconstruction recording one named audio file per name, and answers where it went.""" + reconstruction = recorded_from( + sample_reconstruction([ChannelName.PULSE1]), + [directory / f"{name}.wav" for name in names], + ) + path = (directory / "song").with_suffix(EXT_FILE_RECONSTRUCTION) + reconstruction.save(path) + return path + + +def named(names: Sequence[str]) -> Tuple[NamedRecordingViewModel, ...]: + """The recordings a document written from ``names`` answers with, numbered in the order given.""" + return tuple(NamedRecordingViewModel(stem_id=stem_id, name=name) for stem_id, name in enumerate(names)) + + +def touched_later(path: Path) -> None: + """Writes the file's clock forward, so a rewrite reads as one however fast it followed.""" + written = path.stat().st_mtime + LATER + os.utime(path, (written, written)) + + +class Announcements: + def __init__(self) -> None: + self.read: List[Tuple[Path, Tuple[NamedRecordingViewModel, ...]]] = [] + + def __call__(self, path: Path, recordings: Tuple[NamedRecordingViewModel, ...]) -> None: + self.read.append((path, recordings)) + + +@pytest.fixture +def announcements() -> Announcements: + return Announcements() + + +@pytest.fixture +def reader(announcements: Announcements) -> ReconstructionStemsReader: + instance = ReconstructionStemsReader() + instance.on_recordings_read = announcements + return instance + + +class TestReadingADocument: + def test_an_unread_document_answers_nothing( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + assert reader.recordings(write_document(tmp_path, RECORDINGS)) is None + + def test_the_reading_names_the_recordings_in_record_order( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + + reader.recordings(path) + + assert reader.recordings(path) == named(RECORDINGS) + + def test_the_reading_is_announced_once_it_lands( + self, + reader: ReconstructionStemsReader, + announcements: Announcements, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + + reader.recordings(path) + + assert announcements.read == [(path, named(RECORDINGS))] + + +class TestWhatAReadingOutlives: + def test_a_rewritten_document_is_read_again( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + reader.recordings(path) + + write_document(tmp_path, REWRITTEN) + touched_later(path) + + assert reader.recordings(path) is None + assert reader.recordings(path) == named(REWRITTEN) + + def test_a_document_read_once_is_answered_without_reading_it_again( + self, + reader: ReconstructionStemsReader, + announcements: Announcements, + tmp_path: Path, + ) -> None: + path = write_document(tmp_path, RECORDINGS) + reader.recordings(path) + + reader.recordings(path) + + assert len(announcements.read) == 1 + + +class TestADocumentThatCannotBeRead: + def test_an_unreadable_document_answers_nothing_to_show( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = (tmp_path / "broken").with_suffix(EXT_FILE_RECONSTRUCTION) + path.write_bytes(b"not a document") + + reader.recordings(path) + + assert reader.recordings(path) == () + + def test_a_document_the_disk_does_not_hold_answers_nothing_to_show( + self, + reader: ReconstructionStemsReader, + tmp_path: Path, + ) -> None: + path = (tmp_path / "absent").with_suffix(EXT_FILE_RECONSTRUCTION) + + reader.recordings(path) + + assert reader.recordings(path) == () diff --git a/tests/unit/sampletones_application/logic/reconstruction/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/conftest.py index d2d8b13e9..dfa9d9a89 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/conftest.py @@ -2,6 +2,8 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.shared.renders import RenderCache +from tests.conftest import RENDER_BUDGET from tests.suite.application import scheduling, synchronous_queue __all__ = ["scheduling", "synchronous_queue"] @@ -9,4 +11,4 @@ @pytest.fixture def reconstruction_manager(scheduling: SchedulingBehavior) -> ReconstructionManager: - return ReconstructionManager(scheduling=scheduling) + return ReconstructionManager(scheduling=scheduling, renders=RenderCache(budget_bytes=RENDER_BUDGET)) diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/__init__.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py new file mode 100644 index 000000000..f465420a4 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_queue.py @@ -0,0 +1,540 @@ +from dataclasses import dataclass, field +from typing import Final, List, Optional, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.reconstruction.edit import ( + ChannelEdit, + ReconstructionEdit, + Retune, + StemRemoval, +) +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ( + AfterEdits, + ChannelChange, + RateChange, + StemRemovalRequest, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.reconstructions import Reconstruction +from tests.conftest import ReconstructionFactory +from tests.suite.regeneration import HeldRegeneration +from tests.suite.stems import ( + SHARED_CHANNEL, + SOLE_CHANNEL, + STEM_A_ID, + STEM_B_ID, + taking_turns, +) + +__all__ = ["taking_turns"] + +VOICE_ID: Final[str] = "turns-id" +FIRST_VOLUME: Final[Envelope[int]] = Envelope[int](items=(5, 5)) +SECOND_VOLUME: Final[Envelope[int]] = Envelope[int](items=(4, 4)) +LATER_VOLUME: Final[Envelope[int]] = Envelope[int](items=(3, 3)) +SOLE_VOLUME: Final[Envelope[int]] = Envelope[int](items=(2,)) +ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(0, 3)) +RETUNED_FREQUENCY: Final[int] = 50 +LATER_FREQUENCY: Final[int] = 40 + + +def _change(channel_name: ChannelName, feature_key: FeatureKey, envelope: Envelope[int]) -> ChannelChange: + return ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: envelope}, + initial_pitch=None, + ) + + +def _removal(stem_id: int) -> StemRemovalRequest: + return StemRemovalRequest(stem_id=stem_id, stem_name=str(stem_id)) + + +@dataclass +class Outcome: + """Everything the rewrites reported, in the order they reported it.""" + + edits: List[ReconstructionEdit] = field(default_factory=list) + dropped: int = 0 + failures: List[Exception] = field(default_factory=list) + busy: List[bool] = field(default_factory=list) + + +@pytest.fixture +def regeneration() -> HeldRegeneration: + return HeldRegeneration() + + +@pytest.fixture +def outcome() -> Outcome: + return Outcome() + + +@pytest.fixture +def rewrites( + reconstruction_manager: ReconstructionManager, + regeneration: HeldRegeneration, + outcome: Outcome, + taking_turns: Reconstruction, +) -> ReconstructionRewrites: + """The steps of the two-recording document, each landing edit adopted the way the coordinator adopts it.""" + reconstruction_manager.load_reconstruction_object(taking_turns, name="turns", voice_id=VOICE_ID) + + def adopt(edit: ReconstructionEdit) -> None: + outcome.edits.append(edit) + reconstruction_manager.apply_edited(edit.reconstruction) + + def drop() -> None: + outcome.dropped += 1 + + rewrites = ReconstructionRewrites(reconstruction_manager, regeneration) + rewrites.on_edit = adopt + rewrites.on_dropped = drop + rewrites.on_failed = outcome.failures.append + rewrites.on_busy_changed = outcome.busy.append + return rewrites + + +def _volume_items(reconstruction: Optional[Reconstruction], channel_name: ChannelName) -> Tuple[int, ...]: + """The volumes of the frames a channel sounds.""" + assert reconstruction is not None + return tuple(instruction.volume for instruction in reconstruction.instructions[channel_name] if instruction.on) + + +class TestMergingWhatWaits: + """A change joins the one waiting at the end of the line when both move the same channel, and nothing else.""" + + def test_changes_of_one_channel_waiting_together_rebuild_once( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, SECOND_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO)) + regeneration.finish() + + (merged,) = regeneration.held + assert merged.features.volume == SECOND_VOLUME + assert merged.features.arpeggio == ARPEGGIO + + def test_nothing_merges_into_the_running_rebuild( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, SECOND_VOLUME)) + + (running,) = regeneration.held + assert running.features.volume == FIRST_VOLUME + regeneration.finish() + assert regeneration.held[0].features.volume == SECOND_VOLUME + + def test_nothing_merges_across_another_channel( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, SECOND_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + + rebuilt: List[Tuple[ChannelName, Envelope[int]]] = [] + while regeneration.held: + rebuilt.append((regeneration.held[0].channel_name, regeneration.held[0].features.volume)) + regeneration.finish() + + assert rebuilt == [ + (SHARED_CHANNEL, FIRST_VOLUME), + (SHARED_CHANNEL, SECOND_VOLUME), + (SOLE_CHANNEL, SOLE_VOLUME), + (SHARED_CHANNEL, LATER_VOLUME), + ] + + def test_a_later_rate_replaces_one_waiting( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + rewrites.request(RateChange(nes_frequency=LATER_FREQUENCY)) + regeneration.finish() + + retunes = [edit for edit in outcome.edits if isinstance(edit, Retune)] + assert [retune.nes_frequency for retune in retunes] == [LATER_FREQUENCY] + + +class TestEachStepReadsTheDocumentAtItsTurn: + """A step is built from the document the step before it left, and from the listening as it stands.""" + + def test_a_rebuild_starts_from_what_the_step_before_it_left( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + regeneration.finish() + + (second,) = regeneration.held + assert second.reconstruction is reconstruction_manager.reconstruction + assert _volume_items(second.reconstruction, SHARED_CHANNEL) == FIRST_VOLUME.items + + def test_the_dimensions_a_change_leaves_are_read_from_the_document( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO)) + regeneration.finish() + + (arpeggio,) = regeneration.held + assert arpeggio.features.volume.items[: len(FIRST_VOLUME.items)] == FIRST_VOLUME.items + + def test_the_recordings_heard_are_read_at_the_changes_turn( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.listening.set_channels(STEM_B_ID, frozenset({SOLE_CHANNEL})) + regeneration.finish() + + (shared,) = regeneration.held + assert shared.heard == frozenset({STEM_A_ID}) + + +class TestAStepThatNoLongerApplies: + """A removal or a rate the document already carries is let go at its turn.""" + + def test_a_recording_a_step_before_took_out_is_skipped( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(_removal(STEM_B_ID)) + regeneration.finish() + + assert [type(edit) for edit in outcome.edits] == [ChannelEdit, StemRemoval] + + def test_the_last_recording_is_skipped( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(_removal(STEM_A_ID)) + + assert [type(edit) for edit in outcome.edits] == [StemRemoval] + reconstruction = reconstruction_manager.reconstruction + assert reconstruction is not None + assert list(reconstruction.stems_data.config.entries_by_id) == [STEM_A_ID] + + def test_a_rate_the_document_runs_at_is_skipped( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + taking_turns: Reconstruction, + ) -> None: + rewrites.request(RateChange(nes_frequency=taking_turns.config.nes_frequency)) + + assert outcome.edits == [] + + def test_another_rate_retunes_the_document( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + + (retune,) = outcome.edits + assert isinstance(retune, Retune) + assert retune.nes_frequency == RETUNED_FREQUENCY + assert retune.reconstruction.config.nes_frequency == RETUNED_FREQUENCY + + def test_a_change_with_no_document_open_is_let_go( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + ) -> None: + reconstruction_manager.close_reconstruction() + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + + assert regeneration.held == () + assert outcome.edits == [] + assert not rewrites.is_busy + + +class TestAResultLandsOnItsOwnDocument: + """A rebuild's result lands only on the document it was computed from.""" + + def test_a_result_for_the_open_document_lands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + regeneration.finish() + + (edit,) = outcome.edits + assert isinstance(edit, ChannelEdit) + assert (edit.channel_name, edit.feature_key) == (SHARED_CHANNEL, FeatureKey.VOLUME) + + def test_a_result_for_a_document_put_away_is_dropped_and_the_panel_redrawn( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + outcome: Outcome, + reconstruction_factory: ReconstructionFactory, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="other", voice_id="other") + regeneration.finish() + + assert outcome.edits == [] + assert outcome.dropped == 1 + + def test_a_failed_rebuild_is_reported_and_the_next_step_runs( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + failure = RuntimeError("synthesis failed") + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + + regeneration.fail(failure) + + assert outcome.failures == [failure] + assert outcome.dropped == 1 + assert regeneration.held[0].channel_name is SOLE_CHANNEL + + def test_a_result_with_no_rebuild_running_is_refused( + self, + rewrites: ReconstructionRewrites, + taking_turns: Reconstruction, + ) -> None: + stray = HeldRegeneration() + stray.subscribe(rewrites._on_result) + stray.start(taking_turns, SHARED_CHANNEL, taking_turns.export()[SHARED_CHANNEL], frozenset()) + + with pytest.raises(RuntimeError, match="no rebuild was running"): + stray.finish() + + +class TestDroppingTheLine: + """An outside replacement lets the edits meant for the document it puts away go.""" + + def test_the_waiting_edits_leave_and_the_running_result_lands_nowhere( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + rewrites.request(RateChange(nes_frequency=RETUNED_FREQUENCY)) + + rewrites.drop() + regeneration.finish() + + assert outcome.edits == [] + assert outcome.dropped == 0 + assert regeneration.held == () + assert not rewrites.is_busy + + def test_a_waiting_gesture_keeps_its_place( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + """A save or an undo the reader asked for still happens, on the document now open.""" + gesture = MagicMock() + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(AfterEdits(gesture=gesture)) + + rewrites.drop() + gesture.assert_not_called() + regeneration.finish() + + gesture.assert_called_once_with() + + +class TestThePanelDrawsWhatTheDocumentWillHold: + """The changes on their way are written over the document's envelopes, in the order they were made.""" + + def test_the_running_and_the_waiting_changes_are_drawn( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + document = reconstruction_manager.current_features + assert document is not None + + drawn = rewrites.drawn(document) + + assert drawn[SHARED_CHANNEL].volume == FIRST_VOLUME + assert drawn[SOLE_CHANNEL].volume == SOLE_VOLUME + assert drawn.ownership == document.ownership + + def test_a_later_change_of_a_dimension_is_drawn_over_an_earlier_one( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + document = reconstruction_manager.current_features + assert document is not None + + assert rewrites.drawn(document)[SHARED_CHANNEL].volume == LATER_VOLUME + + def test_the_drawing_leaves_the_line_as_it_stands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + reconstruction_manager: ReconstructionManager, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + document = reconstruction_manager.current_features + assert document is not None + + rewrites.drawn(document) + + assert len(regeneration.held) == 1 + assert rewrites.is_busy + + def test_a_document_put_away_draws_nothing_of_its_rebuild( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + taking_turns: Reconstruction, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + reconstruction_manager.load_reconstruction_object(taking_turns.model_copy(), name="copy", voice_id="copy") + document = reconstruction_manager.current_features + assert document is not None + + assert rewrites.drawn(document) == document + + def test_nothing_on_its_way_draws_the_document( + self, + rewrites: ReconstructionRewrites, + reconstruction_manager: ReconstructionManager, + ) -> None: + document = reconstruction_manager.current_features + assert document is not None + + assert rewrites.drawn(document) is document + + +class TestAStepThatReshapesTheDocument: + """A change drawn while a removal or a whole-document gesture waits is refused, and the panel redrawn once the line empties.""" + + @pytest.mark.parametrize("reshaping", ("removal", "gesture")) + def test_the_change_is_refused( + self, + reshaping: str, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID) if reshaping == "removal" else AfterEdits(gesture=MagicMock())) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + regeneration.finish() + + assert regeneration.held == () + + @pytest.mark.parametrize("reshaping", ("removal", "gesture")) + def test_the_panel_is_redrawn_once_the_line_empties( + self, + reshaping: str, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID) if reshaping == "removal" else AfterEdits(gesture=MagicMock())) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + assert outcome.dropped == 0 + + regeneration.finish() + + assert outcome.dropped == 1 + + def test_a_change_after_the_step_has_run_is_taken( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_removal(STEM_B_ID)) + regeneration.finish() + + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME)) + + assert len(regeneration.held) == 1 + + +class TestTheBusySpan: + """The line reports filling as the first step waits or runs, and emptying once the last one lands.""" + + def test_a_rebuild_fills_the_line_until_it_lands( + self, + rewrites: ReconstructionRewrites, + regeneration: HeldRegeneration, + outcome: Outcome, + ) -> None: + rewrites.request(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + rewrites.request(_change(SOLE_CHANNEL, FeatureKey.VOLUME, SOLE_VOLUME)) + assert rewrites.is_busy + regeneration.finish() + assert outcome.busy == [True] + + regeneration.finish() + + assert outcome.busy == [True, False] + assert not rewrites.is_busy + + def test_steps_taken_at_once_leave_the_line_empty( + self, + rewrites: ReconstructionRewrites, + outcome: Outcome, + ) -> None: + gesture = MagicMock() + + rewrites.request(_removal(STEM_B_ID)) + rewrites.request(AfterEdits(gesture=gesture)) + + gesture.assert_called_once_with() + assert outcome.busy == [] diff --git a/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_steps.py b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_steps.py new file mode 100644 index 000000000..53a7de5ea --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/rewrites/test_steps.py @@ -0,0 +1,118 @@ +from typing import Final + +import pytest + +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features +from sampletones_core.features.envelope import Envelope +from sampletones_core.reconstructions import Reconstruction +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, taking_turns + +__all__ = ["taking_turns"] + +FIRST_VOLUME: Final[Envelope[int]] = Envelope[int](items=(5, 5)) +LATER_VOLUME: Final[Envelope[int]] = Envelope[int](items=(3, 3)) +ARPEGGIO: Final[Envelope[int]] = Envelope[int](items=(0, 3), loop_point=1) +FIRST_PITCH: Final[int] = 62 +LATER_PITCH: Final[int] = 64 + + +def _change( + channel_name: ChannelName, + feature_key: FeatureKey, + envelope: Envelope[int], +) -> ChannelChange: + return ChannelChange( + channel_name=channel_name, + feature_key=feature_key, + envelopes={feature_key: envelope}, + initial_pitch=None, + ) + + +def _pitch(value: int) -> ChannelChange: + return ChannelChange( + channel_name=SHARED_CHANNEL, + feature_key=FeatureKey.INITIAL_PITCH, + envelopes={}, + initial_pitch=value, + ) + + +@pytest.fixture +def features(taking_turns: Reconstruction) -> Features: + """The shared channel's envelopes as the document holds them.""" + return taking_turns.export()[SHARED_CHANNEL] + + +class TestMergingTwoChanges: + """Two changes of one channel make one, carrying what both moved, the later winning where both moved.""" + + def test_the_later_value_of_a_dimension_wins(self) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SHARED_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + ) + + assert merged.envelopes == {FeatureKey.VOLUME: LATER_VOLUME} + + def test_dimensions_moved_apart_are_both_carried(self) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO) + ) + + assert merged.envelopes == {FeatureKey.VOLUME: FIRST_VOLUME, FeatureKey.ARPEGGIO: ARPEGGIO} + + def test_the_dimension_moved_last_names_the_change(self) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SHARED_CHANNEL, FeatureKey.ARPEGGIO, ARPEGGIO) + ) + + assert merged.feature_key is FeatureKey.ARPEGGIO + + def test_a_later_pitch_wins(self) -> None: + assert _pitch(FIRST_PITCH).merged(_pitch(LATER_PITCH)).initial_pitch == LATER_PITCH + + def test_a_pitch_stands_through_a_later_change_that_leaves_it(self) -> None: + merged = _pitch(FIRST_PITCH).merged(_change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME)) + + assert merged.initial_pitch == FIRST_PITCH + assert merged.envelopes == {FeatureKey.VOLUME: FIRST_VOLUME} + + def test_a_change_of_another_channel_is_refused(self) -> None: + with pytest.raises(ValueError, match="merges into a change of that channel alone"): + _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged( + _change(SOLE_CHANNEL, FeatureKey.VOLUME, LATER_VOLUME) + ) + + +class TestWritingAChangeOverTheDocument: + """A change writes what the reader moved over the envelopes the document holds at its turn.""" + + def test_a_moved_dimension_takes_the_readers_value(self, features: Features) -> None: + rebased = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).rebased(features) + + assert rebased.volume == FIRST_VOLUME + + def test_the_dimensions_left_alone_are_read_from_the_document(self, features: Features) -> None: + document = features.with_envelope(FeatureKey.ARPEGGIO, ARPEGGIO) + + rebased = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).rebased(document) + + assert rebased.arpeggio == ARPEGGIO + assert rebased.duty_cycle == document.duty_cycle + assert rebased.initial_pitch == document.initial_pitch + + def test_a_moved_pitch_is_written(self, features: Features) -> None: + rebased = _pitch(LATER_PITCH).rebased(features) + + assert rebased.initial_pitch == LATER_PITCH + assert rebased.envelopes == features.envelopes + + def test_a_merged_change_writes_what_both_moved(self, features: Features) -> None: + merged = _change(SHARED_CHANNEL, FeatureKey.VOLUME, FIRST_VOLUME).merged(_pitch(LATER_PITCH)) + + rebased = merged.rebased(features) + + assert rebased.volume == FIRST_VOLUME + assert rebased.initial_pitch == LATER_PITCH diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_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..fc6b8b1c6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -4,10 +4,29 @@ import numpy as np from sampletones_application.logic.reconstruction.data import ReconstructionData -from sampletones_core.audio import write_wave +from sampletones_application.logic.shared.renders import RenderCache +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, bending_channels +from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def _heard(*stem_ids: int) -> StemSelection: + """The selection hearing every named stem on every channel.""" + return StemSelection.everywhere(frozenset(stem_ids), ChannelName.items()) + + +from tests.suite.stems import RECORDED_SCALE, recorded_from class TestFromReconstruction: @@ -51,14 +70,14 @@ def test_detached_reconstruction_has_no_original_audio( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction.detach_source() + reconstruction = reconstruction.detached() data = ReconstructionData.from_reconstruction( reconstruction, 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 +91,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 = recorded_from(reconstruction_factory(), (source_audio,)) data = ReconstructionData.from_reconstruction( reconstruction, @@ -81,6 +100,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 stands at the level the scale the document records sets. + """ + 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 = recorded_from(reconstruction_factory(), (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)), (0.5 + 0.25) / RECORDED_SCALE, 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 = recorded_from(reconstruction_factory(), (first, missing)) + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + assert data.original_audio is None + class TestReconstructionDataLoad: def test_load_round_trips_reconstruction( @@ -96,6 +154,28 @@ def test_load_round_trips_reconstruction( assert data.filepath == save_path + def test_load_names_the_document_after_its_file( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + reconstruction = recorded_from(reconstruction_factory(), (tmp_path / "kick.wav", tmp_path / "snare.wav")) + save_path = tmp_path / "drums.stn" + reconstruction.save(save_path) + + data = ReconstructionData.load(save_path) + + assert data.name == "drums" + + def test_an_in_memory_document_keeps_the_name_it_was_given( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A project sample carries its own name, whatever recordings it was built from.""" + data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample") + + assert data.name == "Sample" + def test_load_has_no_original_audio_when_source_file_missing( self, reconstruction_factory: Callable[[], Reconstruction], @@ -147,7 +227,7 @@ def test_names_after_the_file_when_audio_is_detached( tmp_path: Path, ) -> None: reconstruction = reconstruction_factory() - reconstruction.detach_source() + reconstruction = reconstruction.detached() data = ReconstructionData.from_reconstruction( reconstruction, name="Sample", @@ -157,7 +237,7 @@ def test_names_after_the_file_when_audio_is_detached( assert copy.name == "lead" - def test_names_after_the_source_audio_when_present( + def test_names_after_the_file_when_audio_is_present( self, reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, @@ -170,8 +250,24 @@ 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 == "lead" + + def test_a_stems_document_is_named_after_its_file( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """The file carries the name the converter gave every recording, which outlasts any of them leaving.""" + drums = tmp_path / "drums" + drums.mkdir() + stems = (drums / "kick.wav", drums / "snare.wav") + reconstruction = recorded_from(reconstruction_factory(), stems) + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + copy = data.detached_copy(tmp_path / "lead.stn") + + assert copy.name == "lead" def test_reuses_the_already_loaded_original_audio( self, @@ -184,7 +280,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 = recorded_from(reconstruction_factory(), (source_audio,)) data = ReconstructionData.from_reconstruction( reconstruction, name="Sample", @@ -192,14 +288,312 @@ 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 + 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, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), + ) + reconstruction = Reconstruction.create( + 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], + ) + ], + scale=RECORDED_SCALE, + ), + ) + return ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + def test_partials_keep_only_the_selected_stems_frames( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + renders: RenderCache, + ) -> None: + data = self._stems_data(reconstruction_factory, tmp_path) + frame_length = data.reconstruction.config.frame_length + expected = rendered_mix(data.reconstruction).copy() + expected[frame_length:] = 0 + + partials = data.partials_for(renders, [ChannelName.PULSE1], _heard(0)) + + np.testing.assert_allclose(partials, expected) + np.testing.assert_allclose( + data.partials_for(renders, [ChannelName.PULSE1], _heard(0, 1)), + rendered_mix(data.reconstruction), + ) + + 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, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), + ) + reconstruction = reconstruction_factory().model_copy( + update={ + "stems_data": StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0, 1], + ) + ], + scale=RECORDED_SCALE, + ).with_sources((first, second)), + } + ) + + 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(rendered_mix(data.reconstruction)), + ) + + def test_a_single_source_with_no_selection_is_silence( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + renders: RenderCache, + ) -> None: + source_audio = tmp_path / "source.wav" + write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + reconstruction = recorded_from(reconstruction_factory(), (source_audio,)) + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + np.testing.assert_array_equal( + data.partials_for(renders, [ChannelName.PULSE1], _heard()), + np.zeros_like(rendered_mix(data.reconstruction)), + ) assert data.original_audio is not None - assert copy.original_audio is data.original_audio + np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.original_audio) + + +class TestTheOriginalAMissingRecordingLeaves: + """With no recording loaded there is no original to mix, so the approximation stands alone.""" + + @staticmethod + def _missing_a_recording( + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + """A document recorded from two takes, the second of which is gone, which costs the whole original.""" + first = tmp_path / "kick.wav" + write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + reconstruction = recorded_from(reconstruction_factory(), (first, tmp_path / "gone.wav")) + return ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + def test_no_recording_mixes_into_no_original( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._missing_a_recording(reconstruction_factory, tmp_path) + + assert data.original_mix_for(_heard(0, 1)) is None + assert data.original_mix_for(_heard()) is None + + def test_the_heard_waveform_carries_no_original_line( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + renders: RenderCache, + ) -> None: + data = self._missing_a_recording(reconstruction_factory, tmp_path) + + assert data.waveform_data(renders, _heard(0, 1)).original_audio is None + + def test_a_detached_document_mixes_into_no_original( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction = reconstruction.detached() + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + assert data.original_mix_for(_heard(0)) is None + + def test_a_loaded_recording_heard_nowhere_is_a_silent_line( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + renders: RenderCache, + ) -> None: + """A loaded recording the reader switched off still has an original, which is silence.""" + source_audio = tmp_path / "source.wav" + write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + data = ReconstructionData.from_reconstruction( + recorded_from(reconstruction_factory(), (source_audio,)), + name="Sample", + ) + + original = data.waveform_data(renders, _heard()).original_audio + + assert original is not None + np.testing.assert_array_equal(original, np.zeros_like(rendered_mix(data.reconstruction))) + + +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 = recorded_from(reconstruction_factory(), tuple(paths)) + 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_letting_a_recording_go_releases_its_audio( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """A recording the edit leaves holding no frame leaves the document, and its audio with it.""" + data = self._three_recordings(reconstruction_factory, tmp_path) + second, third = data.stem_audios[1], data.stem_audios[2] + stream = list(data.reconstruction.instructions[ChannelName.PULSE1]) + stream[1] = PulseInstruction.null_instruction() + edited = data.reconstruction.model_copy(deep=True) + edited = edited.with_channel_data( + ChannelName.PULSE1, + stream, + edited.initial_pitches[ChannelName.PULSE1], + edited.held_features[ChannelName.PULSE1], + heard=edited.recorded_stem_ids, + ) + + remaining = data.with_reconstruction(edited) + + assert [entry.id for entry in edited.stems_data.config.entries] == [0, 2] + assert len(remaining.stem_audios) == 2 + assert all(not np.array_equal(audio, second) for audio in remaining.stem_audios) + np.testing.assert_allclose(remaining.original_mix_for(_heard(2)), third) + + 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 + + def test_a_document_naming_a_recording_the_load_lost_holds_none( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """A restore can bring back an entry this document let go of, whose audio it no longer holds.""" + data = self._three_recordings(reconstruction_factory, tmp_path) + remaining = data.with_reconstruction(without_stem(data.reconstruction, 1)) + + restored = remaining.with_reconstruction(data.reconstruction) + + assert restored.stem_audios == () + assert restored.original_audio is None class TestWaveformData: def test_projects_the_render_relevant_fields( self, reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, ) -> None: reconstruction = reconstruction_factory() data = ReconstructionData.from_reconstruction( @@ -207,11 +601,11 @@ def test_projects_the_render_relevant_fields( name="Sample", ) - waveform_data = data.waveform_data() + waveform_data = data.waveform_data(renders) assert waveform_data.original_audio is data.original_audio - assert waveform_data.approximation is reconstruction.approximation - assert waveform_data.approximations == dict(reconstruction.approximations) + assert waveform_data.approximation is renders.mix(reconstruction) + assert waveform_data.approximations == renders.channels(reconstruction) assert waveform_data.coefficient == reconstruction.coefficient assert waveform_data.frame_length == reconstruction.config.frame_length @@ -220,6 +614,7 @@ class TestReconstructionDataGetPartials: def test_empty_generator_list_returns_zeros( self, reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, ) -> None: reconstruction = reconstruction_factory() data = ReconstructionData.from_reconstruction( @@ -227,13 +622,14 @@ def test_empty_generator_list_returns_zeros( name="Sample", ) - result = data.get_partials([]) + result = data.get_partials(renders, []) assert np.all(result == 0.0) def test_unknown_generator_returns_zeros( self, reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, ) -> None: reconstruction = reconstruction_factory() data = ReconstructionData.from_reconstruction( @@ -241,13 +637,14 @@ def test_unknown_generator_returns_zeros( name="Sample", ) - result = data.get_partials([GeneratorName.TRIANGLE]) + result = data.get_partials(renders, [ChannelName.TRIANGLE]) assert np.all(result == 0.0) def test_known_generator_returns_its_approximation( self, reconstruction_factory: Callable[[], Reconstruction], + renders: RenderCache, ) -> None: reconstruction = reconstruction_factory() data = ReconstructionData.from_reconstruction( @@ -255,7 +652,7 @@ def test_known_generator_returns_its_approximation( name="Sample", ) - result = data.get_partials([GeneratorName.PULSE1]) + result = data.get_partials(renders, [ChannelName.PULSE1]) - expected = reconstruction.approximations[GeneratorName.PULSE1] + expected = rendered_channels(reconstruction)[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..c4ecc0b70 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_editor.py @@ -0,0 +1,240 @@ +from typing import Final, Tuple +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.editing import InstrumentEdit +from sampletones_application.logic.reconstruction.editor import InstrumentEditor +from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +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 +from tests.suite.history.wiring import wired_history + +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 wired_history(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 = ChannelEnvelopesViewModel( + channels={ChannelName.PULSE1: _features()}, + ownership={}, + ) + + edit = editor.edited_instrument() + + assert isinstance(edit, ChannelEnvelopesViewModel) + assert list(edit.channels) == [ChannelName.PULSE1] + + def test_an_instrument_answers_with_what_it_states( + self, + editor: InstrumentEditor, + controller: ProjectController, + history: HistoryManager, + ) -> None: + """The pitch an export reads travels inside the envelopes the tab has in front of it.""" + with history.transaction(HistoryAction.ADD_INSTRUMENT): + 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, + history: HistoryManager, + reconstruction_manager: MagicMock, + ) -> None: + """The tab describes one voice, so its waveform and stems follow what is in front of it.""" + with history.transaction(HistoryAction.ADD_INSTRUMENT): + 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, + history: HistoryManager, + reconstruction_manager: MagicMock, + ) -> None: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + reconstruction_manager.current_features = ChannelEnvelopesViewModel( + channels={ChannelName.PULSE1: _features()}, + ownership={}, + ) + + editor.release_instrument() + + assert isinstance(editor.edited_instrument(), ChannelEnvelopesViewModel) + + def test_an_instrument_removed_from_the_project_leaves_the_tab_holding_nothing( + self, + editor: InstrumentEditor, + controller: ProjectController, + history: HistoryManager, + ) -> None: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + + with history.transaction(HistoryAction.REMOVE_VOICE): + controller.remove_voice(instrument.id) + + assert editor.edited_instrument() is None + + +class TestWhetherTheTabHoldsAnInstrument: + """The tab keeps the instrument it was given until it lets go, so a departed voice is noticed.""" + + def test_it_holds_none_to_begin_with(self, editor: InstrumentEditor) -> None: + assert not editor.holds_instrument + + def test_opening_an_instrument_holds_it( + self, + editor: InstrumentEditor, + controller: ProjectController, + history: HistoryManager, + ) -> None: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = controller.add_instrument(new_instrument("lead")) + + editor.edit_instrument(instrument.id) + + assert editor.holds_instrument + + def test_an_instrument_removed_from_the_project_is_still_held( + self, + editor: InstrumentEditor, + controller: ProjectController, + history: HistoryManager, + ) -> None: + """Whoever follows the project reads this to tell a departed instrument from none at all.""" + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + + with history.transaction(HistoryAction.REMOVE_VOICE): + controller.remove_voice(instrument.id) + + assert editor.holds_instrument + assert editor.instrument is None + + def test_letting_go_holds_none( + self, + editor: InstrumentEditor, + controller: ProjectController, + history: HistoryManager, + ) -> None: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + + editor.release_instrument() + + assert not editor.holds_instrument + + +class TestWritingIntoTheInstrument: + def test_an_envelope_reaches_the_instrument( + self, + editor: InstrumentEditor, + controller: ProjectController, + history: HistoryManager, + ) -> None: + with history.transaction(HistoryAction.ADD_INSTRUMENT): + 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, + history: HistoryManager, + ) -> None: + """A dimension carries the item it repeats from, so an edit writes both at once.""" + with history.transaction(HistoryAction.ADD_INSTRUMENT): + 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_envelopes.py b/tests/unit/sampletones_application/logic/reconstruction/test_envelopes.py new file mode 100644 index 000000000..6f7b79354 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_envelopes.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from typing import Callable + +import pytest + +from sampletones_application.logic.reconstruction.envelopes import heard_envelopes +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters import Features +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.selection import StemSelection +from sampletones_core.reconstructions.reconstruction.stems.source import StemSource +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.configs.settings import StemSettings +from tests.suite.stems import RECORDED_SCALE, STEM_A_ID, STEM_B_ID + + +@pytest.fixture +def reconstruction( + reconstruction_factory: Callable[[], Reconstruction], +) -> Reconstruction: + return reconstruction_factory() + + +@pytest.fixture +def everything_heard(reconstruction: Reconstruction) -> StemSelection: + """The reader listening to every recording on every channel, as a fresh document reads.""" + return StemSelection.everywhere( + frozenset(reconstruction.stems_data.config.entries_by_id), + ChannelName.items(), + ) + + +@pytest.fixture +def envelopes(reconstruction: Reconstruction, everything_heard: StemSelection) -> ChannelEnvelopesViewModel: + return heard_envelopes(reconstruction, everything_heard) + + +@pytest.fixture +def stems_reconstruction(reconstruction: Reconstruction) -> Reconstruction: + """The same document read as two recordings, which is what gives a stretch something to name.""" + entries = [ + StemEntry(id=STEM_A_ID, settings=StemSettings.covering(list(reconstruction.playing_channels))), + StemEntry(id=STEM_B_ID, settings=StemSettings.covering(list(reconstruction.playing_channels))), + ] + assignments = [ + ChannelAssignment( + channel_name=channel_name, + stem_ids=[ + STEM_A_ID if instruction.on and frame % 2 == 0 else STEM_B_ID if instruction.on else RESTING_STEM_ID + for frame, instruction in enumerate(reconstruction.instructions[channel_name]) + ], + ) + for channel_name in reconstruction.playing_channels + ] + stems_data = StemsData( + config=StemsConfig(entries=entries, hierarchy=StemsHierarchy(levels=[[STEM_A_ID], [STEM_B_ID]])), + sources=[ + StemSource(stem_id=STEM_A_ID, name="first", path=None), + StemSource(stem_id=STEM_B_ID, name="second", path=None), + ], + assignments=assignments, + scale=RECORDED_SCALE, + ) + return reconstruction.model_copy(update={"stems_data": stems_data}) + + +@pytest.fixture +def everything_heard_of(stems_reconstruction: Reconstruction) -> StemSelection: + return StemSelection.everywhere( + frozenset(stems_reconstruction.stems_data.config.entries_by_id), + ChannelName.items(), + ) + + +class TestTheEnvelopesOfWhatIsHeard: + def test_every_generator_answers_with_an_entry( + self, + envelopes: ChannelEnvelopesViewModel, + ) -> None: + assert set(envelopes.channels.keys()) == set(ChannelName.items()) + + def test_a_channel_standing_by_carries_empty_envelopes( + self, + reconstruction: Reconstruction, + envelopes: ChannelEnvelopesViewModel, + ) -> None: + """A channel the reconstruction leaves silent is loaded describing no frame.""" + standing_by = set(ChannelName.items()) - set(reconstruction.playing_channels) + assert standing_by + assert all(not envelopes[channel_name].has_frames for channel_name in standing_by) + + def test_the_envelopes_state_the_pitch_they_are_measured_against( + self, + envelopes: ChannelEnvelopesViewModel, + ) -> None: + for features in envelopes.channels.values(): + assert features.initial_pitch is not None + + def test_hearing_every_recording_reads_the_document_itself( + self, + reconstruction: Reconstruction, + envelopes: ChannelEnvelopesViewModel, + ) -> None: + """Each channel that sounds is read as the document writes it, rests and all.""" + whole = reconstruction.export() + sounding = {name: features for name, features in whole.items() if features.has_frames} + assert sounding + + assert {name: envelopes[name] for name in sounding} == sounding + + def test_a_channel_no_recording_is_heard_on_describes_no_frame( + self, + reconstruction: Reconstruction, + ) -> None: + playing = next(iter(reconstruction.playing_channels)) + + envelopes = heard_envelopes(reconstruction, StemSelection(channels={})) + + assert not envelopes[playing].has_frames + + +class TestWhatTheEnvelopesAnswer: + @pytest.mark.parametrize("channel_name", ChannelName.items(), ids=lambda name: name.value) + def test_every_channel_answers_with_its_features( + self, + envelopes: ChannelEnvelopesViewModel, + channel_name: ChannelName, + ) -> None: + assert isinstance(envelopes[channel_name], Features) + + +class TestTheRecordingsBehindWhatIsDrawn: + """The stretches read the same frames the envelopes do, so a bar and its owner line up.""" + + def test_a_document_answering_to_one_recording_tells_none_apart( + self, + envelopes: ChannelEnvelopesViewModel, + ) -> None: + assert envelopes.ownership == {} + + def test_every_sounding_channel_carries_its_stretches( + self, + stems_reconstruction: Reconstruction, + everything_heard_of: StemSelection, + ) -> None: + envelopes = heard_envelopes(stems_reconstruction, everything_heard_of) + + sounding = {name for name, features in envelopes.channels.items() if features.has_frames} + assert sounding + assert set(envelopes.ownership) == sounding + + def test_a_channel_states_one_stretch_per_owner_it_changes_to( + self, + stems_reconstruction: Reconstruction, + everything_heard_of: StemSelection, + ) -> None: + envelopes = heard_envelopes(stems_reconstruction, everything_heard_of) + channel_name = next(iter(envelopes.ownership)) + owners = stems_reconstruction.stems_data.assignments_by_channel[channel_name] + + runs = envelopes.ownership[channel_name].runs + + assert [run.stem_id for run in runs] == [ + owner for index, owner in enumerate(owners) if index == 0 or owner != owners[index - 1] + ] + + def test_the_stretches_stay_within_the_bars_they_stand_under( + self, + stems_reconstruction: Reconstruction, + everything_heard_of: StemSelection, + ) -> None: + """A stretch names a frame the document records, and the bars may draw one more. + + An export releases the note it ends on, so a dimension's last item can stand past the + frames the record holds; the stretches stop where the record does. + """ + envelopes = heard_envelopes(stems_reconstruction, everything_heard_of) + + for channel_name, lane in envelopes.ownership.items(): + recorded = len(stems_reconstruction.stems_data.assignments_by_channel[channel_name]) + assert lane.runs[-1].end_frame == min(recorded, envelopes[channel_name].frame_count) + assert lane.runs[0].start_frame == 0 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py deleted file mode 100644 index 96bb0d6be..000000000 --- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from typing import Callable - -import pytest - -from sampletones_application.logic.reconstruction.feature import FeatureData -from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.reconstructions import Reconstruction - - -@pytest.fixture -def reconstruction( - reconstruction_factory: Callable[[], Reconstruction], -) -> Reconstruction: - return reconstruction_factory() - - -@pytest.fixture -def feature_data(reconstruction: Reconstruction) -> FeatureData: - return FeatureData.load(reconstruction) - - -class TestFeatureDataLoad: - def test_load_creates_entry_for_each_generator( - self, - feature_data: FeatureData, - ) -> None: - assert set(feature_data.generators.keys()) == set(GeneratorName.items()) - - def test_a_channel_standing_by_carries_empty_envelopes( - self, - reconstruction: Reconstruction, - feature_data: FeatureData, - ) -> None: - """A channel the reconstruction leaves silent is loaded describing no frame.""" - standing_by = set(GeneratorName.items()) - set(reconstruction.playing_generators) - assert standing_by - assert all(not feature_data[generator_name].has_frames for generator_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 - - -class TestFeatureDataQueries: - @pytest.mark.parametrize("generator_name", GeneratorName.items(), ids=lambda name: name.value) - def test_every_channel_answers_with_its_features( - self, - feature_data: FeatureData, - generator_name: GeneratorName, - ) -> None: - assert isinstance(feature_data[generator_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..6101445d7 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instrument_history.py @@ -0,0 +1,127 @@ +from typing import Final, List +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.history.action import HistoryAction +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 +from tests.suite.history.wiring import wired_history + +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 = wired_history(self.controller, budget=HISTORY_BUDGET, strict=True) + 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..67df032e4 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -1,25 +1,78 @@ -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.layout.behavior.scheduling.scheduling import SchedulingBehavior -from sampletones_application.logic.reconstruction.feature import FeatureData +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL +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.envelopes import heard_envelopes from sampletones_application.logic.reconstruction.instruments import ( ReconstructionInstrumentsLogic, ) from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.reconstruction.rewrites.queue import ReconstructionRewrites +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) 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, + reconstruction_footprints, total_footprint, ) +from sampletones_core.project.voices.creation import new_instrument from sampletones_core.reconstructions import Reconstruction +from tests.suite.application import HeldQueue, held_queue +from tests.suite.history.wiring import wired_history +from tests.suite.regeneration import HeldRegeneration +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, everything_heard, regenerated, taking_turns + +__all__ = ["held_queue", "taking_turns"] + + +def _heard_features(reconstruction: Reconstruction) -> ChannelEnvelopesViewModel: + """The envelopes of the whole document, which is what a fresh reader hears.""" + return heard_envelopes(reconstruction, everything_heard(reconstruction)) + + +HISTORY_BUDGET: Final[int] = 16 +NEW_PITCH: Final[int] = 61 +SILENCED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def _silent_volume(features: Features) -> Envelope[int]: + """A volume quieting every frame the envelopes describe.""" + return Envelope[int](items=(0,) * features.frame_count) + + +def _editor( + reconstruction_manager: MagicMock, + controller: ProjectController, + history: HistoryManager, +) -> InstrumentEditor: + """The editor over a strict history, which is how the application builds it.""" + return InstrumentEditor( + reconstruction_manager, + controller, + history, + lambda _voice_id, _feature_key: (), + ) + + +@pytest.fixture +def history(project_controller: ProjectController) -> HistoryManager: + """A strict history over the case's controller, so an edit outside a transaction is reported.""" + return wired_history(project_controller, budget=HISTORY_BUDGET, strict=True) @pytest.fixture @@ -28,16 +81,45 @@ def mock_reconstruction_manager() -> MagicMock: @pytest.fixture -def instruments_logic( - mock_reconstruction_manager: MagicMock, - scheduling: SchedulingBehavior, -) -> ReconstructionInstrumentsLogic: - return ReconstructionInstrumentsLogic( +def instrument_editor(mock_reconstruction_manager: MagicMock) -> InstrumentEditor: + """The real source the panel reads, over a stand-in for the document it opens.""" + controller = ProjectController(ProjectManager()) + return _editor( mock_reconstruction_manager, - scheduling=scheduling, + controller, + wired_history(controller, budget=HISTORY_BUDGET, strict=True), ) +@pytest.fixture +def regeneration() -> HeldRegeneration: + return HeldRegeneration() + + +@pytest.fixture +def rewrites( + mock_reconstruction_manager: MagicMock, + regeneration: HeldRegeneration, +) -> ReconstructionRewrites: + """The steps of the open document, each rebuild held until a case lands it.""" + return ReconstructionRewrites(mock_reconstruction_manager, regeneration) + + +def _logic(editor: InstrumentEditor, rewrites: ReconstructionRewrites) -> ReconstructionInstrumentsLogic: + """The panel's logic, sending every change on to the document's steps the way the tab wires it.""" + logic = ReconstructionInstrumentsLogic(editor, rewrites) + logic.on_channel_changed = rewrites.request + return logic + + +@pytest.fixture +def instruments_logic( + instrument_editor: InstrumentEditor, + rewrites: ReconstructionRewrites, +) -> ReconstructionInstrumentsLogic: + return _logic(instrument_editor, rewrites) + + class TestReconstructionInstrumentsLogicUpdateDisplay: def test_no_features_emits_not_loaded_view_model( self, @@ -57,7 +139,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] @@ -68,7 +150,7 @@ def test_with_features_emits_loaded_view_model( mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append instruments_logic.update_display() @@ -80,12 +162,12 @@ def test_with_features_fires_on_feature_data_changed_with_data( mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - feature_data = FeatureData.load(reconstruction_factory()) + feature_data = _heard_features(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data - received: List[Optional[Dict[GeneratorName, Features]]] = [] + received: List[Optional[ChannelEnvelopesViewModel]] = [] instruments_logic.on_feature_data_changed = received.append instruments_logic.update_display() - assert received == [feature_data.generators] + assert received == [ChannelEnvelopesViewModel(channels=feature_data.channels, ownership=feature_data.ownership)] def test_with_features_exposes_the_playing_generators( self, @@ -93,11 +175,11 @@ def test_with_features_exposes_the_playing_generators( mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) 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: @@ -120,15 +202,15 @@ def test_every_playing_channel_is_measured( mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - feature_data = FeatureData.load(reconstruction_factory()) + feature_data = _heard_features(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append 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( @@ -138,7 +220,7 @@ def test_the_size_is_the_one_a_one_shot_export_writes( reconstruction_factory: Callable[[], Reconstruction], ) -> None: """A reconstruction exports its instruments as one-shots, so that is the size shown.""" - feature_data = FeatureData.load(reconstruction_factory()) + feature_data = _heard_features(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append @@ -146,9 +228,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 @@ -159,146 +239,537 @@ def test_an_envelope_edit_is_measured_as_it_arrives( reconstruction_factory: Callable[[], Reconstruction], ) -> None: """The typed envelope is measured at once, so the figure answers what is on screen.""" - feature_data = FeatureData.load(reconstruction_factory()) + feature_data = _heard_features(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data 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 + assert footprint.bytes_for(ChannelName.PULSE1) == features_footprint(edited).total_bytes - def test_a_bar_edit_is_measured_as_it_arrives( + def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - feature_data = FeatureData.load(reconstruction_factory()) + """The regeneration owns the loaded envelopes, so the measurement reads them without writing.""" + feature_data = _heard_features(reconstruction_factory()) mock_reconstruction_manager.current_features = feature_data - received: List[ReconstructionInstrumentsViewModel] = [] - instruments_logic.on_view_changed = received.append + loaded_volume = feature_data.channels[ChannelName.PULSE1].volume - instruments_logic.handle_bar_point_clicked( - GeneratorName.PULSE1, - FeatureKey.ARPEGGIO, - np.zeros(6, dtype=np.int8), + instruments_logic.handle_envelope_changed( + ChannelName.PULSE1, + FeatureKey.VOLUME, + Envelope[int](items=(15, 12, 8, 4, 0)), ) - assert received[0].footprint is not None + assert feature_data.channels[ChannelName.PULSE1].volume == loaded_volume - def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are( + def test_a_refresh_reports_the_view_alone( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - """The regeneration owns the loaded envelopes, so the measurement reads a copy.""" - feature_data = FeatureData.load(reconstruction_factory()) - mock_reconstruction_manager.current_features = feature_data - loaded_volume = feature_data.generators[GeneratorName.PULSE1].volume.copy() + """A regenerated reconstruction refreshes the figures, leaving the edited envelopes displayed.""" + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) + received: List[ReconstructionInstrumentsViewModel] = [] + feature_updates: List[Optional[Dict[ChannelName, Features]]] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.on_feature_data_changed = feature_updates.append - instruments_logic.handle_raw_data_changed( - GeneratorName.PULSE1, - FeatureKey.VOLUME, - np.array([15, 12, 8, 4, 0], dtype=np.int8), - ) + instruments_logic.refresh_view() + + assert len(received) == 1 + assert received[0].footprint is not None + assert feature_updates == [] - assert np.array_equal(feature_data.generators[GeneratorName.PULSE1].volume, loaded_volume) - def test_a_refresh_reports_the_view_alone( +class TestAnEditSilencingAChannel: + """A channel an edit writes silent stands by in the document, and the panel says so too.""" + + @pytest.fixture + def reconstruction(self, reconstruction_factory: Callable[[], Reconstruction]) -> Reconstruction: + return reconstruction_factory() + + @pytest.fixture + def silenced(self, reconstruction: Reconstruction) -> Features: + """The first pulse's envelopes with every frame's volume at nothing.""" + features = _heard_features(reconstruction)[SILENCED_CHANNEL] + return features.with_envelope(FeatureKey.VOLUME, _silent_volume(features)) + + def test_an_edit_writing_every_frame_silent_is_measured_standing_by( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, - reconstruction_factory: Callable[[], Reconstruction], + reconstruction: Reconstruction, + silenced: Features, ) -> None: - """A regenerated reconstruction refreshes the figures, leaving the edited envelopes displayed.""" - mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory()) + """The figure the edit shows at once is the one the regenerated document reports.""" + mock_reconstruction_manager.current_features = _heard_features(reconstruction) received: List[ReconstructionInstrumentsViewModel] = [] - feature_updates: List[Optional[Dict[GeneratorName, Features]]] = [] instruments_logic.on_view_changed = received.append + + instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) + + landed = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + footprint = received[0].footprint + assert footprint is not None + assert SILENCED_CHANNEL not in received[0].playing_channels + assert footprint.bytes_for(SILENCED_CHANNEL) is None + assert footprint.total_bytes == total_footprint(reconstruction_footprints(landed).values()).total_bytes + + def test_an_edit_standing_a_channel_by_redraws_it_empty( + self, + instruments_logic: ReconstructionInstrumentsLogic, + regeneration: HeldRegeneration, + mock_reconstruction_manager: MagicMock, + reconstruction: Reconstruction, + silenced: Features, + ) -> None: + """Once the regeneration lands, the panel draws what the document holds for that channel.""" + mock_reconstruction_manager.current_features = _heard_features(reconstruction) + feature_updates: List[Optional[ChannelEnvelopesViewModel]] = [] instruments_logic.on_feature_data_changed = feature_updates.append + instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) + + rebuilt = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + regeneration.finish_with(rebuilt) + landed = _heard_features(rebuilt) + mock_reconstruction_manager.current_features = landed + instruments_logic.refresh_view() + + assert feature_updates == [ + ChannelEnvelopesViewModel(channels={SILENCED_CHANNEL: landed[SILENCED_CHANNEL]}, ownership={}) + ] + assert not landed[SILENCED_CHANNEL].has_frames + + def test_the_channel_is_redrawn_once( + self, + instruments_logic: ReconstructionInstrumentsLogic, + regeneration: HeldRegeneration, + mock_reconstruction_manager: MagicMock, + reconstruction: Reconstruction, + silenced: Features, + ) -> None: + mock_reconstruction_manager.current_features = _heard_features(reconstruction) + feature_updates: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = feature_updates.append + instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) + rebuilt = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + regeneration.finish_with(rebuilt) + mock_reconstruction_manager.current_features = _heard_features(rebuilt) + + instruments_logic.refresh_view() + instruments_logic.refresh_view() + + assert len(feature_updates) == 1 + + def test_an_edit_that_sounds_again_before_it_lands_leaves_the_envelopes_displayed( + self, + instruments_logic: ReconstructionInstrumentsLogic, + regeneration: HeldRegeneration, + mock_reconstruction_manager: MagicMock, + reconstruction: Reconstruction, + silenced: Features, + ) -> None: + """The latest edit decides, so a channel written back into play keeps what the reader drew.""" + features = _heard_features(reconstruction) + mock_reconstruction_manager.current_features = features + feature_updates: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = feature_updates.append + instruments_logic.handle_envelope_changed(SILENCED_CHANNEL, FeatureKey.VOLUME, silenced.volume) + instruments_logic.handle_envelope_changed( + SILENCED_CHANNEL, + FeatureKey.VOLUME, + features[SILENCED_CHANNEL].volume, + ) + rebuilt = regenerated(reconstruction, SILENCED_CHANNEL, silenced) + regeneration.finish_with(rebuilt) + mock_reconstruction_manager.current_features = _heard_features(rebuilt) instruments_logic.refresh_view() - assert len(received) == 1 - assert received[0].footprint is not None assert feature_updates == [] class TestReconstructionInstrumentsLogicHandlePitchValueChanged: - def test_schedules_reconstruction_update( + def test_a_moved_pitch_travels_on_as_a_change( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], ) -> None: + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_pitch_value_changed(GeneratorName.PULSE1, 61) + instruments_logic.on_channel_changed = callback + instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) callback.assert_called_once() - def test_forwards_generator_pitch_feature_and_value( + def test_the_change_carries_the_channel_and_the_pitch_alone( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], ) -> None: - 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 - assert feature_key == FeatureKey.INITIAL_PITCH - assert value == 61 + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) + received: List[ChannelChange] = [] + instruments_logic.on_channel_changed = received.append + + instruments_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) + (change,) = received + assert change.channel_name == ChannelName.PULSE1 + assert change.feature_key == FeatureKey.INITIAL_PITCH + assert change.initial_pitch == NEW_PITCH + assert change.envelopes == {} -class TestReconstructionInstrumentsLogicHandleBarPoint: - def test_handle_bar_point_clicked_schedules_update( + +class TestReconstructionInstrumentsLogicHandleEnvelope: + def test_an_edited_envelope_travels_on_as_a_change( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], ) -> None: + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_bar_point_clicked( - GeneratorName.PULSE1, + instruments_logic.on_channel_changed = callback + 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() + def test_an_edited_envelope_keeps_the_point_it_was_given( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """The panel states values and loop point together, so the change carries both.""" + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) + received: List[ChannelChange] = [] + instruments_logic.on_channel_changed = received.append + arpeggio = Envelope[int](items=(0, 4, 7), loop_point=1) + + instruments_logic.handle_envelope_changed( + ChannelName.PULSE1, + FeatureKey.ARPEGGIO, + arpeggio, + ) + + assert received[0].envelopes == {FeatureKey.ARPEGGIO: arpeggio} -class TestReconstructionInstrumentsLogicHandleRawData: - def test_handle_raw_data_changed_schedules_update( + def test_the_change_carries_the_dimension_moved_alone( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """The rest of the channel is read at the change's turn, so a step before it stands.""" + mock_reconstruction_manager.current_features = _heard_features(reconstruction_factory()) + received: List[ChannelChange] = [] + instruments_logic.on_channel_changed = received.append + + instruments_logic.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + (change,) = received + assert change.feature_key == FeatureKey.VOLUME + assert list(change.envelopes) == [FeatureKey.VOLUME] + assert change.initial_pitch is None + + +class TestTheInstrumentsPanelShowsAnInstrument: + """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, + history: HistoryManager, + rewrites: ReconstructionRewrites, + ) -> ReconstructionInstrumentsLogic: + mock_reconstruction_manager.current_features = None + editor = _editor(mock_reconstruction_manager, project_controller, history) + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = project_controller.add_instrument(new_instrument("lead")) + with history.transaction(HistoryAction.EDIT_INSTRUMENT): + project_controller.set_instrument_envelope(instrument.id, FeatureKey.VOLUME, Envelope(items=(15, 12))) + editor.edit_instrument(instrument.id) + return _logic(editor, rewrites) + + def test_the_view_names_the_instrument_it_shows( + self, + instrument_logic: ReconstructionInstrumentsLogic, ) -> None: - callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic.handle_raw_data_changed( - GeneratorName.PULSE1, + 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, + history: HistoryManager, + ) -> None: + """An export writes what has frames, so a voice holding none is offered no export.""" + instrument = project_controller.project.voices[0] + with history.transaction(HistoryAction.EDIT_INSTRUMENT): + 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[ChannelEnvelopesViewModel]] = [] + instrument_logic.on_feature_data_changed = received.append + + instrument_logic.update_display() + + envelopes = received[-1] + assert envelopes is not None + assert list(envelopes.channels) == [INSTRUMENT_CHANNEL] + assert envelopes.ownership == {} + + 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_channel_changed = regenerated.append + + instrument_logic.handle_envelope_changed( + INSTRUMENT_CHANNEL, FeatureKey.ARPEGGIO, - np.zeros(4, dtype=np.float32), + Envelope[int](items=(0, 7)), ) - callback.assert_called_once() + 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 + + +class TestAnEditReachingAVoiceThatLeft: + """A gesture the panel sent before its voice left draws the panel as it now stands and goes nowhere.""" + + @pytest.fixture + def project_controller(self) -> ProjectController: + return ProjectController(ProjectManager()) + + @pytest.fixture + def left_logic( + self, + mock_reconstruction_manager: MagicMock, + project_controller: ProjectController, + history: HistoryManager, + rewrites: ReconstructionRewrites, + ) -> ReconstructionInstrumentsLogic: + """The panel's logic over an instrument the project has since removed.""" + mock_reconstruction_manager.current_features = None + editor = _editor(mock_reconstruction_manager, project_controller, history) + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = project_controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + with history.transaction(HistoryAction.REMOVE_VOICE): + project_controller.remove_voice(instrument.id) + return _logic(editor, rewrites) + + def test_an_envelope_edit_draws_the_panel_empty( + self, + left_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + left_logic.on_view_changed = views.append + + left_logic.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert len(views) == 1 + assert views[0].instrument is None + assert not views[0].reconstruction_loaded + + def test_an_envelope_edit_goes_nowhere( + self, + left_logic: ReconstructionInstrumentsLogic, + ) -> None: + regenerated: List[object] = [] + left_logic.on_channel_changed = regenerated.append + + left_logic.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert regenerated == [] -class TestReconstructionInstrumentsLogicOnUpdateScheduled: - def test_no_pending_update_is_a_no_op( + def test_a_pitch_change_draws_the_panel_empty( + self, + left_logic: ReconstructionInstrumentsLogic, + ) -> None: + views: List[ReconstructionInstrumentsViewModel] = [] + left_logic.on_view_changed = views.append + regenerated: List[object] = [] + left_logic.on_channel_changed = regenerated.append + + left_logic.handle_pitch_value_changed(ChannelName.PULSE1, NEW_PITCH) + + assert len(views) == 1 + assert views[0].instrument is None + assert regenerated == [] + + def test_an_envelope_edit_after_the_document_closed_goes_nowhere( self, instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, ) -> None: - callback = MagicMock() - instruments_logic.on_reconstruction_instrument_updated = callback - instruments_logic._pending_reconstruction_update = None - instruments_logic._on_reconstruction_update_scheduled() - callback.assert_not_called() + """A close lands on the panel a moment later, so an edit can reach it in between.""" + mock_reconstruction_manager.current_features = None + received: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = received.append + regenerated: List[object] = [] + instruments_logic.on_channel_changed = regenerated.append + + instruments_logic.handle_envelope_changed(ChannelName.PULSE1, FeatureKey.VOLUME, Envelope[int](items=(7,))) + + assert received == [None] + assert regenerated == [] + + def test_a_pitch_change_reaching_an_instrument_goes_nowhere( + self, + mock_reconstruction_manager: MagicMock, + project_controller: ProjectController, + history: HistoryManager, + rewrites: ReconstructionRewrites, + ) -> None: + """The panel offers the pitch on a reconstruction's channels alone.""" + mock_reconstruction_manager.current_features = None + editor = _editor(mock_reconstruction_manager, project_controller, history) + with history.transaction(HistoryAction.ADD_INSTRUMENT): + instrument = project_controller.add_instrument(new_instrument("lead")) + editor.edit_instrument(instrument.id) + logic = _logic(editor, rewrites) + views: List[ReconstructionInstrumentsViewModel] = [] + logic.on_view_changed = views.append + regenerated: List[object] = [] + logic.on_channel_changed = regenerated.append + + logic.handle_pitch_value_changed(INSTRUMENT_CHANNEL, NEW_PITCH) + + assert regenerated == [] + assert len(views) == 1 + assert views[0].instrument is not None + + +class TestEveryGestureReachesTheDocument: + """Each gesture the panel makes travels on to the document, however quickly the next one follows.""" + + def test_two_channels_edited_within_one_frame_both_reach_the_document( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + held_queue: HeldQueue, + taking_turns: Reconstruction, + ) -> None: + mock_reconstruction_manager.current_features = _heard_features(taking_turns) + reached: List[ChannelName] = [] + instruments_logic.on_channel_changed = lambda change: reached.append(change.channel_name) + + instruments_logic.handle_envelope_changed(SHARED_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(5, 5))) + instruments_logic.handle_envelope_changed(SOLE_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(3,))) + held_queue.drain() + + assert reached == [SHARED_CHANNEL, SOLE_CHANNEL] + + def test_a_change_waiting_behind_a_rebuild_is_measured( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + taking_turns: Reconstruction, + ) -> None: + """The figures answer for what the document will hold, a change still waiting included.""" + mock_reconstruction_manager.current_features = _heard_features(taking_turns) + views: List[ReconstructionInstrumentsViewModel] = [] + sole = Envelope[int](items=(3, 3, 3)) + instruments_logic.handle_envelope_changed(SHARED_CHANNEL, FeatureKey.VOLUME, Envelope[int](items=(5, 5))) + instruments_logic.handle_envelope_changed(SOLE_CHANNEL, FeatureKey.VOLUME, sole) + instruments_logic.on_view_changed = views.append + + instruments_logic.refresh_view() + + edited = _heard_features(taking_turns)[SOLE_CHANNEL].with_envelope(FeatureKey.VOLUME, sole) + footprint = views[-1].footprint + assert footprint is not None + assert footprint.bytes_for(SOLE_CHANNEL) == features_footprint(edited).total_bytes + + def test_a_redraw_draws_the_changes_on_their_way( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + taking_turns: Reconstruction, + ) -> None: + """A redraw while a change waits draws the change, so the bars the reader moved stay where they left them.""" + mock_reconstruction_manager.current_features = _heard_features(taking_turns) + shared = Envelope[int](items=(5, 5)) + sole = Envelope[int](items=(3,)) + instruments_logic.handle_envelope_changed(SHARED_CHANNEL, FeatureKey.VOLUME, shared) + instruments_logic.handle_envelope_changed(SOLE_CHANNEL, FeatureKey.VOLUME, sole) + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + instruments_logic.on_feature_data_changed = drawn.append + + instruments_logic.update_display() + + envelopes = drawn[-1] + assert envelopes is not None + assert envelopes[SHARED_CHANNEL].volume == shared + assert envelopes[SOLE_CHANNEL].volume == sole diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_listening.py b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py new file mode 100644 index 000000000..f381f8e3a --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_listening.py @@ -0,0 +1,224 @@ +from typing import Final, List + +import pytest + +from sampletones_application.logic.reconstruction.listening import StemListening, offered_channels +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, bending_channels +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.stems import RECORDED_SCALE + +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +STEM_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] + + +def _stems_data(*assignments: ChannelAssignment) -> StemsData: + entries = [ + StemEntry( + id=stem_id, + settings=StemSettings(channels=STEM_CHANNELS, bends=bending_channels(STEM_CHANNELS)), + ) + for stem_id in (STEM_A, STEM_B) + ] + return StemsData( + config=StemsConfig( + entries=entries, + hierarchy=StemsHierarchy(levels=[[STEM_A, STEM_B]]), + ), + assignments=list(assignments), + scale=RECORDED_SCALE, + ) + + +@pytest.fixture +def listening() -> StemListening: + return StemListening() + + +class TestWhatARecordingOffers: + def test_a_recording_offers_the_channels_it_holds_frames_on(self) -> None: + stems_data = _stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A, RESTING_STEM_ID]), + ) + + offered = offered_channels(stems_data) + + assert offered[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + assert offered[STEM_B] == frozenset({ChannelName.PULSE1}) + + def test_a_recording_holding_nothing_offers_nothing(self) -> None: + stems_data = _stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A])) + + assert offered_channels(stems_data)[STEM_B] == frozenset() + + def test_the_frames_the_reader_wrote_offer_a_row_of_their_own(self) -> None: + stems_data = _stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, AUTHORED_STEM_ID]), + ) + + assert offered_channels(stems_data)[AUTHORED_STEM_ID] == frozenset({ChannelName.PULSE1}) + + +class TestWhatAFreshDocumentIsHeardAs: + def test_every_recording_is_heard_everywhere_it_holds_frames(self, listening: StemListening) -> None: + stems_data = _stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A, RESTING_STEM_ID]), + ) + + listening.adopt(stems_data) + + assert listening.heard == listening.offered + + def test_the_selection_answers_each_channel_with_the_recordings_on_it(self, listening: StemListening) -> None: + stems_data = _stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A, RESTING_STEM_ID]), + ) + + listening.adopt(stems_data) + + assert listening.heard_on(ChannelName.PULSE1) == frozenset({STEM_A, STEM_B}) + assert listening.heard_on(ChannelName.TRIANGLE) == frozenset({STEM_A}) + assert listening.heard_on(ChannelName.NOISE) == frozenset() + + +class TestWhatTheChoiceCarriesAcrossAnEdit: + """A choice the reader made survives the next reading of the record.""" + + @staticmethod + def _held_on(*channels: ChannelName) -> StemsData: + return _stems_data( + *(ChannelAssignment(channel_name=channel_name, stem_ids=[STEM_A, STEM_B]) for channel_name in channels) + ) + + def test_a_channel_a_recording_keeps_holding_keeps_the_readers_choice(self, listening: StemListening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) + + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1}) + + def test_a_channel_a_recording_newly_reaches_joins_what_is_heard(self, listening: StemListening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1)) + listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) + + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + + def test_a_channel_a_recording_stops_holding_leaves_what_is_heard(self, listening: StemListening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + + listening.adopt(self._held_on(ChannelName.PULSE1)) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1}) + + def test_a_recording_leaving_the_record_leaves_the_choice(self, listening: StemListening) -> None: + """An edit letting a recording go takes its row away, and the rest keep what the reader chose.""" + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) + + listening.adopt( + _stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_A])).without_entries( + frozenset({STEM_B}) + ) + ) + + assert listening.offered == {STEM_A: frozenset({ChannelName.PULSE1})} + assert listening.heard == {STEM_A: frozenset({ChannelName.PULSE1})} + assert STEM_B not in listening.selection.stems_for(ChannelName.PULSE1) + + def test_a_released_choice_hears_the_next_document_whole(self, listening: StemListening) -> None: + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + listening.set_channels(STEM_A, frozenset()) + + listening.release() + listening.adopt(self._held_on(ChannelName.PULSE1, ChannelName.TRIANGLE)) + + assert listening.heard == listening.offered + + +class TestSoloingARecording: + """A solo hears one recording alone and remembers the choice it replaced.""" + + @staticmethod + def _held() -> StemsData: + return _stems_data( + ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A, STEM_B]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[STEM_A, STEM_B]), + ) + + def test_the_recording_is_heard_on_every_channel_it_offers_and_no_other_is_heard( + self, + listening: StemListening, + ) -> None: + listening.adopt(self._held()) + listening.set_channels(STEM_A, frozenset({ChannelName.PULSE1})) + + listening.solo(STEM_A) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + assert listening.heard[STEM_B] == frozenset() + + def test_a_second_solo_returns_to_the_choice_it_replaced(self, listening: StemListening) -> None: + listening.adopt(self._held()) + listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) + before = listening.heard + + listening.solo(STEM_A) + listening.solo(STEM_A) + + assert listening.heard == before + + def test_soloing_another_recording_keeps_the_choice_the_first_solo_replaced( + self, + listening: StemListening, + ) -> None: + listening.adopt(self._held()) + listening.set_channels(STEM_B, frozenset({ChannelName.TRIANGLE})) + before = listening.heard + + listening.solo(STEM_A) + listening.solo(STEM_B) + listening.solo(STEM_B) + + assert listening.heard == before + + def test_a_solo_with_nothing_remembered_hears_every_recording_whole(self, listening: StemListening) -> None: + listening.adopt(self._held()) + listening.solo(STEM_A) + listening.adopt(self._held()) + + listening.solo(STEM_A) + + assert listening.heard == listening.offered + + def test_a_choice_made_by_hand_stands_as_the_new_state(self, listening: StemListening) -> None: + listening.adopt(self._held()) + listening.solo(STEM_A) + + listening.set_channels(STEM_B, frozenset({ChannelName.PULSE1})) + listening.solo(STEM_A) + + assert listening.heard[STEM_A] == frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) + assert listening.heard[STEM_B] == frozenset() + + def test_a_recording_offering_nothing_leaves_the_others_heard( + self, + listening: StemListening, + ) -> None: + listening.adopt(_stems_data(ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[STEM_A]))) + + listening.solo(STEM_B) + listening.solo(STEM_B) + + assert listening.heard == listening.offered diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index cb0a74df3..4f5884e70 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -1,23 +1,60 @@ from pathlib import Path -from typing import Callable -from unittest.mock import MagicMock +from typing import Callable, Final, List +from unittest.mock import MagicMock, patch import numpy as np import pytest from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.shared.renders import RenderCache +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, bending_channels 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.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.configs.settings import StemSettings from sampletones_shared.exceptions import LoadReconstructionError +from tests.conftest import RENDER_BUDGET from tests.suite.errors import DIRECTORY_READ_ERRORS +from tests.suite.stems import RECORDED_SCALE, single_entry_stems_data + +SAMPLE_VOICE_ID: Final[str] = "lead-id" + + +def _two_entry_stems_data() -> StemsData: + return StemsData( + config=StemsConfig( + entries=[ + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + ), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0, 1])], + scale=RECORDED_SCALE, + ) + + +def _two_frames() -> List[PulseInstruction]: + """A frame for each of the two recordings, which every recording a document names holds.""" + return [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] * 2 class TestLoadReconstructionPropagatesErrors: @staticmethod def _manager() -> ReconstructionManager: - return ReconstructionManager(scheduling=MagicMock()) + return ReconstructionManager(scheduling=MagicMock(), renders=RenderCache(budget_bytes=RENDER_BUDGET)) def test_missing_file_raises_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): @@ -79,7 +116,11 @@ def test_save_with_explicit_path_saves_file( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) save_path = tmp_path / "saved.stn" assert reconstruction_manager.save_reconstruction(save_path) assert save_path.exists() @@ -89,7 +130,11 @@ def test_save_with_no_path_and_no_filepath_is_no_op( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert not reconstruction_manager.save_reconstruction() def test_save_when_nothing_loaded_is_no_op( @@ -112,7 +157,11 @@ def test_false_for_in_memory_object( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert not reconstruction_manager.is_file_backed def test_true_after_file_load( @@ -127,6 +176,67 @@ def test_true_after_file_load( assert reconstruction_manager.is_file_backed +class TestTheFileADocumentIsBackedBy: + """A conversion can write over the file the open document came from, so the manager tells its + own file however the path to it is spelled.""" + + @pytest.fixture(name="opened") + def opened_fixture( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> Path: + path = tmp_path / "song.stn" + reconstruction_factory().save(path) + reconstruction_manager.load_reconstruction(path) + return path + + def test_the_file_it_was_loaded_from( + self, + reconstruction_manager: ReconstructionManager, + opened: Path, + ) -> None: + assert reconstruction_manager.is_backed_by(opened) + + def test_the_same_file_spelled_another_way( + self, + reconstruction_manager: ReconstructionManager, + opened: Path, + ) -> None: + (opened.parent / "folder").mkdir() + + assert reconstruction_manager.is_backed_by(opened.parent / "folder" / ".." / opened.name) + + def test_another_file( + self, + reconstruction_manager: ReconstructionManager, + opened: Path, + ) -> None: + assert not reconstruction_manager.is_backed_by(opened.with_name("other.stn")) + + def test_nothing_open( + self, + reconstruction_manager: ReconstructionManager, + tmp_path: Path, + ) -> None: + assert not reconstruction_manager.is_backed_by(tmp_path / "song.stn") + + def test_a_document_held_in_memory( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + assert not reconstruction_manager.is_backed_by(tmp_path / "Sample.stn") + + class TestReconstructionManagerSaveReconstructionAs: def test_writes_the_file( self, @@ -134,7 +244,11 @@ def test_writes_the_file( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) target = tmp_path / "detached.stn" reconstruction_manager.save_reconstruction_as(target) assert target.exists() @@ -145,7 +259,11 @@ def test_rebinds_to_a_file_backed_document( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) target = tmp_path / "detached.stn" reconstruction_manager.save_reconstruction_as(target) assert reconstruction_manager.is_file_backed @@ -158,7 +276,7 @@ def test_severs_reconstruction_identity_from_the_original( tmp_path: Path, ) -> None: original = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(original, name="Sample") + reconstruction_manager.load_reconstruction_object(original, name="Sample", voice_id=SAMPLE_VOICE_ID) reconstruction_manager.save_reconstruction_as(tmp_path / "detached.stn") assert reconstruction_manager.reconstruction is not original @@ -168,7 +286,11 @@ def test_marks_session_saved( reconstruction_factory: Callable[[], Reconstruction], tmp_path: Path, ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.mark_updated() reconstruction_manager.save_reconstruction_as(tmp_path / "detached.stn") assert not reconstruction_manager.session.unsaved_changes @@ -189,7 +311,11 @@ def test_load_object_marks_session_loaded( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.session.is_loaded def test_load_object_sets_current_reconstruction( @@ -197,7 +323,11 @@ def test_load_object_sets_current_reconstruction( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.current_reconstruction is not None def test_load_object_uses_supplied_name_for_session( @@ -205,7 +335,11 @@ def test_load_object_uses_supplied_name_for_session( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Kick drum") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Kick drum", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.session.name == "Kick drum" def test_load_object_sets_reconstruction_by_identity( @@ -214,7 +348,7 @@ def test_load_object_sets_reconstruction_by_identity( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) assert reconstruction_manager.reconstruction is reconstruction def test_load_object_fires_on_reconstruction_loaded_callback( @@ -224,7 +358,11 @@ def test_load_object_fires_on_reconstruction_loaded_callback( ) -> None: callback = MagicMock() reconstruction_manager.on_reconstruction_loaded = callback - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) callback.assert_called_once() @@ -259,7 +397,11 @@ def test_close_resets_current_reconstruction_to_none( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() assert reconstruction_manager.current_reconstruction is None @@ -270,7 +412,11 @@ def test_close_fires_on_reconstruction_closed_callback( ) -> None: callback = MagicMock() reconstruction_manager.on_reconstruction_closed = callback - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() callback.assert_called_once() @@ -279,18 +425,26 @@ def test_close_marks_session_closed( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() assert not reconstruction_manager.session.is_loaded - 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.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.close_reconstruction() - assert reconstruction_manager.audio_filepath is None + assert reconstruction_manager.source_paths == () class TestReconstructionManagerProperties: @@ -300,7 +454,7 @@ def test_reconstruction_property_returns_the_reconstruction_object( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) assert reconstruction_manager.reconstruction is reconstruction def test_filepath_property_is_none_for_in_memory_reconstruction( @@ -308,24 +462,32 @@ def test_filepath_property_is_none_for_in_memory_reconstruction( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.filepath is None - def test_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 + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + assert reconstruction_manager.source_paths == reconstruction.audio_filepath def test_current_features_is_populated_after_load( self, reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) assert reconstruction_manager.current_features is not None @@ -342,11 +504,152 @@ 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 TestAnEditLettingARecordingGo: + """The open document follows an edit that lets a recording go, and so does what is heard of it.""" + + @staticmethod + def _edited(reconstruction: Reconstruction) -> Reconstruction: + """The document with the second recording's frame written silent, which it held alone.""" + edited = reconstruction.model_copy(deep=True) + edited = edited.with_channel_data( + ChannelName.PULSE1, + [_two_frames()[0], PulseInstruction.null_instruction()], + edited.initial_pitches[ChannelName.PULSE1], + edited.held_features[ChannelName.PULSE1], + heard=edited.recorded_stem_ids, + ) + return edited + + def test_the_recording_leaves_what_the_reader_hears( + self, + reconstruction_manager: ReconstructionManager, + tmp_path: Path, + ) -> None: + reconstruction = Reconstruction.create( + instructions={ChannelName.PULSE1: _two_frames()}, + config=Config(), + coefficient=1.0, + audio_filepath=(tmp_path / "kick.wav", tmp_path / "snare.wav"), + stems_data=_two_entry_stems_data(), + ) + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + + reconstruction_manager.apply_edited(self._edited(reconstruction)) + + assert reconstruction_manager.listening.offered == {0: frozenset({ChannelName.PULSE1})} + assert reconstruction_manager.source_paths == (tmp_path / "kick.wav",) + features = reconstruction_manager.current_features + assert features is not None + assert features[ChannelName.PULSE1].volume.items[-1] == 0 + + +class TestTheVoiceTheDocumentIs: + """The open document remembers the project voice it is, which is how the tab follows it.""" + + def test_a_project_sample_names_its_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID + assert reconstruction_manager.is_project_sample + + def test_a_file_names_no_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + path = tmp_path / "song.stn" + reconstruction_factory().save(path) + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.load_reconstruction(path) + + assert reconstruction_manager.voice_id is None + assert not reconstruction_manager.is_project_sample + + def test_an_edit_keeps_the_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.apply_edited(reconstruction_factory()) + + assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID + + def test_save_as_lets_the_voice_go( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """The saved copy is a standalone document, so its edits reach only its file.""" + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.save_reconstruction_as(tmp_path / "detached.stn") + + assert reconstruction_manager.voice_id is None + + def test_a_failed_save_as_keeps_the_voice( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + with pytest.raises(OSError): + reconstruction_manager.save_reconstruction_as(tmp_path / "missing" / "detached.stn") + + assert reconstruction_manager.voice_id == SAMPLE_VOICE_ID + + def test_closing_lets_the_voice_go( + self, + reconstruction_manager: ReconstructionManager, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) + + reconstruction_manager.close_reconstruction() + + assert reconstruction_manager.voice_id is None + assert not reconstruction_manager.is_project_sample class TestReconstructionManagerMarkUpdated: @@ -355,7 +658,11 @@ def test_mark_updated_sets_unsaved_changes( reconstruction_manager: ReconstructionManager, reconstruction_factory: Callable[[], Reconstruction], ) -> None: - reconstruction_manager.load_reconstruction_object(reconstruction_factory(), name="Sample") + reconstruction_manager.load_reconstruction_object( + reconstruction_factory(), + name="Sample", + voice_id=SAMPLE_VOICE_ID, + ) reconstruction_manager.mark_updated() assert reconstruction_manager.session.unsaved_changes @@ -377,14 +684,16 @@ def test_locate_audio_raises_file_not_found_when_audio_missing( ) -> None: 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)]}, + 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( + [ChannelName.PULSE1], + {ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, + ), ) - reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample") + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) with pytest.raises(FileNotFoundError): reconstruction_manager.locate_original_audio() @@ -393,3 +702,48 @@ 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( + instructions={ChannelName.PULSE1: _two_frames()}, + config=Config(), + coefficient=1.0, + audio_filepath=(first, second), + stems_data=_two_entry_stems_data(), + ) + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + + with patch("sampletones_application.logic.reconstruction.manager.open_paths_in_explorer") as open_paths: + reconstruction_manager.locate_original_audio() + + 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( + instructions={ChannelName.PULSE1: _two_frames()}, + config=Config(), + coefficient=1.0, + audio_filepath=(present, missing), + stems_data=_two_entry_stems_data(), + ) + reconstruction_manager.load_reconstruction_object(reconstruction, name="Sample", voice_id=SAMPLE_VOICE_ID) + + 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_ownership.py b/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py new file mode 100644 index 000000000..77d5e6f28 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/test_ownership.py @@ -0,0 +1,104 @@ +from pathlib import Path +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_application.logic.reconstruction.ownership import ( + named_recordings, + ownership_lanes, + tells_owners_apart, +) +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, bending_channels +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.stems import RECORDED_SCALE + +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +STEM_C: Final[int] = 2 +STEM_CHANNELS: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] + + +def _stems_data(stem_ids: Sequence[int], *, owners: Sequence[int] = (STEM_A, STEM_B)) -> StemsData: + """A record of the given recordings, the first channel holding ``stem_ids`` frame by frame.""" + entries = [ + StemEntry( + id=stem_id, + settings=StemSettings(channels=STEM_CHANNELS, bends=bending_channels(STEM_CHANNELS)), + ) + for stem_id in owners + ] + return StemsData( + config=StemsConfig(entries=entries, hierarchy=StemsHierarchy(levels=[list(owners)])), + assignments=[ChannelAssignment(channel_name=CHANNEL, stem_ids=list(stem_ids))], + scale=RECORDED_SCALE, + ) + + +def _lane_runs(stem_ids: Sequence[int]) -> Tuple[Tuple[int, int, int], ...]: + """The stretches the first channel divides into, each as its start, end and owner.""" + assignments: Dict[ChannelName, List[int]] = {CHANNEL: list(stem_ids)} + lanes = ownership_lanes(assignments, lambda _channel: {STEM_A, STEM_B}) + return tuple((run.start_frame, run.end_frame, run.stem_id) for run in lanes[CHANNEL].runs) + + +class TestWhatALaneDividesInto: + """A lane names an owner wherever the record does, and a rest answers to none. + + A resting stretch shows the ground of whatever surface carries the lane, so a stretch painted + for it would take the ribbon's own ground onto a plot answering to a different color. + """ + + def test_each_recording_takes_the_stretch_it_holds(self) -> None: + assert _lane_runs([STEM_A, STEM_A, STEM_B]) == ((0, 2, STEM_A), (2, 3, STEM_B)) + + def test_a_resting_stretch_takes_none_of_its_own(self) -> None: + assert _lane_runs([STEM_A, RESTING_STEM_ID, STEM_B]) == ((0, 1, STEM_A), (2, 3, STEM_B)) + + def test_a_channel_resting_throughout_carries_no_stretch(self) -> None: + assert _lane_runs([RESTING_STEM_ID, RESTING_STEM_ID]) == () + + def test_a_trailing_rest_leaves_the_recordings_where_they_stand(self) -> None: + assert _lane_runs([STEM_A, STEM_A, RESTING_STEM_ID]) == ((0, 2, STEM_A),) + + +class TestALaneStandsWhateverTheDocumentHoldsToTellApart: + """Whether a lane is worth drawing is left to the caller, so a document answering to a single + owner still divides into the one stretch that owner holds.""" + + def test_a_single_owner_still_takes_a_lane(self) -> None: + assignments: Dict[ChannelName, List[int]] = {CHANNEL: [STEM_A, STEM_A]} + + lanes = ownership_lanes(assignments, lambda _channel: {STEM_A}) + + assert [(run.start_frame, run.end_frame, run.stem_id) for run in lanes[CHANNEL].runs] == [(0, 2, STEM_A)] + + +class TestWhetherTheDocumentHasOwnersToTellApart: + def test_one_owner_has_nothing_to_tell_apart(self) -> None: + assert not tells_owners_apart(_stems_data([STEM_A], owners=(STEM_A,))) + + def test_two_owners_tell_apart(self) -> None: + assert tells_owners_apart(_stems_data([STEM_A, STEM_B])) + + +class TestTheRecordingsADocumentNames: + """A list of recordings carries the id each was converted as, which is what colors its mark.""" + + def test_each_recording_is_named_with_the_id_it_was_converted_as(self) -> None: + stems_data = _stems_data([STEM_A, STEM_C], owners=(STEM_A, STEM_C)).with_sources( + (Path("/music/Drums.wav"), Path("/music/Bass.wav")) + ) + + assert named_recordings(stems_data) == ( + NamedRecordingViewModel(stem_id=STEM_A, name="Drums"), + NamedRecordingViewModel(stem_id=STEM_C, name="Bass"), + ) + + def test_a_record_naming_no_recording_lists_nothing(self) -> None: + assert named_recordings(_stems_data([STEM_A, STEM_B])) == () diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 0d426cfbc..19eb6d316 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -1,48 +1,75 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, Final, List +from typing import Callable, Dict, Final, List, Optional from unittest.mock import MagicMock import numpy as np import pytest +from sampletones_application.exports import ExportBackends from sampletones_application.logic.reconstruction.data import ReconstructionData +from sampletones_application.logic.reconstruction.envelopes import heard_envelopes +from sampletones_application.logic.reconstruction.listening import StemListening 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.logic.shared.renders import RenderCache +from sampletones_application.view_model.reconstruction.envelopes import ( + ChannelEnvelopesViewModel, +) +from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, +) +from sampletones_application.view_model.reconstruction.rate import RateLock +from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) +from sampletones_application.view_model.shared.audio_data import AudioData +from sampletones_application.view_model.shared.ownership import OwnershipRibbonViewModel +from sampletones_application.view_model.shared.waveform_data import WaveformData from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import AudioSourceType, GeneratorName -from sampletones_core.instructions import TriangleInstruction +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID +from sampletones_core.constants.enums import AudioSourceType, ChannelName, bending_channels +from sampletones_core.exports.format import ExportFormat +from sampletones_core.instructions import PulseInstruction, 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.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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +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.conftest import RENDER_BUDGET from tests.suite.case import BaseRegularTestCase +from tests.suite.stems import RECORDED_SCALE, recorded_from 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] @@ -65,38 +92,58 @@ def mock_export_service() -> MagicMock: def mock_reconstruction_manager() -> MagicMock: mock = MagicMock(spec=ReconstructionManager) mock.current_reconstruction = None + mock.is_project_sample = False mock.audio_filepath = None + mock.listening = StemListening() + mock.refresh_features.side_effect = lambda: _refresh_features(mock) + mock.renders = RenderCache(budget_bytes=RENDER_BUDGET) return mock +def _open(manager: MagicMock, reconstruction_data: ReconstructionData) -> None: + """Puts a document in front of the panel the way the manager's own adoption does.""" + manager.current_reconstruction = reconstruction_data + manager.listening.adopt(reconstruction_data.reconstruction.stems_data) + _refresh_features(manager) + + +def _refresh_features(manager: MagicMock) -> None: + """Reads the envelopes of what is heard, the way the manager's own refresh does.""" + if manager.current_reconstruction is not None: + manager.current_features = heard_envelopes( + manager.current_reconstruction.reconstruction, + manager.listening.selection, + ) + + @pytest.fixture 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 ExportBackends.build().by_format.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 +158,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 +194,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 = recorded_from(reconstruction_factory(), (source_audio,)) return ReconstructionData.from_reconstruction(reconstruction, name="Sample") @@ -142,7 +214,7 @@ def test_display_fires_on_view_changed( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_view_changed = callback panel_logic.display_reconstruction() @@ -154,7 +226,7 @@ def test_display_fires_on_waveform_load_changed( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_waveform_load_changed = callback panel_logic.display_reconstruction() @@ -166,7 +238,7 @@ def test_display_fires_on_audio_data_changed( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_audio_data_changed = callback panel_logic.display_reconstruction() @@ -181,10 +253,10 @@ def test_detached_reconstruction_reports_both_locations_not_applicable( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction.detach_source() - mock_reconstruction_manager.current_reconstruction = ReconstructionData.from_reconstruction( - reconstruction, - name="Sample", + reconstruction = reconstruction.detached() + _open( + mock_reconstruction_manager, + ReconstructionData.from_reconstruction(reconstruction, name="Sample"), ) captured: List[ReconstructionViewModel] = [] panel_logic.on_view_changed = captured.append @@ -224,16 +296,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( @@ -251,7 +339,7 @@ def test_update_fires_on_waveform_update_changed( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_waveform_update_changed = callback panel_logic.update_reconstruction() @@ -263,7 +351,7 @@ def test_update_emits_audio_when_source_is_not_original( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_audio_data_changed = callback panel_logic.update_reconstruction() @@ -275,7 +363,7 @@ def test_update_skips_audio_when_source_is_original( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic._current_audio_source = AudioSourceType.ORIGINAL callback = MagicMock() panel_logic.on_audio_data_changed = callback @@ -298,13 +386,13 @@ def test_display_offers_the_channels_that_play( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) received = self._received(panel_logic) 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, @@ -312,13 +400,13 @@ def test_an_edit_reports_the_view_again( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic.display_reconstruction() received = self._received(panel_logic) 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, @@ -326,14 +414,14 @@ def test_a_channel_switched_off_by_hand_survives_an_edit( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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, @@ -341,21 +429,23 @@ def test_a_channel_gaining_its_first_frame_joins_the_waveform( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic.display_reconstruction() - loaded_data.reconstruction.update_generator_data( - GeneratorName.TRIANGLE, - [TriangleInstruction(on=True, pitch=48)], - np.ones(64, dtype=np.float32), - 48, - (), + mock_reconstruction_manager.current_reconstruction = loaded_data.with_reconstruction( + loaded_data.reconstruction.with_channel_data( + ChannelName.TRIANGLE, + [TriangleInstruction(on=True, pitch=48)], + 48, + (), + heard=loaded_data.reconstruction.recorded_stem_ids, + ) ) received = self._received(panel_logic) 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, @@ -363,21 +453,170 @@ def test_a_channel_taken_out_of_play_leaves_the_waveform( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic.display_reconstruction() - loaded_data.reconstruction.update_generator_data( - GeneratorName.PULSE1, - [], - np.zeros(0, dtype=np.float32), - 60, - (), + mock_reconstruction_manager.current_reconstruction = loaded_data.with_reconstruction( + loaded_data.reconstruction.with_channel_data( + ChannelName.PULSE1, + [], + 60, + (), + heard=loaded_data.reconstruction.recorded_stem_ids, + ) ) received = self._received(panel_logic) 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: + _open(mock_reconstruction_manager, 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 TestTheRateLock: + """The view names why the open document keeps its rate: it is a sample of the project, or it has no file.""" + + @staticmethod + def _shown(panel_logic: ReconstructionPanelLogic) -> ReconstructionViewModel: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + panel_logic.display_reconstruction() + return received[-1] + + def test_a_document_on_disk_is_free_to_retime( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data.detached_copy(tmp_path / "standing.stn")) + + assert self._shown(panel_logic).rate_lock is None + + def test_a_document_with_no_file_keeps_its_rate_for_that_reason( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """A document whose file was taken away belongs to no project, so its lock says so.""" + _open(mock_reconstruction_manager, loaded_data) + + assert self._shown(panel_logic).rate_lock is RateLock.NO_FILE + + def test_a_sample_of_the_project_follows_the_projects_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.is_project_sample = True + _open(mock_reconstruction_manager, loaded_data) + + assert self._shown(panel_logic).rate_lock is RateLock.PROJECT_SAMPLE + + def test_a_closed_tab_names_no_lock(self, panel_logic: ReconstructionPanelLogic) -> None: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + + panel_logic.close_reconstruction() + + assert received[-1].rate_lock is None + + +class TestAReTimedDocumentIsShown: + """A document re-timed to another rate is shown at it, every reading of it re-answered. + + The rate reaches the document as a step of its edits, and the tab then asks for every reading + again with the waveform re-fitted, since the audio spans another length. + """ + + @staticmethod + def _retime(manager: MagicMock, loaded_data: ReconstructionData) -> None: + """Opens the document, then lets the stand-in manager adopt it re-timed, as a landed retune leaves it.""" + _open(manager, loaded_data) + manager.current_reconstruction = loaded_data.with_reconstruction( + loaded_data.reconstruction.with_nes_frequency(PAL_FREQUENCY) + ) + + def test_the_view_states_the_new_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + self._retime(mock_reconstruction_manager, loaded_data) + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + + panel_logic.update_reconstruction(refit_waveform=True) + + assert received[-1].nes_frequency == PAL_FREQUENCY + + def test_the_waveform_and_the_audio_follow_the_new_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + self._retime(mock_reconstruction_manager, loaded_data) + waveforms: List[WaveformData] = [] + audio: List[Optional[AudioData]] = [] + panel_logic.on_waveform_update_changed = lambda waveform, _channels, **_kwargs: waveforms.append(waveform) + panel_logic.on_audio_data_changed = audio.append + + panel_logic.update_reconstruction(refit_waveform=True) + + assert len(waveforms) == 1 + assert len(audio) == 1 + + def test_the_waveform_view_re_fits_to_the_retuned_length( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """A retune moves the audio's own length, so the old view no longer answers to it.""" + self._retime(mock_reconstruction_manager, loaded_data) + refits: List[bool] = [] + panel_logic.on_waveform_update_changed = lambda _waveform, _channels, *, refit: refits.append(refit) + + panel_logic.update_reconstruction(refit_waveform=True) + + assert refits == [True] class TestReconstructionPanelLogicClose: @@ -415,7 +654,7 @@ def test_close_resets_audio_source_to_reconstruction( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic.set_audio_source(AudioSourceType.ORIGINAL) panel_logic.close_reconstruction() assert panel_logic._current_audio_source == AudioSourceType.RECONSTRUCTION @@ -429,9 +668,10 @@ def test_display_without_original_audio_switches_source_to_reconstruction( reconstruction_factory: Callable[[], Reconstruction], ) -> None: reconstruction = reconstruction_factory() - reconstruction.detach_source() - mock_reconstruction_manager.current_reconstruction = ReconstructionData.from_reconstruction( - reconstruction, name="Sample" + reconstruction = reconstruction.detached() + _open( + mock_reconstruction_manager, + ReconstructionData.from_reconstruction(reconstruction, name="Sample"), ) panel_logic.set_audio_source(AudioSourceType.ORIGINAL) received: List[AudioSourceType] = [] @@ -447,7 +687,7 @@ def test_display_with_original_audio_keeps_selected_source( mock_reconstruction_manager: MagicMock, data_with_original_audio: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = data_with_original_audio + _open(mock_reconstruction_manager, data_with_original_audio) panel_logic.set_audio_source(AudioSourceType.ORIGINAL) received: List[AudioSourceType] = [] panel_logic.on_waveform_source_changed = received.append @@ -462,7 +702,7 @@ def test_set_audio_source_changes_to_original( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic.set_audio_source(AudioSourceType.ORIGINAL) assert panel_logic._current_audio_source == AudioSourceType.ORIGINAL @@ -472,23 +712,23 @@ def test_set_audio_source_emits_audio_data( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_audio_data_changed = callback panel_logic.set_audio_source(AudioSourceType.ORIGINAL) callback.assert_called_once() -class TestReconstructionPanelLogicSelectedGenerators: +class TestReconstructionPanelLogicSelectedChannels: def test_set_selected_generators_updates_selection( self, panel_logic: ReconstructionPanelLogic, mock_reconstruction_manager: MagicMock, 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] + _open(mock_reconstruction_manager, loaded_data) + panel_logic.set_selected_channels([ChannelName.PULSE1]) + assert panel_logic._selected_channels == [ChannelName.PULSE1] def test_set_selected_generators_fires_waveform_load( self, @@ -496,10 +736,10 @@ def test_set_selected_generators_fires_waveform_load( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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 +748,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() + _open(mock_reconstruction_manager, loaded_data) + + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) - def test_request_export_instrument_dialog_suggests_the_slice_name( + assert exportable is not None + assert exportable.source.features == mock_reconstruction_manager.current_features[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)" + _open(mock_reconstruction_manager, loaded_data) - 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) - def test_request_export_instrument_dialog_sends_the_generator_to_the_dialog( + assert exportable is not None + assert exportable.name == "Sample (pulse1)" + + 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 + _open(mock_reconstruction_manager, loaded_data) - 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: - 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() + """A backend sounding the slice on its own plays it through the channel it names.""" + _open(mock_reconstruction_manager, loaded_data) - 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) + + assert exportable is not None + assert exportable.source.channel == 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( + 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: - 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] + """A loop belongs to a sample placed in a project, so a reconstruction states none.""" + _open(mock_reconstruction_manager, loaded_data) - @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, - ) + _open(mock_reconstruction_manager, 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 +852,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, @@ -668,10 +860,10 @@ def test_request_export_instruments_dialog_fires_dialog_callback( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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( @@ -683,10 +875,10 @@ def test_request_export_instruments_dialog_suggests_the_reconstruction_name( """The tracker is settled before the dialog opens, so the suggestion ends in the extension that tracker writes. """ - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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( @@ -696,11 +888,11 @@ def test_request_export_instruments_dialog_carries_the_chosen_tracker( loaded_data: ReconstructionData, ) -> None: """The dialog offers one type, so the tracker travels with the request.""" - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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 +902,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() @@ -722,10 +914,10 @@ def test_handle_export_instruments_confirmed_calls_export_sample( mock_export_service: MagicMock, tmp_path: Path, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) panel_logic.handle_export_instruments_confirmed( tmp_path / "sample.fti", - TrackerFormat.FAMITRACKER, + ExportFormat.FAMITRACKER, ) mock_export_service.export_sample.assert_called_once() @@ -737,15 +929,33 @@ def test_handle_export_instruments_confirmed_names_the_batch_after_the_destinati mock_export_service: MagicMock, tmp_path: Path, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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: + _open(mock_reconstruction_manager, 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,20 +967,61 @@ 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: """The action names the tracker, so the destination's own extension leaves the backend it is written through untouched. """ - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, 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 TestReconstructionPanelLogicSampleRequest: + """A format setting its export up in a dialog of its own names the slices before a destination + exists, so the request carries the reconstruction's own name.""" + + def test_with_no_data_raises_assertion_error( + self, + panel_logic: ReconstructionPanelLogic, + ) -> None: + with pytest.raises(AssertionError): + panel_logic.sample_request() + + def test_the_request_is_named_after_the_reconstruction( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + + request = panel_logic.sample_request() + + assert request.name == loaded_data.name + assert [instrument.name for instrument in request.instruments] == [f"{loaded_data.name} (pulse1)"] + + def test_the_request_holds_the_slices_a_confirmed_export_writes( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + mock_export_service: MagicMock, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data) + panel_logic.handle_export_instruments_confirmed( + tmp_path / f"{loaded_data.name}{EXT_FILE_INSTRUMENT}", + ExportFormat.FAMITRACKER, + ) + + assert panel_logic.sample_request() == mock_export_service.export_sample.call_args.args[2] class TestReconstructionPanelLogicExportWav: @@ -787,7 +1038,7 @@ def test_request_export_wav_dialog_fires_dialog_callback( mock_reconstruction_manager: MagicMock, loaded_data: ReconstructionData, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data + _open(mock_reconstruction_manager, loaded_data) callback = MagicMock() panel_logic.on_open_export_wav_dialog = callback panel_logic.request_export_wav_dialog() @@ -810,8 +1061,8 @@ def test_handle_export_wav_confirmed_calls_export_wav_with_sample_rate( mock_export_service: MagicMock, tmp_path: Path, ) -> None: - mock_reconstruction_manager.current_reconstruction = loaded_data - panel_logic._selected_generators = [GeneratorName.PULSE1] + _open(mock_reconstruction_manager, loaded_data) + 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 +1081,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 +1097,628 @@ 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: + """Two recordings taking turns on the first pulse, one frame each.""" + stems_reconstruction = recorded_from(reconstruction_factory(), (tmp_path / "a.wav", tmp_path / "b.wav")) + 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: + _open(mock_reconstruction_manager, stems_data) + stems_views = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + assert mock_reconstruction_manager.listening.heard == { + 0: frozenset({ChannelName.PULSE1}), + 1: frozenset({ChannelName.PULSE1}), + } + 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_document_where_no_recording_holds_a_frame_offers_no_box_for_any( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + """Every recording stays on a record whose frames answer to none of them, and none offers a box.""" + reconstruction = stems_data.reconstruction + frames = len(reconstruction.instructions[ChannelName.PULSE1]) + authored = reconstruction.model_copy( + update={ + "stems_data": reconstruction.stems_data.with_assignments( + [ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[AUTHORED_STEM_ID] * frames)] + ), + } + ) + _open(mock_reconstruction_manager, stems_data.with_reconstruction(authored)) + 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() + 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: + _open(mock_reconstruction_manager, 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_a_row_carries_the_id_its_recording_was_converted_as( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + """The swatch beside a name and the stretches in the ribbon read one id.""" + _open(mock_reconstruction_manager, 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"].stem_id == 0 + assert rows["1"].stem_id == 1 + + def test_a_recording_keeps_its_color_once_another_leaves( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + """The recording standing second keeps the id that colored it, standing first afterward.""" + _open(mock_reconstruction_manager, stems_data.with_reconstruction(without_stem(stems_data.reconstruction, 0))) + 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.stem_id) for row in rows] == [("1", 1)] + + def test_silencing_a_recording_filters_waveform_and_playback( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + renders: RenderCache, + ) -> None: + _open(mock_reconstruction_manager, 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( + renders, + 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: + _open(mock_reconstruction_manager, 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, + renders: RenderCache, + ) -> None: + _open(mock_reconstruction_manager, 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( + renders, + panel_logic._selected_channels, + panel_logic._stem_selection, + ) + np.testing.assert_allclose(exported_audio, expected) + + +class TestWhatTheEnvelopesShow: + """The envelopes, the figures and an instrument export read the part the reader hears.""" + + @pytest.fixture(name="stems_data") + def stems_data_fixture(self, tmp_path: Path) -> ReconstructionData: + """Two recordings sharing the first pulse, a sounding frame each.""" + channels = [ChannelName.PULSE1] + stems_config = StemsConfig( + entries=[ + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) + for stem_id in (0, 1) + ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), + ) + stems_reconstruction = Reconstruction.create( + instructions={ + ChannelName.PULSE1: [ + PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0), + PulseInstruction(on=True, pitch=62, volume=8, duty_cycle=0), + ] + }, + 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])], + scale=RECORDED_SCALE, + ), + ) + return ReconstructionData.from_reconstruction(stems_reconstruction, name="Sample") + + def test_a_recording_switched_off_leaves_the_envelopes_it_held( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count + + panel_logic.set_stem_channels(1, frozenset()) + + assert mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count < whole + + def test_a_channel_no_recording_is_heard_on_describes_no_frame( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + + panel_logic.set_stem_channels(0, frozenset()) + panel_logic.set_stem_channels(1, frozenset()) + + assert not mock_reconstruction_manager.current_features[ChannelName.PULSE1].has_frames + + def test_a_channel_no_recording_is_heard_on_is_written_nowhere( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + + panel_logic.set_stem_channels(0, frozenset()) + panel_logic.set_stem_channels(1, frozenset()) + + assert panel_logic.exportable_instrument(ChannelName.PULSE1) is None + assert ChannelName.PULSE1 not in {export.channel for export in panel_logic.sample_request().instruments} + + def test_an_export_writes_what_the_panel_draws( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + + panel_logic.set_stem_channels(1, frozenset()) + + exportable = panel_logic.exportable_instrument(ChannelName.PULSE1) + assert exportable is not None + assert exportable.source.features == mock_reconstruction_manager.current_features[ChannelName.PULSE1] + + def test_the_reader_is_told_the_envelopes_moved( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + reported: List[None] = [] + panel_logic.on_heard_changed = lambda: reported.append(None) + + panel_logic.set_stem_channels(1, frozenset()) + + assert len(reported) == 1 + + def test_a_solo_leaves_the_envelopes_of_the_recording_alone( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count + + panel_logic.solo_stem(0) + + assert mock_reconstruction_manager.listening.heard[1] == frozenset() + assert 0 < mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count < whole + + def test_a_second_solo_returns_to_the_whole_mix( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + whole = mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count + + panel_logic.solo_stem(0) + panel_logic.solo_stem(0) + + assert mock_reconstruction_manager.current_features[ChannelName.PULSE1].frame_count == whole + + def test_a_solo_tells_the_reader_the_envelopes_moved_once( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + reported: List[None] = [] + panel_logic.on_heard_changed = lambda: reported.append(None) + + panel_logic.solo_stem(0) + + assert len(reported) == 1 + + +class TestTheLanesTheRibbonStandsOn: + """The lanes answer for the channels the document plays, and what fills them for the listening.""" + + @pytest.fixture(name="stems_data") + def stems_data_fixture(self, tmp_path: Path) -> ReconstructionData: + """Two recordings, one holding the first pulse and one the triangle.""" + pulse = [ChannelName.PULSE1] + triangle = [ChannelName.TRIANGLE] + stems_config = StemsConfig( + entries=[ + StemEntry(id=0, settings=StemSettings(channels=pulse, bends=bending_channels(pulse))), + StemEntry(id=1, settings=StemSettings(channels=triangle, bends=bending_channels(triangle))), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), + ) + stems_reconstruction = Reconstruction.create( + instructions={ + ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)], + ChannelName.TRIANGLE: [TriangleInstruction(on=True, pitch=48)], + }, + 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]), + ChannelAssignment(channel_name=ChannelName.TRIANGLE, stem_ids=[1]), + ], + scale=RECORDED_SCALE, + ), + ) + return ReconstructionData.from_reconstruction(stems_reconstruction, name="Sample") + + @staticmethod + def _ribbons(panel_logic: ReconstructionPanelLogic) -> List[OwnershipRibbonViewModel]: + received: List[OwnershipRibbonViewModel] = [] + panel_logic.on_ownership_changed = received.append + return received + + def test_a_lane_stands_for_every_channel_the_document_plays( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + received = self._ribbons(panel_logic) + + panel_logic.display_reconstruction() + + assert [lane.channel_name for lane in received[-1].lanes] == [ChannelName.PULSE1, ChannelName.TRIANGLE] + + def test_a_channel_switched_off_keeps_its_lane_and_stands_empty( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + """The rows beneath the waveform hold still while a reader picks their way through it.""" + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + received = self._ribbons(panel_logic) + + panel_logic.set_selected_channels([ChannelName.PULSE1]) + + lanes = {lane.channel_name: lane for lane in received[-1].lanes} + assert list(lanes) == [ChannelName.PULSE1, ChannelName.TRIANGLE] + assert lanes[ChannelName.TRIANGLE].runs == () + assert lanes[ChannelName.PULSE1].runs + + def test_the_lanes_stand_where_nothing_is_switched_on( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + """The ribbon measures the record, so its rows hold even with nothing to paint in them.""" + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + received = self._ribbons(panel_logic) + + panel_logic.set_selected_channels([]) + + ribbon = received[-1] + assert [lane.channel_name for lane in ribbon.lanes] == [ChannelName.PULSE1, ChannelName.TRIANGLE] + assert all(lane.runs == () for lane in ribbon.lanes) + assert ribbon.is_drawn + + def test_a_stretch_the_reader_hears_stands_under_its_recording( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + received = self._ribbons(panel_logic) + + panel_logic.display_reconstruction() + + pulse_lane = next(lane for lane in received[-1].lanes if lane.channel_name == ChannelName.PULSE1) + assert [(run.stem_id, run.heard) for run in pulse_lane.runs] == [(0, True)] + + def test_a_recording_switched_off_keeps_its_stretch_under_its_own_name( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + """A stretch names the recording holding it whether or not the reader is listening to it.""" + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + received = self._ribbons(panel_logic) + + panel_logic.set_stem_channels(0, frozenset()) + + pulse_lane = next(lane for lane in received[-1].lanes if lane.channel_name == ChannelName.PULSE1) + assert [(run.stem_id, run.heard) for run in pulse_lane.runs] == [(0, False)] + + def test_a_document_answering_to_one_recording_still_offers_a_lane( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """A single recording throughout still answers whether a channel is sounding and by whom, + so its lane stands the way every other channel's does.""" + _open(mock_reconstruction_manager, loaded_data) + received = self._ribbons(panel_logic) + + panel_logic.display_reconstruction() + + ribbon = received[-1] + assert ribbon.is_drawn + assert [lane.channel_name for lane in ribbon.lanes] == [ChannelName.PULSE1] + assert ribbon.lanes[0].runs + + +class TestTheScopeAnEditWritesIn: + """What the reader hears on a channel is what an edit there reaches.""" + + @pytest.fixture(name="stems_data") + def stems_data_fixture(self, tmp_path: Path) -> ReconstructionData: + """Two recordings sharing the first pulse, a frame each, so both are heard there.""" + channels = [ChannelName.PULSE1] + stems_config = StemsConfig( + entries=[ + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) + for stem_id in (0, 1) + ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), + ) + stems_reconstruction = Reconstruction.create( + instructions={ + ChannelName.PULSE1: [ + PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0), + PulseInstruction(on=True, pitch=62, volume=8, duty_cycle=0), + ] + }, + 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])], + scale=RECORDED_SCALE, + ), + ) + return ReconstructionData.from_reconstruction(stems_reconstruction, name="Sample") + + def test_a_freshly_opened_document_lets_an_edit_reach_every_recording( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + + panel_logic.display_reconstruction() + + assert panel_logic.heard_on(ChannelName.PULSE1) == frozenset({0, 1}) + + def test_a_recording_switched_off_leaves_the_scope( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + + panel_logic.set_stem_channels(1, frozenset()) + + assert panel_logic.heard_on(ChannelName.PULSE1) == frozenset({0}) + + def test_a_channel_no_recording_is_heard_on_takes_no_edit( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + stems_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, stems_data) + panel_logic.display_reconstruction() + + assert panel_logic.heard_on(ChannelName.TRIANGLE) == frozenset() + + +class TestTheRowTheReadersOwnFramesStandIn: + """Frames the reader wrote answer to no recording, so they gather in a row of their own.""" + + @pytest.fixture(name="edited_data") + def edited_data_fixture( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + reconstruction = reconstruction_factory() + channels = [ChannelName.PULSE1] + stems_config = StemsConfig( + entries=[StemEntry(id=0, settings=StemSettings(channels=channels, bends=bending_channels(channels)))], + hierarchy=StemsHierarchy(levels=[[0]]), + ) + frames = len(reconstruction.instructions[ChannelName.PULSE1]) + authored = reconstruction.model_copy( + update={ + "stems_data": StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[AUTHORED_STEM_ID] * frames, + ) + ], + scale=RECORDED_SCALE, + ).with_sources((tmp_path / "a.wav",)), + } + ) + return ReconstructionData.from_reconstruction(authored, name="Sample") + + def test_the_row_stands_after_every_recording( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + edited_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, edited_data) + stems_views: List[ReconstructionStemsViewModel] = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + rows = stems_views[0].stems.rows + assert [row.key for row in rows] == ["0", str(AUTHORED_STEM_ID)] + assert rows[-1].level == rows[0].level + 1 + + def test_the_row_offers_the_channels_it_holds_frames_on( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + edited_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, edited_data) + stems_views: List[ReconstructionStemsViewModel] = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + edits = stems_views[0].stems.row(str(AUTHORED_STEM_ID)) + assert edits is not None + assert edits.offered_channels == frozenset({ChannelName.PULSE1}) + assert edits.stands_for_edits + + def test_a_removal_stands_outside_the_row( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + edited_data: ReconstructionData, + ) -> None: + _open(mock_reconstruction_manager, edited_data) + stems_views: List[ReconstructionStemsViewModel] = [] + panel_logic.on_stems_view_changed = stems_views.append + + panel_logic.display_reconstruction() + + edits = stems_views[0].stems.row(str(AUTHORED_STEM_ID)) + assert edits is not None + assert not edits.releasable diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py index df2ee2414..fcaac3bbf 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, @@ -247,8 +247,8 @@ def test_a_stop_holds_its_message_over_the_reports_still_arriving( ) ) - assert render.view.phase == RenderPhase.CANCELLING - assert render.view.status_text == "settings.render.message.status_cancelling" + assert render.view.phase == RenderPhase.CANCELING + assert render.view.status_text == "settings.render.message.status_canceling" def test_a_stop_reaches_the_service(self, render: RenderFixture) -> None: render.configure() @@ -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..bc8ac0715 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,11 @@ 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_PERIOD, MAX_PITCH, MAX_TRANSPOSE, MIN_PLAYED_PITCH, MIN_TRANSPOSE +from sampletones_core.project.patterns.pitch import Note, Step +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.utils.frequencies import period_to_name, pitch_to_name SAMPLE_IDS: List[str] = ["kick", "snare", "hat"] @@ -17,18 +20,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 +41,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 +60,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), ) @@ -70,33 +73,43 @@ class TestTheFormAFieldTakes: """Every field carries what the grid shows in its cell, each kind in its own width.""" def test_a_cell_of_values_prints_the_three_the_grid_prints(self, text: TrackerBlockText) -> None: - block = TrackerBlock(notes={(0, 0): "snare"}, transposes={(0, 1): 0}, volumes={(0, 2): 15}) + block = TrackerBlock(notes={(0, 0): "snare"}, pitches={(0, 1): Step(value=0)}, volumes={(0, 2): 15}) assert _body(text, block, PULSE1_CELL) == ["01 +00 F"] def test_an_empty_cell_prints_the_dots_beneath_it(self, text: TrackerBlockText) -> None: - block = TrackerBlock(notes={(0, 0): None}, transposes={(0, 1): None}, volumes={(0, 2): None}) + block = TrackerBlock(notes={(0, 0): None}, pitches={(0, 1): None}, volumes={(0, 2): None}) assert _body(text, block, PULSE1_CELL) == [".. ... ."] def test_a_mixed_cell_fills_its_fields_with_marks(self, text: TrackerBlockText) -> None: - block = TrackerBlock(notes={}, transposes={}, volumes={}) + block = TrackerBlock(notes={}, pitches={}, volumes={}) assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"] def test_a_cut_prints_the_mark_the_note_column_shows(self, text: TrackerBlockText) -> None: - block = TrackerBlock(notes={(0, 0): NoteOff()}, transposes={}, volumes={}) + block = TrackerBlock(notes={(0, 0): NoteOff()}, pitches={}, volumes={}) assert _body(text, block, PULSE1_CELL) == ["~~ ??? ?"] - def test_a_transpose_below_zero_prints_its_sign(self, text: TrackerBlockText) -> None: - block = TrackerBlock(notes={}, transposes={(0, 1): -10}, volumes={}) + def test_a_step_below_zero_prints_its_sign_in_decimal(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, pitches={(0, 1): Step(value=-10)}, volumes={}) - assert _body(text, block, PULSE1_CELL) == ["?? -0A ?"] + assert _body(text, block, PULSE1_CELL) == ["?? -10 ?"] + + def test_a_note_prints_its_name(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, pitches={(0, 1): Note(value=61)}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == [f"?? {pitch_to_name(61)} ?"] + + def test_a_period_prints_its_name(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, pitches={(0, 1): Note(value=5)}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == [f"?? {period_to_name(5)} ?"] def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: TrackerBlockText) -> None: """A paste has nothing to place for it, so the text states nothing about that cell.""" - block = TrackerBlock(notes={(0, 0): "cowbell"}, transposes={}, volumes={}) + block = TrackerBlock(notes={(0, 0): "cowbell"}, pitches={}, volumes={}) assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"] @@ -104,28 +117,28 @@ 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, ) - header = text.state(TrackerBlock(notes={}, transposes={}, volumes={}), region).splitlines()[0] + header = text.state(TrackerBlock(notes={}, pitches={}, volumes={}), region).splitlines()[0] assert header == "SampleToNES/1 tracker rows=4 slots=3..8" 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) == ["?? ??? ? | ?? ??? ?"] + assert _body(text, TrackerBlock(notes={}, pitches={}, volumes={}), region) == ["?? ??? ? | ?? ??? ?"] 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={}) + block = TrackerBlock(notes={}, pitches={(0, 1): Step(value=1), (2, 1): Step(value=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, ) @@ -142,58 +155,101 @@ class RoundTripCase: ROUND_TRIPS: List[RoundTripCase] = [ RoundTripCase( "the three states across one cell", - TrackerBlock(notes={(0, 0): "kick"}, transposes={(0, 1): None}, volumes={}), + TrackerBlock(notes={(0, 0): "kick"}, pitches={(0, 1): None}, volumes={}), PULSE1_CELL, ), RoundTripCase( "a cut and an empty note", - TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}), + TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, pitches={}, 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, ), ), RoundTripCase( - "the whole transpose range", - TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}), + "the whole step range", + TrackerBlock( + notes={}, + pitches={(0, 1): Step(value=MIN_TRANSPOSE), (1, 1): Step(value=MAX_TRANSPOSE), (2, 1): Step(value=0)}, + volumes={}, + ), + _region( + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), + rows=3, + ), + ), + RoundTripCase( + "the whole note range", + TrackerBlock( + notes={}, + pitches={(0, 1): Note(value=MIN_PLAYED_PITCH), (1, 1): Note(value=60), (2, 1): Note(value=MAX_PITCH)}, + 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, ), ), + RoundTripCase( + "the whole period range", + TrackerBlock(notes={}, pitches={(0, 1): Note(value=0), (1, 1): Note(value=MAX_PERIOD)}, volumes={}), + _region( + first_slot=_slot(ChannelName.NOISE, SubColumn.VOICE), + last_slot=_slot(ChannelName.NOISE, SubColumn.VOLUME), + rows=2, + ), + ), + RoundTripCase( + "both faces down one column", + TrackerBlock(notes={}, pitches={(0, 1): Note(value=60), (1, 1): Step(value=-3)}, volumes={}), + _region( + first_slot=_slot(ChannelName.PULSE1, SubColumn.VOICE), + last_slot=_slot(ChannelName.PULSE1, SubColumn.VOLUME), + rows=2, + ), + ), + RoundTripCase( + "a note in the sample column", + TrackerBlock(notes={}, pitches={(0, 1): Note(value=60)}, volumes={}), + _region( + first_slot=_slot(None, SubColumn.VOICE), + last_slot=_slot(None, SubColumn.VOLUME), + ), + ), RoundTripCase( "the whole volume range", - TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}), + TrackerBlock(notes={}, pitches={}, 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, ), ), RoundTripCase( "a block anchored at the sample column", - TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}), + TrackerBlock(notes={(0, 0): "hat"}, pitches={(0, 4): Step(value=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}), + TrackerBlock(notes={(0, 3): "snare"}, pitches={(0, 1): Step(value=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}), + TrackerBlock(notes={(0, 12): "kick"}, pitches={(1, 1): Step(value=-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, ), ), @@ -213,31 +269,56 @@ def test_a_block_survives_being_stated_and_read( def test_a_note_reaches_the_sample_standing_at_its_position(self, text: TrackerBlockText) -> None: """The position is what crosses, so a block lands on the list the reading project holds.""" - block = TrackerBlock(notes={(0, 0): "snare"}, transposes={}, volumes={}) + block = TrackerBlock(notes={(0, 0): "snare"}, pitches={}, volumes={}) stated = text.state(block, PULSE1_CELL) elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass", "clap"])) - assert elsewhere.parse(stated) == TrackerBlock(notes={(0, 0): "clap"}, transposes={}, volumes={}) + assert elsewhere.parse(stated) == TrackerBlock(notes={(0, 0): "clap"}, pitches={}, volumes={}) def test_a_position_the_reading_list_falls_short_of_states_nothing(self, text: TrackerBlockText) -> None: - block = TrackerBlock(notes={(0, 0): "hat"}, transposes={}, volumes={}) + block = TrackerBlock(notes={(0, 0): "hat"}, pitches={}, volumes={}) stated = text.state(block, PULSE1_CELL) elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass"])) - assert elsewhere.parse(stated) == TrackerBlock(notes={}, transposes={}, volumes={}) + assert elsewhere.parse(stated) == TrackerBlock(notes={}, pitches={}, volumes={}) class TestTextTypedByHand: """The form is readable, so a reader typing it reaches the same block a copy would.""" def test_hexadecimal_reads_in_either_case(self, text: TrackerBlockText) -> None: - upper = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0a f") - lower = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0A F") + upper = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -10 f") + lower = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -10 F") assert upper == lower - assert upper == TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 1): -10}, volumes={(0, 2): 15}) + assert upper == TrackerBlock(notes={(0, 0): "hat"}, pitches={(0, 1): Step(value=-10)}, volumes={(0, 2): 15}) + + @pytest.mark.parametrize( + ("field", "expected"), + [ + ("c#3", Note(value=61)), + ("C-0", Note(value=MIN_PLAYED_PITCH)), + ("B-7", Note(value=MAX_PITCH)), + ("a-#", Note(value=10)), + ("+7", Step(value=7)), + ("-07", Step(value=-7)), + ], + ) + def test_a_pitch_typed_by_hand_reads_as_the_grid_prints_it( + self, + text: TrackerBlockText, + field: str, + expected: object, + ) -> None: + block = text.parse(f"SampleToNES/1 tracker rows=1 slots=4..4\n{field}") + + assert block == TrackerBlock(notes={}, pitches={(0, 1): expected}, volumes={}) + + @pytest.mark.parametrize("field", ["C-8", "H-3", "G-#", "12", "+87", "-87", "+0A"]) + def test_a_pitch_the_form_has_no_reading_for_refuses_the_text(self, text: TrackerBlockText, field: str) -> None: + assert text.parse(f"SampleToNES/1 tracker rows=1 slots=4..4\n{field}") is None def test_the_bars_between_columns_are_a_reading_aid(self, text: TrackerBlockText) -> None: with_bars = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F | .. ... .") @@ -270,8 +351,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 step past the range", f"{HEADER}\n01 +{MAX_TRANSPOSE + 1:02d} F\n01 +00 F"), + RefusalCase("a step below the range", f"{HEADER}\n01 -{abs(MIN_TRANSPOSE) + 1:02d} 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..b142576cf 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -1,33 +1,32 @@ from pathlib import Path -from typing import Callable, FrozenSet, Iterable +from typing import Callable, Final, FrozenSet, Iterable import numpy as np import pytest from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager -from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import 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 ALL_CHANNELS, ChannelName +from sampletones_core.project.patterns.pitch import Step +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]: - """The fully audible mask a synthesiser renders under unless a test moves it.""" +def all_channels() -> FrozenSet[ChannelName]: + """The fully audible mask a synthesizer renders under unless a test moves it.""" return ALL_CHANNELS @@ -36,9 +35,9 @@ 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.""" + """A synthesizer rendering at ``sample_rate``, standing in for the output a caller supplies.""" return RowSynthesizer( controller, config, @@ -47,101 +46,40 @@ 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: + """An instrument 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), - transpose=transpose, + command=NoteOn(voice_id=voice_id), + pitch=Step(value=transpose) if transpose is not None else None, volume=volume, ) @@ -149,29 +87,29 @@ 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, + pitch=Step(value=transpose) if transpose is not None else None, volume=volume, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py deleted file mode 100644 index 2b4dcaa08..000000000 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py +++ /dev/null @@ -1,369 +0,0 @@ -from dataclasses import dataclass -from typing import Any - -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 tests.suite.case import BaseRegularTestCase, BaseTestCase - - -@dataclass(frozen=True, kw_only=True) -class VolumeScalingCase(BaseRegularTestCase): - instruction_volume: int - row_volume: int - expected: Any - - -VOLUME_SCALING_CASES = [ - VolumeScalingCase( - label="max×max", - instruction_volume=15, - row_volume=15, - expected=15, - ), - VolumeScalingCase( - label="half×half", - instruction_volume=8, - row_volume=8, - expected=4, - ), - VolumeScalingCase( - label="zero row", - instruction_volume=15, - row_volume=0, - expected=0, - ), - VolumeScalingCase( - label="zero instruction", - instruction_volume=0, - row_volume=15, - expected=0, - ), - VolumeScalingCase( - label="max×half", - instruction_volume=15, - row_volume=7, - expected=7, - ), - VolumeScalingCase( - label="one×one", - instruction_volume=1, - row_volume=1, - expected=0, - ), - VolumeScalingCase( - label="ten×ten", - instruction_volume=10, - row_volume=10, - expected=7, - ), - VolumeScalingCase( - label="max×mid", - instruction_volume=15, - row_volume=8, - expected=8, - ), -] - - -class TestPulseVolumeScaling: - @pytest.mark.parametrize("case", VOLUME_SCALING_CASES, ids=lambda c: c.label) - def test_volume_scaled_correctly( - self, - case: VolumeScalingCase, - ) -> None: - instruction = PulseInstruction( - on=True, - pitch=60, - volume=case.instruction_volume, - duty_cycle=0, - ) - result = apply_modifiers( - instruction, - transpose=0, - row_volume=case.row_volume, - ) - assert isinstance(result, PulseInstruction) - assert result.volume == case.expected - - -class TestNoiseVolumeScaling: - @pytest.mark.parametrize("case", VOLUME_SCALING_CASES, ids=lambda c: c.label) - def test_volume_scaled_correctly( - self, - case: VolumeScalingCase, - ) -> None: - instruction = NoiseInstruction( - on=True, - period=3, - volume=case.instruction_volume, - short=False, - ) - result = apply_modifiers( - instruction, - transpose=0, - row_volume=case.row_volume, - ) - assert isinstance(result, NoiseInstruction) - assert result.volume == case.expected - - -@dataclass(frozen=True, kw_only=True) -class PulseTransposeCase(BaseTestCase): - label: str - pitch: int - transpose: int - expected_pitch: int - - -PULSE_TRANSPOSE_CASES = [ - PulseTransposeCase( - label="shift +5", - pitch=60, - transpose=5, - expected_pitch=65, - ), - PulseTransposeCase( - label="shift -12", - pitch=60, - transpose=-12, - expected_pitch=48, - ), - PulseTransposeCase( - label="shift +1", - pitch=60, - transpose=1, - expected_pitch=61, - ), - PulseTransposeCase( - label="clamped at MAX_PITCH", - pitch=MAX_PITCH, - transpose=20, - expected_pitch=MAX_PITCH, - ), - PulseTransposeCase( - label="clamped at MIN_PITCH", - pitch=MIN_PITCH, - transpose=-20, - expected_pitch=MIN_PITCH, - ), - PulseTransposeCase( - label="no transpose", - pitch=60, - transpose=0, - expected_pitch=60, - ), -] - - -class TestPulseTranspose: - @pytest.mark.parametrize( - "case", - PULSE_TRANSPOSE_CASES, - ids=lambda c: c.label, - ) - def test_pitch_transposed_correctly( - self, - case: PulseTransposeCase, - ) -> None: - instruction = PulseInstruction( - on=True, - pitch=case.pitch, - volume=15, - duty_cycle=0, - ) - result = apply_modifiers( - instruction, - transpose=case.transpose, - row_volume=MAX_VOLUME, - ) - assert isinstance(result, PulseInstruction) - assert result.pitch == case.expected_pitch - - -@dataclass(frozen=True, kw_only=True) -class NoiseTransposeCase(BaseTestCase): - label: str - period: int - transpose: int - expected_period: int - - -NOISE_TRANSPOSE_CASES = [ - NoiseTransposeCase( - label="shift +5", - period=3, - transpose=5, - expected_period=8, - ), - NoiseTransposeCase( - label="wraps past 15", - period=14, - transpose=5, - expected_period=3, - ), - NoiseTransposeCase( - label="no transpose", - period=7, - transpose=0, - expected_period=7, - ), - NoiseTransposeCase( - label="negative wrap", - period=2, - transpose=-5, - expected_period=13, - ), - NoiseTransposeCase( - label="full wrap +16", - period=3, - transpose=16, - expected_period=3, - ), - NoiseTransposeCase( - label="shift to boundary", - period=0, - transpose=15, - expected_period=15, - ), -] - - -class TestNoiseTranspose: - @pytest.mark.parametrize( - "case", - NOISE_TRANSPOSE_CASES, - ids=lambda c: c.label, - ) - def test_period_transposed_correctly( - self, - case: NoiseTransposeCase, - ) -> None: - instruction = NoiseInstruction( - on=True, - period=case.period, - volume=15, - short=False, - ) - result = apply_modifiers( - instruction, - transpose=case.transpose, - row_volume=MAX_VOLUME, - ) - assert isinstance(result, NoiseInstruction) - assert result.period == case.expected_period - - -@dataclass(frozen=True, kw_only=True) -class TriangleModifiersCase(BaseTestCase): - label: str - pitch: int - transpose: int - row_volume: int - expected_pitch: int - expected_on: bool - - -TRIANGLE_MODIFIERS_CASES = [ - TriangleModifiersCase( - label="volume zero forces off", - pitch=60, - transpose=0, - row_volume=0, - expected_pitch=60, - expected_on=False, - ), - TriangleModifiersCase( - label="volume at half threshold forces off", - pitch=60, - transpose=0, - row_volume=MAX_VOLUME // 2, - expected_pitch=60, - expected_on=False, - ), - TriangleModifiersCase( - label="volume one above half threshold preserves on", - pitch=60, - transpose=0, - row_volume=MAX_VOLUME // 2 + 1, - expected_pitch=60, - expected_on=True, - ), - TriangleModifiersCase( - label="nonzero volume preserves on", - pitch=60, - transpose=0, - row_volume=8, - expected_pitch=60, - expected_on=True, - ), - TriangleModifiersCase( - label="pitch shifted by positive transpose", - pitch=60, - transpose=3, - row_volume=15, - expected_pitch=63, - expected_on=True, - ), - TriangleModifiersCase( - label="pitch shifted by negative transpose", - pitch=60, - transpose=-7, - row_volume=15, - expected_pitch=53, - expected_on=True, - ), - TriangleModifiersCase( - label="pitch clamped at MAX_PITCH", - pitch=MAX_PITCH, - transpose=20, - row_volume=15, - expected_pitch=MAX_PITCH, - expected_on=True, - ), - TriangleModifiersCase( - label="pitch clamped at MIN_PITCH", - pitch=MIN_PITCH, - transpose=-20, - row_volume=15, - expected_pitch=MIN_PITCH, - expected_on=True, - ), - TriangleModifiersCase( - label="zero volume with pitch shift — pitch still applied", - pitch=60, - transpose=5, - row_volume=0, - expected_pitch=65, - expected_on=False, - ), -] - - -class TestTriangleModifiers: - @pytest.mark.parametrize( - "case", - TRIANGLE_MODIFIERS_CASES, - ids=lambda c: c.label, - ) - def test_modifiers_applied( - self, - case: TriangleModifiersCase, - ) -> None: - instruction = TriangleInstruction(on=True, pitch=case.pitch) - result = apply_modifiers( - instruction, - transpose=case.transpose, - row_volume=case.row_volume, - ) - assert isinstance(result, TriangleInstruction) - assert result.pitch == case.expected_pitch - assert result.on == case.expected_on 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..49f11c7ec 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py @@ -2,7 +2,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.playback.synthesizer import SongLength -from sampletones_application.logic.sequencer.playback.synthesizer.timing import SongTiming +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming FRACTIONAL_RATE: Final[int] = 22050 EXACT_RATE: Final[int] = 44100 @@ -13,26 +13,27 @@ def measure(controller: ProjectController, sample_rate: int) -> SongLength: class TestTheOrderStatesTheTicks: - def test_the_song_lasts_its_groove_once_for_each_order_position( + def test_the_song_lasts_until_the_frame_past_its_last_would_start( self, controller: ProjectController, ) -> None: - groove = SongTiming.from_project(controller.project).groove() + timing = SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS) length = measure(controller, EXACT_RATE) - assert length.ticks == controller.project.song.order_length() * groove.total_ticks + assert length.ticks == timing.frame_tick(controller.project.song.order_length()) - def test_appending_a_frame_lengthens_the_song_by_a_pattern( + def test_appending_a_frame_lengthens_the_song_by_the_ticks_that_frame_plays( self, controller: ProjectController, ) -> None: before = measure(controller, EXACT_RATE) - groove = SongTiming.from_project(controller.project).groove() + appended = controller.project.song.order_length() controller.append_frame() - assert measure(controller, EXACT_RATE).ticks == before.ticks + groove.total_ticks + groove = SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS).groove(appended) + assert measure(controller, EXACT_RATE).ticks == before.ticks + sum(groove.ticks) class TestTheRateStatesTheSamples: diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py index c6cfc27a5..0db6536ff 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py @@ -1,4 +1,5 @@ -from typing import List, Tuple +from dataclasses import dataclass +from typing import Callable, List, Tuple from unittest.mock import MagicMock import pytest @@ -12,6 +13,8 @@ ) from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_core.project.song_position import SongPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( make_controller, ) @@ -315,6 +318,93 @@ def test_playback_error_fires_on_error_callback(self) -> None: assert errors == [error] +class TestTheTransitionHook(BaseTestSuite): + """Every transition of the song reaches ``on_change_audio_state`` once, and nothing else does. + + The menu and the toolbar follow the song through that hook, whichever gesture moved it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + gesture: Callable[[SongPlayerLogic], None] + is_open: bool = True + playing: bool = False + paused: bool = False + expected: int + + test_cases = ( + TestCase(label="play", gesture=SongPlayerLogic.play, expected=1), + TestCase(label="play with no project", gesture=SongPlayerLogic.play, is_open=False, expected=0), + TestCase(label="play from a row", gesture=lambda logic: logic.play_from(1, 4), expected=1), + TestCase(label="the toggle starting", gesture=SongPlayerLogic.pause_or_resume, expected=1), + TestCase(label="pause", gesture=SongPlayerLogic.pause_or_resume, playing=True, expected=1), + TestCase(label="resume", gesture=SongPlayerLogic.pause_or_resume, paused=True, expected=1), + TestCase(label="stop while playing", gesture=SongPlayerLogic.stop, playing=True, expected=1), + TestCase(label="stop while paused", gesture=SongPlayerLogic.stop, paused=True, expected=1), + TestCase(label="stop of a silent song", gesture=SongPlayerLogic.stop, expected=0), + TestCase( + label="the song ending by itself", + gesture=lambda logic: logic._on_service_result(SongPlaybackStopped()), + playing=True, + expected=1, + ), + TestCase( + label="a playback error", + gesture=lambda logic: logic._on_service_result(SongPlaybackError(error=RuntimeError("device lost"))), + playing=True, + expected=1, + ), + TestCase( + label="a row played", + gesture=lambda logic: logic._on_service_result( + SongPositionUpdate(position=SongPosition(order_position=0, row_index=1)) + ), + playing=True, + expected=0, + ), + TestCase(label="a seek while playing", gesture=lambda logic: logic.seek(2), playing=True, expected=0), + TestCase( + label="a follow mode chosen", + gesture=lambda logic: logic.set_follow_mode(FollowMode.PATTERNS), + playing=True, + expected=0, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_transitions_reach_the_hook(self, test_case: TestCase) -> None: + logic = _make_logic(is_open=test_case.is_open) + logic._service.is_playing = test_case.playing + logic._service.is_paused = test_case.paused + logic._service.alive = test_case.playing or test_case.paused + logic.on_view_changed = lambda _view: None + logic.on_position_changed = lambda _order, _row: None + logic.on_error = lambda _error: None + transitions: List[None] = [] + logic.on_change_audio_state = lambda: transitions.append(None) + + test_case.gesture(logic) + + assert len(transitions) == test_case.expected + + def test_the_hook_reads_the_state_the_transition_left(self) -> None: + """The surfaces read the song as the hook fires, so the song is engaged by then.""" + logic = _make_logic() + logic._service.alive = False + engaged: List[bool] = [] + logic.on_view_changed = lambda _view: None + logic._service.start.side_effect = lambda **_position: setattr(logic._service, "alive", True) + logic.on_change_audio_state = lambda: engaged.append(logic.is_engaged()) + + logic.play() + + assert engaged == [True] + + class TestOnProjectReplaced: def test_on_project_replaced_stops_playback_and_resets_position(self) -> None: logic = _make_logic(is_open=True) 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..cd6cc0e07 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,27 @@ import numpy as np import pytest -from sampletones_application.constants.playback import ( - MAX_TICKS_PER_ROW, - MIN_TICKS_PER_ROW, -) +from automation.scenario import BaseTestScenario, ScenarioStep 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 ALL_CHANNELS, ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.performance import ChannelPerformance from sampletones_core.reconstructions import Reconstruction -from sampletones_core.timing import Metre, RowRate, calculate_groove -from tests.suite.scenario import BaseTestScenario, ScenarioStep +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from tests.suite.performance import ( + make_noise_reconstruction, + make_pulse_reconstruction, + make_triangle_reconstruction, +) 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, @@ -32,6 +34,7 @@ SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE SUSTAINED_FRAMES: Final[int] = 64 QUIET_VOLUME: Final[int] = 3 +FIRST_FRAME: Final[int] = 0 class MaskProvider: @@ -40,12 +43,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 +59,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 +76,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: @@ -86,15 +89,9 @@ def _render(context: SynthesizerContext) -> np.ndarray: return audio -def _groove_ticks(controller: ProjectController) -> Tuple[int, ...]: - """The ticks each row of a pattern owes the project's timing, from the timing package itself.""" - settings = controller.project.settings - return calculate_groove( - RowRate.from_settings(settings), - Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern), - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ).ticks +def _groove_ticks(controller: ProjectController, frame: int) -> Tuple[int, ...]: + """The ticks each row of a frame owes the project's timing, from the timing package itself.""" + return SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS).groove(frame).ticks def _row_ticks( @@ -114,15 +111,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 +142,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 +153,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 +179,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 +224,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 +272,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 +323,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 +360,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 +384,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 +482,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 +500,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 +522,18 @@ 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, + transpose=0, ) def render_row_0_and_assert_non_silence(context: SynthesizerContext) -> None: @@ -546,15 +545,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 +566,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 +590,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 +604,16 @@ 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, + transpose=0, ) controller.append_frame() @@ -628,15 +627,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 +649,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) @@ -701,7 +700,7 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None: settings = controller.project.settings frame_length = settings.sample_rate // settings.nes_frequency audio = _render(context) - assert len(audio) == frame_length * _groove_ticks(controller)[0] + assert len(audio) == frame_length * _groove_ticks(controller, FIRST_FRAME)[0] BaseTestScenario( label="chunk length matches the groove's first row", @@ -716,7 +715,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, @@ -731,15 +730,15 @@ def test_a_pattern_plays_the_groove_the_metre_yields( rendered = _row_ticks(synthesizer, controller.project.song.rows_per_pattern) assert rendered == (5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) - assert rendered == _groove_ticks(controller) + assert rendered == _groove_ticks(controller, FIRST_FRAME) - def test_the_groove_restarts_with_the_pattern( + def test_the_song_start_plays_its_first_rows_again( self, controller: ProjectController, synthesizer: RowSynthesizer, ) -> None: - """Every row reads the groove entry its position in the pattern names, so returning to - row 0 plays row 0's duration again — the phase an exported module also restarts on. + """Every row reads its duration from its place in the song, so returning to the first row + plays that row's duration again, as an exported song does when it comes round. """ controller.set_rows_per_pattern(16) controller.set_tempo(210) @@ -751,6 +750,23 @@ def test_the_groove_restarts_with_the_pattern( assert opening == (5, 4, 5) assert again == (opening[0],) + def test_the_next_frame_plays_the_groove_its_place_gives_it( + self, + controller: ProjectController, + synthesizer: RowSynthesizer, + ) -> None: + """A 16-row pattern lasts 68 4/7 ticks at tempo 210, so the second frame plays one tick fewer.""" + controller.set_rows_per_pattern(16) + controller.set_tempo(210) + controller.append_frame() + rows = controller.project.song.rows_per_pattern + + first = _row_ticks(synthesizer, rows) + second = _row_ticks(synthesizer, rows) + + assert (first, second) == (_groove_ticks(controller, FIRST_FRAME), _groove_ticks(controller, FIRST_FRAME + 1)) + assert (sum(first), sum(second)) == (69, 68) + def test_tempo_change_between_rows_rebuilds_the_groove( self, controller: ProjectController, @@ -765,7 +781,7 @@ def test_tempo_change_between_rows_rebuilds_the_groove( after_change = _row_ticks(synthesizer, 1) assert at_reference_tempo == (speed,) - assert after_change == (_groove_ticks(controller)[1],) + assert after_change == (_groove_ticks(controller, FIRST_FRAME)[1],) def test_highlight_change_regroups_the_same_row_rate( self, @@ -804,7 +820,7 @@ def render_and_assert_chunk_uses_project_frequency( settings = controller.project.settings frame_length = round(settings.sample_rate / settings.nes_frequency) audio = _render(context) - assert len(audio) == frame_length * _groove_ticks(controller)[0] + assert len(audio) == frame_length * _groove_ticks(controller, FIRST_FRAME)[0] BaseTestScenario( label="frame length tracks the project NES frequency", @@ -840,27 +856,27 @@ 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: """A dimension an instrument leaves to the channel sounds at the value the channel holds. - The channel carries that value from the start of a song, taking up a new one wherever an - instrument writes it, so an instrument with an empty volume envelope plays at whatever the - one before it left behind. + Every note starts that value where a song starts it, and the channel takes up a new one + wherever the instrument writes it, so an instrument with an empty volume envelope plays at full + volume whatever the one before it wrote. """ @staticmethod @@ -874,9 +890,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,9 +910,10 @@ 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: + def test_a_sample_holding_its_level_starts_at_full_volume_after_a_quieter_one(self) -> None: + """A level one note wrote ends with it, so the next note sounds as a song would start it.""" context = _make_context() self._place( context, @@ -914,11 +931,18 @@ def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None: row_index=1, name="holds", ) + loud = _make_context() + self._place( + loud, + make_pulse_reconstruction(volume=MAX_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) - written = _render(context) + _render(context) held = _render(context) - assert self._peak(held) == pytest.approx(self._peak(written)) + assert self._peak(held) == pytest.approx(self._peak(_render(loud))) def test_a_song_starts_a_held_level_at_full_volume(self) -> None: holding = _make_context() @@ -954,4 +978,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..44f5bd928 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,19 @@ 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 SONG_TICK_BOUNDS, SongTiming, TickClock +from sampletones_shared.exceptions import NoOutputDeviceError 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, ) @@ -26,13 +27,8 @@ 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), - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ).ticks + """The ticks each row of the song's first frame lasts, where the synthesizer starts.""" + return SongTiming.from_project(controller.project, bounds=SONG_TICK_BOUNDS).groove(0).ticks class TestRowsFollowTheTickClock(BaseTestSuite): @@ -126,7 +122,7 @@ class TestTheOutputRateIsFollowed(BaseTestSuite): """The audio is rendered at the rate its consumer reports, so a rendered second lasts a second. Live playback opens its device stream at that rate and a render writes its file at it, so a - synthesiser fixed to some other rate plays the song at the ratio between the two. + synthesizer fixed to some other rate plays the song at the ratio between the two. """ @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) @@ -171,7 +167,7 @@ def __init__(self) -> None: def __call__(self) -> int: self.reads += 1 if self.rate is None: - raise ValueError("No audio device selected") + raise NoOutputDeviceError("No audio device selected") return self.rate @@ -223,9 +219,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 +232,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/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py deleted file mode 100644 index e7b1778eb..000000000 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py +++ /dev/null @@ -1,304 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, Final, Iterable, List, Sequence - -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.general import MAX_VOLUME -from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS -from sampletones_core.instructions import ( - InstructionUnion, - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) -from sampletones_core.reconstructions import Reconstruction -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase - -AUDIO_LENGTH: Final[int] = 64 -REFERENCE_PITCH: Final[int] = 60 -REFERENCE_PERIOD: Final[int] = 4 -SAMPLE_VOLUME: Final[int] = 9 -CHANNEL_VOLUME: Final[int] = 4 -CHANNEL_ARPEGGIO: Final[int] = 7 -CHANNEL_DUTY_CYCLE: Final[int] = 1 -CHANNEL_LONG_MODE: Final[int] = 0 -DUTY_CYCLE: Final[int] = 2 - - -def _reconstruction( - generator_name: GeneratorName, - instructions: Sequence[InstructionUnion], - held_features: Iterable[FeatureKey], -) -> Reconstruction: - """A one-channel reconstruction whose instrument leaves ``held_features`` to the channel.""" - 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)}, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) - reconstruction.update_generator_data( - generator_name, - list(instructions), - np.ones(AUDIO_LENGTH, dtype=np.float32), - reconstruction.initial_pitches[generator_name], - held_features, - ) - return reconstruction - - -def _voice( - generator_name: GeneratorName, - instructions: Sequence[InstructionUnion], - held_features: Iterable[FeatureKey], -) -> SampleVoice: - return SampleVoice.read(_reconstruction(generator_name, instructions, held_features), generator_name) - - -def _channel_values() -> Dict[FeatureKey, int]: - return CHANNEL_FEATURE_DEFAULTS.copy() - - -class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite): - """An instrument writing every dimension sounds its frames exactly as it holds them.""" - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - generator_name: GeneratorName - instructions: List[InstructionUnion] - - test_cases = ( - TestCase( - label="pulse", - generator_name=GeneratorName.PULSE1, - instructions=[ - PulseInstruction( - on=True, - pitch=REFERENCE_PITCH, - volume=SAMPLE_VOLUME, - duty_cycle=DUTY_CYCLE, - ) - ], - ), - TestCase( - label="triangle", - generator_name=GeneratorName.TRIANGLE, - instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], - ), - TestCase( - label="noise", - generator_name=GeneratorName.NOISE, - instructions=[ - NoiseInstruction( - on=True, - period=REFERENCE_PERIOD, - volume=SAMPLE_VOLUME, - short=True, - ) - ], - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - 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, ()) - - assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0] - - @pytest.mark.parametrize( - "test_case", - test_cases, - 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, ()) - values = _channel_values() - - voice.sound(test_case.instructions[0], values) - - assert values[FeatureKey.ARPEGGIO] == 0 - assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME) - - -class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite): - """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds. - - Each channel offers its own dimensions and spells them in its own terms — an arpeggio is a - pitch on pulse and triangle and a period on noise, and a duty cycle is a waveform on pulse and - the noise mode on noise — so every dimension a channel offers is held here in the terms that - channel reads it in. - """ - - _INSTRUCTION = PulseInstruction( - on=True, - pitch=REFERENCE_PITCH, - volume=SAMPLE_VOLUME, - duty_cycle=DUTY_CYCLE, - ) - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - generator_name: GeneratorName - instruction: InstructionUnion - held_feature: FeatureKey - channel_value: int - expected: InstructionUnion - - test_cases = ( - TestCase( - label="pulse volume", - generator_name=GeneratorName.PULSE1, - instruction=_INSTRUCTION, - held_feature=FeatureKey.VOLUME, - channel_value=CHANNEL_VOLUME, - expected=PulseInstruction( - on=True, - pitch=REFERENCE_PITCH, - volume=CHANNEL_VOLUME, - duty_cycle=DUTY_CYCLE, - ), - ), - TestCase( - label="pulse arpeggio", - generator_name=GeneratorName.PULSE1, - instruction=_INSTRUCTION, - held_feature=FeatureKey.ARPEGGIO, - channel_value=CHANNEL_ARPEGGIO, - expected=PulseInstruction( - on=True, - pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO, - volume=SAMPLE_VOLUME, - duty_cycle=DUTY_CYCLE, - ), - ), - TestCase( - label="pulse duty cycle", - generator_name=GeneratorName.PULSE1, - instruction=_INSTRUCTION, - held_feature=FeatureKey.DUTY_CYCLE, - channel_value=CHANNEL_DUTY_CYCLE, - expected=PulseInstruction( - on=True, - pitch=REFERENCE_PITCH, - volume=SAMPLE_VOLUME, - duty_cycle=CHANNEL_DUTY_CYCLE, - ), - ), - TestCase( - label="triangle arpeggio", - generator_name=GeneratorName.TRIANGLE, - instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH), - held_feature=FeatureKey.ARPEGGIO, - channel_value=CHANNEL_ARPEGGIO, - expected=TriangleInstruction(on=True, pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO), - ), - TestCase( - label="noise period", - generator_name=GeneratorName.NOISE, - instruction=NoiseInstruction( - on=True, - period=REFERENCE_PERIOD, - volume=SAMPLE_VOLUME, - short=True, - ), - held_feature=FeatureKey.ARPEGGIO, - channel_value=CHANNEL_ARPEGGIO, - expected=NoiseInstruction( - on=True, - period=REFERENCE_PERIOD + CHANNEL_ARPEGGIO, - volume=SAMPLE_VOLUME, - short=True, - ), - ), - TestCase( - label="noise mode", - generator_name=GeneratorName.NOISE, - instruction=NoiseInstruction( - on=True, - period=REFERENCE_PERIOD, - volume=SAMPLE_VOLUME, - short=True, - ), - held_feature=FeatureKey.DUTY_CYCLE, - channel_value=CHANNEL_LONG_MODE, - expected=NoiseInstruction( - on=True, - period=REFERENCE_PERIOD, - volume=SAMPLE_VOLUME, - short=False, - ), - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - 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,)) - values = _channel_values() - values[test_case.held_feature] = test_case.channel_value - - assert voice.sound(test_case.instruction, values) == test_case.expected - - @pytest.mark.parametrize( - "test_case", - test_cases, - 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,)) - values = _channel_values() - values[test_case.held_feature] = test_case.channel_value - - voice.sound(test_case.instruction, values) - - assert values[test_case.held_feature] == test_case.channel_value - - def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None: - """The channel carries a value across samples, which is what makes an empty envelope mean this.""" - writes = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], ()) - holds = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) - values = _channel_values() - - writes.sound(self._INSTRUCTION, values) - - assert holds.sound(self._INSTRUCTION, values).volume == SAMPLE_VOLUME - - def test_an_instrument_holding_its_level_sounds_a_silent_frame(self) -> None: - """Silence is stated by a volume envelope, so an instrument leaving one out plays on.""" - rest = PulseInstruction.null_instruction() - voice = _voice(GeneratorName.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], ()) - values = _channel_values() - - assert voice.sound(rest, values).on is False - assert values[FeatureKey.VOLUME] == 0 - - 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], ()) - values = _channel_values() - values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE - - voice.sound(rest, values) - - assert values[FeatureKey.DUTY_CYCLE] == DUTY_CYCLE diff --git a/tests/unit/sampletones_application/logic/sequencer/test_channels.py b/tests/unit/sampletones_application/logic/sequencer/test_channels.py index 965284fac..00ebc225b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_channels.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_channels.py @@ -4,29 +4,28 @@ import pytest from sampletones_application.logic.sequencer.channels import ( - ALL_CHANNELS, SequencerChannelsLogic, ) from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ALL_CHANNELS, 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 +48,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..8f71c81e7 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import Callable, List, Tuple from unittest.mock import MagicMock import pytest @@ -9,8 +9,9 @@ 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.logic.shared.renders import RenderCache from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, @@ -22,11 +23,13 @@ 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.patterns.pitch import Step +from sampletones_core.project.voices.creation import new_instrument +from sampletones_core.utils.display import display_id +from tests.conftest import RENDER_BUDGET +from tests.suite.sequencer import UNKNOWN_SAMPLE_ID, sample_reconstruction Pair = Tuple[str, HistoryDetailRole] @@ -37,13 +40,14 @@ def _controller() -> ProjectController: def _formatter(controller: ProjectController) -> SequencerHistoryDetail: tracker_logic = SequencerTrackerLogic(controller) - samples_logic = SequencerSamplesLogic( + voices_logic = SequencerVoicesLogic( controller, MagicMock(), MagicMock(), scheduling=MagicMock(), + renders=RenderCache(budget_bytes=RENDER_BUDGET), ) - return SequencerHistoryDetail(tracker_logic, samples_logic) + return SequencerHistoryDetail(tracker_logic, voices_logic) def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]: @@ -53,11 +57,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 +74,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 +93,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, Step(value=5), None) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -99,6 +103,25 @@ def test_edit_row_transpose_shows_subcolumn_and_value(self) -> None: ("+05", HistoryDetailRole.TRANSPOSE), ] + def test_a_volume_in_the_sample_column_names_the_channels_still_playing_the_sample(self) -> None: + controller = _controller() + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + SequencerTrackerLogic(controller).place_note(0, None, sample.id) + formatter = _formatter(controller) + + segments = formatter.edit_row(2, None, None, None, 8) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("PT", HistoryDetailRole.CHANNEL), + ("02", HistoryDetailRole.ROW), + ("v", HistoryDetailRole.VOLUME), + ("8", HistoryDetailRole.VOLUME), + ] + def test_note_off_sample_column_lists_every_channel(self) -> None: controller = _controller() formatter = _formatter(controller) @@ -115,7 +138,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 +154,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 +172,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 +186,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 +202,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 +224,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 +266,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 +288,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 +320,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,71 +328,83 @@ 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()) + def test_rename_sample_shows_the_voice_then_its_new_name(self) -> None: + 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")) == [ + ("00:", HistoryDetailRole.SAMPLE), + ("Bass", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), - ("Kick", HistoryDetailRole.NAME), + ("Kick", HistoryDetailRole.SAMPLE), ] - def test_move_sample_shows_source_position_and_destination(self) -> None: + def test_move_sample_shows_the_voice_then_its_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)) == [ - ("00", HistoryDetailRole.SAMPLE), + assert _pairs(formatter.move_voice(sample.id, 5)) == [ + ("00:", HistoryDetailRole.SAMPLE), + ("Bass", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), ("05", HistoryDetailRole.VALUE), ] - def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None: + @pytest.mark.parametrize( + "line", + [ + lambda formatter, voice_id: formatter.remove_voice(voice_id), + lambda formatter, voice_id: formatter.replace_sample(voice_id, "Kick"), + lambda formatter, voice_id: formatter.rename_voice(voice_id, "Kick"), + lambda formatter, voice_id: formatter.move_voice(voice_id, 0), + lambda formatter, voice_id: formatter.duplicate_voice(voice_id), + ], + ids=["remove", "replace", "rename", "move", "duplicate"], + ) + def test_every_pool_line_opens_with_the_voice_position_and_name( + self, + line: Callable[[SequencerHistoryDetail, str], Tuple[HistoryDetailSegment, ...]], + ) -> None: + """A reader finds the voice in the list whichever gesture the line records.""" controller = _controller() - sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") + controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Lead") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) - on_segments = formatter.set_sample_loop(sample.id, True) - off_segments = formatter.set_sample_loop(sample.id, False) - - 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, - ) + assert _pairs(line(formatter, sample.id))[:2] == [ + ("01:", HistoryDetailRole.SAMPLE), + ("Bass", HistoryDetailRole.SAMPLE), + ] def test_value_wraps_a_number(self) -> None: formatter = _formatter(_controller()) @@ -377,13 +412,82 @@ def test_value_wraps_a_number(self) -> None: 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() + controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") + instrument = controller.add_instrument(new_instrument("Pad")) + formatter = _formatter(controller) + + assert _pairs(formatter.remove_voice(instrument.id)) == [ + ("01:", HistoryDetailRole.INSTRUMENT), + ("Pad", HistoryDetailRole.INSTRUMENT), + ] + + 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) + + assert _pairs(formatter.rename_voice(instrument.id, "Strings")) == [ + ("00:", HistoryDetailRole.INSTRUMENT), + ("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()) + + 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 +495,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 +544,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..b330986ef --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -0,0 +1,652 @@ +from pathlib import Path +from typing import Callable, Dict, List, 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.logic.shared.renders import RenderCache +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_core.reconstructions.reconstruction.renders import rendered_mix +from sampletones_shared.exceptions import LoadInstrumentError, NoOutputDeviceError +from tests.conftest import RENDER_BUDGET +from tests.suite.sequencer import sample_reconstruction + + +def _logic() -> Tuple[ProjectController, SequencerVoicesLogic]: + controller = ProjectController(ProjectManager()) + logic = SequencerVoicesLogic( + controller, + MagicMock(), + MagicMock(), + scheduling=MagicMock(), + renders=RenderCache(budget_bytes=RENDER_BUDGET), + ) + 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(), + renders=RenderCache(budget_bytes=RENDER_BUDGET), + ) + 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], rendered_mix(sample.reconstruction)) + 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() + + def test_a_device_refusing_the_preview_reports_the_refusal( + self, reconstruction_factory: Callable[[], Reconstruction] + ) -> None: + controller, logic, _, audio_device_manager = _logic_with_mocks() + sample = controller.add_sample(reconstruction_factory(), name="lead") + refusal = NoOutputDeviceError("no device") + audio_device_manager.play.side_effect = refusal + reported: List[Exception] = [] + logic.on_autoplay_error = reported.append + + logic.play_voice(sample.id) + + assert reported == [refusal] + + +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: + """An instrument sits in the same list as a sample, 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..7089f166b 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,34 +32,34 @@ class Grid: controller: ProjectController logic: SequencerTrackerLogic adjuster: TrackerRegionAdjuster - 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 of the channels. - Which channels a sample governs is what the sample column fans a shift out over, so a governed - row and an ungoverned one both stand available to a case. + Which channels a sample plays on is what the sample column fans a shift out over, so a row + placing it, a row below it and a row where nothing plays all stand available to a case. """ 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", ) 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,12 +118,12 @@ 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=( - ".. +0E . | .. ... . | .. ... . | .. ... .", + ".. +14 . | .. ... . | .. ... . | .. ... .", EMPTY, EMPTY, ), @@ -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, ), @@ -154,23 +157,38 @@ class TestCase(BaseRegularTestCase): ), ), TestCase( - label="an ungoverned sample column reaches every channel", + label="a sample column where no sample plays moves nothing", region=_region( - (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOICE), (None, SubColumn.VOLUME), ), delta=3, + expected=(EMPTY, EMPTY, EMPTY), + ), + TestCase( + label="a sample column below a sample reaches the channels it still plays on", + frame=( + f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .", + ".. ... . | ~~ ... . | .. ... . | .. ... .", + ), + region=_region( + (None, SubColumn.VOICE), + (None, SubColumn.VOLUME), + first_row=2, + last_row=2, + ), + delta=3, expected=( - ".. +03 . | .. +03 . | .. +03 . | .. +03 .", - EMPTY, - EMPTY, + "00 ... . | 00 ... . | .. ... . | .. ... .", + ".. ... . | ~~ ... . | .. ... . | .. ... .", + ".. +03 . | .. ... . | .. ... . | .. ... .", ), ), TestCase( 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 +202,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 +214,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 +238,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 +259,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 +273,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 +287,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 +301,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 +323,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_context.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_context.py new file mode 100644 index 000000000..33a2b1140 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_context.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.logic.sequencer.tracker.context import ( + ContextRows, + FrameRows, + rows_after, + rows_before, +) +from sampletones_application.view_model.sequencer.tracker import SequencerRowViewModel +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +Position = Tuple[int, int] + + +def _frames_of(lengths: Tuple[int, ...]) -> FrameRows: + """Reads a song whose frames hold ``lengths`` rows each, every row blank.""" + + def read(frame_index: int) -> Tuple[SequencerRowViewModel, ...]: + return tuple( + SequencerRowViewModel( + index=index, + cells={}, + sample_channels=frozenset(), + carried_channels=frozenset(), + ) + for index in range(lengths[frame_index]) + ) + + return read + + +def _positions(rows: ContextRows) -> Tuple[Position, ...]: + """Where each gathered row stands in the song: its frame, then its row in that frame.""" + return tuple((row.frame_index, row.row.index) for row in rows) + + +class TestTheRowsBeforeAFrame(BaseTestSuite): + """The rows leading into a frame are the song's own, gathered back across as many frames as it takes.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + lengths: Tuple[int, ...] + frame_index: int + reach: int + expected: Tuple[Position, ...] + + test_cases = ( + TestCase( + label="the previous frame's last rows", + lengths=(4, 4, 4), + frame_index=1, + reach=2, + expected=((0, 2), (0, 3)), + ), + TestCase( + label="nothing before the first frame", + lengths=(4, 4), + frame_index=0, + reach=2, + expected=(), + ), + TestCase( + label="a short frame gives all of its rows and the one before it the rest", + lengths=(4, 2, 2), + frame_index=2, + reach=3, + expected=((0, 3), (1, 0), (1, 1)), + ), + TestCase( + label="the song's start ends the walk", + lengths=(2, 4), + frame_index=1, + reach=3, + expected=((0, 0), (0, 1)), + ), + TestCase( + label="frames of different lengths", + lengths=(3, 5, 4), + frame_index=2, + reach=6, + expected=((0, 2), (1, 0), (1, 1), (1, 2), (1, 3), (1, 4)), + ), + TestCase( + label="no reach", + lengths=(4, 4), + frame_index=1, + reach=0, + expected=(), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rows_gathered(self, test_case: TestCase) -> None: + rows = rows_before(test_case.frame_index, test_case.reach, _frames_of(test_case.lengths)) + + assert _positions(rows) == test_case.expected + + +class TestTheRowsAfterAFrame(BaseTestSuite): + """The rows following a frame are the song's own, gathered on across as many frames as it takes.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + lengths: Tuple[int, ...] + frame_index: int + reach: int + expected: Tuple[Position, ...] + + test_cases = ( + TestCase( + label="the next frame's first rows", + lengths=(4, 4, 4), + frame_index=1, + reach=2, + expected=((2, 0), (2, 1)), + ), + TestCase( + label="nothing after the last frame", + lengths=(4, 4), + frame_index=1, + reach=2, + expected=(), + ), + TestCase( + label="a short frame gives all of its rows and the one after it the rest", + lengths=(4, 2, 2), + frame_index=0, + reach=3, + expected=((1, 0), (1, 1), (2, 0)), + ), + TestCase( + label="the song's end ends the walk", + lengths=(4, 2), + frame_index=0, + reach=3, + expected=((1, 0), (1, 1)), + ), + TestCase( + label="no reach", + lengths=(4, 4), + frame_index=0, + reach=0, + expected=(), + ), + TestCase( + label="an order holding no frame", + lengths=(), + frame_index=0, + reach=2, + expected=(), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rows_gathered(self, test_case: TestCase) -> None: + rows = rows_after( + test_case.frame_index, + len(test_case.lengths), + test_case.reach, + _frames_of(test_case.lengths), + ) + + assert _positions(rows) == test_case.expected 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..513ac2676 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_pitch_faces.py @@ -0,0 +1,156 @@ +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.patterns.pitch import Note, RowPitch, Step +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 sampletones_shared.constants.symbols import MIXED +from tests.suite.sequencer import sample_reconstruction + +ROOT_PITCH: Final[int] = 60 +ROOT_PERIOD: Final[int] = 5 +STEP: Final[int] = 4 +NOTE: Final[int] = 64 +PERIOD: Final[int] = 9 +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, + pitch: Optional[RowPitch] = None, +) -> None: + pattern_index = controller.project.song.order[0][channel] + controller.set_row( + channel, + pattern_index, + row_index, + command=command, + pitch=pitch, + ) + + +def _pitch_cell(logic: SequencerTrackerLogic, channel: ChannelName, row_index: int) -> str: + return logic.build_grid().rows[row_index].cells[channel].transpose + + +class TestACellReadsTheFaceItWasWrittenIn: + def test_a_step_on_a_sample_reads_as_the_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), pitch=Step(value=STEP)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(STEP) + + def test_a_note_on_a_sample_reads_as_the_note(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), pitch=Note(value=NOTE)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == pitch_to_name(NOTE) + + def test_a_step_on_an_instrument_reads_as_the_step(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), pitch=Step(value=STEP)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == display_transpose(STEP) + + def test_a_note_on_an_instrument_reads_as_the_note(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), pitch=Note(value=NOTE)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 0) == pitch_to_name(NOTE) + + def test_a_note_on_noise_names_its_period(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.NOISE, 0, command=NoteOn(voice_id=instrument.id), pitch=Note(value=PERIOD)) + + assert _pitch_cell(logic, ChannelName.NOISE, 0) == period_to_name(PERIOD) + + 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), pitch=Note(value=NOTE)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 1) == NOTE_BLANK + + +class TestTheFaceStandsWhereverTheRowStands: + def test_a_bend_below_an_instrument_reads_what_it_stores(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), pitch=Note(value=NOTE)) + _write(controller, ChannelName.PULSE1, 1, pitch=Step(value=BEND)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 1) == display_transpose(BEND) + + def test_a_bend_below_a_sample_reads_what_it_stores(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), pitch=Step(value=0)) + _write(controller, ChannelName.PULSE1, 1, pitch=Note(value=NOTE)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 1) == pitch_to_name(NOTE) + + def test_a_row_past_a_note_off_reads_what_it_stores(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=instrument.id), pitch=Note(value=NOTE)) + _write(controller, ChannelName.PULSE1, 1, command=NoteOff()) + _write(controller, ChannelName.PULSE1, 2, pitch=Note(value=NOTE)) + + assert _pitch_cell(logic, ChannelName.PULSE1, 2) == pitch_to_name(NOTE) + + def test_a_frame_naming_no_voice_reads_what_it_stores(self) -> None: + controller, logic = _logic() + _write(controller, ChannelName.PULSE1, 0, pitch=Step(value=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() + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]), name="bass") + _write(controller, ChannelName.PULSE1, 0, command=NoteOn(voice_id=sample.id), pitch=Step(value=0)) + _write(controller, ChannelName.PULSE2, 0, command=NoteOn(voice_id=sample.id), pitch=Note(value=ROOT_PITCH)) + + assert logic.build_grid().rows[0].transpose == MIXED 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..c991acd40 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,9 @@ 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.patterns.pitch import Step +from sampletones_core.project.voices.note_off import NoteOff from tests.suite.sequencer import sample_reconstruction @@ -42,17 +43,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 +63,7 @@ def _cell( def _column( - generator: Optional[GeneratorName], + channel: Optional[ChannelName], *, last_row: int = 0, ) -> TrackerRegion: @@ -70,8 +71,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,16 +86,16 @@ 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, pitch=Step(value=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.transposes[_key(SubColumn.TRANSPOSE)] == 5 + assert block.notes[_key(SubColumn.VOICE)] == sample.id + assert block.pitches[_key(SubColumn.TRANSPOSE)] == Step(value=5) assert block.volumes[_key(SubColumn.VOLUME)] == 3 def test_an_empty_cell_carries_its_emptiness( @@ -102,10 +103,10 @@ 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.transposes[_key(SubColumn.TRANSPOSE)] is None + assert block.notes[_key(SubColumn.VOICE)] is None + assert block.pitches[_key(SubColumn.TRANSPOSE)] is None assert block.volumes[_key(SubColumn.VOLUME)] is None def test_a_cut_cell_carries_the_cut( @@ -113,11 +114,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,11 +126,11 @@ 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, pitch=Step(value=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 + assert block.pitches[_key(SubColumn.TRANSPOSE)] == Step(value=0) def test_rows_past_the_pattern_read_empty( self, @@ -138,9 +139,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,16 +158,16 @@ 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) - logic.set_cell_subcolumn(0, None, transpose=7) + logic.set_cell_subcolumn(0, None, pitch=Step(value=7)) block = reader.read(_column(None)) - assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id - assert block.transposes[_key(SubColumn.TRANSPOSE)] == 7 + assert block.notes[_key(SubColumn.VOICE)] == sample.id + assert block.pitches[_key(SubColumn.TRANSPOSE)] == Step(value=7) def test_a_note_carries_as_the_sample_it_names( self, @@ -176,37 +177,91 @@ 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, + controller: ProjectController, logic: SequencerTrackerLogic, 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) + """The sample spans two channels and only one of them holds a transpose.""" + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + logic.place_note(0, None, sample.id) + logic.set_cell_subcolumn(0, ChannelName.PULSE1, pitch=Step(value=5)) block = reader.read(_cell(0, None, SubColumn.TRANSPOSE)) - assert _key(SubColumn.TRANSPOSE) not in block.transposes + assert _key(SubColumn.TRANSPOSE) not in block.pitches + + def test_a_row_where_no_sample_plays_carries_its_offsets_as_empty( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """The column's pitch and volume reach no channel there, so they read empty, as the grid + shows them, whatever the channel columns hold.""" + logic.set_cell_subcolumn(0, ChannelName.PULSE1, pitch=Step(value=5), volume=3) + + block = reader.read(_column(None)) + + assert block.pitches[_key(SubColumn.TRANSPOSE)] is None + assert block.volumes[_key(SubColumn.VOLUME)] is None + + def test_offsets_below_a_sample_read_the_channels_it_still_plays_on( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """The triangle was cut since the sample began, so its own volume stays out of the reading.""" + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + logic.place_note(0, None, sample.id) + logic.cut_note(1, ChannelName.TRIANGLE) + logic.set_cell_subcolumn(2, ChannelName.PULSE1, volume=4) + logic.set_cell_subcolumn(2, ChannelName.TRIANGLE, volume=9) + + block = reader.read(_cell(2, None, SubColumn.VOLUME)) + + assert block.volumes[_key(SubColumn.VOLUME)] == 4 + + def test_a_cut_row_copied_through_the_sample_column_keeps_its_cut( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """No sample plays on a row cut on every channel, and its voice slot still speaks for all four.""" + logic.cut_note(0, None) + + block = reader.read(_column(None)) + + assert block.notes[_key(SubColumn.VOICE)] == NoteOff() + assert block.pitches[_key(SubColumn.TRANSPOSE)] is None + assert block.volumes[_key(SubColumn.VOLUME)] is None def test_a_half_cut_row_leaves_its_note_out( self, 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 +270,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,8 +281,8 @@ 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.transposes[_key(SubColumn.TRANSPOSE)] is None + assert block.notes[_key(SubColumn.VOICE)] is None + assert block.pitches[_key(SubColumn.TRANSPOSE)] is None assert block.volumes[_key(SubColumn.VOLUME)] is None @@ -236,23 +291,29 @@ class TestOffsets: def test_a_mixed_edge_column_leaves_only_itself_out( self, + controller: ProjectController, logic: SequencerTrackerLogic, 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) + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.PULSE2]), + name="chord", + ) + logic.place_note(0, None, sample.id) + 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.transposes) == {_key(SubColumn.TRANSPOSE)} + assert set(block.notes) == {_key(SubColumn.VOICE)} + assert set(block.pitches) == {_key(SubColumn.TRANSPOSE)} assert _key(SubColumn.VOLUME) not in block.volumes def test_the_offsets_are_measured_from_the_column_the_block_begins_in( @@ -268,11 +329,11 @@ 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), ) ) - assert set(block.transposes) == {(0, 1)} + assert set(block.pitches) == {(0, 1)} assert set(block.volumes) == {(0, 2)} assert set(block.notes) == {(0, 3)} 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..2c5bce67c 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -1,14 +1,42 @@ +from dataclasses import dataclass +from typing import Final, List, Tuple + +import pytest + 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_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_application.view_model.sequencer.tracker import SequencerTrackerViewModel +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.pitch import Note, Step 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, display_volume from sampletones_shared.constants.symbols import MIXED -from tests.suite.sequencer import sample_reconstruction +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import ( + UNKNOWN_SAMPLE, + UNKNOWN_SAMPLE_ID, + fill_frame, + render_frame, + render_slots, + sample_reconstruction, +) + +FRAME_ROWS: Final[int] = 4 +EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." +LEAD: Final[str] = "00" +BASS: Final[str] = "01" +PAD: Final[str] = "02" +CONTEXT_REACH: Final[int] = 2 +VOLUME_BEFORE: Final[int] = 5 +VOLUME_AFTER: Final[int] = 9 def _controller() -> ProjectController: @@ -17,25 +45,61 @@ 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), + ) + + +@dataclass(frozen=True, kw_only=True) +class Grid: + """A four-row frame with two samples and an instrument, the state a sample-column case starts from.""" + + controller: ProjectController + logic: SequencerTrackerLogic + voice_ids: Tuple[str, ...] + + +@pytest.fixture +def grid() -> Grid: + """A frame short enough for a case to state whole, beside a sample over the first pulse and the + triangle, one over the second pulse, and an instrument. + + Which channels a sample plays on is what the sample column's pitch and volume follow, and the + instrument is what takes a channel away from a sample playing above it. + """ + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_rows_per_pattern(FRAME_ROWS) + lead = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + bass = controller.add_sample( + sample_reconstruction([ChannelName.PULSE2]), + name="bass", + ) + pad = controller.add_instrument(new_instrument("pad")) + return Grid( + controller=controller, + logic=logic, + voice_ids=(lead.id, bass.id, pad.id), ) @@ -43,70 +107,71 @@ 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, pitch=Step(value=5)) + logic.set_row(ChannelName.PULSE2, 0, pitch=Step(value=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).pitch is None + assert _row(controller, ChannelName.PULSE2).pitch == Step(value=7) def test_the_sample_column_clears_every_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - logic.set_sample_subcolumn(0, transpose=5) + for channel in ChannelName.items(): + logic.set_row(channel, 0, pitch=Step(value=5)) 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).pitch 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, pitch=Step(value=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) - assert row.transpose is None + row = _row(controller, ChannelName.PULSE1) + assert row.pitch 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, pitch=Step(value=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).pitch 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).pitch == Step(value=5) class TestWriteCell: @@ -114,53 +179,72 @@ 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: + def test_a_voice_and_a_pitch_land_together_in_a_channel_cell(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="lead") - logic.write_cell(0, None, None, 5, None) + logic.write_cell(0, ChannelName.PULSE1, sample.id, Note(value=60), None) - for generator in GeneratorName.items(): - assert _row(controller, generator).transpose == 5 + row = _row(controller, ChannelName.PULSE1) + assert row.command == NoteOn(voice_id=sample.id) + assert row.pitch == Note(value=60) + + def test_a_sample_and_a_pitch_in_the_sample_column_reach_the_channels_the_sample_spreads_over(self) -> None: + """The sample is placed first, so the pitch finds the channels it has just covered.""" + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.TRIANGLE]), + name="lead", + ) + + logic.write_cell(0, None, sample.id, Note(value=60), None) + + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + assert _row(controller, channel).command == NoteOn(voice_id=sample.id) + assert _row(controller, channel).pitch == Note(value=60) + + for channel in (ChannelName.PULSE2, ChannelName.NOISE): + assert _row(controller, channel).pitch is None 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, pitch=Step(value=5)) - logic.write_cell(0, GeneratorName.PULSE1, None, None, 10) + logic.write_cell(0, ChannelName.PULSE1, None, None, 10) - row = _row(controller, GeneratorName.PULSE1) - assert row.transpose == 5 + row = _row(controller, ChannelName.PULSE1) + assert row.pitch == Step(value=5) assert row.volume == 10 def test_an_edit_carrying_no_value_leaves_the_frame_alone(self) -> None: @@ -170,9 +254,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 +264,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 +275,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: @@ -218,16 +302,106 @@ def test_an_order_without_frames_counts_nothing(self) -> None: assert logic.frame_row_count() == 0 +@dataclass(frozen=True, kw_only=True) +class ThreeFrames: + """A song of three short frames with the middle one shown.""" + + controller: ProjectController + logic: SequencerTrackerLogic + + +@pytest.fixture +def three_frames() -> ThreeFrames: + """Three frames of :data:`FRAME_ROWS` rows, a volume on the first frame's last row and on the last + frame's first row, so the rows standing either side of the middle frame tell themselves apart. + """ + controller = _controller() + controller.set_rows_per_pattern(FRAME_ROWS) + controller.append_frame() + controller.append_frame() + logic = SequencerTrackerLogic(controller) + logic.set_cell_subcolumn(FRAME_ROWS - 1, ChannelName.PULSE1, volume=VOLUME_BEFORE) + logic.select_frame(2) + logic.set_cell_subcolumn(0, ChannelName.PULSE1, volume=VOLUME_AFTER) + logic.select_frame(1) + return ThreeFrames(controller=controller, logic=logic) + + +class TestTheSongAroundTheFrame: + """The grid carries the rows the song plays either side of the shown frame, as far as it reaches.""" + + def test_the_grid_carries_the_rows_either_side(self, three_frames: ThreeFrames) -> None: + three_frames.logic.set_reach(CONTEXT_REACH) + + grid = three_frames.logic.build_grid() + + assert [(row.frame_index, row.row.index) for row in grid.lead] == [(0, 2), (0, 3)] + assert [(row.frame_index, row.row.index) for row in grid.trail] == [(2, 0), (2, 1)] + + def test_a_neighboring_row_reads_as_it_does_in_its_own_frame(self, three_frames: ThreeFrames) -> None: + three_frames.logic.set_reach(CONTEXT_REACH) + + grid = three_frames.logic.build_grid() + + assert grid.lead[-1].row == three_frames.logic.frame_rows(0)[FRAME_ROWS - 1] + assert grid.trail[0].row == three_frames.logic.frame_rows(2)[0] + assert grid.lead[-1].row.cells[ChannelName.PULSE1].volume == display_volume(VOLUME_BEFORE) + assert grid.trail[0].row.cells[ChannelName.PULSE1].volume == display_volume(VOLUME_AFTER) + + def test_without_a_reach_the_grid_holds_the_frame_alone(self, three_frames: ThreeFrames) -> None: + grid = three_frames.logic.build_grid() + + assert grid.lead == () + assert grid.trail == () + assert len(grid.rows) == FRAME_ROWS + + def test_a_new_reach_builds_the_grid_and_the_same_one_builds_nothing(self, three_frames: ThreeFrames) -> None: + pushed: List[SequencerTrackerViewModel] = [] + three_frames.logic.on_tracker_changed = pushed.append + + three_frames.logic.set_reach(CONTEXT_REACH) + three_frames.logic.set_reach(CONTEXT_REACH) + + assert len(pushed) == 1 + assert len(pushed[0].lead) == CONTEXT_REACH + + +class TestSelectingAFrame: + """A frame is built when it is shown, and the frame already shown is only reported again.""" + + def test_the_shown_frame_is_reported_and_builds_nothing(self, three_frames: ThreeFrames) -> None: + pushed: List[SequencerTrackerViewModel] = [] + reported: List[int] = [] + three_frames.logic.on_tracker_changed = pushed.append + three_frames.logic.on_frame_changed = reported.append + + three_frames.logic.select_frame(1) + + assert pushed == [] + assert reported == [1] + + def test_another_frame_is_built_and_reported(self, three_frames: ThreeFrames) -> None: + pushed: List[SequencerTrackerViewModel] = [] + reported: List[int] = [] + three_frames.logic.on_tracker_changed = pushed.append + three_frames.logic.on_frame_changed = reported.append + + three_frames.logic.select_frame(2) + + assert [grid.frame_index for grid in pushed] == [2] + assert reported == [2] + + 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, pitch=Step(value=5)) - row = logic.row(GeneratorName.PULSE1, 0) + row = logic.row(ChannelName.PULSE1, 0) assert row is not None - assert row.transpose == 5 + assert row.pitch == Step(value=5) def test_a_channel_without_a_pattern_has_no_row(self) -> None: controller = _controller() @@ -235,33 +409,36 @@ 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: + """Nothing plays there either, so a pitch or a volume reaches no channel while the voice + slot still reads every one of them.""" 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) == [] + assert logic.note_channels(0) == ChannelName.items() class TestSetNoteOff: @@ -269,10 +446,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 +459,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 +468,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,77 +513,63 @@ 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, pitch=Step(value=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.pitch == Step(value=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.pitch == Step(value=5) assert synced.volume == 10 - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - row = _row(controller, generator) - assert row.transpose is None + for channel in (ChannelName.PULSE2, ChannelName.NOISE): + row = _row(controller, channel) + assert row.pitch is None assert row.volume is None - def test_synchronises_across_all_channels_when_no_sample_is_referenced( - self, - ) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - - logic.set_sample_subcolumn(0, transpose=5, volume=10) - - for generator in GeneratorName.items(): - row = _row(controller, generator) - assert row.command is None - assert row.transpose == 5 - assert row.volume == 10 - 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_sample_subcolumn(0, transpose=5) + logic.set_row_sample(0, sample.id) + logic.set_sample_subcolumn(0, pitch=Step(value=5)) logic.set_sample_subcolumn(0, volume=10) - logic.clear_sample_subcolumn(0, transpose=True) + logic.clear_sample_subcolumn(0, pitch=True) - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - row = _row(controller, generator) - assert row.transpose is None + for channel in (ChannelName.PULSE1, ChannelName.TRIANGLE): + row = _row(controller, channel) + assert row.pitch is None assert row.volume == 10 assert row.command is not None @@ -420,40 +579,40 @@ 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).pitch == Step(value=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).pitch == Step(value=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, pitch=Step(value=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).pitch == Step(value=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.pitch == Step(value=2) assert row.volume == MAX_VOLUME - 1 @@ -462,26 +621,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 +648,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, pitch=Step(value=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_sample_subcolumn(0, transpose=5) + logic.set_row_sample(0, sample.id) + logic.set_sample_subcolumn(0, pitch=Step(value=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 +711,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, pitch=Step(value=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).pitch == Step(value=5) + assert song.order[1][ChannelName.PULSE2] is None def test_empty_frame_still_shows_editable_rows(self) -> None: controller = _controller() @@ -569,3 +728,292 @@ 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 + + +class TestTheSampleColumnFollowsTheSampleInForce: + """A pitch or a volume typed in the sample column reaches the channels a sample is playing on.""" + + def test_a_volume_below_a_placed_sample_reaches_the_channels_it_plays(self, grid: Grid) -> None: + fill_frame( + grid.logic, + (f"{LEAD} ... . | .. ... . | {LEAD} ... . | .. ... .",), + voice_ids=grid.voice_ids, + ) + + grid.logic.write_cell(2, None, None, None, 10) + + assert render_frame(grid.logic) == ( + "00 ... . | .. ... . | 00 ... . | .. ... .", + EMPTY, + ".. ... A | .. ... . | .. ... A | .. ... .", + EMPTY, + ) + + def test_a_channel_cut_since_the_sample_began_takes_nothing(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ( + f"{LEAD} ... . | .. ... . | {LEAD} ... . | .. ... .", + ".. ... . | .. ... . | ~~ ... . | .. ... .", + ), + voice_ids=grid.voice_ids, + ) + + grid.logic.write_cell(2, None, None, Step(value=3), None) + + assert render_frame(grid.logic)[2] == ".. +03 . | .. ... . | .. ... . | .. ... ." + + def test_a_channel_now_carrying_an_instrument_takes_nothing(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ( + f"{LEAD} ... . | .. ... . | {LEAD} ... . | .. ... .", + f".. ... . | .. ... . | {PAD} ... . | .. ... .", + ), + voice_ids=grid.voice_ids, + ) + + grid.logic.write_cell(2, None, None, None, 10) + + assert render_frame(grid.logic)[2] == ".. ... A | .. ... . | .. ... . | .. ... ." + + def test_a_row_where_no_sample_plays_writes_nothing_and_creates_no_pattern(self, grid: Grid) -> None: + position = grid.controller.project.song.order_length() + grid.controller.append_frame() + grid.logic.select_frame(position) + + grid.logic.write_cell(0, None, None, 5, None) + grid.logic.write_cell(0, None, None, None, 10) + + assert render_slots(grid.controller, position) == ".. .. .. .." + + def test_a_row_placing_a_sample_keeps_its_whole_span(self, grid: Grid) -> None: + """The sample the row names decides, so its empty triangle cell takes the value and the + second pulse, still playing a sample from above, keeps its own.""" + fill_frame( + grid.logic, + ( + f".. ... . | {BASS} ... . | .. ... . | .. ... .", + f"{LEAD} ... . | .. ... . | .. ... . | .. ... .", + ), + voice_ids=grid.voice_ids, + ) + + grid.logic.write_cell(1, None, None, None, 10) + + assert render_frame(grid.logic)[1] == "00 ... A | .. ... . | .. ... A | .. ... ." + + def test_the_sample_in_force_starts_over_at_each_frame(self, grid: Grid) -> None: + """The reading runs down one frame, so the next frame's first row carries no sample.""" + fill_frame( + grid.logic, + (f"{LEAD} ... . | .. ... . | {LEAD} ... . | .. ... .",), + voice_ids=grid.voice_ids, + ) + position = grid.controller.project.song.order_length() + grid.controller.append_frame() + grid.logic.select_frame(position) + fill_frame( + grid.logic, + (EMPTY, EMPTY, EMPTY, ".. ... 5 | .. ... . | .. ... 5 | .. ... ."), + voice_ids=grid.voice_ids, + ) + + grid.logic.write_cell(0, None, None, None, 10) + + assert render_frame(grid.logic)[0] == EMPTY + + +class TestTheSampleColumnReadsWhereItWrites(BaseTestSuite): + """The grid summarizes the sample column over the channels an edit there reaches. + + Each frame is read row by row, so the cell a reader types into and the cells that value lands + in stay one group whatever the frame holds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + frame: Tuple[str, ...] + + test_cases = ( + TestCase( + label="a sample playing down the frame", + frame=(f"{LEAD} ... . | .. ... . | {LEAD} ... . | .. ... .",), + ), + TestCase( + label="a channel cut and another taken by an instrument", + frame=( + f"{LEAD} ... . | {BASS} ... . | {LEAD} ... . | .. ... .", + ".. ... . | .. ... . | ~~ ... . | .. ... .", + f"{PAD} ... . | .. ... . | .. ... . | .. ... .", + ), + ), + TestCase( + label="a second sample placed below the first", + frame=( + f".. ... . | {BASS} ... . | .. ... . | .. ... .", + f"{LEAD} ... . | .. ... . | .. ... . | .. ... .", + ), + ), + TestCase( + label="nothing playing beside values in the channel columns", + frame=( + ".. +02 . | .. ... 5 | .. ... . | .. ... .", + f".. ... . | .. ... . | .. ... . | {PAD} ... .", + ), + ), + TestCase( + label="a sample the project no longer holds", + frame=(f"{UNKNOWN_SAMPLE} ... . | .. ... . | .. ... . | .. ... .",), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_pitch_and_a_volume_are_read_where_they_are_written( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids) + + rows = grid.logic.build_grid().rows + + for row in rows: + assert frozenset(grid.logic.relevant_channels(row.index)) == row.offset_channels + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_voice_is_read_where_it_is_written( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, voice_ids=grid.voice_ids) + + rows = grid.logic.build_grid().rows + + for row in rows: + assert frozenset(grid.logic.note_channels(row.index)) == row.note_channels diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_pitch.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_pitch.py new file mode 100644 index 000000000..f37a33e9b --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_write_pitch.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +import pytest + +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.constants.general import MAX_PERIOD, MIN_PLAYED_PITCH, NUM_PERIODS +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +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 tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import sample_reconstruction + +ROOT_PITCH: Final[int] = 60 +TYPED_NOTE: Final[int] = 67 +TYPED_STEP: Final[int] = -4 +TYPED_PERIOD: Final[int] = 9 + + +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 _pitch(logic: SequencerTrackerLogic, channel: ChannelName, row_index: int) -> Optional[RowPitch]: + row = logic.row(channel, row_index) + return row.pitch if row is not None else None + + +def _instrument(controller: ProjectController) -> Instrument: + return controller.add_instrument( + Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,))), + initial_pitch=ROOT_PITCH, + ) + ) + + +class TestATypedPitchIsStoredAsWritten(BaseTestSuite): + """A pitch lands in the face the reader typed, whichever voice the channel carries.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: RowPitch + pitch: RowPitch + channel: ChannelName = ChannelName.PULSE1 + + test_cases: Tuple["TestATypedPitchIsStoredAsWritten.TestCase", ...] = ( + TestCase(label="a note on an instrument", pitch=Note(value=TYPED_NOTE), expected=Note(value=TYPED_NOTE)), + TestCase(label="a step on an instrument", pitch=Step(value=TYPED_STEP), expected=Step(value=TYPED_STEP)), + TestCase( + label="a note below the channel's range is held at its lowest note", + pitch=Note(value=MAX_PERIOD + 1), + expected=Note(value=MIN_PLAYED_PITCH), + ), + TestCase( + label="a period on noise", + channel=ChannelName.NOISE, + pitch=Note(value=TYPED_PERIOD), + expected=Note(value=TYPED_PERIOD), + ), + TestCase( + label="a pitch on noise is held among the sixteen periods", + channel=ChannelName.NOISE, + pitch=Note(value=TYPED_NOTE), + expected=Note(value=MAX_PERIOD), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_pitch_the_row_holds(self, test_case: TestCase) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, test_case.channel, 0, NoteOn(voice_id=instrument.id)) + + logic.write_cell(0, test_case.channel, None, test_case.pitch, None) + + assert _pitch(logic, test_case.channel, 0) == test_case.expected + + +class TestThePitchLandsWhateverTheChannelCarries: + def test_a_sample_row_takes_a_note(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_cell(0, ChannelName.PULSE1, None, Note(value=TYPED_NOTE), None) + + assert _pitch(logic, ChannelName.PULSE1, 0) == Note(value=TYPED_NOTE) + + def test_a_row_below_the_note_takes_a_note(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) + + logic.write_cell(2, ChannelName.PULSE1, None, Note(value=TYPED_NOTE), None) + + assert _pitch(logic, ChannelName.PULSE1, 2) == Note(value=TYPED_NOTE) + + def test_a_row_carrying_no_voice_takes_a_note(self) -> None: + _, logic = _logic() + + logic.write_cell(0, ChannelName.PULSE1, None, Note(value=TYPED_NOTE), None) + + assert _pitch(logic, ChannelName.PULSE1, 0) == Note(value=TYPED_NOTE) + + def test_a_row_past_a_note_off_takes_a_note(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) + _write(controller, ChannelName.PULSE1, 1, NoteOff()) + + logic.write_cell(2, ChannelName.PULSE1, None, Note(value=TYPED_NOTE), None) + + assert _pitch(logic, ChannelName.PULSE1, 2) == Note(value=TYPED_NOTE) + + def test_the_voice_and_the_volume_stand(self) -> None: + controller, logic = _logic() + instrument = _instrument(controller) + _write(controller, ChannelName.PULSE1, 0, NoteOn(voice_id=instrument.id)) + logic.adjust_volume(ChannelName.PULSE1, 0, -1) + + logic.write_cell(0, ChannelName.PULSE1, None, Step(value=TYPED_STEP), None) + + row = logic.row(ChannelName.PULSE1, 0) + assert row is not None + assert row.command == NoteOn(voice_id=instrument.id) + assert row.volume is not None + + +class TestANoteWrittenThroughTheSampleColumn: + """The sample column hands a note to the channels playing the sample, each naming it its own way.""" + + def test_the_noise_channel_takes_the_period_the_pitch_names(self) -> None: + controller, logic = _logic() + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.NOISE]), + name="kit", + ) + logic.place_note(0, None, sample.id) + + logic.write_cell(0, None, None, Note(value=TYPED_NOTE), None) + + assert _pitch(logic, ChannelName.PULSE1, 0) == Note(value=TYPED_NOTE) + assert _pitch(logic, ChannelName.NOISE, 0) == Note(value=TYPED_NOTE % NUM_PERIODS) + + def test_a_step_reaches_every_channel_as_it_stands(self) -> None: + controller, logic = _logic() + sample = controller.add_sample( + sample_reconstruction([ChannelName.PULSE1, ChannelName.NOISE]), + name="kit", + ) + logic.place_note(0, None, sample.id) + + logic.write_cell(0, None, None, Step(value=TYPED_STEP), None) + + assert _pitch(logic, ChannelName.PULSE1, 0) == Step(value=TYPED_STEP) + assert _pitch(logic, ChannelName.NOISE, 0) == Step(value=TYPED_STEP) 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..246f2ff31 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,9 @@ 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.patterns.pitch import Step +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 +30,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 +94,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 +106,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 +115,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 +149,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 +159,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 +176,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 +188,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 +201,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 +214,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,24 +227,45 @@ 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( - label="an empty transpose through an ungoverned sample column clears every channel", + label="an empty transpose through the sample column clears the channels a sample still plays on", + frame=( + f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .", + ".. +02 . | .. +02 . | .. +02 . | .. +02 .", + ), + block=("...",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=1, channel=None), + expected=( + "00 ... . | 00 ... . | .. ... . | .. ... .", + ".. ... . | .. ... . | .. +02 . | .. +02 .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="an empty transpose through the sample column where no sample plays clears nothing", frame=(".. +02 . | .. +02 . | .. +02 . | .. +02 .",), block=("...",), first_subcolumn=SubColumn.TRANSPOSE, - origin=TrackerCell(row=0, generator=None), - expected=(EMPTY, EMPTY, EMPTY, EMPTY), + origin=TrackerCell(row=0, channel=None), + expected=( + ".. +02 . | .. +02 . | .. +02 . | .. +02 .", + EMPTY, + EMPTY, + EMPTY, + ), ), TestCase( label="a transpose through a governed sample column reaches its channels alone", 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 +277,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 +289,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 +300,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 +313,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 +326,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 +347,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 +367,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, pitch=Step(value=2)) assert pasted == render_frame(typed) @@ -340,15 +385,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 +403,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,24 +428,24 @@ 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 -class TestMaterialisation: +class TestMaterialization: """A paste reaches a channel holding no pattern by giving it one, the way an edit does.""" def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Grid) -> None: @@ -412,9 +457,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 +471,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/conftest.py b/tests/unit/sampletones_application/logic/shared/conftest.py index 7803055ab..50ea89760 100644 --- a/tests/unit/sampletones_application/logic/shared/conftest.py +++ b/tests/unit/sampletones_application/logic/shared/conftest.py @@ -1,3 +1,3 @@ -from tests.suite.application import synchronous_queue +from tests.suite.application import held_queue, synchronous_queue -__all__ = ["synchronous_queue"] +__all__ = ["held_queue", "synchronous_queue"] diff --git a/tests/unit/sampletones_application/logic/shared/test_audio_player.py b/tests/unit/sampletones_application/logic/shared/test_audio_player.py index 710aa9e4f..3125a805f 100644 --- a/tests/unit/sampletones_application/logic/shared/test_audio_player.py +++ b/tests/unit/sampletones_application/logic/shared/test_audio_player.py @@ -1,7 +1,17 @@ -from typing import Tuple +from typing import Final, List, Tuple from unittest.mock import MagicMock +import numpy as np +import pytest + from sampletones_application.logic.shared.audio_player import AudioPlayer +from sampletones_application.view_model.shared.audio_data import AudioData +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO +from sampletones_shared.exceptions import NoOutputDeviceError +from tests.suite.application import HeldQueue +from tests.suite.device import FakeAudioDevice + +AUDIO_LENGTH: Final[int] = 100 def _player(*, owned: bool, paused: bool) -> Tuple[AudioPlayer, MagicMock]: @@ -11,6 +21,31 @@ def _player(*, owned: bool, paused: bool) -> Tuple[AudioPlayer, MagicMock]: return AudioPlayer(device), device +class Reports: + """What a player tells its listeners: each position it reports, and each change of state.""" + + def __init__(self, player: AudioPlayer) -> None: + self.positions: List[int] = [] + self.state_changes = 0 + player.on_position_changed = self.positions.append + player.on_change_audio_state = self._count_state_change + + def _count_state_change(self) -> None: + self.state_changes += 1 + + +@pytest.fixture +def device() -> FakeAudioDevice: + return FakeAudioDevice() + + +@pytest.fixture +def player(device: FakeAudioDevice) -> AudioPlayer: + player = AudioPlayer(device) + player.load_audio_data(AudioData.from_array(np.zeros(AUDIO_LENGTH, dtype=np.float32), DEFAULT_SAMPLE_RATE)) + return player + + class TestEngagementFollowsOwnership: """A player reports itself engaged only while its own audio owns the shared output.""" @@ -31,3 +66,124 @@ def test_paused_requires_ownership(self) -> None: assert foreign.is_paused is False assert owned.is_paused is True + + +class TestSeekMovesItsOwnPlayback: + """A seek reaches the device only while the player's own audio holds it, and reports where it went.""" + + def test_an_owned_paused_player_moves_the_device_and_reports_the_sample( + self, + player: AudioPlayer, + device: FakeAudioDevice, + ) -> None: + player.play(start=START_OF_AUDIO) + player.pause() + reports = Reports(player) + + player.seek(40) + + assert device.position == 40 + assert player.current_position == 40 + assert reports.positions == [40] + + def test_a_seek_leaves_the_output_another_owner_holds_where_it_is( + self, + player: AudioPlayer, + device: FakeAudioDevice, + ) -> None: + device.play(np.zeros(AUDIO_LENGTH, dtype=np.float32), priority=0, owner=object(), start=70) + reports = Reports(player) + + moved = player.seek(40) + + assert (moved, device.position, player.current_position, reports.positions) == ( + False, + 70, + START_OF_AUDIO, + [], + ) + + +class TestPlayStartsWhereItIsAsked: + def test_the_device_is_started_at_the_sample_asked_for(self, player: AudioPlayer, device: FakeAudioDevice) -> None: + player.play(start=30) + + assert device.owner is player + assert device.position == 30 + + +class TestARefusedPlay: + """A device with no output in force refuses the play, and the refusal reaches the caller's recovery + boundary as the device raised it, with no change of state reported.""" + + def test_the_refusal_reaches_the_caller(self) -> None: + refusal = NoOutputDeviceError("no device") + player, device = _player(owned=False, paused=False) + player.load_audio_data(AudioData.from_array(np.zeros(AUDIO_LENGTH, dtype=np.float32), DEFAULT_SAMPLE_RATE)) + device.play.side_effect = refusal + reports = Reports(player) + + with pytest.raises(NoOutputDeviceError) as raised: + player.play(start=START_OF_AUDIO) + + assert raised.value is refusal + assert reports.state_changes == 0 + + +class TestDeviceReportsCrossToTheRenderThread: + """A report from the playback thread waits for the render loop, and reads the device when it runs.""" + + def test_a_report_reaches_the_listeners_only_once_the_render_loop_runs_it( + self, + player: AudioPlayer, + device: FakeAudioDevice, + held_queue: HeldQueue, + ) -> None: + player.play(start=START_OF_AUDIO) + device.position = 64 + reports = Reports(player) + + player._on_device_position_changed(64) + assert reports.positions == [] + + held_queue.drain() + + assert reports.positions == [64] + + def test_a_report_overtaken_by_a_seek_reports_the_seek( + self, + player: AudioPlayer, + device: FakeAudioDevice, + held_queue: HeldQueue, + ) -> None: + player.play(start=START_OF_AUDIO) + device.position = 64 + player._on_device_position_changed(64) + player.seek(40) + reports = Reports(player) + + held_queue.drain() + + assert reports.positions == [40] + + def test_the_report_of_a_playback_winding_down_changes_the_state( + self, + player: AudioPlayer, + device: FakeAudioDevice, + ) -> None: + player.play(start=START_OF_AUDIO) + device.stop() + reports = Reports(player) + + player._on_device_position_changed(START_OF_AUDIO) + + assert reports.positions == [START_OF_AUDIO] + assert reports.state_changes == 1 + + def test_a_report_of_a_sounding_playback_keeps_the_state(self, player: AudioPlayer) -> None: + player.play(start=START_OF_AUDIO) + reports = Reports(player) + + player._on_device_position_changed(64) + + assert reports.state_changes == 0 diff --git a/tests/unit/sampletones_application/logic/shared/test_file_playback.py b/tests/unit/sampletones_application/logic/shared/test_file_playback.py new file mode 100644 index 000000000..715dc0459 --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_file_playback.py @@ -0,0 +1,118 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.logic.shared.file_playback import FilePlayback +from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_shared.exceptions import InvalidReconstructionError, NoOutputDeviceError +from sampletones_shared.paths import extensions + +RECONSTRUCTION_LOAD = "sampletones_application.logic.shared.file_playback.Reconstruction.load" +RECONSTRUCTION_RENDER = "sampletones_application.logic.shared.file_playback.rendered_mix" + + +class TestWhatItSounds: + """A file the player knows how to sound is a reconstruction or an audio file.""" + + def test_a_reconstruction_sounds(self, tmp_path: Path) -> None: + assert FilePlayback.plays(tmp_path / f"song{extensions.EXT_FILE_RECONSTRUCTION}") is True + + def test_an_audio_file_sounds(self, tmp_path: Path) -> None: + assert FilePlayback.plays(tmp_path / "audio.wav") is True + + def test_anything_else_stays_quiet(self, tmp_path: Path) -> None: + assert FilePlayback.plays(tmp_path / "notes.txt") is False + + +class TestHowItSounds: + """A file named on demand outranks the preview a selection sounds on its own.""" + + def test_an_audio_file_asked_for_by_name_plays_at_normal_priority(self, tmp_path: Path) -> None: + audio_device_manager = MagicMock() + + FilePlayback(audio_device_manager).play(tmp_path / "audio.wav") + + audio_device_manager.play_file.assert_called_once_with( + tmp_path / "audio.wav", + update=False, + priority=PlaybackPriority.NORMAL, + ) + + def test_a_priority_the_caller_names_is_the_one_it_plays_at(self, tmp_path: Path) -> None: + audio_device_manager = MagicMock() + + FilePlayback(audio_device_manager).play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) + + audio_device_manager.play_file.assert_called_once_with( + tmp_path / "audio.wav", + update=False, + priority=PlaybackPriority.PREVIEW, + ) + + def test_a_file_of_another_kind_sounds_nothing(self, tmp_path: Path) -> None: + audio_device_manager = MagicMock() + + FilePlayback(audio_device_manager).play(tmp_path / "notes.txt") + + audio_device_manager.play_file.assert_not_called() + audio_device_manager.play.assert_not_called() + + +class TestAReconstructionThatWillNotRead: + """A reconstruction file under the cursor is untrusted input: any load or playback failure in + the domain (``SampleToNESError``) or I/O (``OSError``) families reports through ``on_error``; + a failure outside those families is a bug and propagates.""" + + @pytest.mark.parametrize( + "error", + [InvalidReconstructionError("corrupt"), PermissionError("denied")], + ids=["domain", "io"], + ) + def test_a_load_failure_is_reported(self, tmp_path: Path, error: Exception) -> None: + audio_device_manager = MagicMock() + playback = FilePlayback(audio_device_manager) + playback.on_error = MagicMock() + + with patch(RECONSTRUCTION_LOAD, side_effect=error): + playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + + playback.on_error.assert_called_once_with(error) + audio_device_manager.play.assert_not_called() + + def test_an_unexpected_failure_propagates(self, tmp_path: Path) -> None: + playback = FilePlayback(MagicMock()) + playback.on_error = MagicMock() + + with patch(RECONSTRUCTION_LOAD, side_effect=RuntimeError("bug")), pytest.raises(RuntimeError): + playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + + playback.on_error.assert_not_called() + + +class TestADeviceThatRefuses: + """A device with no output in force refuses the playback, and the refusal reports through ``on_error`` + for an audio file and for a reconstruction alike.""" + + def test_an_audio_file_reports_the_refusal(self, tmp_path: Path) -> None: + refusal = NoOutputDeviceError("no device") + audio_device_manager = MagicMock() + audio_device_manager.play_file.side_effect = refusal + playback = FilePlayback(audio_device_manager) + playback.on_error = MagicMock() + + playback.play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) + + playback.on_error.assert_called_once_with(refusal) + + def test_a_reconstruction_reports_the_refusal(self, tmp_path: Path) -> None: + refusal = NoOutputDeviceError("no device") + audio_device_manager = MagicMock() + audio_device_manager.play.side_effect = refusal + playback = FilePlayback(audio_device_manager) + playback.on_error = MagicMock() + + with patch(RECONSTRUCTION_LOAD), patch(RECONSTRUCTION_RENDER): + playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + + playback.on_error.assert_called_once_with(refusal) diff --git a/tests/unit/sampletones_application/logic/shared/test_player.py b/tests/unit/sampletones_application/logic/shared/test_player.py new file mode 100644 index 000000000..947764720 --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_player.py @@ -0,0 +1,83 @@ +from typing import Final, List + +import numpy as np +import pytest + +from sampletones_application.logic.shared.player import PlayerLogic +from sampletones_application.view_model.shared.audio_data import AudioData +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO +from tests.suite.device import FakeAudioDevice + +AUDIO_LENGTH: Final[int] = 1000 + + +@pytest.fixture +def device() -> FakeAudioDevice: + return FakeAudioDevice() + + +@pytest.fixture +def player(device: FakeAudioDevice) -> PlayerLogic: + player = PlayerLogic(device) + player.load_audio_data(AudioData.from_array(np.zeros(AUDIO_LENGTH, dtype=np.float32), DEFAULT_SAMPLE_RATE)) + return player + + +def _positions_reported(player: PlayerLogic) -> List[int]: + positions: List[int] = [] + player.on_position_changed = positions.append + return positions + + +class TestPlayFromPutsThePlayheadAtASample: + """A pointed-at sample is where the playhead goes, whatever the player was doing.""" + + def test_an_idle_player_starts_sounding_from_the_sample(self, player: PlayerLogic, device: FakeAudioDevice) -> None: + player.play_from(400) + + assert player.is_playing() is True + assert player.is_paused() is False + assert device.position == 400 + + def test_a_paused_player_moves_there_and_stays_paused(self, player: PlayerLogic, device: FakeAudioDevice) -> None: + player.play() + player.pause() + positions = _positions_reported(player) + + player.play_from(400) + + assert player.is_paused() is True + assert device.position == 400 + assert positions == [400] + + def test_a_sounding_player_moves_there_and_goes_on_sounding( + self, + player: PlayerLogic, + device: FakeAudioDevice, + ) -> None: + player.play() + device.position = 700 + positions = _positions_reported(player) + + player.play_from(400) + + assert player.is_playing() is True + assert player.is_paused() is False + assert device.position == 400 + assert positions == [400] + + def test_a_player_holding_no_audio_stays_idle(self, device: FakeAudioDevice) -> None: + player = PlayerLogic(device) + + player.play_from(400) + + assert player.is_playing() is False + assert device.audio is None + + def test_play_starts_from_the_beginning(self, player: PlayerLogic, device: FakeAudioDevice) -> None: + player.play_from(400) + player.stop() + + player.play() + + assert device.position == START_OF_AUDIO 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..e7570a36d 100644 --- a/tests/unit/sampletones_application/logic/shared/test_project_source.py +++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py @@ -1,5 +1,3 @@ -from typing import Callable - import pytest from sampletones_application.logic.project.controller import ProjectController @@ -7,9 +5,7 @@ from sampletones_application.logic.shared.project_source import ( ProjectSnapshot, ProjectSource, - snapshot_project, ) -from sampletones_core.reconstructions import Reconstruction from tests.suite.base import BaseTestSuite @@ -18,30 +14,8 @@ def project_controller() -> ProjectController: return ProjectController(ProjectManager()) -class TestSnapshotIndependence(BaseTestSuite): - def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None: - project_controller.set_tempo(120) - - snapshot = snapshot_project(project_controller.project) - project_controller.set_tempo(200) - - assert snapshot.settings.tempo == 120 - assert snapshot.song is not project_controller.project.song - - def test_reconstruction_audio_is_shared( - self, - project_controller: ProjectController, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = project_controller.add_sample(reconstruction_factory(), name="lead") - - snapshot = snapshot_project(project_controller.project) - - assert snapshot.samples[sample.id].reconstruction is sample.reconstruction - - class TestASnapshotIsASource(BaseTestSuite): - """A captured document reads as the source a synthesiser takes.""" + """A captured document reads as the source a synthesizer takes.""" def test_the_live_controller_is_a_source(self, project_controller: ProjectController) -> None: source: ProjectSource = project_controller diff --git a/tests/unit/sampletones_application/logic/shared/test_renders.py b/tests/unit/sampletones_application/logic/shared/test_renders.py new file mode 100644 index 000000000..9d06581e4 --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_renders.py @@ -0,0 +1,119 @@ +import gc +from pathlib import Path +from typing import Final, Tuple + +import numpy as np +import pytest + +from sampletones_application.logic.shared.renders import RenderCache +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +from tests.conftest import RENDER_BUDGET +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, taking_turns_reconstruction + +SOURCES: Final[Tuple[Path, Path]] = (Path("a.wav"), Path("b.wav")) +EDITED: Final[PulseInstruction] = PulseInstruction(on=True, pitch=67, volume=11, duty_cycle=1) +EDITED_PITCH: Final[int] = 67 + + +def _document() -> Reconstruction: + return taking_turns_reconstruction(SOURCES).detached() + + +def _edited(document: Reconstruction) -> Reconstruction: + return document.with_channel_data( + SHARED_CHANNEL, + [EDITED, EDITED], + EDITED_PITCH, + (), + heard=document.recorded_stem_ids, + ) + + +@pytest.fixture +def document() -> Reconstruction: + return _document() + + +class TestReadingARender: + """A render is read from the streams it sounds and kept for the next read.""" + + def test_the_render_is_what_the_document_sounds( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + channels = renders.channels(document) + + expected = rendered_channels(document) + assert channels.keys() == expected.keys() + assert all(np.array_equal(channels[name], expected[name]) for name in expected) + assert np.array_equal(renders.mix(document), rendered_mix(document)) + + def test_a_second_read_answers_with_the_kept_render( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + first = renders.channels(document) + mixed = renders.mix(document) + + assert renders.channels(document) == first + assert renders.mix(document) is mixed + + def test_a_stream_two_documents_share_renders_once( + self, + renders: RenderCache, + document: Reconstruction, + ) -> None: + before = renders.channels(document) + + after = renders.channels(_edited(document)) + + assert after[SOLE_CHANNEL] is before[SOLE_CHANNEL] + assert after[SHARED_CHANNEL] is not before[SHARED_CHANNEL] + + def test_equal_streams_of_two_documents_render_apart(self, renders: RenderCache) -> None: + first = renders.channels(_document()) + second = renders.channels(_document()) + + assert second[SOLE_CHANNEL] is not first[SOLE_CHANNEL] + assert np.array_equal(second[SOLE_CHANNEL], first[SOLE_CHANNEL]) + + +class TestTheBudget: + """The cache keeps audio within its budget, letting the renders read longest ago go first.""" + + def test_the_oldest_render_goes_first(self, document: Reconstruction) -> None: + sole = rendered_channels(document)[SOLE_CHANNEL].nbytes + renders = RenderCache(budget_bytes=sole) + kept = renders.channels(document) + + renders.channels(_edited(document)) + + assert renders.held_bytes <= sole + assert renders.channels(document)[SHARED_CHANNEL] is not kept[SHARED_CHANNEL] + + def test_a_render_read_again_stays(self, document: Reconstruction) -> None: + renders = RenderCache(budget_bytes=RENDER_BUDGET) + kept = renders.channels(document) + + renders.channels(_edited(document)) + + assert renders.channels(document) == kept + + +class TestARenderGoesWithItsStream: + """A render leaves the cache with the last document holding the stream it was made from.""" + + def test_a_collected_document_releases_its_renders(self, renders: RenderCache) -> None: + document = _document() + renders.mix(document) + assert renders.held_bytes > 0 + + del document + gc.collect() + + assert renders.held_bytes == 0 diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index afc741b38..a085d6cdc 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -8,6 +8,7 @@ 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.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.logic.shared.tree import TreeLogic from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode @@ -32,7 +33,6 @@ def _tree( scheduling = SchedulingBehavior( delays=SchedulingDelays( schedule=0, - reconstruction_update=0, cancel=0, ), priorities=SchedulingPriorities( @@ -46,7 +46,7 @@ def _tree( return TreeLogic( session_manager, - audio_device_manager, + FilePlayback(audio_device_manager), scheduling=scheduling, ) @@ -100,6 +100,54 @@ def test_unlock_fires_lock_state_changed_callback(self) -> None: assert True in states +class TestARebuildAskedForUnderTheLock: + """A rebuild asked for while the tree is held runs once the lock is let go.""" + + def test_a_free_tree_is_taken(self) -> None: + tree = _tree() + retries: List[str] = [] + + taken = tree.lock_unless_locked(lambda: retries.append("rebuild")) + + assert (taken, tree.locked, retries) == (True, True, []) + + def test_a_held_tree_asks_again_at_its_release(self) -> None: + tree = _tree() + retries: List[str] = [] + tree.lock() + + taken = tree.lock_unless_locked(lambda: retries.append("rebuild")) + held = list(retries) + tree.unlock() + + assert (taken, held, retries) == (False, [], ["rebuild"]) + + def test_only_the_release_of_the_last_holder_asks_again(self) -> None: + tree = _tree() + retries: List[str] = [] + tree.lock() + tree.lock() + tree.lock_unless_locked(lambda: retries.append("rebuild")) + + tree.unlock() + after_first = list(retries) + tree.unlock() + + assert (after_first, retries) == ([], ["rebuild"]) + + def test_the_latest_request_is_the_one_asked_again(self) -> None: + tree = _tree() + retries: List[str] = [] + tree.lock() + tree.lock_unless_locked(lambda: retries.append("first")) + tree.lock_unless_locked(lambda: retries.append("second")) + + tree.unlock() + tree.unlock() + + assert retries == ["second"] + + class TestTreeLogicAutoplay: def test_cancel_autoplay_clears_pending_node(self, tmp_path: Path) -> None: tree = _tree() @@ -340,49 +388,3 @@ def test_schedule_search_update_clears_pending_query_after_execution(self) -> No tree.on_search_update_needed = lambda: None tree.schedule_search_update("test") assert tree._pending_search_query is None - - -class TestReconstructionAutoplayFailure: - """A reconstruction file under the cursor is untrusted input: any load or playback failure in - the domain (``SampleToNESError``) or I/O (``OSError``) families reports through - ``on_autoplay_error``; a failure outside those families is a bug and propagates.""" - - @pytest.mark.parametrize( - "error", - [InvalidReconstructionError("corrupt"), PermissionError("denied")], - ids=["domain", "io"], - ) - def test_load_failure_reports_autoplay_error( - self, - tmp_path: Path, - error: Exception, - ) -> None: - audio_device_manager = MagicMock() - tree = _tree(audio_device_manager=audio_device_manager) - tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") - - with patch( - "sampletones_application.logic.shared.tree.Reconstruction.load", - side_effect=error, - ): - tree.request_autoplay(node) - - tree.on_autoplay_error.assert_called_once_with(error) - audio_device_manager.play.assert_not_called() - - def test_unexpected_failure_propagates(self, tmp_path: Path) -> None: - tree = _tree() - tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") - - with ( - patch( - "sampletones_application.logic.shared.tree.Reconstruction.load", - side_effect=RuntimeError("bug"), - ), - pytest.raises(RuntimeError), - ): - tree.request_autoplay(node) - - tree.on_autoplay_error.assert_not_called() diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py index 715763e14..3e9a8ba63 100644 --- a/tests/unit/sampletones_application/services/export/test_result.py +++ b/tests/unit/sampletones_application/services/export/test_result.py @@ -6,8 +6,9 @@ from sampletones_application.services.export.error import ExportError from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS 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 +17,24 @@ def test_stores_kind_and_filepath(self) -> None: success = ExportSuccess( kind=ExportKind.WAV, filepath=filepath, - tracker_format=None, + export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) 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, + skipped_rows=NO_SKIPPED_ROWS, ) - assert success.tracker_format == TrackerFormat.BITPHASE + assert success.export_format == ExportFormat.BITPHASE def test_stores_the_truncation(self) -> None: truncation = EnvelopeTruncation( @@ -42,8 +45,9 @@ 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, + skipped_rows=NO_SKIPPED_ROWS, ) assert success.truncation == truncation @@ -51,8 +55,9 @@ def test_frozen(self) -> None: success = ExportSuccess( kind=ExportKind.WAV, filepath=Path("/x"), - tracker_format=None, + export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) with pytest.raises(FrozenInstanceError): success.kind = ExportKind.INSTRUMENT # type: ignore[misc] @@ -62,24 +67,28 @@ def test_equality(self) -> None: assert ExportSuccess( kind=ExportKind.WAV, filepath=path, - tracker_format=None, + export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) == ExportSuccess( kind=ExportKind.WAV, filepath=path, - tracker_format=None, + export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) assert ExportSuccess( kind=ExportKind.WAV, filepath=path, - tracker_format=None, + export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) != ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, - tracker_format=None, + export_format=None, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None: @@ -87,13 +96,15 @@ 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, + skipped_rows=NO_SKIPPED_ROWS, ) != ExportSuccess( kind=ExportKind.INSTRUMENT, filepath=path, - tracker_format=TrackerFormat.BITPHASE, + export_format=ExportFormat.BITPHASE, truncation=None, + skipped_rows=NO_SKIPPED_ROWS, ) @@ -102,17 +113,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 +133,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 +146,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..40eeb36c1 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,24 +9,45 @@ 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.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason 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: - """Records what the service asked for and returns a prepared artefact. + """Records what the service asked for and returns a prepared artifact. The service under test owns the thread boundary and the result contract; what lands on disk belongs to the real backends and is exercised in their own tests. @@ -36,14 +57,17 @@ def __init__( self, truncation: Optional[EnvelopeTruncation] = None, exception: Optional[Exception] = None, + skipped_rows: Tuple[SkippedRow, ...] = NO_SKIPPED_ROWS, ) -> None: self.truncation = truncation self.exception = exception + self.skipped_rows = skipped_rows 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 +80,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 - return ExportArtifact(paths=(destination,), truncation=self.truncation) + announce(report, ExportStage.WRITING, ONE_FILE, ONE_FILE) + return ExportArtifact(paths=(destination,), truncation=self.truncation, skipped_rows=self.skipped_rows) 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 +141,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 +165,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 +195,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 +223,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 +252,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 +279,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 +307,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 +321,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 +332,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 +361,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 +381,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 +396,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 +408,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 +425,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 +445,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 +465,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 +481,45 @@ def test_a_wav_export_reports_no_truncation( np.zeros(100), ) - assert results[0].truncation is None + assert outcome(results).truncation is None + + +class TestExportSkippedRowsReporting: + SKIPPED = ( + SkippedRow( + voice_id="voice", + channel=ChannelName.PULSE1, + order_position=3, + row_index=26, + reason=SkipReason.NO_INSTRUMENT, + ), + ) + + def test_a_project_carries_the_rows_the_backend_left_silent( + self, + service, + tmp_path, + ) -> None: + export_service, results = service + + export_service.export_project( + tmp_path / "song.ftm", + StubBackend(skipped_rows=self.SKIPPED), + build_project(), + ) + + assert outcome(results).skipped_rows == self.SKIPPED + + def test_a_project_the_backend_wrote_whole_reports_no_rows( + self, + service, + tmp_path, + ) -> None: + export_service, results = service + + export_service.export_project(tmp_path / "song.ftm", StubBackend(), build_project()) + + assert outcome(results).skipped_rows == () class TestExportServiceConcurrency: @@ -507,3 +570,121 @@ def on_result(result: Any) -> None: ) assert call_count == 0 + + +class CancelingBackend: + """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, skipped_rows=NO_SKIPPED_ROWS) + + 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", + CancelingBackend(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", + CancelingBackend(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 = CancelingBackend(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..a2fbe6f4a 100644 --- a/tests/unit/sampletones_application/services/render/conftest.py +++ b/tests/unit/sampletones_application/services/render/conftest.py @@ -21,8 +21,8 @@ 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 - can be checked sample by sample without modelling a generator. + 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 modeling a generator. """ def __init__( diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py index 2a1ce82c3..b8c20f836 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" @@ -147,17 +147,17 @@ def test_the_spill_file_is_removed(self, tmp_path: Path) -> None: assert not (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists() -class TestCancelling(BaseTestSuite): - """A cancelled render reports itself cancelled and names no file.""" +class TestCanceling(BaseTestSuite): + """A canceled render reports itself canceled and names no file.""" - def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: + def _canceling_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( - synthesizer=self._cancelling_synthesizer(service), + synthesizer=self._canceling_synthesizer(service), destination=destination, spec=wave_spec(), normalize=False, @@ -166,24 +166,24 @@ 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) service.start( - synthesizer=self._cancelling_synthesizer(service), + synthesizer=self._canceling_synthesizer(service), destination=tmp_path / "song.wav", spec=wave_spec(), normalize=False, 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), + synthesizer=self._canceling_synthesizer(service), destination=tmp_path / "song.wav", spec=wave_spec(), normalize=True, diff --git a/tests/unit/sampletones_application/services/retune/test_retune.py b/tests/unit/sampletones_application/services/retune/test_retune.py index 0c1d1759e..19c5bf837 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,15 +46,16 @@ def test_emits_service_error_when_a_retune_raises(self) -> None: assert len(results) == 1 assert isinstance(results[0], ServiceError) - def test_result_carries_the_sample_id_and_retuned_reconstruction(self) -> None: + def test_result_carries_the_voice_id_the_retuned_reconstruction_and_its_source(self) -> None: service = SampleRetuneService() results: List[Any] = [] service.subscribe(results.append) retuned = MagicMock() + source = _reconstruction(retuned) - service._run([("lead", _reconstruction(retuned))], 60) + service._run([("lead", source)], 60) - assert results[0].value == RetunedSample(sample_id="lead", reconstruction=retuned) + assert results[0].value == RetunedSample(voice_id="lead", reconstruction=retuned, source=source) class TestSampleRetuneServiceStart: diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index 40db1bcbd..6e30b398b 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 @@ -3,25 +3,29 @@ from unittest.mock import MagicMock, patch import numpy as np +import pytest -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.audio.device import CurrentDevice from sampletones_core.project.song_position import SongPosition +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError SAMPLE_RATE: Final[int] = 44100 +SPEAKERS: Final[CurrentDevice] = CurrentDevice(device_index=0, name="Speakers", sample_rate=SAMPLE_RATE, host_api=0) WRITE_BLOCK: Final[int] = 64 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( @@ -113,6 +117,7 @@ def _close_stream(stream: _FakeStream) -> None: def _make_device_manager(stream: Optional[_FakeStream] = None) -> MagicMock: """A device manager that winds a handed-back stream down as the real one does.""" audio_device_manager = MagicMock() + audio_device_manager.require_output.return_value = SPEAKERS audio_device_manager.sample_rate = SAMPLE_RATE audio_device_manager.buffer_size = WRITE_BLOCK audio_device_manager.open_output_stream.return_value = stream @@ -134,6 +139,13 @@ def _make_streaming_service( ) +def _assert_names_the_failed_write(result: SongPlayerResult, error: OSError) -> None: + """Holds the song's end to the playback error a failed write names, the words every playback reports it in.""" + assert isinstance(result, SongPlaybackError) + assert isinstance(result.error, PlaybackError) + assert result.error.__cause__ is error + + def _wedged_thread(gate: threading.Event) -> threading.Thread: """A started worker that stays alive until ``gate`` is set.""" thread = threading.Thread( @@ -183,7 +195,7 @@ def test_seek_does_nothing_when_not_alive(self) -> None: def test_seek_sets_synthesizer_position_when_alive(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service.seek(2) @@ -191,7 +203,7 @@ def test_seek_sets_synthesizer_position_when_alive(self) -> None: def test_seek_does_not_reset_voices(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service.seek(2) @@ -208,7 +220,7 @@ def test_relocate_does_nothing_when_not_alive(self) -> None: def test_relocate_keeps_current_row_when_alive(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service._synthesizer.row_index = 5 service.relocate(2) @@ -217,7 +229,7 @@ def test_relocate_keeps_current_row_when_alive(self) -> None: def test_relocate_does_not_reset_voices(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service._synthesizer.row_index = 0 service.relocate(2) @@ -392,7 +404,8 @@ def test_render_loop_ends_when_finished_without_loop(self) -> None: assert list(service._buffer) == [None] service._synthesizer.render_row.assert_not_called() - def test_drain_writes_buffered_rows_then_reports_stopped(self) -> None: + def test_drain_writes_buffered_rows_then_answers_the_end(self) -> None: + """The drain answers that the song ended, and the writer reports it once the stream is back.""" service = _make_service() received = [] service.subscribe(received.append) @@ -403,12 +416,12 @@ def test_drain_writes_buffered_rows_then_reports_stopped(self) -> None: service._buffer.append(None) mock_stream = MagicMock() - service._drain_to_stream(mock_stream) + ended = service._drain_to_stream(mock_stream) + assert ended is True mock_stream.write.assert_called_once() - assert isinstance(received[0], SongPositionUpdate) + assert [type(result) for result in received] == [SongPositionUpdate] assert received[0].position is position - assert isinstance(received[-1], SongPlaybackStopped) def test_drain_returns_without_terminal_when_stopping(self) -> None: service = _make_service() @@ -419,8 +432,9 @@ def test_drain_returns_without_terminal_when_stopping(self) -> None: service._buffer.append(_RenderedRow(chunk=np.ones(10, dtype=np.float32), position=SongPosition())) mock_stream = MagicMock() - service._drain_to_stream(mock_stream) + ended = service._drain_to_stream(mock_stream) + assert ended is False mock_stream.write.assert_not_called() assert received == [] @@ -510,6 +524,7 @@ def test_stop_returns_after_the_writer_closed_its_stream(self) -> None: def test_stop_keeps_a_worker_that_outlives_the_deadline(self) -> None: gate = threading.Event() service = _make_service() + service._holding_output.set() service._write_thread = _wedged_thread(gate) try: @@ -525,6 +540,7 @@ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None: gate = threading.Event() audio_device_manager = _make_device_manager(_FakeStream()) service = _make_streaming_service(audio_device_manager) + service._holding_output.set() service._write_thread = _wedged_thread(gate) try: @@ -536,6 +552,161 @@ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None: gate.set() +class TestSongPlayerServiceWithNoOutput: + """A machine offering no output device refuses the song as it starts, before a thread starts. + + The refusal is the device manager's own, raised from ``start`` for the caller's recovery boundary, + and the service stays idle with its synthesizer where it stood. A device in force starts the song. + """ + + def test_start_raises_the_refusal_and_stays_idle(self) -> None: + audio_device_manager = _make_device_manager(_FakeStream()) + audio_device_manager.require_output.side_effect = NoOutputDeviceError("no device") + synthesizer = MagicMock() + service = SongPlayerService( + audio_device_manager, + synthesizer, + should_loop=lambda: False, + master_gain=lambda: 1.0, + ) + + with pytest.raises(NoOutputDeviceError): + service.start(order_position=2, row_index=3) + + assert service.alive is False + assert service._render_thread is None + assert service._write_thread is None + synthesizer.set_position.assert_not_called() + audio_device_manager.open_output_stream.assert_not_called() + + def test_a_device_in_force_starts_the_song(self) -> None: + stream = _FakeStream() + service = _make_streaming_service(_make_device_manager(stream)) + service.subscribe(lambda result: None) + + service.start() + + assert stream.entered_write.wait(timeout=WAIT_TIMEOUT) + service.stop() + + +class TestSongPlayerServiceEndOfSong: + """The song lets go of the output before it reports how it ended, so a listener reads it free.""" + + def record_ends(self, service: SongPlayerService) -> Tuple[List[Tuple[SongPlayerResult, bool]], threading.Event]: + """Subscribes a listener noting each terminal result with whether the song held the output then.""" + ends: List[Tuple[SongPlayerResult, bool]] = [] + reported = threading.Event() + + def listen(result: SongPlayerResult) -> None: + if isinstance(result, (SongPlaybackStopped, SongPlaybackError)): + ends.append((result, service.alive)) + reported.set() + + service.subscribe(listen) + return ends, reported + + def test_the_output_is_held_from_the_start(self) -> None: + gate = threading.Event() + stream = _FakeStream(gate=gate) + service = _make_streaming_service(_make_device_manager(stream), rows=8) + service.subscribe(lambda result: None) + + service.start() + try: + assert service.alive is True + assert stream.entered_write.wait(timeout=WAIT_TIMEOUT) + assert service.alive is True + finally: + gate.set() + service.stop() + + def test_a_song_reaching_its_end_reports_it_with_the_output_free(self) -> None: + stream = _FakeStream() + service = _make_streaming_service(_make_device_manager(stream)) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackStopped(), False)] + assert stream.closed.is_set() + + def test_a_failing_write_reports_it_with_the_output_free(self) -> None: + error = OSError("device disappeared") + service = _make_streaming_service(_make_device_manager(_FakeStream(error=error))) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + [(result, alive)] = ends + _assert_names_the_failed_write(result, error) + assert alive is False + + def test_a_stream_the_device_refuses_reports_the_error_with_the_output_free(self) -> None: + """The refusal reaches the listener once, as the error the device manager raised.""" + refusal = PlaybackError("Failed to open audio stream: device busy") + audio_device_manager = _make_device_manager() + audio_device_manager.open_output_stream.side_effect = refusal + service = _make_streaming_service(audio_device_manager) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert service._write_thread is not None + service._write_thread.join(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackError(error=refusal), False)] + + def test_a_close_the_device_fails_reports_it_with_the_output_free(self) -> None: + failure = PlaybackError("Failed to close the audio stream: the device went away") + audio_device_manager = _make_device_manager(_FakeStream()) + audio_device_manager.close_output_stream.side_effect = failure + service = _make_streaming_service(audio_device_manager) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackError(error=failure), False)] + + def test_a_close_the_device_failed_leaves_the_song_free_to_start_again(self) -> None: + audio_device_manager = _make_device_manager(_FakeStream()) + audio_device_manager.close_output_stream.side_effect = PlaybackError("Failed to close the audio stream") + service = _make_streaming_service(audio_device_manager) + _, reported = self.record_ends(service) + service.start() + assert reported.wait(timeout=WAIT_TIMEOUT) + assert service._write_thread is not None + service._write_thread.join(timeout=WAIT_TIMEOUT) + + audio_device_manager.close_output_stream.side_effect = _close_stream + service.start() + service.stop() + + assert audio_device_manager.open_output_stream.call_count == 2 + + def test_a_stop_reports_no_end(self) -> None: + gate = threading.Event() + stream = _FakeStream(gate=gate) + service = _make_streaming_service(_make_device_manager(stream), rows=8) + ends, _ = self.record_ends(service) + + service.start() + assert stream.entered_write.wait(timeout=WAIT_TIMEOUT) + releaser = threading.Timer(WRITE_RELEASE_DELAY, gate.set) + releaser.start() + try: + service.stop() + finally: + releaser.cancel() + gate.set() + + assert ends == [] + assert service.alive is False + + class TestSongPlayerServiceStreamOwnership: """The device hands out a stream against a release, and gets it back when the writer finishes.""" @@ -573,7 +744,8 @@ def test_a_failing_write_reports_a_playback_error(self) -> None: service._write_loop() - assert received == [SongPlaybackError(error=error)] + [result] = received + _assert_names_the_failed_write(result, error) def test_a_failing_write_still_closes_the_stream(self) -> None: stream = _FakeStream(error=OSError("device disappeared")) diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index d7580c142..61cc96d3e 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -1,32 +1,96 @@ from pathlib import Path from time import sleep -from typing import Any, Callable, Dict, Iterator, List, Tuple, TypeAlias +from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, TypeAlias from unittest.mock import MagicMock, patch import pytest -from sampletones_application.services.conversion import ConversionService +from sampletones_application.services.conversion.result import ConversionItem, ReconstructionStep +from sampletones_application.services.conversion.service import ConversionService from sampletones_application.services.result import ( - ServiceCancelled, + ServiceCanceled, ServiceError, ServiceIntermediate, ServiceProgress, ServiceStarted, ServiceSuccess, ) +from sampletones_core.constants.enums import ChannelName from sampletones_core.parallelization import TaskProgress, TaskStatus +from sampletones_core.parallelization.task import TaskStep +from sampletones_core.reconstructions.converter import ConversionJob +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_core.reconstructions.stage import ReconstructionStage +from tests.suite.base import BaseTestSuite MockConverterClass: TypeAlias = Tuple[MagicMock, MagicMock, Dict[str, Callable[..., Any]]] Service: TypeAlias = Tuple[ConversionService, MagicMock, Dict[str, Callable[..., Any]], List[Any]] +Reading: TypeAlias = Callable[[TaskProgress], ServiceProgress[ConversionItem]] + +SOURCE: Final[str] = "/audio/kick.wav" +OUTPUT: Final[str] = "/reconstructions/kick.stn" +JOB: Final[ConversionJob] = ConversionJob( + sources=(Path(SOURCE),), + stems=StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])), + output_path=Path(OUTPUT), +) +FRAMES: Final[int] = 1100 +ONE_RECONSTRUCTION: Final[int] = 1 +SEVERAL_RECONSTRUCTIONS: Final[int] = 5 +NOTHING_WRITTEN: Final[int] = 0 +ONE_WRITTEN: Final[int] = 1 +TWO_WRITTEN: Final[int] = 2 +SAMPLE_GAP: Final[float] = 0.02 +EARLY: Final[float] = 0.2 +HALFWAY: Final[float] = 0.5 +NEARLY_DONE: Final[float] = 0.9 + + +def _under_way(*fractions: float) -> Tuple[TaskStep, ...]: + """A step per reconstruction a run holds under way, each that far through its own frames.""" + return tuple( + TaskStep( + stage=ReconstructionStage.MATCHING.value, + completed=round(fraction * FRAMES), + total=FRAMES, + fraction=fraction, + ) + for fraction in fractions + ) + + +def _account( + total: int, + completed: int, + steps: Tuple[TaskStep, ...] = (), +) -> TaskProgress: + """The account a run of ``total`` reconstructions gives of itself at one moment.""" + return TaskProgress(total=total, completed=completed, current_item=SOURCE, steps=steps) + + +def _run_of(service: Service, total: int) -> Reading: + """Starts a run writing ``total`` reconstructions and hands back its reading of each account.""" + _, converter, callbacks, results = service + converter.total_tasks = total + callbacks["on_start"]() + + def read(task_progress: TaskProgress) -> ServiceProgress[ConversionItem]: + results.clear() + callbacks["on_progress"](TaskStatus.RUNNING, task_progress) + return results[-1] + + return read @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 instance.total_tasks = 5 + instance.job_under_way.return_value = JOB captured: Dict[str, Callable[..., Any]] = {} instance.set_callbacks.side_effect = lambda **kwargs: captured.update(kwargs) @@ -76,7 +140,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( @@ -114,7 +178,7 @@ def test_on_progress_running_emits_service_progress( callbacks["on_start"]() results.clear() - progress = TaskProgress(total=5, completed=2, current_item="/some/file.wav") + progress = TaskProgress(total=5, completed=2, current_item=SOURCE) callbacks["on_progress"](TaskStatus.RUNNING, progress) assert len(results) == 1 @@ -122,9 +186,21 @@ 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 == ConversionItem(source=Path(SOURCE), output_path=Path(OUTPUT)) - def test_on_progress_cancelling_emits_service_progress( + def test_the_item_names_the_job_the_progress_counts_up_to( + self, + service: Service, + ) -> None: + """The item is read from the job the converter is at once the progress's jobs are counted.""" + _, converter, callbacks, _ = service + callbacks["on_start"]() + + callbacks["on_progress"](TaskStatus.RUNNING, TaskProgress(total=5, completed=3)) + + converter.job_under_way.assert_called_with(3) + + def test_on_progress_canceling_emits_service_progress( self, service: Service, ) -> None: @@ -133,7 +209,7 @@ def test_on_progress_cancelling_emits_service_progress( results.clear() progress = TaskProgress(total=5, completed=3) - callbacks["on_progress"](TaskStatus.CANCELLING, progress) + callbacks["on_progress"](TaskStatus.CANCELING, progress) assert len(results) == 1 assert isinstance(results[0], ServiceProgress) @@ -168,7 +244,8 @@ def test_on_progress_current_item_none_when_absent( self, service: Service, ) -> None: - _, _, callbacks, results = service + _, converter, callbacks, results = service + converter.job_under_way.return_value = None callbacks["on_start"]() results.clear() @@ -202,15 +279,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, @@ -285,7 +362,7 @@ def test_eta_seconds_populated_after_two_samples( assert results[-1].eta_seconds is not None assert results[-1].eta_seconds > 0 - def test_eta_estimator_reset_on_cleanup( + def test_eta_estimator_reset_on_release( self, service: Service, ) -> None: @@ -293,35 +370,98 @@ def test_eta_estimator_reset_on_cleanup( callbacks["on_start"]() assert conversion_service._eta_estimator is not None - conversion_service.cleanup() + conversion_service.release() assert conversion_service._eta_estimator is None -class TestConversionServiceLifecycle: - def test_cleanup_resets_converter(self, service: Service) -> None: - conversion_service, _, _, _ = service - conversion_service.cleanup() - assert conversion_service._converter is None +class TestTheUnitARunReadsIn(BaseTestSuite): + """A run writing one reconstruction reads as that reconstruction; a batch reads as its files. - def test_cleanup_disposes_running_converter( - self, - service: Service, - ) -> None: + A batch holds as many reconstructions under way at once as it has workers, so what a reader + follows is the reconstructions written: the count, the bar and the estimate all answer in + files. A run writing one counts to one, so the part of that one reconstruction done is the + whole of the reading, and the stage it is at travels with it. + """ + + def test_a_run_of_one_reports_the_stage_its_reconstruction_is_at(self, service: Service) -> None: + read = _run_of(service, ONE_RECONSTRUCTION) + step = _under_way(HALFWAY) + + reading = read(_account(ONE_RECONSTRUCTION, NOTHING_WRITTEN, step)) + + assert reading.current_item is not None + assert reading.current_item.step == ReconstructionStep( + stage=ReconstructionStage.MATCHING, + completed=step[0].completed, + total=step[0].total, + ) + + def test_a_run_of_one_reads_as_the_part_of_its_reconstruction_done(self, service: Service) -> None: + read = _run_of(service, ONE_RECONSTRUCTION) + + reading = read(_account(ONE_RECONSTRUCTION, NOTHING_WRITTEN, _under_way(HALFWAY))) + + assert reading.fraction == pytest.approx(HALFWAY) + + def test_a_run_of_one_estimates_from_the_reconstruction_under_way(self, service: Service) -> None: + read = _run_of(service, ONE_RECONSTRUCTION) + + read(_account(ONE_RECONSTRUCTION, NOTHING_WRITTEN, _under_way(EARLY))) + sleep(SAMPLE_GAP) + read(_account(ONE_RECONSTRUCTION, NOTHING_WRITTEN, _under_way(HALFWAY))) + sleep(SAMPLE_GAP) + reading = read(_account(ONE_RECONSTRUCTION, NOTHING_WRITTEN, _under_way(NEARLY_DONE))) + + assert reading.eta_seconds is not None + + def test_a_batch_names_the_recording_it_is_at_and_no_stage(self, service: Service) -> None: + read = _run_of(service, SEVERAL_RECONSTRUCTIONS) + + reading = read(_account(SEVERAL_RECONSTRUCTIONS, TWO_WRITTEN, _under_way(EARLY, HALFWAY, NEARLY_DONE))) + + assert reading.current_item == ConversionItem(source=Path(SOURCE), output_path=Path(OUTPUT)) + + def test_a_batch_reads_as_the_reconstructions_it_has_written(self, service: Service) -> None: + read = _run_of(service, SEVERAL_RECONSTRUCTIONS) + + reading = read(_account(SEVERAL_RECONSTRUCTIONS, TWO_WRITTEN, _under_way(NEARLY_DONE, NEARLY_DONE))) + + assert reading.fraction == pytest.approx(TWO_WRITTEN / SEVERAL_RECONSTRUCTIONS) + + def test_a_batch_estimates_from_the_reconstructions_written(self, service: Service) -> None: + """Frames matched move a batch no nearer its end; a reconstruction written does.""" + read = _run_of(service, SEVERAL_RECONSTRUCTIONS) + + read(_account(SEVERAL_RECONSTRUCTIONS, ONE_WRITTEN, _under_way(EARLY, EARLY))) + sleep(SAMPLE_GAP) + matching = read(_account(SEVERAL_RECONSTRUCTIONS, ONE_WRITTEN, _under_way(NEARLY_DONE, NEARLY_DONE))) + sleep(SAMPLE_GAP) + written = read(_account(SEVERAL_RECONSTRUCTIONS, TWO_WRITTEN, _under_way(EARLY, EARLY))) + + assert matching.eta_seconds is None + assert written.eta_seconds is not None + + +class TestConversionServiceLifecycle: + def test_a_release_ends_the_converter_run_before_letting_it_go(self, service: Service) -> None: conversion_service, converter, _, _ = service - converter.is_running.return_value = True - conversion_service.cleanup() - converter.cleanup.assert_called_once() + + conversion_service.release() + + converter.shutdown.assert_called_once_with() assert conversion_service._converter is None - def test_shutdown_tears_down_converter_synchronously( + def test_a_new_run_releases_the_converter_the_last_one_left( self, + mock_converter_class: MockConverterClass, service: Service, ) -> None: conversion_service, converter, _, _ = service - conversion_service.shutdown() - converter.shutdown.assert_called_once() - assert conversion_service._converter is None + + conversion_service.start(MagicMock(), MagicMock()) + + converter.shutdown.assert_called_once_with() def test_is_running_true_when_converter_running( self, diff --git a/tests/unit/sampletones_application/services/test_folder_scan.py b/tests/unit/sampletones_application/services/test_folder_scan.py new file mode 100644 index 000000000..aed9676f3 --- /dev/null +++ b/tests/unit/sampletones_application/services/test_folder_scan.py @@ -0,0 +1,264 @@ +from pathlib import Path +from typing import Iterator, List + +import pytest + +from sampletones_application.services.folder_scan import service as service_module +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_application.services.folder_scan.service import REPORT_EVERY, FolderScanService +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from tests.suite.files import ( + LOCKED_FOLDER, + NAMES_ONLY_FOLDER, + held_at, + requires_folder_permissions, +) + +PRIORITY = 0 + + +@pytest.fixture(name="service") +def service_fixture() -> FolderScanService: + return FolderScanService(priority=PRIORITY) + + +@pytest.fixture(name="reports") +def reports_fixture(service: FolderScanService) -> List[FolderScanResult]: + """Every report the service makes, in the order it makes them.""" + reports: List[FolderScanResult] = [] + service.subscribe(reports.append) + return reports + + +@pytest.fixture(name="shutdown") +def shutdown_fixture() -> Iterator[None]: + """Leaves the executor live for the next case after one that shuts it down.""" + yield + SingleThreadExecutor.reset_shutdown() + + +def tree(root: Path, count: int, *, deep: int = 0) -> Path: + """A folder holding ``count`` recordings, and ``deep`` more in a folder below it.""" + root.mkdir(parents=True, exist_ok=True) + for index in range(count): + (root / f"take_{index:04d}.wav").touch() + + if deep: + tree(root / "below", deep) + + return root + + +def found(reports: List[FolderScanResult]) -> List[FolderScanSuccess]: + return [report for report in reports if isinstance(report, FolderScanSuccess)] + + +class TestWhatAWalkFinds: + """The walk goes as deep as the folder does and reports what it found against the request.""" + + def test_every_recording_below_the_folder( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 3, deep=2))) + + assert len(found(reports)[0].recordings) == 5 + + def test_every_report_names_the_request_it_answers( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + request = FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY)) + + service.start(request) + + assert all(report.request is request for report in reports) + + def test_they_arrive_in_name_order( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 4))) + + recordings = found(reports)[0].recordings + assert list(recordings) == sorted(recordings) + + def test_a_folder_holding_none_answers_with_none( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 0))) + + assert found(reports)[0].recordings == () + + @requires_folder_permissions + def test_the_folders_it_may_not_read_are_passed_over( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + """A locked folder and one listing names only keep their recordings; the rest are found.""" + root = tree(tmp_path / "takes", 2, deep=1) + locked = tree(root / "locked", 3) + names_only = tree(root / "names_only", 3) + + with held_at(locked, LOCKED_FOLDER), held_at(names_only, NAMES_ONLY_FOLDER): + service.start(FolderScanRequest(root=root)) + + assert len(found(reports)[0].recordings) == 3 + + @requires_folder_permissions + def test_a_folder_listing_names_only_is_a_failure( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + """The folder asked for lists its recordings and keeps them closed, so the reader hears that it + can't be read, as for a folder that can't be opened.""" + root = tree(tmp_path / "takes", 2) + + with held_at(root, NAMES_ONLY_FOLDER): + service.start(FolderScanRequest(root=root)) + + failure = reports[-1] + assert isinstance(failure, FolderScanError) + assert isinstance(failure.exception, PermissionError) + + +class TestWhatTheReaderIsTold: + """The reader hears that the reading began and how far it has got.""" + + def test_the_reading_says_it_began_before_it_counts( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 1))) + + assert isinstance(reports[0], FolderScanStarted) + + def test_the_count_rises_while_it_walks( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY * 2))) + + counts = [report.count for report in reports if isinstance(report, FolderScanProgress)] + assert counts == [REPORT_EVERY, REPORT_EVERY * 2] + + +class TestGivingUp: + """A reader who asked for the wrong folder stops the walk rather than waiting it out.""" + + def test_a_stopped_walk_reports_that_it_gave_up( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + request = FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY * 4)) + service.subscribe(lambda report: service.stop() if isinstance(report, FolderScanProgress) else None) + + service.start(request) + + assert found(reports) == [] + assert reports[-1] == FolderScanCanceled(request=request) + + def test_a_stop_of_one_reading_leaves_the_next_alone( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + root = tree(tmp_path / "takes", 2) + service.stop() + + service.start(FolderScanRequest(root=root)) + + assert len(found(reports)) == 1 + + def test_a_walk_that_fails_reports_its_failure( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + failure = OSError("the tree went away") + + def raising(_root: Path) -> Iterator[Path]: + raise failure + + monkeypatch.setattr(service_module, "walk_entries", raising) + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 2))) + + assert isinstance(reports[-1], FolderScanError) + assert reports[-1].exception is failure + + @requires_folder_permissions + def test_a_folder_it_may_not_open_reports_its_failure( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + root = tree(tmp_path / "takes", 2) + + with held_at(root, LOCKED_FOLDER): + service.start(FolderScanRequest(root=root)) + + assert isinstance(reports[-1], FolderScanError) + assert isinstance(reports[-1].exception, PermissionError) + + +class TestShuttingDown: + """A walk ends with the run: a shutdown is heard at the next entry, and nothing reports after it.""" + + def test_a_shutdown_ends_the_walk_with_no_report( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + shutdown: None, + ) -> None: + service.subscribe( + lambda report: SingleThreadExecutor.request_shutdown() if isinstance(report, FolderScanProgress) else None + ) + + service.start(FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY * 4))) + + assert [type(report) for report in reports] == [FolderScanStarted, FolderScanProgress] + + def test_a_walk_asked_for_after_the_shutdown_never_starts( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + shutdown: None, + ) -> None: + SingleThreadExecutor.request_shutdown() + + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 2))) + + assert reports == [] 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..02fb188ba --- /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_traveling_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..8090e1a31 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,55 +1,41 @@ -import threading from types import SimpleNamespace -from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, TypeAlias, cast +from typing import Any, Callable, Dict, Final, FrozenSet, Iterator, List, Tuple, TypeAlias, cast from unittest.mock import MagicMock, patch 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, ServiceError, ServiceSuccess, ) -from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.enums import ChannelName from sampletones_core.exporters import Features +from sampletones_core.features import CHANNEL_GENERATOR_KIND, supported_features +from sampletones_core.features.envelope import Envelope from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix from tests.conftest import ReconstructionFactory REFERENCE_PITCH: Final[int] = 60 MockReconstruction: TypeAlias = MagicMock SynthesisMocks: TypeAlias = SimpleNamespace +EVERY_STEM: Final[FrozenSet[int]] = frozenset({0}) 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,105 +72,51 @@ def reconstruction() -> MockReconstruction: class TestRegenerationServiceStart: - def test_start_when_not_cancelled_returns_true( - self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction + def test_start_runs_the_rebuild_on_the_worker( + self, + synthesis_mocks: SynthesisMocks, + reconstruction: MockReconstruction, + features: Features, ) -> None: - service = RegenerationService() - result = service.start( - reconstruction, - synthesis_mocks.generator_name, - cast(Features, {}), - FeatureKey.VOLUME, - 1, - ) - assert result is True - - def test_start_when_cancelled_returns_false(self) -> None: - service = RegenerationService() - service.cancel() - - result = service.start(MagicMock(), MagicMock(), cast(Features, {}), MagicMock(), MagicMock()) - - assert result is False - - def test_start_when_cancelled_does_not_emit(self) -> None: + """The worker runs the job it is handed, and its result reaches the subscriber.""" service = RegenerationService() results: List[Any] = [] service.subscribe(results.append) - service.cancel() service.start( - MagicMock(), - MagicMock(), - cast(Features, {}), - MagicMock(), - MagicMock(), + reconstruction, + synthesis_mocks.channel_name, + features, + EVERY_STEM, ) - assert results == [] + assert len(results) == 1 + assert isinstance(results[0], ServiceSuccess) - def test_start_reports_a_submit_failure(self) -> None: - """``start`` propagates the executor's accepted/rejected verdict. + def test_a_job_started_as_the_last_one_winds_down_still_runs( + self, + synthesis_mocks: SynthesisMocks, + reconstruction: MockReconstruction, + features: Features, + ) -> None: + """One job runs at a time, and a job started while the worker winds down runs once it has. - The coalescing worker owns the launch; ``start`` merely forwards whether the - submission was accepted, so a caller can gate on it. + The rewrites start the next rebuild as the previous result arrives, so every start is + answered and the line moves on. """ service = RegenerationService() - with patch.object(service._executor, "submit", return_value=False): - result = service.start( - MagicMock(), - MagicMock(), - cast(Features, {}), - MagicMock(), - MagicMock(), - ) - - assert result is False - - def test_cancel_sets_cancelled_flag(self) -> None: - service = RegenerationService() - assert not service._cancelled - - service.cancel() - - assert service._cancelled - - -class TestRegenerationServiceIsRunning: - def test_is_running_delegates_to_the_executor(self) -> None: - service = RegenerationService() - service._executor = MagicMock() + with patch.object(service._executor, "execute") as execute: + service.start(reconstruction, synthesis_mocks.channel_name, features, EVERY_STEM) - service._executor.is_running = True - assert service.is_running() is True - - service._executor.is_running = False - assert service.is_running() is False + assert execute.call_args.kwargs == {"wait": True} class TestRegenerationServiceRun: - def test_run_when_cancelled_emits_service_cancelled(self) -> None: - service = RegenerationService() - results: List[Any] = [] - service.subscribe(results.append) - service._cancelled = True - - service._run( - MagicMock(), - MagicMock(), - cast(Features, {}), - MagicMock(), - MagicMock(), - ) - - assert len(results) == 1 - assert isinstance(results[0], ServiceCancelled) - def test_run_success_emits_service_success( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: service = RegenerationService() results: List[Any] = [] @@ -192,67 +124,56 @@ def test_run_success_emits_service_success( service._run( reconstruction, - synthesis_mocks.generator_name, - cast(Features, features), - FeatureKey.VOLUME, - 1, + synthesis_mocks.channel_name, + features, + EVERY_STEM, ) assert len(results) == 1 assert isinstance(results[0], ServiceSuccess) outcome = results[0].value - assert outcome.reconstruction is reconstruction.model_copy.return_value + assert outcome.reconstruction is reconstruction.with_channel_data.return_value assert outcome.reconstruction is not reconstruction - assert outcome.generator_name is synthesis_mocks.generator_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, features, EVERY_STEM) - assert features[feature_key] == new_value + _, _, initial_pitch, held = reconstruction.with_channel_data.call_args.args + assert initial_pitch == features.initial_pitch + assert held == features.held_features - def test_run_updates_reconstruction_copy( + def test_run_asks_the_document_for_its_edited_channel( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: service = RegenerationService() service._run( reconstruction, - synthesis_mocks.generator_name, - cast(Features, features), - FeatureKey.VOLUME, - 1, + synthesis_mocks.channel_name, + features, + EVERY_STEM, ) - 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 + reconstruction.with_channel_data.assert_called_once() + call_args = reconstruction.with_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,58 +184,49 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit( service._run( reconstruction, - synthesis_mocks.generator_name, - cast(Features, features), - FeatureKey.ARPEGGIO, - np.array([12, 0], dtype=np.int8), + synthesis_mocks.channel_name, + features, + EVERY_STEM, ) - call_args = reconstruction.model_copy.return_value.update_generator_data.call_args - assert call_args.args[3] == REFERENCE_PITCH + call_args = reconstruction.with_channel_data.call_args + assert call_args.args[2] == 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, moved, EVERY_STEM) - call_args = reconstruction.model_copy.return_value.update_generator_data.call_args - assert call_args.args[3] == moved_pitch + _, _, initial_pitch, _ = reconstruction.with_channel_data.call_args.args + assert initial_pitch == REFERENCE_PITCH + 12 - def test_run_calls_generator_for_each_instruction( + def test_run_hands_on_every_instruction_the_envelopes_describe( self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction, - features: FakeFeatures, + features: Features, ) -> None: extra_instruction = MagicMock() - synthesis_mocks.exporter.from_features.return_value = [ - synthesis_mocks.instruction, - extra_instruction, - ] + stream = [synthesis_mocks.instruction, extra_instruction] + synthesis_mocks.exporter.from_features.return_value = stream service = RegenerationService() service._run( reconstruction, - synthesis_mocks.generator_name, - cast(Features, features), - FeatureKey.VOLUME, - 1, + synthesis_mocks.channel_name, + features, + EVERY_STEM, ) - assert synthesis_mocks.generator.call_count == 2 + call_args = reconstruction.with_channel_data.call_args + assert call_args.args[1] == stream def test_run_exception_emits_service_error( self, @@ -326,18 +238,17 @@ def test_run_exception_emits_service_error( exception = RuntimeError("synthesis failed") mock_exporter = MagicMock() - mock_exporter.get_generator_type.side_effect = exception + mock_exporter.from_features.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, + ChannelName.PULSE1, cast(Features, {}), - FeatureKey.VOLUME, - 1, + EVERY_STEM, ) assert len(results) == 1 @@ -351,21 +262,20 @@ def test_run_exception_does_not_update_reconstruction( ) -> None: service = RegenerationService() mock_exporter = MagicMock() - mock_exporter.get_generator_type.side_effect = RuntimeError("fail") + mock_exporter.from_features.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, + ChannelName.PULSE1, cast(Features, {}), - FeatureKey.VOLUME, - 1, + EVERY_STEM, ) - reconstruction.update_generator_data.assert_not_called() + reconstruction.with_channel_data.assert_not_called() class TestClearingEveryEnvelope: @@ -379,18 +289,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, + ChannelName.PULSE1, features, - FeatureKey.VOLUME, - np.array([], dtype=np.int8), + EVERY_STEM, ) assert isinstance(results[0], ServiceSuccess) @@ -405,8 +314,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, @@ -416,8 +325,8 @@ def test_a_cleared_instrument_sounds_as_an_empty_waveform( regenerated = self._regenerated(reconstruction) - assert regenerated.approximations == {} - assert regenerated.approximation.size == 0 + assert rendered_channels(regenerated) == {} + assert rendered_mix(regenerated).size == 0 def test_the_cleared_channel_records_every_dimension_as_the_channels( self, @@ -427,12 +336,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,88 +349,4 @@ def test_the_reconstruction_the_edit_was_made_from_keeps_playing( self._regenerated(reconstruction) - assert reconstruction.playing_generators == (GeneratorName.PULSE1,) - - -class TestRegenerationServiceCancellationConstraints: - """Tests that document the non-preemptive cancellation behaviour. - - cancel() only prevents new tasks from starting. It does NOT interrupt - synthesis that is already in progress. - """ - - def test_cancel_while_running_does_not_interrupt_synthesis( - self, - synthesis_mocks: SynthesisMocks, - features: FakeFeatures, - ) -> None: - service = RegenerationService() - results: List[Any] = [] - done = threading.Event() - - def on_result(result: Any) -> None: - results.append(result) - done.set() - - service.subscribe(on_result) - - task_started = threading.Event() - task_unblock = threading.Event() - - def blocking_from_features(edited_features: Any) -> List[MagicMock]: - task_started.set() - task_unblock.wait(timeout=2.0) - return [synthesis_mocks.instruction] - - synthesis_mocks.exporter.from_features.side_effect = blocking_from_features - reconstruction = MagicMock() - reconstruction.config = MagicMock() - - thread = threading.Thread( - target=lambda: service._run( - reconstruction, - synthesis_mocks.generator_name, - cast(Features, features), - FeatureKey.VOLUME, - 1, - ), - ) - thread.start() - task_started.wait(timeout=2.0) - - service.cancel() - task_unblock.set() - - done.wait(timeout=2.0) - thread.join(timeout=2.0) - - assert len(results) == 1 - assert isinstance(results[0], ServiceSuccess) - - def test_cancel_after_completion_prevents_new_tasks( - self, - synthesis_mocks: SynthesisMocks, - reconstruction: MockReconstruction, - ) -> None: - service = RegenerationService() - results: List[Any] = [] - service.subscribe(results.append) - - service.start( - reconstruction, - synthesis_mocks.generator_name, - cast(Features, {}), - FeatureKey.VOLUME, - 1, - ) - - service.cancel() - second_result = service.start( - reconstruction, - synthesis_mocks.generator_name, - cast(Features, {}), - FeatureKey.VOLUME, - 2, - ) - - assert second_result is False + assert reconstruction.playing_channels == (ChannelName.PULSE1,) 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/tags/test_compose.py b/tests/unit/sampletones_application/tags/test_compose.py index 75d3eda0f..62648979d 100644 --- a/tests/unit/sampletones_application/tags/test_compose.py +++ b/tests/unit/sampletones_application/tags/test_compose.py @@ -4,7 +4,12 @@ import pytest -from sampletones_application.tags.compose import TAG_SEPARATOR, compose_tag +from sampletones_application.tags.compose import ( + TAG_DIGEST_LENGTH, + TAG_SEPARATOR, + compose_tag, + identity_part, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error @@ -110,3 +115,27 @@ def test_every_segment_is_separated(self) -> None: def test_casing_and_spacing_of_a_runtime_name_do_not_change_the_tag(self) -> None: assert compose_tag("base", "My Layer") == compose_tag("base", "my_layer") + + +class TestIdentityPart: + """A tag built from a name a user gave carries the identity of that name beside it, since the + composer reads two names differing only in case or spacing as one segment.""" + + def test_names_the_composer_reads_alike_carry_parts_of_their_own(self) -> None: + assert identity_part("Kick.wav") != identity_part("kick.wav") + assert compose_tag("base", "Kick.wav") == compose_tag("base", "kick.wav") + + def test_one_name_carries_one_part(self) -> None: + assert identity_part("/audio/kick.wav") == identity_part("/audio/kick.wav") + + def test_a_part_composes_into_a_tag_as_one_segment(self) -> None: + """A name carrying separators of its own contributes them, and the part stands beside it.""" + part = identity_part("kick.wav") + + composed = compose_tag("base", "kick.wav", part, "text") + + assert composed.split(TAG_SEPARATOR).count(part) == 1 + assert composed.endswith(f"{TAG_SEPARATOR}text") + + def test_a_part_is_the_length_a_tag_holds(self) -> None: + assert len(identity_part("kick.wav")) == TAG_DIGEST_LENGTH diff --git a/tests/unit/sampletones_application/test_application_channels.py b/tests/unit/sampletones_application/test_application_channels.py index 39a46fc0f..7c098124d 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 source settings", 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_exit.py b/tests/unit/sampletones_application/test_application_exit.py new file mode 100644 index 000000000..c296ea9a4 --- /dev/null +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -0,0 +1,553 @@ +from dataclasses import dataclass +from typing import Callable, Final, Iterator, List, Optional, Tuple +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest + +from sampletones_application.application import Application +from sampletones_application.utils.callbacks.gates import LatestRequestFlight +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_shared.types.callback import VoidCallback +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import Frames, held_frames +from tests.suite.questions import OnScreenDocument, dialogs_on_the_line + +__all__ = ["held_frames"] + +PROJECT: Final[str] = "project" +RECONSTRUCTION: Final[str] = "reconstruction" +CONVERSION: Final[str] = "conversion" +LIBRARY: Final[str] = "library" +OWNERS: Final[Tuple[str, ...]] = (PROJECT, RECONSTRUCTION, CONVERSION, LIBRARY) +CLOSE: Final[str] = "close" +EXIT: Final[str] = "exit" +JOB_REPORT: Final[str] = "job report" +PROJECT_PROPERTIES: Final[str] = "project properties" + + +class Owner: + """One owner of something the exit asks about, answering the way a real prompt would. + + It stands in for each of the four owners, so it answers each one's reading of whether it has + something to ask by whether it is unfinished. + """ + + def __init__(self, name: str, asked: List[str]) -> None: + self.name = name + self.unfinished = False + self.editing = False + self._asked = asked + self._proceed: Optional[VoidCallback] = None + self._decline: Optional[VoidCallback] = None + self._after_edits: List[VoidCallback] = [] + + def after_edits(self, gesture: VoidCallback) -> None: + """Holds the gesture while an edit is on its way, in line, the way the reconstruction's rewrites do.""" + if not self.editing: + gesture() + return + + self._after_edits.append(gesture) + + def land(self) -> None: + """The edit on its way landing, which lets every gesture waiting on it run in the order it came.""" + assert self._after_edits + gestures, self._after_edits = self._after_edits, [] + self.editing = False + for gesture in gestures: + gesture() + + @property + def is_unsaved(self) -> bool: + return self.unfinished + + def is_unsaved_standalone(self) -> bool: + return self.unfinished + + def is_converter_active(self) -> bool: + return self.unfinished + + def is_library_generating(self) -> bool: + return self.unfinished + + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + if not self.unfinished: + proceed() + return + + self._asked.append(self.name) + self._proceed = proceed + self._decline = decline + + @property + def is_asking(self) -> bool: + return self._proceed is not None + + def go_on(self) -> None: + """The reader answering the question with Save, Exit or Discard.""" + assert self._proceed is not None + proceed = self._proceed + self._proceed, self._decline = None, None + proceed() + + def cancel(self) -> None: + """The reader answering the question with Cancel, which turns the exit away.""" + assert self._decline is not None + decline = self._decline + self._proceed, self._decline = None, None + decline() + + +class Exiting: + """An application whose owners ask about what they hold, and whose exit is only recorded.""" + + def __init__(self) -> None: + self.asked: List[str] = [] + self.owners = {name: Owner(name, self.asked) for name in OWNERS} + self.application = Application.__new__(Application) + self.application._project_coordinator = self.owners[PROJECT] + self.application._reconstruction_coordinator = self.owners[RECONSTRUCTION] + self.application._main_tab = self.owners[CONVERSION] + self.application._instructions_tab = self.owners[LIBRARY] + self.application.dialogs = dialogs_on_the_line() + self.exit = MagicMock() + self.application._exit_application = self.exit + self.application._exiting = self.application._exit_flight() + + def unfinished(self, *names: str) -> None: + for name in names: + self.owners[name].unfinished = True + + def close(self) -> None: + self.application._exiting() + + +@pytest.fixture(name="exiting") +def exiting_fixture() -> Exiting: + return Exiting() + + +class TestExitingWithNothingUnfinished: + def test_the_application_exits_at_once(self, exiting: Exiting) -> None: + exiting.close() + + exiting.exit.assert_called_once_with() + assert exiting.asked == [] + + +class TestExitingWithEverythingUnfinished: + """Each owner asks in turn, and the application exits once the last one lets it go.""" + + @pytest.fixture(name="closing") + def closing_fixture(self, exiting: Exiting) -> Exiting: + exiting.unfinished(*OWNERS) + exiting.close() + return exiting + + def test_the_project_asks_first(self, closing: Exiting) -> None: + assert closing.asked == [PROJECT] + closing.exit.assert_not_called() + + def test_its_answer_leads_to_the_reconstruction_question(self, closing: Exiting) -> None: + closing.owners[PROJECT].go_on() + + assert closing.asked == [PROJECT, RECONSTRUCTION] + closing.exit.assert_not_called() + + def test_every_owner_is_asked_in_turn(self, closing: Exiting) -> None: + for name in OWNERS: + closing.owners[name].go_on() + + assert closing.asked == list(OWNERS) + closing.exit.assert_called_once_with() + + def test_canceling_any_question_keeps_the_application_open(self, closing: Exiting) -> None: + closing.owners[PROJECT].go_on() + closing.owners[RECONSTRUCTION].cancel() + + assert closing.asked == [PROJECT, RECONSTRUCTION] + closing.exit.assert_not_called() + + +class TestAStateSettledWhileAQuestionStood: + """An owner reads what it holds when the exit reaches it, so a job ending while the project + question stands is asked about no more.""" + + def test_a_finished_conversion_asks_nothing(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT, CONVERSION) + exiting.close() + + exiting.owners[CONVERSION].unfinished = False + exiting.owners[PROJECT].go_on() + + assert exiting.asked == [PROJECT] + exiting.exit.assert_called_once_with() + + +class TestEachOwnerAlone: + """Whichever owner holds something unfinished, its answer is what reaches the exit.""" + + @pytest.mark.parametrize("name", OWNERS) + def test_going_on_exits(self, exiting: Exiting, name: str) -> None: + exiting.unfinished(name) + exiting.close() + + exiting.owners[name].go_on() + + assert exiting.asked == [name] + exiting.exit.assert_called_once_with() + + @pytest.mark.parametrize("name", OWNERS) + def test_canceling_stays(self, exiting: Exiting, name: str) -> None: + exiting.unfinished(name) + exiting.close() + + exiting.owners[name].cancel() + + exiting.exit.assert_not_called() + + +class TestExitingWhileAnEditIsOnItsWay: + """The exit waits for the edits of the open reconstruction first, so every question asks about what the reader drew.""" + + def test_nothing_is_asked_before_the_edit_lands(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.unfinished(PROJECT) + + exiting.close() + + assert exiting.asked == [] + exiting.exit.assert_not_called() + + def test_the_questions_follow_once_it_lands(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.unfinished(PROJECT) + exiting.close() + + exiting.owners[RECONSTRUCTION].land() + + assert exiting.asked == [PROJECT] + + def test_the_application_exits_once_it_lands_with_nothing_unfinished(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.close() + + exiting.owners[RECONSTRUCTION].land() + + exiting.exit.assert_called_once_with() + + +class TestClosingTwiceBeforeTheAnswer: + """A close asked for again while the exit's questions stand is absorbed, and Cancel ends the exit. + + A close made after the answer asks again, so the reader can always leave. + """ + + def test_two_closes_over_a_question_ask_once(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT) + + exiting.close() + exiting.close() + + assert exiting.asked == [PROJECT] + + def test_cancel_leaves_no_question_behind(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT) + exiting.close() + exiting.close() + + exiting.owners[PROJECT].cancel() + + assert not exiting.owners[PROJECT].is_asking + assert exiting.asked == [PROJECT] + exiting.exit.assert_not_called() + + def test_a_close_after_cancel_asks_again(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT) + exiting.close() + exiting.owners[PROJECT].cancel() + + exiting.close() + exiting.owners[PROJECT].go_on() + + assert exiting.asked == [PROJECT, PROJECT] + exiting.exit.assert_called_once_with() + + def test_a_close_while_a_later_question_stands_is_absorbed(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT, CONVERSION) + exiting.close() + exiting.owners[PROJECT].go_on() + + exiting.close() + + assert exiting.asked == [PROJECT, CONVERSION] + + def test_two_closes_while_an_edit_is_on_its_way_ask_once_it_lands(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.unfinished(RECONSTRUCTION) + exiting.close() + exiting.close() + + exiting.owners[RECONSTRUCTION].land() + + assert exiting.asked == [RECONSTRUCTION] + + +def run_at_once(gesture: VoidCallback) -> None: + """The edits of the open reconstruction, with none on their way.""" + gesture() + + +class LeavingOverAQuestion: + """An application whose owners ask on the screen, so an exit can be asked for while another question stands. + + The project and the reconstruction each close through a conversation of their own, and closing one + settles its changes. + """ + + def __init__(self) -> None: + self.asked: List[str] = [] + self.documents = {name: OnScreenDocument(name, self.asked) for name in OWNERS} + project = MagicMock() + project.guard_close.side_effect = self.documents[PROJECT].guard(CLOSE) + project.guard_exit.side_effect = self.documents[PROJECT].guard(EXIT) + project.close_project.side_effect = self.documents[PROJECT].finish + reconstruction = MagicMock() + reconstruction.after_edits.side_effect = run_at_once + reconstruction.guard_close.side_effect = self.documents[RECONSTRUCTION].guard(CLOSE) + reconstruction.guard_exit.side_effect = self.documents[RECONSTRUCTION].guard(EXIT) + reconstruction.close.side_effect = self.documents[RECONSTRUCTION].finish + self.application = Application.__new__(Application) + self.application._project_coordinator = project + self.application._reconstruction_coordinator = reconstruction + self.application._main_tab = MagicMock() + self.application._main_tab.guard_exit.side_effect = self.documents[CONVERSION].guard(EXIT) + self.application._instructions_tab = MagicMock() + self.application._instructions_tab.guard_exit.side_effect = self.documents[LIBRARY].guard(EXIT) + type(project).is_unsaved = PropertyMock(side_effect=self.unfinished(PROJECT)) + reconstruction.is_unsaved_standalone.side_effect = self.unfinished(RECONSTRUCTION) + self.application._main_tab.is_converter_active.side_effect = self.unfinished(CONVERSION) + self.application._instructions_tab.is_library_generating.side_effect = self.unfinished(LIBRARY) + self.application.dialogs = dialogs_on_the_line() + self.exit = MagicMock() + self.application._exit_application = self.exit + self.leave = self.application._exit_flight() + self.closing = { + PROJECT: self.application._document_flight(project.guard_close, project.close_project), + RECONSTRUCTION: self.application._document_flight(reconstruction.guard_close, reconstruction.close), + } + + def close(self, name: str) -> LatestRequestFlight[[]]: + return self.closing[name] + + def unfinished(self, name: str) -> Callable[[], bool]: + """The owner's reading of whether it has something to ask, taken when the application reads it.""" + return lambda: self.documents[name].unfinished + + +@pytest.fixture(name="leaving") +def leaving_fixture() -> LeavingOverAQuestion: + return LeavingOverAQuestion() + + +class TestAnExitAskedWhileACloseAsks(BaseTestSuite): + """An exit asked for while the question of a close stands waits for that question's answer, and then asks + about what the answer left. + + The reported case: the project's close asks, the window is closed, and Discard closes the project. The exit + then has nothing left to ask about the project, so the application leaves. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + document: str + + test_cases = ( + TestCase(label="project", document=PROJECT), + TestCase(label="reconstruction", document=RECONSTRUCTION), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_exit_asks_nothing_while_the_close_asks( + self, + test_case: TestCase, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[test_case.document].unfinished = True + leaving.close(test_case.document)() + + leaving.leave() + held_frames.render() + + assert leaving.asked == [f"{test_case.document} {CLOSE}"] + assert ModalQueue.snapshot().turns == 1 + leaving.exit.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_discard_leaves_without_a_second_question( + self, + test_case: TestCase, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[test_case.document].unfinished = True + leaving.close(test_case.document)() + leaving.leave() + + leaving.documents[test_case.document].go_on() + held_frames.render() + + assert leaving.asked == [f"{test_case.document} {CLOSE}"] + leaving.exit.assert_called_once_with() + assert ModalQueue.snapshot().is_settled + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_cancel_leaves_the_exit_to_ask_about_the_changes_kept( + self, + test_case: TestCase, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[test_case.document].unfinished = True + leaving.close(test_case.document)() + leaving.leave() + + leaving.documents[test_case.document].cancel() + held_frames.render() + + assert leaving.asked == [f"{test_case.document} {CLOSE}", f"{test_case.document} {EXIT}"] + leaving.exit.assert_not_called() + + +class TestAnExitAskedWhileAWindowStands: + """An exit asked for while another window holds the screen reads what it asks about once the window leaves.""" + + def test_a_conversion_ending_meanwhile_is_asked_about_no_more( + self, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[CONVERSION].unfinished = True + ModalQueue.open(JOB_REPORT, lambda: None) + leaving.leave() + + leaving.documents[CONVERSION].finish() + ModalQueue.leave(JOB_REPORT) + held_frames.render() + + assert leaving.asked == [] + leaving.exit.assert_called_once_with() + + def test_nothing_unfinished_leaves_at_once(self, leaving: LeavingOverAQuestion, held_frames: Frames) -> None: + """Closing the window over a dialog, with nothing to ask about, leaves with the dialog standing.""" + ModalQueue.open(JOB_REPORT, lambda: None) + + leaving.leave() + + leaving.exit.assert_called_once_with() + assert leaving.asked == [] + + def test_a_conversion_still_running_is_asked_about( + self, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[CONVERSION].unfinished = True + ModalQueue.open(JOB_REPORT, lambda: None) + leaving.leave() + + ModalQueue.leave(JOB_REPORT) + held_frames.render() + + assert leaving.asked == [f"{CONVERSION} {EXIT}"] + leaving.exit.assert_not_called() + + +class TestAnExitAskedWhileAnEditingDialogStands: + """A dialog standing as the window is closed can leave something unsaved, so every owner reads what it holds + once the dialog has left. + + The reported case: Project properties stands over a clean project and an unsaved reconstruction, the window + is closed, and OK changes the title. The exit then asks about the project before the reconstruction. + """ + + @pytest.fixture(name="edited") + def edited_fixture(self, leaving: LeavingOverAQuestion, held_frames: Frames) -> LeavingOverAQuestion: + leaving.documents[RECONSTRUCTION].unfinished = True + ModalQueue.open(PROJECT_PROPERTIES, lambda: None) + leaving.leave() + + leaving.documents[PROJECT].unfinished = True + ModalQueue.leave(PROJECT_PROPERTIES) + held_frames.render() + return leaving + + def test_the_project_the_dialog_left_unsaved_is_asked_about_first(self, edited: LeavingOverAQuestion) -> None: + assert edited.asked == [f"{PROJECT} {EXIT}"] + edited.exit.assert_not_called() + + def test_the_exit_leaves_once_both_are_answered( + self, + edited: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + edited.documents[PROJECT].go_on() + held_frames.render() + edited.documents[RECONSTRUCTION].go_on() + held_frames.render() + + assert edited.asked == [f"{PROJECT} {EXIT}", f"{RECONSTRUCTION} {EXIT}"] + edited.exit.assert_called_once_with() + + def test_cancel_on_the_project_keeps_the_application_open( + self, + edited: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + edited.documents[PROJECT].cancel() + held_frames.render() + + assert edited.asked == [f"{PROJECT} {EXIT}"] + edited.exit.assert_not_called() + assert ModalQueue.snapshot().is_settled + + def test_a_dialog_that_changed_nothing_leaves_the_question_to_the_reconstruction( + self, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[RECONSTRUCTION].unfinished = True + ModalQueue.open(PROJECT_PROPERTIES, lambda: None) + leaving.leave() + + ModalQueue.leave(PROJECT_PROPERTIES) + held_frames.render() + + assert leaving.asked == [f"{RECONSTRUCTION} {EXIT}"] + + +class TestTheFrameTheExitIsDecidedIn: + """The work waiting on the render thread stays unrun once the exit is decided, so nothing the reader + left behind starts after they chose to leave.""" + + @pytest.fixture(autouse=True) + def live_queue(self) -> Iterator[None]: + CallbackQueue.start() + yield + CallbackQueue.stop() + CallbackQueue.start() + + def test_the_work_due_in_that_frame_stays_unrun(self) -> None: + application = Application.__new__(Application) + ran: List[str] = [] + CallbackQueue.add(lambda: ran.append("late")) + + with patch("dearpygui.dearpygui.stop_dearpygui") as stop_dearpygui: + application._exit_application() + CallbackQueue.process(budget_seconds=1.0) + + stop_dearpygui.assert_called_once_with() + assert not ran diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py new file mode 100644 index 000000000..c643ccec8 --- /dev/null +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -0,0 +1,489 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.application import Application +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.shell import ShortcutBindings +from sampletones_core.exports.format import ExportFormat +from sampletones_shared.types.callback import VoidCallback +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import Frames, held_frames +from tests.suite.gates import HeldGate, held_gate +from tests.suite.questions import OnScreenDocument + +__all__ = ["held_frames", "held_gate"] + +COLLABORATORS: Final[Tuple[str, ...]] = ( + "_project_coordinator", + "_render_coordinator", + "_sequencer_tab", + "_config_coordinator", + "_display_coordinator", + "_keybindings_coordinator", + "_main_tab", + "_shell", +) +OWN_GESTURES: Final[Tuple[str, ...]] = ( + "_add_current_reconstruction_to_sequencer", + "_export_reconstruction_wav_dialog", + "_export_reconstruction_instruments_dialog", + "_exiting", +) +PROJECT_GUARDS: Final[Tuple[str, ...]] = ("guard_new", "guard_open", "guard_close") +RECONSTRUCTION_GUARDS: Final[Tuple[str, ...]] = ("guard_load", "guard_close") +FIRST_VOICE: Final[str] = "first-voice" +SECOND_VOICE: Final[str] = "second-voice" +PROJECT: Final[str] = "project" +CLOSE: Final[str] = "close" +OPEN: Final[str] = "open" + + +def let_through(proceed: VoidCallback, _decline: VoidCallback) -> None: + """A guard with nothing to ask about, which lets the request through at once.""" + proceed() + + +@pytest.fixture +def app(held_gate: HeldGate) -> Application: + """An application whose collaborators are stand-ins, and whose open reconstruction has an edit on its way. + + Each document's guard has nothing to ask about, so a gesture reaches its action once the edits land. + """ + app = Application.__new__(Application) + for name in COLLABORATORS: + setattr(app, name, MagicMock()) + for name in OWN_GESTURES: + setattr(app, name, MagicMock()) + for name in PROJECT_GUARDS: + getattr(app._project_coordinator, name).side_effect = let_through + app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) + app._reconstruction_coordinator.after_edits.side_effect = held_gate + for name in RECONSTRUCTION_GUARDS: + getattr(app._reconstruction_coordinator, name).side_effect = let_through + app._reconstruction_opening = app._reconstruction_opening_flight() + return app + + +@pytest.fixture +def bindings(app: Application) -> ShortcutBindings: + return app._create_shortcut_bindings() + + +class TestAWholeDocumentGestureWaitsForTheEdits(BaseTestSuite): + """A menu item or a key that reads or puts away a whole document runs once the edits before it land.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + press: Callable[[ShortcutBindings], None] + gesture: Callable[[Application], MagicMock] + + test_cases = ( + TestCase( + label="save_project", + press=lambda bindings: bindings.save_project(), + gesture=lambda app: app._project_coordinator.save, + ), + TestCase( + label="save_project_as", + press=lambda bindings: bindings.save_project_as(), + gesture=lambda app: app._project_coordinator.save_as_dialog, + ), + TestCase( + label="new_project", + press=lambda bindings: bindings.new_project(), + gesture=lambda app: app._project_coordinator.new_project, + ), + TestCase( + label="open_project", + press=lambda bindings: bindings.open_project(), + gesture=lambda app: app._project_coordinator.open_project, + ), + TestCase( + label="close_project", + press=lambda bindings: bindings.close_project(), + gesture=lambda app: app._project_coordinator.close_project, + ), + TestCase( + label="open_reconstruction", + press=lambda bindings: bindings.open_reconstruction(), + gesture=lambda app: app._reconstruction_coordinator.open, + ), + TestCase( + label="save_reconstruction", + press=lambda bindings: bindings.save_reconstruction(), + gesture=lambda app: app._reconstruction_coordinator.save, + ), + TestCase( + label="save_reconstruction_as", + press=lambda bindings: bindings.save_reconstruction_as(), + gesture=lambda app: app._reconstruction_coordinator.save_as_dialog, + ), + TestCase( + label="close_reconstruction", + press=lambda bindings: bindings.close_reconstruction(), + gesture=lambda app: app._reconstruction_coordinator.close, + ), + TestCase( + label="export_wav", + press=lambda bindings: bindings.export_wav(), + gesture=lambda app: app._export_reconstruction_wav_dialog, + ), + TestCase( + label="add_to_sequencer", + press=lambda bindings: bindings.add_reconstruction_to_sequencer(), + gesture=lambda app: app._add_current_reconstruction_to_sequencer, + ), + TestCase( + label="render_song", + press=lambda bindings: bindings.render_song(), + gesture=lambda app: app._render_coordinator.open, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_gesture_runs_once_the_edits_have_landed( + self, + test_case: TestCase, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + test_case.press(bindings) + test_case.gesture(app).assert_not_called() + + held_gate.release() + + test_case.gesture(app).assert_called_once_with() + + def test_an_export_keeps_the_format_it_was_asked_for( + self, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + bindings.export_instruments(ExportFormat.FAMITRACKER) + held_gate.release() + + app._export_reconstruction_instruments_dialog.assert_called_once_with(ExportFormat.FAMITRACKER) + + def test_a_project_export_keeps_the_format_it_was_asked_for( + self, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + bindings.export_project(ExportFormat.BITPHASE) + app._project_coordinator.export_project_dialog.assert_not_called() + + held_gate.release() + + app._project_coordinator.export_project_dialog.assert_called_once_with(ExportFormat.BITPHASE) + + def test_undo_leaves_the_wait_to_the_sequencer( + self, + app: Application, + bindings: ShortcutBindings, + ) -> None: + """The sequencer's undo waits on the same edits, so the key reaches it as it stands.""" + assert bindings.undo == app._sequencer_tab.undo + assert bindings.redo == app._sequencer_tab.redo + + +class TestADocumentGestureAsksOnce(BaseTestSuite): + """A gesture that replaces or closes a document, asked for twice while the edits before it are on + their way, asks its question once, and the answer goes on with the request made last. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + press: Callable[[ShortcutBindings], None] + guard: Callable[[Application], MagicMock] + + test_cases = ( + TestCase( + label="new_project", + press=lambda bindings: bindings.new_project(), + guard=lambda app: app._project_coordinator.guard_new, + ), + TestCase( + label="open_project", + press=lambda bindings: bindings.open_project(), + guard=lambda app: app._project_coordinator.guard_open, + ), + TestCase( + label="close_project", + press=lambda bindings: bindings.close_project(), + guard=lambda app: app._project_coordinator.guard_close, + ), + TestCase( + label="open_reconstruction", + press=lambda bindings: bindings.open_reconstruction(), + guard=lambda app: app._reconstruction_coordinator.guard_load, + ), + TestCase( + label="close_reconstruction", + press=lambda bindings: bindings.close_reconstruction(), + guard=lambda app: app._reconstruction_coordinator.guard_close, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_two_presses_ask_once( + self, + test_case: TestCase, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + test_case.guard(app).side_effect = None + + test_case.press(bindings) + test_case.press(bindings) + held_gate.release() + + test_case.guard(app).assert_called_once() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_press_after_the_answer_asks_again( + self, + test_case: TestCase, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + test_case.guard(app).side_effect = None + test_case.press(bindings) + held_gate.release() + _, decline = test_case.guard(app).call_args.args + + decline() + test_case.press(bindings) + held_gate.release() + + assert test_case.guard(app).call_count == 2 + + def test_a_browser_and_the_menu_share_one_opening( + self, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + """Opening a reconstruction asks once whichever door it was asked for through. + + The menu's Open, asked for last, is what the answer goes on with, so the reader picks a file. + """ + guard = app._reconstruction_coordinator.guard_load + guard.side_effect = None + + app._reconstruction_opening(Path("browsed.stn")) + bindings.open_reconstruction() + held_gate.release() + + guard.assert_called_once() + proceed, _ = guard.call_args.args + proceed() + app._reconstruction_coordinator.open.assert_called_once_with() + + def test_two_browsed_files_open_the_second( + self, + app: Application, + held_gate: HeldGate, + ) -> None: + guard = app._reconstruction_coordinator.guard_load + guard.side_effect = None + + app._reconstruction_opening(Path("drums.stn")) + app._reconstruction_opening(Path("bass.stn")) + held_gate.release() + + guard.assert_called_once() + proceed, _ = guard.call_args.args + proceed() + app._reconstruction_coordinator.open.assert_called_once_with(Path("bass.stn")) + + def test_editing_two_voices_opens_the_second( + self, + app: Application, + held_gate: HeldGate, + ) -> None: + guard = app._reconstruction_coordinator.guard_edit_voice + editing = app._voice_editing_flight() + + editing(FIRST_VOICE) + editing(SECOND_VOICE) + held_gate.release() + + guard.assert_called_once() + proceed, _ = guard.call_args.args + proceed() + app._reconstruction_coordinator.open_project_voice.assert_called_once_with(SECOND_VOICE) + + +class TestAnOpenAskedWhileACloseAsks: + """Opening a project while the question of its close stands asks about the project the answer leaves. + + Discard closes the project, so the opening has nothing to ask and goes on to its file dialog. Cancel + keeps the changes, so the opening asks about them. + """ + + @pytest.fixture + def asked(self) -> List[str]: + return [] + + @pytest.fixture + def project(self, app: Application, asked: List[str]) -> OnScreenDocument: + project = OnScreenDocument(PROJECT, asked) + project.unfinished = True + app._project_coordinator.guard_close.side_effect = project.guard(CLOSE) + app._project_coordinator.guard_open.side_effect = project.guard(OPEN) + app._project_coordinator.close_project.side_effect = project.finish + return project + + @pytest.fixture + def opening_over_the_close( + self, + bindings: ShortcutBindings, + held_gate: HeldGate, + project: OnScreenDocument, + ) -> OnScreenDocument: + """Close project asked for, its question standing, and Open project asked for after it.""" + bindings.close_project() + held_gate.release() + bindings.open_project() + return project + + def test_the_opening_asks_nothing_while_the_close_asks( + self, + app: Application, + opening_over_the_close: OnScreenDocument, + asked: List[str], + held_frames: Frames, + ) -> None: + held_frames.render() + + assert asked == [f"{PROJECT} {CLOSE}"] + app._project_coordinator.open_project.assert_not_called() + + def test_discard_opens_without_a_second_question( + self, + app: Application, + opening_over_the_close: OnScreenDocument, + asked: List[str], + held_frames: Frames, + ) -> None: + opening_over_the_close.go_on() + held_frames.render() + + assert asked == [f"{PROJECT} {CLOSE}"] + app._project_coordinator.close_project.assert_called_once_with() + app._project_coordinator.open_project.assert_called_once_with() + + def test_cancel_leaves_the_opening_to_ask_about_the_changes_kept( + self, + app: Application, + opening_over_the_close: OnScreenDocument, + asked: List[str], + held_frames: Frames, + ) -> None: + opening_over_the_close.cancel() + held_frames.render() + + assert asked == [f"{PROJECT} {CLOSE}", f"{PROJECT} {OPEN}"] + app._project_coordinator.open_project.assert_not_called() + + +class TestLoadingWhatARunWroteAsksOnce: + """The Converter's Load asks about the file it loads once, however often it is pressed meanwhile. + + The question speaks of the file the first press asked for, so the answer loads that file. + """ + + @pytest.fixture + def guard(self, app: Application) -> MagicMock: + guard = app._reconstruction_coordinator.guard_load_converted + guard.side_effect = None + return guard + + def test_two_presses_while_an_edit_is_on_its_way_ask_once( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + + loading(Path("written.stn")) + loading(Path("written.stn")) + held_gate.release() + + guard.assert_called_once() + assert guard.call_args.args[0] == Path("written.stn") + app._reconstruction_coordinator.load.assert_not_called() + + def test_a_second_file_asked_for_meanwhile_keeps_the_first( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + + loading(Path("written.stn")) + loading(Path("rewritten.stn")) + held_gate.release() + + guard.assert_called_once() + filepath, proceed, _ = guard.call_args.args + assert filepath == Path("written.stn") + proceed() + app._reconstruction_coordinator.load.assert_called_once_with(Path("written.stn")) + + def test_the_answer_loads_the_file_the_question_spoke_of( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + loading(Path("written.stn")) + held_gate.release() + + _, proceed, _ = guard.call_args.args + proceed() + + app._reconstruction_coordinator.load.assert_called_once_with(Path("written.stn")) + + def test_a_press_after_cancel_asks_again( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + loading(Path("written.stn")) + held_gate.release() + _, _, decline = guard.call_args.args + + decline() + loading(Path("written.stn")) + held_gate.release() + + assert guard.call_count == 2 + app._reconstruction_coordinator.load.assert_not_called() diff --git a/tests/unit/sampletones_application/test_application_playback.py b/tests/unit/sampletones_application/test_application_playback.py new file mode 100644 index 000000000..3377d4c08 --- /dev/null +++ b/tests/unit/sampletones_application/test_application_playback.py @@ -0,0 +1,77 @@ +import threading +from typing import Iterator, List, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.application import Application +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.render_thread import claim_render_thread, release_render_thread +from sampletones_shared.exceptions import PlaybackError + + +class _PresenterRecorder: + """Notes the thread each failure is presented on, which is where its dialog is built.""" + + def __init__(self) -> None: + self.presented: List[Tuple[int, Exception]] = [] + + def present_playing_failure(self, exception: Exception) -> None: + self.presented.append((threading.get_ident(), exception)) + + +@pytest.fixture(name="presenter") +def presenter_fixture() -> _PresenterRecorder: + return _PresenterRecorder() + + +@pytest.fixture(name="application") +def application_fixture(presenter: _PresenterRecorder) -> Application: + application = Application.__new__(Application) + application._playback_failures = MagicMock(spec=PlaybackFailurePresenter) + application._playback_failures.present_playing_failure.side_effect = presenter.present_playing_failure + return application + + +@pytest.fixture(name="drawing") +def drawing_fixture() -> Iterator[None]: + """A run drawing its frames on this thread, over a queue live enough to drain what reaches it.""" + CallbackQueue.start() + claim_render_thread() + yield + release_render_thread() + CallbackQueue.stop() + CallbackQueue.start() + + +class TestAPlaybackTheDeviceRefused: + """A stream that fails to open is reported where the interface is drawn, whichever thread heard of it.""" + + @pytest.mark.usefixtures("drawing") + def test_a_report_from_the_playing_thread_waits_for_the_render_thread( + self, + application: Application, + presenter: _PresenterRecorder, + ) -> None: + failure = PlaybackError("Failed to open audio stream") + worker = threading.Thread(target=application._on_playback_error, args=(failure,)) + worker.start() + worker.join() + + assert presenter.presented == [] + CallbackQueue.process(1.0) + + assert presenter.presented == [(threading.get_ident(), failure)] + + @pytest.mark.usefixtures("drawing") + def test_a_report_on_the_render_thread_is_presented_at_once( + self, + application: Application, + presenter: _PresenterRecorder, + ) -> None: + failure = PlaybackError("Failed to open audio stream") + + application._on_playback_error(failure) + + assert presenter.presented == [(threading.get_ident(), failure)] diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index 66684f8e6..dcdd7d448 100644 --- a/tests/unit/sampletones_application/test_application_retune.py +++ b/tests/unit/sampletones_application/test_application_retune.py @@ -1,150 +1,222 @@ -from typing import List, Optional +from typing import Final, List, Optional from unittest.mock import MagicMock from sampletones_application.application import Application -from sampletones_application.services.result import ServiceCancelled +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.edit import Retune +from sampletones_application.logic.reconstruction.rewrites.steps import RateChange +from sampletones_application.services.result import ServiceCanceled from sampletones_application.services.retune import RetunedSample +from sampletones_core.project.voices.sample import Sample +OPEN_VOICE_ID: Final[str] = "open" +RATE: Final[int] = 60 -def _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, source: MagicMock) -> RetunedSample: reconstruction = MagicMock() reconstruction.config.nes_frequency = rate - return RetunedSample(sample_id=sample_id, reconstruction=reconstruction) + return RetunedSample(voice_id=voice_id, reconstruction=reconstruction, source=source) def _app( current_rate: int, - sample: Optional[MagicMock], - open_reconstruction: Optional[MagicMock] = None, + sample: Optional[Sample], + *, + open_voice_id: Optional[str], ) -> Application: app = Application.__new__(Application) app.project_manager = MagicMock() app.project_manager.current.settings.nes_frequency = current_rate - app.project_manager.current.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.reconstruction_manager.voice_id = open_voice_id app.history = MagicMock() app.project_controller = MagicMock() app._sequencer_tab = MagicMock() - app._reconstructions_tab = MagicMock() + app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) return app class TestApplyRetunedSample: def test_swaps_the_reconstruction_when_the_rate_matches(self) -> None: - app = _app(current_rate=60, sample=MagicMock()) - retuned = _retuned("lead", 60) + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) + retuned = _retuned("lead", RATE, source=sample.reconstruction) app._apply_retuned_sample(retuned) app.project_controller.replace_sample_reconstruction.assert_called_once_with("lead", retuned.reconstruction) def test_discards_a_stale_result_from_a_superseded_rate(self) -> None: - app = _app(current_rate=30, sample=MagicMock()) - retuned = _retuned("lead", 60) + sample = _sample_double() + app = _app(current_rate=30, sample=sample, open_voice_id=None) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned("lead", RATE, source=sample.reconstruction)) app.project_controller.replace_sample_reconstruction.assert_not_called() def test_ignores_a_removed_sample(self) -> None: - app = _app(current_rate=60, sample=None) - retuned = _retuned("lead", 60) + app = _app(current_rate=RATE, sample=None, open_voice_id=None) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned("lead", RATE, source=MagicMock())) app.project_controller.replace_sample_reconstruction.assert_not_called() - def test_rebinds_the_open_editor_when_it_shows_the_sample(self) -> None: - open_reconstruction = MagicMock() - sample = MagicMock() - sample.reconstruction = open_reconstruction - app = _app(current_rate=60, sample=sample, open_reconstruction=open_reconstruction) - retuned = _retuned("lead", 60) + def test_folds_the_retune_into_the_rate_change_entry(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) - app._apply_retuned_sample(retuned) + app._apply_retuned_sample(_retuned("lead", RATE, source=sample.reconstruction)) - app.reconstruction_manager.apply_regenerated.assert_called_once_with(retuned.reconstruction) - app._reconstructions_tab.update_reconstruction.assert_called_once() + action = app.history.transaction.call_args.args[0] + assert action is HistoryAction.SET_NES_FREQUENCY + assert app.history.transaction.call_args.kwargs["coalesce"] == (RATE,) - def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: - sample = MagicMock() - sample.reconstruction = MagicMock() - app = _app(current_rate=60, sample=sample, open_reconstruction=MagicMock()) - retuned = _retuned("lead", 60) + def test_a_sample_opened_since_the_batch_started_takes_the_rate_as_a_step(self) -> None: + """The open document changes one step at a time, so the rate waits for the edits made there.""" + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=sample.id) + + app._apply_retuned_sample(_retuned(sample.id, RATE, source=sample.reconstruction)) + + app._reconstruction_coordinator.request_rewrite.assert_called_once_with(RateChange(nes_frequency=RATE)) + app.project_controller.replace_sample_reconstruction.assert_not_called() + + def test_another_samples_retune_leaves_the_open_document_alone(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=OPEN_VOICE_ID) + + app._apply_retuned_sample(_retuned("lead", RATE, source=sample.reconstruction)) + + app._reconstruction_coordinator.request_rewrite.assert_not_called() + + def test_a_discarded_result_reaches_no_document(self) -> None: + sample = _sample_double() + app = _app(current_rate=30, sample=sample, open_voice_id=sample.id) + + app._apply_retuned_sample(_retuned(sample.id, RATE, source=sample.reconstruction)) + + app._reconstruction_coordinator.request_rewrite.assert_not_called() - app._apply_retuned_sample(retuned) - app.reconstruction_manager.apply_regenerated.assert_not_called() - app._reconstructions_tab.update_reconstruction.assert_not_called() +class TestASampleChangedSinceTheBatchStarted: + """A batch retunes what each sample held as it started, so a sample edited since is retuned afresh.""" + def test_it_is_retuned_from_what_it_now_holds(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) -def _sample(sample_id: str, rate: int) -> MagicMock: - sample = MagicMock() - sample.id = sample_id + app._apply_retuned_sample(_retuned("lead", RATE, source=MagicMock())) + + app.project_controller.replace_sample_reconstruction.assert_called_once_with( + "lead", + sample.reconstruction.with_nes_frequency.return_value, + ) + + def test_a_sample_already_at_the_rate_is_left_as_it_is(self) -> None: + sample = _sample_double() + sample.reconstruction.with_nes_frequency.return_value = sample.reconstruction + app = _app(current_rate=RATE, sample=sample, open_voice_id=None) + + app._apply_retuned_sample(_retuned("lead", RATE, source=MagicMock())) + + app.project_controller.replace_sample_reconstruction.assert_not_called() + + +def _sample(voice_id: str, rate: int) -> Sample: + sample = _sample_double() + sample.id = voice_id sample.reconstruction.config.nes_frequency = rate return sample def _app_for_rate( - samples: List[MagicMock], - open_reconstruction: Optional[MagicMock], + samples: List[Sample], + open_sample: Optional[Sample], 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.project_manager.current.voice.side_effect = {sample.id: sample for sample in samples}.get app.reconstruction_manager = MagicMock() - app.reconstruction_manager.reconstruction = open_reconstruction + app.reconstruction_manager.voice_id = None if open_sample is None else open_sample.id app.retune_service = MagicMock() app.retune_service.start.return_value = True app.retune_service.is_running.return_value = running app.status_bar = MagicMock() app.language_manager = MagicMock() app._reconstructions_tab = MagicMock() + app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) return app -class TestRetuneDim: - def test_dims_the_open_reconstruction_when_it_will_be_retuned(self) -> None: - open_sample = _sample("open", 30) - app = _app_for_rate( - [open_sample, _sample("other", 30)], - open_reconstruction=open_sample.reconstruction, - ) +class TestTheOpenSampleTakesTheRateAsAStep: + """The sample open on the Reconstructions tab is re-timed as a step of its document, after the edits made there.""" - app._retune_samples_for_rate(60) + def test_the_open_sample_asks_for_the_rate(self) -> None: + open_sample = _sample(OPEN_VOICE_ID, 30) + app = _app_for_rate([open_sample, _sample("other", 30)], open_sample=open_sample) - app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(True) + app._retune_samples_for_rate(RATE) - def test_does_not_dim_when_the_open_sample_already_matches(self) -> None: - open_sample = _sample("open", 60) - app = _app_for_rate( - [open_sample, _sample("other", 30)], - open_reconstruction=open_sample.reconstruction, - ) + app._reconstruction_coordinator.request_rewrite.assert_called_once_with(RateChange(nes_frequency=RATE)) + + def test_the_open_sample_is_left_out_of_the_batch(self) -> None: + open_sample = _sample(OPEN_VOICE_ID, 30) + other = _sample("other", 30) + app = _app_for_rate([open_sample, other], open_sample=open_sample) + + app._retune_samples_for_rate(RATE) + + targets, rate = app.retune_service.start.call_args.args + assert [voice_id for voice_id, _reconstruction in targets] == [other.id] + assert rate == RATE + + def test_no_sample_open_asks_for_no_step(self) -> None: + app = _app_for_rate([_sample("a", 30), _sample("b", 30)], open_sample=None) + + app._retune_samples_for_rate(RATE) + + app._reconstruction_coordinator.request_rewrite.assert_not_called() + assert len(app.retune_service.start.call_args.args[0]) == 2 - app._retune_samples_for_rate(60) + def test_the_status_clears_when_the_batch_finishes(self) -> None: + app = _app_for_rate([], open_sample=None, running=False) - app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() + app._on_retune_result(ServiceCanceled()) - def test_does_not_dim_when_no_reconstruction_is_open(self) -> None: - app = _app_for_rate([_sample("a", 30), _sample("b", 30)], open_reconstruction=None) + app.status_bar.set.assert_called_once_with("") - app._retune_samples_for_rate(60) + def test_the_status_stands_while_the_batch_is_running(self) -> None: + app = _app_for_rate([], open_sample=None, running=True) - app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() + app._on_retune_result(ServiceCanceled()) - def test_restores_the_dim_when_the_batch_finishes(self) -> None: - app = _app_for_rate([], open_reconstruction=None, running=False) + app.status_bar.set.assert_not_called() - app._on_retune_result(ServiceCancelled()) - app._reconstructions_tab.set_reconstruction_dimmed.assert_called_once_with(False) +class TestARetuneOfTheOpenSampleIsRecorded: + """A retune landing on the open sample joins the rate change it follows.""" - def test_keeps_the_dim_while_the_batch_is_running(self) -> None: - app = _app_for_rate([], open_reconstruction=None, running=True) + def test_it_is_recorded_as_the_rate_change(self) -> None: + sample = _sample_double() + app = _app(current_rate=RATE, sample=sample, open_voice_id=sample.id) + app.project_manager.current.voice.return_value = sample + retuned = MagicMock() - app._on_retune_result(ServiceCancelled()) + app._on_reconstruction_updated(Retune(reconstruction=retuned, nes_frequency=RATE)) - app._reconstructions_tab.set_reconstruction_dimmed.assert_not_called() + transaction = app.history.transaction.call_args + assert transaction.args[0] is HistoryAction.SET_NES_FREQUENCY + assert transaction.kwargs["coalesce"] == (RATE,) + assert transaction.kwargs["detail"] is app._sequencer_tab.nes_frequency_detail.return_value + app._sequencer_tab.nes_frequency_detail.assert_called_once_with(RATE) + app.project_controller.replace_sample_reconstruction.assert_called_once_with(sample.id, retuned) diff --git a/tests/unit/sampletones_application/test_application_sample_rebind.py b/tests/unit/sampletones_application/test_application_sample_rebind.py deleted file mode 100644 index dabb9a97c..000000000 --- a/tests/unit/sampletones_application/test_application_sample_rebind.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Optional -from unittest.mock import MagicMock - -from sampletones_application.application import Application - - -def _app( - sample: Optional[MagicMock], - open_reconstruction: Optional[MagicMock], -) -> Application: - app = Application.__new__(Application) - app.project_manager = MagicMock() - app.project_manager.current.sample.return_value = sample - app.reconstruction_manager = MagicMock() - app.reconstruction_manager.reconstruction = open_reconstruction - app._reconstructions_tab = MagicMock() - return app - - -class TestRebindReplacedSample: - def test_rebinds_the_editor_showing_the_replaced_sample(self) -> None: - outgoing = MagicMock() - sample = MagicMock() - 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._reconstructions_tab.update_reconstruction.assert_called_once() - - def test_leaves_the_editor_alone_when_a_different_sample_is_open(self) -> None: - sample = MagicMock() - 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._reconstructions_tab.update_reconstruction.assert_not_called() - - def test_leaves_the_editor_alone_when_no_document_is_open(self) -> None: - sample = MagicMock() - 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._reconstructions_tab.update_reconstruction.assert_not_called() - - def test_ignores_an_unknown_sample(self) -> None: - app = _app(sample=None, open_reconstruction=MagicMock()) - - app._rebind_replaced_sample("gone", MagicMock()) - - app.reconstruction_manager.apply_regenerated.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..52f994c2c 100644 --- a/tests/unit/sampletones_application/test_busy_lock.py +++ b/tests/unit/sampletones_application/test_busy_lock.py @@ -1,6 +1,21 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable from unittest.mock import MagicMock +import pytest + 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.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.exports.stage import ExportStage +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +NOTHING_MEASURED: int = 0 +BYTES_SO_FAR: int = 812 def _application( @@ -8,6 +23,8 @@ def _application( converter_running: bool = False, library_generating: bool = False, rendering: bool = False, + setting_up_nsf: bool = False, + exporting: bool = False, ) -> Application: """An application with only the attributes the busy methods touch, bypassing the full composition root constructor.""" @@ -19,13 +36,17 @@ def _application( application._reconstructions_tab = MagicMock() application._render_coordinator = MagicMock() application._render_coordinator.is_active = rendering + application._nsf_exports = MagicMock() + application._nsf_exports.is_active = setting_up_nsf + application.export_service = MagicMock() + application.export_service.is_running.return_value = exporting application._update_menu = MagicMock() 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 +57,49 @@ def test_busy_while_library_generates(self) -> None: def test_busy_while_song_renders(self) -> None: assert _application(rendering=True)._is_operation_active() is True + def test_busy_while_an_nsf_export_is_set_up(self) -> None: + assert _application(setting_up_nsf=True)._is_operation_active() is True + + def test_busy_while_an_export_writes(self) -> None: + assert _application(exporting=True)._is_operation_active() is True + 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, + skipped_rows=NO_SKIPPED_ROWS, + ) + ) + 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, @@ -48,14 +108,29 @@ class TestBusyRefreshPropagation: def test_refresh_nudges_both_tabs(self) -> None: application = _application() application._refresh_busy_state() - application._instructions_tab.refresh_generate_button.assert_called_once_with() + application._instructions_tab.follow_busy_state.assert_called_once_with() def test_refresh_reaches_the_menu(self) -> None: application = _application() application._refresh_busy_state() application._update_menu.assert_called_once_with() - def test_a_render_edge_refreshes_the_converter_view(self) -> None: + +class TestOtherOperationEdges(BaseTestSuite): + """The edge of an operation outside the converter reaches the converter's view, which hears of + its own edges by itself.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + edge: Callable[[Application], None] + + test_cases = ( + TestCase(label="a_dialog_edge", edge=Application._on_dialog_activity_changed), + TestCase(label="a_library_edge", edge=Application._on_library_operation_changed), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_edge_reaches_the_converter_view(self, test_case: TestCase) -> None: application = _application() - application._on_render_activity_changed() - application._main_tab.refresh_converter_view.assert_called_once_with() + test_case.edge(application) + application._main_tab.follow_busy_state.assert_called_once_with() diff --git a/tests/unit/sampletones_application/test_project_properties_history.py b/tests/unit/sampletones_application/test_project_properties_history.py index ce9ff781b..65c2a3ac3 100644 --- a/tests/unit/sampletones_application/test_project_properties_history.py +++ b/tests/unit/sampletones_application/test_project_properties_history.py @@ -2,9 +2,9 @@ from sampletones_application.application import Application 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 tests.suite.history.wiring import wired_history HISTORY_BUDGET: Final[int] = 10 FIRST_HIGHLIGHT: Final[int] = 3 @@ -17,8 +17,7 @@ def _application() -> Application: """ application = Application.__new__(Application) controller = ProjectController(ProjectManager()) - history = HistoryManager(controller, budget=HISTORY_BUDGET, strict=True) - controller.on_mutation = history.handle_mutation + history = wired_history(controller, budget=HISTORY_BUDGET, strict=True) controller.new() history.reset() application.project_controller = controller @@ -47,7 +46,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..f457bf4ec 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1,18 +1,57 @@ from contextlib import ExitStack, contextmanager from pathlib import Path -from typing import Any, Callable, Dict, Final, Generator, List -from unittest.mock import PropertyMock, patch +from typing import Any, Callable, Dict, Final, FrozenSet, Generator, List, Optional, Tuple, Union +from unittest.mock import MagicMock, PropertyMock, patch import dearpygui.dearpygui as dpg import pytest from sampletones_application.application import Application from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.managers.application import ApplicationConfigManager from sampletones_application.config.managers.session import SessionManager -from sampletones_application.config.profile import UserProfile +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.instruments import INSTRUMENT_CHANNEL from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SettingsField, SourceKind from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.logic.reconstruction.edit import ChannelEdit +from sampletones_application.logic.reconstruction.rewrites.steps import ChannelChange +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_GROUP, + SUF_HANDLER_REGISTRY, + SUF_STRIP, + SUF_TABLE, + SUF_TABLE_COLUMN, + SUF_TEXT, + TAG_GLOBAL_THEME_STEMS_ROW_INERT, +) +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, + TAG_MAIN_CONFIG_PANEL, + TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, + TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, + TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_CONVERTER_RADIO_MODE, + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_WINDOW_STEMS, + TAG_MAIN_SOURCE_PANEL, + TAG_MAIN_SOURCE_TABLE_CHANNELS, + TAG_MAIN_SOURCE_TEXT_SUBJECT, +) +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.panels.main import explorer as explorer_module +from sampletones_application.ui.panels.main.source.rows import ChannelSettingsRows +from sampletones_application.ui.panels.main.source.steps import ChannelCapSteps from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.utils.gui.keyboard.modifiers import Modifier from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, TAB_SHORTCUT_IDS, @@ -22,49 +61,56 @@ 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, ConverterViewModel +from sampletones_application.view_model.reconstruction.envelopes import ChannelEnvelopesViewModel +from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.audio import CurrentDevice +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.constants.audio import BufferSize, SampleRate +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.converter.paths import get_audio_files +from sampletones_core.structures.tree import FileSystemNode, NodeType +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT +from tests.conftest import ReconstructionFactory +from tests.suite.application import HeldQueue, held_queue, settled +from tests.suite.gestures import DOUBLE_CLICKED, click_row_name +from tests.suite.headless import ( + VIEWPORT_CLIENT_HEIGHT, + VIEWPORT_CLIENT_WIDTH, + app, + display_patches, + headless_application, + profile_in, +) REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} +DRAG_PAYLOAD_SLOT: Final[int] = 3 +LOUD_DRIVE: Final[float] = 1.75 +UNBUILT_ROW: Final[str] = "browser.row.unbuilt" +EDITED_VOLUME: Final[Envelope[int]] = Envelope[int](items=(7, 7)) +REMEMBERED_DEVICE: Final[CurrentDevice] = CurrentDevice( + device_index=7, + name="Remembered headphones", + sample_rate=48000, + host_api=0, +) +SPEAKERS: Final[Dict[str, Union[int, str]]] = { + "index": 0, + "name": "Speakers", + "maxOutputChannels": 2, + "defaultSampleRate": 44100, + "hostApi": 0, +} +CHOSEN_SAMPLE_RATE: Final[SampleRate] = 48000 +CHOSEN_BUFFER_SIZE: Final[BufferSize] = 512 +APPLIED_BUFFER_SIZE: Final[BufferSize] = 2048 -_DPG_DISPLAY_FUNCTIONS = [ - "create_context", - "create_viewport", - "setup_dearpygui", - "show_viewport", - "render_dearpygui_frame", - "set_viewport_clear_color", - "set_viewport_pos", - "set_viewport_width", - "set_viewport_height", - "set_viewport_title", - "set_viewport_decorated", - "set_viewport_resize_callback", - "toggle_viewport_fullscreen", - "set_exit_callback", - "set_primary_window", -] - -_VIEWPORT_CLIENT_WIDTH: Final[int] = 1280 -_VIEWPORT_CLIENT_HEIGHT: Final[int] = 720 - - -def _display_patches() -> List[Any]: - display_patches = [patch(f"dearpygui.dearpygui.{name}", return_value=None) for name in _DPG_DISPLAY_FUNCTIONS] - display_patches.append( - patch( - "dearpygui.dearpygui.get_viewport_client_width", - return_value=_VIEWPORT_CLIENT_WIDTH, - ) - ) - display_patches.append( - patch( - "dearpygui.dearpygui.get_viewport_client_height", - return_value=_VIEWPORT_CLIENT_HEIGHT, - ) - ) - display_patches.append(patch("sampletones_application.utils.callbacks.queue.CallbackQueue.start")) - return display_patches +__all__ = ["app", "held_queue"] @contextmanager @@ -80,17 +126,27 @@ def _no_audio_devices() -> Generator[None, None, None]: yield -def _profile(directory: Path) -> UserProfile: - """Starts the application on a profile of its own, in the state a first run finds. +@contextmanager +def _viewport_geometry() -> Generator[None, None, None]: + """The window's place and size as the session reads them when a run leaves.""" + with ( + patch("dearpygui.dearpygui.get_viewport_pos", return_value=[0, 0]), + patch("dearpygui.dearpygui.get_viewport_width", return_value=VIEWPORT_CLIENT_WIDTH), + patch("dearpygui.dearpygui.get_viewport_height", return_value=VIEWPORT_CLIENT_HEIGHT), + ): + yield + - The settings and the keys an application comes up on are read from its profile, so a suite - given the user's own answers for whatever that machine prefers. A directory per test is what - holds a run to the shipped defaults. - """ - return UserProfile( - config=directory / "config.yaml", - state=directory / "state.yaml", - ) +@contextmanager +def _one_audio_device() -> Generator[None, None, None]: + """A machine offering one pair of speakers as its default output.""" + with ( + patch("pyaudio.PyAudio.get_device_count", return_value=1), + patch("pyaudio.PyAudio.get_device_info_by_index", return_value=SPEAKERS), + patch("pyaudio.PyAudio.get_default_output_device_info", return_value=SPEAKERS), + patch("pyaudio.PyAudio.is_format_supported", return_value=True), + ): + yield class TestGUIStartup: @@ -102,14 +158,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(): + for display_patch in display_patches(): stack.enter_context(display_patch) - Application(profile=_profile(tmp_path)) + headless_application(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 @@ -117,27 +173,124 @@ def test_initialises_where_nothing_can_play(self, tmp_path: Path) -> None: sounds works on it. """ with ExitStack() as stack: - for display_patch in _display_patches(): + for display_patch in display_patches(): stack.enter_context(display_patch) stack.enter_context(_no_audio_devices()) - Application(profile=_profile(tmp_path)) + headless_application(tmp_path) -@pytest.fixture -def app(tmp_path: Path) -> Generator[Any, Application, Any]: - dpg.create_context() - try: - with ExitStack() as stack: - for display_patch in _display_patches(): - stack.enter_context(display_patch) +class TestLeaving: + """Leaving lets go of everything the run holds and writes the session, whatever the machine offers. + + The session keeps the output device the user last committed in Audio settings. A machine offering + no output device therefore leaves cleanly, and the device the session remembers stays remembered + while it is unplugged, as a remembered folder does. + """ - yield Application(profile=_profile(tmp_path)) - finally: + @pytest.fixture(autouse=True) + def dpg_context(self) -> Generator[Any, Application, Any]: + dpg.create_context() + yield stop_background_workers() SingleThreadExecutor.reset_shutdown() dpg.destroy_context() + @staticmethod + def _remember_device(directory: Path) -> None: + """Writes a session whose last committed device is one this machine does not offer.""" + settings = ApplicationConfigManager(profile_in(directory).config) + settings.set_audio_settings(REMEMBERED_DEVICE, CHOSEN_BUFFER_SIZE) + settings.save() + + @staticmethod + def _leave(application: Application) -> None: + """Takes the teardown a run takes once its loop has ended, leaving the context to the fixture.""" + with patch("dearpygui.dearpygui.destroy_context") as destroy_context, application._teardown(): + pass + + destroy_context.assert_called_once_with() + + @staticmethod + def _remembered_device(directory: Path) -> CurrentDevice: + return ApplicationConfigManager(profile_in(directory).config).current_audio_device + + def test_a_machine_offering_no_device_leaves_and_keeps_the_remembered_one(self, tmp_path: Path) -> None: + self._remember_device(tmp_path) + with ExitStack() as stack: + for display_patch in display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_no_audio_devices()) + + application = headless_application(tmp_path) + assert application.audio_device_manager.get_current_device() is None + self._leave(application) + + assert self._remembered_device(tmp_path) == REMEMBERED_DEVICE + assert profile_in(tmp_path).state.exists() + + def test_a_committed_device_is_what_the_session_keeps(self, tmp_path: Path) -> None: + self._remember_device(tmp_path) + with ExitStack() as stack: + for display_patch in display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_one_audio_device()) + + application = headless_application(tmp_path) + application._apply_audio_settings( + int(SPEAKERS["index"]), + CHOSEN_SAMPLE_RATE, + APPLIED_BUFFER_SIZE, + ) + committed = application.audio_device_manager.get_current_device() + self._leave(application) + + assert committed is not None + assert committed.sample_rate == CHOSEN_SAMPLE_RATE + assert self._remembered_device(tmp_path) == committed + assert ApplicationConfigManager(profile_in(tmp_path).config).current_buffer_size == APPLIED_BUFFER_SIZE + + def test_a_failing_step_leaves_the_later_ones_taken(self, tmp_path: Path) -> None: + with ExitStack() as stack: + for display_patch in display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_no_audio_devices()) + + application = headless_application(tmp_path) + with ( + patch.object(application._main_tab, "cleanup", side_effect=RuntimeError), + patch("dearpygui.dearpygui.destroy_context") as destroy_context, + pytest.raises(RuntimeError), + application._teardown(), + ): + pass + + destroy_context.assert_called_once_with() + assert application.audio_device_manager._pyaudio is None + assert profile_in(tmp_path).state.exists() + + def test_a_failing_step_keeps_the_failure_that_ended_the_run(self, tmp_path: Path) -> None: + """The traceback of a step failing on the way out names the failure the run ended on.""" + with ExitStack() as stack: + for display_patch in display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_no_audio_devices()) + + application = headless_application(tmp_path) + with ( + patch.object(application._main_tab, "cleanup", side_effect=RuntimeError("cleanup")), + patch("dearpygui.dearpygui.destroy_context"), + pytest.raises(RuntimeError) as raised, + application._teardown(), + ): + raise ValueError("the run") + + assert isinstance(raised.value.__context__, ValueError) + class TestKeybindingPreferences: """The application runs on the keys the session stores, which is what makes a rebind stick. @@ -151,7 +304,7 @@ def application(self, tmp_path: Path) -> Generator[Any, Application, Any]: dpg.create_context() try: with ExitStack() as stack: - for display_patch in _display_patches(): + for display_patch in display_patches(): stack.enter_context(display_patch) stack.enter_context( @@ -170,7 +323,7 @@ def application(self, tmp_path: Path) -> Generator[Any, Application, Any]: return_value=REBOUND_UNDO, ) ) - yield Application(profile=_profile(tmp_path)) + yield headless_application(tmp_path) finally: stop_background_workers() SingleThreadExecutor.reset_shutdown() @@ -196,7 +349,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: @@ -212,10 +365,34 @@ def test_reconstruction_restore_delegates_to_coordinator(self, app: Application) load_reconstruction_safely.assert_called_once_with(Path("last.stn")) def test_library_load_delegates_to_coordinator(self, app: Application) -> None: - with patch.object(app._instructions_tab, "load_library_safely") as load_library_safely: + with patch.object(app._instructions_tab, "load_library_file") as load_library_file: app._try_load_library(Path("last.ins")) - load_library_safely.assert_called_once_with(Path("last.ins")) + load_library_file.assert_called_once_with(Path("last.ins")) + + +@pytest.fixture +def embedded_sample( + app: Application, + reconstruction_factory: ReconstructionFactory, +) -> Sample: + """A sample added to a new project and opened on the Reconstructions tab, as Edit opens it.""" + app.project_controller.new() + with app.history.transaction(HistoryAction.ADD_SAMPLE): + sample = app.project_controller.add_sample(reconstruction_factory(), "Lead") + app._reconstruction_coordinator.open_project_voice(sample.id) + return sample + + +@pytest.fixture +def embedded_instrument(app: Application) -> Instrument: + """An instrument added to a new project and opened on the Reconstructions tab, as Edit opens it.""" + app.project_controller.new() + app._add_instrument() + instrument = app.project_manager.current.voices[0] + assert isinstance(instrument, Instrument) + app._reconstruction_coordinator.open_project_voice(instrument.id) + return instrument class TestReconstructionSaveAsDetachment: @@ -226,48 +403,32 @@ class TestReconstructionSaveAsDetachment: while the project's sample keeps its original reconstruction object. """ - def _embed_sample( - self, - app: Application, - reconstruction_factory: Callable[[], Reconstruction], - ) -> Any: - app.project_controller.new() - reconstruction = reconstruction_factory() - with app.history.transaction(HistoryAction.ADD_SAMPLE): - sample = app.project_controller.add_sample(reconstruction, "Lead") - app._edit_project_sample(sample.id) - return sample - def test_embedded_reconstruction_is_owned_and_not_saveable( self, app: Application, - reconstruction_factory: Callable[[], Reconstruction], + embedded_sample: Sample, ) -> None: - sample = self._embed_sample(app, reconstruction_factory) - - assert app._owning_project_sample() is sample + assert app._owning_project_sample() is embedded_sample assert not app._reconstruction_coordinator.is_saveable() assert not app._build_menu_bar_viewmodel().reconstruction_saveable def test_embedded_reconstruction_needs_no_save_prompt_when_edited( self, app: Application, - reconstruction_factory: Callable[[], Reconstruction], + embedded_sample: Sample, ) -> None: - self._embed_sample(app, reconstruction_factory) app.reconstruction_manager.mark_updated() assert app._reconstruction_coordinator.is_unsaved() - assert not app._reconstruction_coordinator._requires_save_confirmation() + assert not app._reconstruction_coordinator.is_unsaved_standalone() def test_save_as_detaches_open_document_from_the_project( self, app: Application, - reconstruction_factory: Callable[[], Reconstruction], + embedded_sample: Sample, tmp_path: Path, ) -> None: - sample = self._embed_sample(app, reconstruction_factory) - original = sample.reconstruction + original = embedded_sample.reconstruction app.reconstruction_manager.save_reconstruction_as(tmp_path / "lead.stn") @@ -275,8 +436,8 @@ def test_save_as_detaches_open_document_from_the_project( assert app._reconstruction_coordinator.is_saveable() assert app._build_menu_bar_viewmodel().reconstruction_saveable assert app.reconstruction_manager.reconstruction is not original - assert sample.reconstruction is original - assert original in [sample.reconstruction for sample in app.project_manager.current.samples] + assert embedded_sample.reconstruction is original + assert original in [sample.reconstruction for sample in app.project_manager.current.voices] class TestAddOpenReconstructionToSequencer: @@ -306,12 +467,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,12 +486,359 @@ 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() +class TestTheReconstructionsTabFollowsTheProject: + """The Reconstructions tab knows the voice it shows by its id, so a restore and a removal reach it. + + An undo or a redo keeping the voice shows it as the project now holds it, and a change that takes + the voice out of the project, or puts another project in place, empties the tab. + """ + + @staticmethod + def _edit(app: Application, reconstruction_factory: ReconstructionFactory) -> Reconstruction: + """A regenerated instrument landing on the open sample, the way a drag on the panel lands. + + A rebuild starts from the open sample, which names no recording's location, so the edit + it lands names none either. + """ + edited = reconstruction_factory().detached() + app._reconstruction_coordinator.apply_edit( + ChannelEdit( + reconstruction=edited, + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + ) + ) + return edited + + @staticmethod + def _shows_nothing(app: Application, held_queue: HeldQueue) -> bool: + """Whether the tab stands empty once the close it queued has run.""" + held_queue.drain() + return ( + app.reconstruction_manager.current_reconstruction is None + and not app._reconstruction_coordinator.is_loaded() + ) + + @staticmethod + def _drawn_views(app: Application) -> List[ReconstructionInstrumentsViewModel]: + """Every view the instruments panel is handed from here on.""" + views: List[ReconstructionInstrumentsViewModel] = [] + app._reconstructions_tab._reconstruction_instruments_logic.on_view_changed = views.append + return views + + @staticmethod + def _drawn_envelopes(app: Application) -> List[Optional[ChannelEnvelopesViewModel]]: + """Every set of envelopes the instruments panel is handed from here on.""" + drawn: List[Optional[ChannelEnvelopesViewModel]] = [] + app._reconstructions_tab._reconstruction_instruments_logic.on_feature_data_changed = drawn.append + return drawn + + def test_an_undo_shows_the_reconstruction_it_restores( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + original = embedded_sample.reconstruction + self._edit(app, reconstruction_factory) + + app.history.undo() + + assert app.reconstruction_manager.reconstruction is original + + def test_the_sample_an_undo_keeps_still_owns_the_tab( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """The title and the next edit reach the sample, so the tab stays a view of the project.""" + self._edit(app, reconstruction_factory) + + app.history.undo() + + owner = app._owning_project_sample() + title = app._reconstruction_title_part() + assert owner is not None + assert owner.id == embedded_sample.id + assert title is not None + assert title.included + + def test_an_undo_redraws_the_instruments_panel( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + self._edit(app, reconstruction_factory) + drawn = self._drawn_envelopes(app) + + app.history.undo() + + assert drawn == [app.reconstruction_manager.current_features] + + def test_a_redo_shows_the_edit_again( + self, + app: Application, + embedded_sample: Sample, + reconstruction_factory: ReconstructionFactory, + ) -> None: + edited = self._edit(app, reconstruction_factory) + app.history.undo() + + app.history.redo() + + owner = app._owning_project_sample() + assert app.reconstruction_manager.reconstruction is edited + assert owner is not None + assert owner.id == embedded_sample.id + + def test_an_undo_of_an_instrument_edit_draws_the_envelope_it_restores( + self, + app: Application, + embedded_instrument: Instrument, + ) -> None: + before = embedded_instrument.instrument_features().volume + instruments_logic = app._reconstructions_tab._reconstruction_instruments_logic + instruments_logic.handle_envelope_changed(INSTRUMENT_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) + drawn = self._drawn_envelopes(app) + + app.history.undo() + + assert drawn + envelopes = drawn[-1] + assert envelopes is not None + assert envelopes[INSTRUMENT_CHANNEL].volume == before + + def test_an_undo_taking_the_sample_out_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + app.history.undo() + + assert app.project_manager.current.voice(embedded_sample.id) is None + assert self._shows_nothing(app, held_queue) + + def test_a_redo_bringing_the_sample_back_leaves_the_tab_empty( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + """A voice that comes back is the reader's to open again.""" + app.history.undo() + + app.history.redo() + + assert app.project_manager.current.voice(embedded_sample.id) is not None + assert self._shows_nothing(app, held_queue) + + def test_removing_the_open_sample_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + app._sequencer_tab._voices.remove(embedded_sample.id) + + assert self._shows_nothing(app, held_queue) + + def test_removing_the_open_instrument_empties_the_panel( + self, + app: Application, + embedded_instrument: Instrument, + ) -> None: + views = self._drawn_views(app) + + app._sequencer_tab._voices.remove(embedded_instrument.id) + + assert views + assert views[-1].instrument is None + assert not views[-1].reconstruction_loaded + + def test_closing_the_project_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> None: + app.project_controller.close() + + assert self._shows_nothing(app, held_queue) + + def test_reopening_the_saved_project_empties_the_tab( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + tmp_path: Path, + ) -> None: + """The file keeps the voice's id, so the reopened project holds a voice the tab no longer shows.""" + path = tmp_path / f"song{EXT_FILE_PROJECT}" + app.project_controller.save(path) + + app.project_controller.load(path) + + assert isinstance(app.project_manager.current.voice(embedded_sample.id), Sample) + assert self._shows_nothing(app, held_queue) + + def test_a_reconstruction_opened_from_a_file_outlasts_a_new_and_a_closed_project( + self, + app: Application, + reconstruction_factory: ReconstructionFactory, + held_queue: HeldQueue, + tmp_path: Path, + ) -> None: + path = tmp_path / "lead.stn" + reconstruction_factory().save(path) + app.reconstruction_manager.load_reconstruction(path) + + app.project_controller.new() + app.project_controller.close() + + held_queue.drain() + assert app.reconstruction_manager.filepath == path + + +class TestAGestureOnTheWholeDocumentWaitsForTheEdits: + """A gesture that reads or puts away the open document runs once the edit on its way has landed. + + The edit's rebuild is held, so each gesture meets an edit still on its way, the way a click + right after a drag meets it. + """ + + @pytest.fixture + def edit_on_its_way( + self, + app: Application, + embedded_sample: Sample, + held_queue: HeldQueue, + ) -> Sample: + """The open sample with a volume edit rebuilt and its result held until the case drains the queue.""" + held_queue.drain() + with patch.object( + app.regeneration_service._executor, + "execute", + side_effect=lambda target, wait: target() or True, + ): + app._reconstruction_coordinator.request_rewrite( + ChannelChange( + channel_name=ChannelName.PULSE1, + feature_key=FeatureKey.VOLUME, + envelopes={FeatureKey.VOLUME: EDITED_VOLUME}, + initial_pitch=None, + ) + ) + + assert app.reconstruction_rewrites.is_busy + return embedded_sample + + @staticmethod + def _edited(reconstruction: Optional[Reconstruction]) -> bool: + assert reconstruction is not None + return ( + reconstruction.export()[ChannelName.PULSE1].volume.items[: len(EDITED_VOLUME.items)] == EDITED_VOLUME.items + ) + + def test_a_browser_load_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + path = tmp_path / "other.stn" + reconstruction_factory().save(path) + + app._reconstructions_tab._browser_panel.on_load_reconstruction(path) + assert app.reconstruction_manager.voice_id == edit_on_its_way.id + held_queue.drain() + + assert app.reconstruction_manager.filepath == path + sample = app.project_manager.current.voice(edit_on_its_way.id) + assert isinstance(sample, Sample) + assert self._edited(sample.reconstruction) + + def test_loading_a_conversion_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + tmp_path: Path, + ) -> None: + path = tmp_path / "converted.stn" + reconstruction_factory().save(path) + + app._main_tab._hooks.on_load_file(path) + assert app.reconstruction_manager.voice_id == edit_on_its_way.id + held_queue.drain() + + assert app.reconstruction_manager.filepath == path + + def test_opening_another_voice_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + reconstruction_factory: ReconstructionFactory, + ) -> None: + with app.history.transaction(HistoryAction.ADD_SAMPLE): + other = app.project_controller.add_sample(reconstruction_factory(), "Other") + + app._sequencer_tab._on_edit_voice_requested(other.id) + assert app.reconstruction_manager.voice_id == edit_on_its_way.id + held_queue.drain() + + sample = app.project_manager.current.voice(edit_on_its_way.id) + assert isinstance(sample, Sample) + assert app.reconstruction_manager.voice_id == other.id + assert self._edited(sample.reconstruction) + + def test_removing_the_open_voice_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + ) -> None: + """The edit is recorded before the removal, so an undo brings the sample back as the reader left it.""" + app._sequencer_tab._sequencer_voices_panel.on_remove_requested(edit_on_its_way.id) + assert app.project_manager.current.voice(edit_on_its_way.id) is not None + held_queue.drain() + + assert app.project_manager.current.voice(edit_on_its_way.id) is None + assert [entry.action for entry in app.history.entries[-2:]] == [ + HistoryAction.EDIT_RECONSTRUCTION, + HistoryAction.REMOVE_VOICE, + ] + + def test_exporting_an_instrument_waits( + self, + app: Application, + edit_on_its_way: Sample, + held_queue: HeldQueue, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + exports = MagicMock() + monkeypatch.setattr(app._reconstructions_tab, "_instrument_exports", exports) + + app._reconstructions_tab._reconstruction_instruments_panel.on_instrument_export(ChannelName.PULSE1) + exports.request.assert_not_called() + held_queue.drain() + + source = exports.request.call_args.args[0] + assert source.features.volume.items[: len(EDITED_VOLUME.items)] == EDITED_VOLUME.items + + def _press_shortcut(app: Application, shortcut_id: ShortcutId) -> None: """Routes the press the scheme in place gives an action, so a rebind carries the case with it.""" combination = app._shortcut_source.shortcut(shortcut_id).combination @@ -347,40 +855,122 @@ 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) + @staticmethod + def _gathered(app: Application, tmp_path: Path) -> List[Path]: + paths = [] + for name in ["a.wav", "b.wav"]: + path = tmp_path / name + path.touch() + paths.append(path) - self._press(app, GeneratorName.TRIANGLE, Tab.MAIN) + app._main_tab._converter_logic.gather_recordings(paths) + return paths - assert frozenset(app.config_manager.config.generation.generators) == selected ^ {GeneratorName.TRIANGLE} + def test_the_main_tab_settles_the_channel_on_the_row_picked_out( + self, + app: Application, + tmp_path: Path, + ) -> None: + """The key answers for the row the settings card is pointed at, which is the box beside it.""" + paths = self._gathered(app, tmp_path) + app._main_tab._converter_logic.select_row(paths[0], SourceKind.RECORDING) + held = ChannelName.TRIANGLE in _row_of(app, paths[0]).channels + + self._press(app, ChannelName.TRIANGLE, Tab.MAIN) + + assert (ChannelName.TRIANGLE in _row_of(app, paths[0]).channels) is not held + + def test_the_rows_it_was_not_pointed_at_stand_as_they_were( + self, + app: Application, + tmp_path: Path, + ) -> None: + paths = self._gathered(app, tmp_path) + app._main_tab._converter_logic.select_row(paths[0], SourceKind.RECORDING) + held = ChannelName.TRIANGLE in _row_of(app, paths[1]).channels + + self._press(app, ChannelName.TRIANGLE, Tab.MAIN) + + assert (ChannelName.TRIANGLE in _row_of(app, paths[1]).channels) is held + + def test_the_main_tab_holding_nothing_picked_out_leaves_the_list_alone( + self, + app: Application, + tmp_path: Path, + ) -> None: + paths = self._gathered(app, tmp_path) + standing = [_row_of(app, path).channels for path in paths] + + self._press(app, ChannelName.TRIANGLE, Tab.MAIN) + + assert [_row_of(app, path).channels for path in paths] == standing 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 +class TestTheRemovalKey: + """The key that takes a recording off the list reaches the row the reader picked out. + + The whole application answers here, so a press travels the way it does at runtime: the router + hands it to the dispatcher, the scheme names the action, and the tab in front decides whether + the converter's list is what the press reaches. + """ + + @staticmethod + def _picked(app: Application, tmp_path: Path) -> Path: + """One gathered recording, clicked so the list holds it picked out.""" + path = tmp_path / "a.wav" + path.touch() + app._main_tab._converter_logic.gather_recordings([path]) + _click_row(app, path) + return path + + @staticmethod + def _press(app: Application, tab: Tab) -> None: + with patch.object(app._shell, "get_current_tab", return_value=tab): + _press_shortcut(app, ShortcutId.SOURCES_REMOVE_SOURCE) + + def test_the_main_tab_takes_the_picked_row_off_the_list(self, app: Application, tmp_path: Path) -> None: + path = self._picked(app, tmp_path) + + self._press(app, Tab.MAIN) + + assert not dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) + + def test_another_tab_in_front_leaves_the_row_where_it_is(self, app: Application, tmp_path: Path) -> None: + """A picked row outlives a move to another tab, so which tab stands in front is read at + the moment the press lands.""" + path = self._picked(app, tmp_path) + + self._press(app, Tab.SEQUENCER) + + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) + + class TestTabKeys: """One key per tab, bringing it to the front from wherever the reader stands. @@ -399,3 +989,566 @@ 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) + + +def _click_row(app: Application, path: Path) -> None: + """Clicks a row the way DearPyGui reports a selectable being picked.""" + name_tag = stems_list(app).tags.row(str(path), SUF_TEXT) + dpg.get_item_callback(name_tag)(name_tag, True, str(path)) + + +def _click_slot_box(field: SettingsField, channel_name: ChannelName) -> None: + """Clicks one of the settings card's boxes, the way DearPyGui reports a checkbox.""" + box = ChannelSettingsRows.box_tag(channel_name, field) + dpg.get_item_callback(box)(box, True, dpg.get_item_user_data(box)) + + +def _release_drive(channel_name: ChannelName, drive: float) -> None: + """Drags one of the settings card's drives to ``drive`` and lets go, the way DearPyGui reports it.""" + slider = ChannelSettingsRows.slider_tag(channel_name) + dpg.set_value(slider, drive) + [handler] = dpg.get_item_children(compose_tag(TAG_MAIN_SOURCE_TABLE_CHANNELS, SUF_HANDLER_REGISTRY), 1) + dpg.get_item_callback(handler)(handler, slider) + + +def _click_step(step: int) -> None: + """Clicks one of the settings card's counts, the way DearPyGui reports a button.""" + button = ChannelCapSteps.step_tag(step) + dpg.get_item_callback(button)(button, None, dpg.get_item_user_data(button)) + + +def _row_of(app: Application, path: Path) -> StemRowViewModel: + """The row the converter last drew for ``path``.""" + row = stems_list(app).row(str(path)) + assert row is not None + return row + + +def _level_of(app: Application, path: Path) -> str: + """The level band the row for ``path`` is drawn in.""" + return str(dpg.get_item_parent(stems_list(app).tags.row(str(path), SUF_GROUP))) + + +def _reports_running(app: Application, status_text: str, progress: float) -> None: + """Puts the panel in front of a conversion under way, the way the converter reports one.""" + converter_logic = app._main_tab._converter_logic + emitted: List[ConverterViewModel] = [] + listener = converter_logic.on_view_changed + converter_logic.on_view_changed = emitted.append + converter_logic.emit_initial_view() + converter_logic.on_view_changed = listener + + running = emitted[0].model_copy( + update={"phase": ConversionPhase.RUNNING, "status_text": status_text, "progress": progress} + ) + app._main_tab._on_converter_view_changed(running) + + +def _ctrl_click_folder(app: Application, directory: Path) -> None: + """Reports a Ctrl-click on a folder's row, the way the browser does.""" + panel = app._main_tab._explorer_panel + node = FileSystemNode(directory.name, node_type=NodeType.DIRECTORY, filepath=directory) + with patch.object(explorer_module, "capture_modifiers", return_value=frozenset({Modifier.CTRL})): + panel._directory_node_clicked(node, UNBUILT_ROW) + + +def _double_click_name(prefix: str, key: str) -> None: + """Double-click one row's name in a stems list, the way DearPyGui reports the gesture.""" + click_row_name(StemsTags(prefix=prefix), key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) + + +class TestGatheringAFolderIntoAMix: + """A folder bringing in more than a mix holds is a question, and the answer reaches the mix. + + The question names the recordings to gather, so what the reader picks is what the setup takes + up — the whole chain from the browser gesture to the rows the card ends up drawing. + """ + + @staticmethod + def _folder(tmp_path: Path, count: int) -> Path: + directory = tmp_path / "takes" + directory.mkdir() + for index in range(count): + (directory / f"take_{index:02d}.wav").touch() + + return directory + + @staticmethod + def _ask(app: Application, directory: Path) -> None: + """Ctrl-clicks the folder and waits for the reading, the way a reader does.""" + settled(lambda: _ctrl_click_folder(app, directory)) + + def test_it_asks_rather_than_gathers(self, app: Application, tmp_path: Path) -> None: + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + opened.assert_called_once() + assert app._main_tab._converter_logic.gathered_paths == () + + def test_what_the_reader_picks_is_what_the_mix_takes(self, app: Application, tmp_path: Path) -> None: + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + offered, _room, answer = opened.call_args.args + picked = [row.path for row in offered[:MAX_STEM_SOURCES]] + answer(picked) + + assert set(app._main_tab._converter_logic.gathered_paths) == set(picked) + + def test_the_switch_reads_the_output_the_setup_holds(self, app: Application, tmp_path: Path) -> None: + """A question about a mix leaves the run as it is until the reader answers it.""" + converter_logic = app._main_tab._converter_logic + paths = [] + for index in range(MAX_STEM_SOURCES + 2): + path = tmp_path / f"take_{index:02d}.wav" + path.touch() + paths.append(path) + + converter_logic.gather_recordings(paths) + standing = dpg.get_value(TAG_MAIN_CONVERTER_RADIO_MODE) + + with patch.object(app._main_tab._stem_selection_window, "open"): + app._main_tab._request_output(OutputKind.MIXED) + + assert dpg.get_value(TAG_MAIN_CONVERTER_RADIO_MODE) == standing + + def test_a_folder_the_mix_still_holds_is_gathered(self, app: Application, tmp_path: Path) -> None: + directory = self._folder(tmp_path, MAX_STEM_SOURCES - 1) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + opened.assert_not_called() + assert len(app._main_tab._converter_logic.gathered_paths) == MAX_STEM_SOURCES - 1 + + def test_a_recording_in_the_question_sounds_where_the_reader_asks_for_it( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A reader decides by ear, so a double-click in the question reaches the player the + converter's list reaches, the whole way from the gesture to the device.""" + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + offered, room, answer = opened.call_args.args + window = app._main_tab._stem_selection_window + window.open(offered, room, answer) + recording = offered[0] + + with patch.object(app.audio_device_manager, "play_file") as sounded: + _double_click_name(PRE_MAIN_CONVERTER_CANDIDATE, recording.key) + + assert sounded.call_args.args[0] == recording.path + + +class TestMainTabReadingOrder: + """The tab reads in one direction: what a run is set up with, what it gathers, what a row takes. + + The reconstruction card names whichever row the converter's list stands on, so it follows the + list it reads rather than standing above it. + """ + + @staticmethod + def _identity(item: Union[int, str]) -> int: + """One reading of an item, since DearPyGui answers with an alias where a tag names one.""" + return dpg.get_alias_id(item) if isinstance(item, str) else item + + @classmethod + def _place(cls, tag: str) -> Tuple[int, int]: + """Where a card stands: the parent holding it, and its place among that parent's children.""" + item = cls._identity(tag) + parent = cls._identity(dpg.get_item_parent(item)) + children = [cls._identity(child) for child in dpg.get_item_children(parent)[1]] + return parent, children.index(item) + + @classmethod + def _stands_within(cls, tag: str, ancestor: str) -> bool: + item = cls._identity(tag) + wanted = cls._identity(ancestor) + while item: + if item == wanted: + return True + item = cls._identity(dpg.get_item_parent(item)) + + return False + + def test_the_reconstruction_card_follows_the_converter(self, app: Application) -> None: + converter_parent, converter_place = self._place(TAG_MAIN_CONVERTER_PANEL) + card_parent, card_place = self._place(TAG_MAIN_SOURCE_PANEL) + + assert card_parent == converter_parent + assert card_place > converter_place + + def test_the_settings_cards_share_the_row_above(self, app: Application) -> None: + assert self._stands_within(TAG_MAIN_CONFIG_PANEL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW) + assert self._stands_within(TAG_MAIN_ADVANCED_PANEL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW) + + def test_the_row_holds_its_height_until_both_cards_collapse(self, app: Application) -> None: + """The row is the two settings cards' own, so it is theirs to give up.""" + coordinator = app._main_tab + with ( + patch.object(type(coordinator._config_panel), "collapsed", PropertyMock(return_value=True)), + patch.object(type(coordinator._advanced_settings_panel), "collapsed", PropertyMock(return_value=True)), + ): + coordinator._sync_config_row_height() + + assert dpg.get_item_configuration(TAG_MAIN_CONFIG_TABLE_CONFIG_ROW)["height"] == 0 + + @staticmethod + def _column(cell_tag: str) -> Dict[str, Any]: + """The configuration of the settings row's column behind a cell.""" + configuration: Dict[str, Any] = dpg.get_item_configuration(compose_tag(cell_tag, SUF_TABLE_COLUMN)) + return configuration + + def test_the_advanced_card_leaves_the_row_and_comes_back_to_its_half(self, app: Application) -> None: + """One toggle leaves the row to the general card, the other gives the advanced one its half. + + Which way the first toggle goes is whatever the session was left at, so the pair of readings + is what the rule states: the column leaves the row while the card is put away, and stands + again at the general card's own share. The general card stands throughout. + """ + coordinator = app._main_tab + general = self._column(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL)["init_width_or_weight"] + + coordinator.toggle_advanced_settings() + first = self._column(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + first_general = self._column(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL)["enabled"] + coordinator.toggle_advanced_settings() + second = self._column(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + second_general = self._column(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL)["enabled"] + + assert general > 0 + assert {first["enabled"], second["enabled"]} == {False, True} + assert (first["init_width_or_weight"], second["init_width_or_weight"]) == (general, general) + assert (first_general, second_general) == (True, True) + + +class TestBrowserGathering: + """What a gesture in the browser gathers: a plain click walks it, and gathering is asked for. + + Reading every recording below a folder is work a reader asks for, so it answers the gathering + gesture alone. A plain click walks the browser and leaves the conversion as it is — opening a + folder, playing a recording — which is what keeps navigating a large tree from gathering it. + Ctrl brings in whatever the row names, and a double-click brings in a recording. + """ + + @staticmethod + def _folder(directory: Path) -> FileSystemNode: + return FileSystemNode(directory.name, node_type=NodeType.DIRECTORY, filepath=directory) + + @staticmethod + def _tree(tmp_path: Path) -> Path: + directory = tmp_path / "takes" + directory.mkdir() + (directory / "one.wav").touch() + (directory / "deeper").mkdir() + (directory / "deeper" / "two.wav").touch() + return directory + + def _click(self, app: Application, directory: Path, *, modifiers: FrozenSet[Modifier]) -> None: + """Clicks a folder's row, with whatever the reader was holding down, and lets it settle.""" + panel = app._main_tab._explorer_panel + with patch.object(explorer_module, "capture_modifiers", return_value=modifiers): + settled(lambda: panel._directory_node_clicked(self._folder(directory), UNBUILT_ROW)) + + def test_a_plain_click_gathers_nothing(self, app: Application, tmp_path: Path) -> None: + directory = self._tree(tmp_path) + + self._click(app, directory, modifiers=frozenset()) + + assert app._main_tab._converter_logic.gathered_paths == () + + def test_ctrl_gathers_the_whole_tree_below_it(self, app: Application, tmp_path: Path) -> None: + directory = self._tree(tmp_path) + + self._click(app, directory, modifiers=frozenset({Modifier.CTRL})) + + assert set(app._main_tab._converter_logic.gathered_paths) == { + directory / "one.wav", + directory / "deeper" / "two.wav", + } + + def test_a_plain_click_on_a_recording_gathers_nothing(self, app: Application, tmp_path: Path) -> None: + """A plain click previews a recording, so listening through a folder leaves the run alone.""" + recording = self._recording(tmp_path) + panel = app._main_tab._explorer_panel + + with patch.object(explorer_module, "capture_modifiers", return_value=frozenset()): + panel._audio_node_clicked(self._node(recording)) + + assert app._main_tab._converter_logic.gathered_paths == () + + def test_a_double_click_on_a_recording_gathers_it(self, app: Application, tmp_path: Path) -> None: + """A recording is one path, so naming it costs nothing and one gesture brings it in.""" + recording = self._recording(tmp_path) + panel = app._main_tab._explorer_panel + + panel._on_file_node_double_clicked(0, (dpg.mvMouseButton_Left, 0), (self._node(recording), 0)) + + assert app._main_tab._converter_logic.gathered_paths == (recording,) + + def _recording(self, tmp_path: Path) -> Path: + return self._tree(tmp_path) / "one.wav" + + @staticmethod + def _node(recording: Path) -> FileSystemNode: + return FileSystemNode(recording.name, node_type=NodeType.FILE, filepath=recording) + + +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_output(OutputKind.MIXED) + converter_logic.gather_recordings(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).tags.row(str(path), SUF_GROUP)) + assert dpg.does_item_exist(stems_list(app).tags.row(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 = app.session_manager.converter_settings.channels + kept, cleared = enabled[-1], enabled[0] + + converter_logic.set_source_channels(path, frozenset({kept})) + + assert dpg.get_value(stems_list(app).tags.channel(str(path), kept)) is True + assert dpg.get_value(stems_list(app).tags.channel(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).tags.row(str(first), SUF_GROUP)) + assert dpg.does_item_exist(stems_list(app).tags.row(str(second), SUF_GROUP)) + + def test_the_list_stands_whichever_run_the_switch_names(self, app: Application, tmp_path: Path) -> None: + """The gathered sources are what a run converts either way, so the list is always on screen.""" + path = self._gather(app, tmp_path, ["a.wav"])[0] + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True + + app._main_tab._converter_logic.set_output(OutputKind.PER_RECORDING) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) + + 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] + + _reports_running(app, "running", 0.5) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True + assert dpg.get_item_configuration(stems_list(app).tags.row(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).tags.level(0, SUF_TABLE)) + assert dpg.does_item_exist(stems_list(app).tags.level(1, SUF_TABLE)) + assert dpg.does_item_exist(stems_list(app).tags.level(2, SUF_STRIP)) + assert dpg.get_item_parent(stems_list(app).tags.row(str(first), SUF_GROUP)) == stems_list(app).tags.level( + 0, SUF_TABLE + ) + assert dpg.get_item_parent(stems_list(app).tags.row(str(second), SUF_GROUP)) == stems_list(app).tags.level( + 1, SUF_TABLE + ) + + def test_a_row_is_the_thing_you_drag_it_by(self, app: Application, tmp_path: Path) -> None: + """A level is a turn to choose, so the drag that rearranges them arrives with the second.""" + first, _second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + + assert dpg.get_item_children(stems_list(app).tags.row(str(first), SUF_TEXT), DRAG_PAYLOAD_SLOT) + + def test_one_recording_in_a_mix_is_its_own_order(self, app: Application, tmp_path: Path) -> None: + path = self._gather(app, tmp_path, ["a.wav"])[0] + + assert not dpg.does_item_exist(stems_list(app).tags.level(0, SUF_TEXT)) + assert not dpg.get_item_children(stems_list(app).tags.row(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).tags.row(str(second), SUF_TEXT), str(first)) + + assert _level_of(app, first) == _level_of(app, second) + assert not dpg.does_item_exist(stems_list(app).tags.level(1, SUF_TABLE)) + + 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"]) + + drop(stems_list(app).tags.level(1, SUF_STRIP), str(first)) + + assert dpg.does_item_exist(stems_list(app).tags.level(1, SUF_TABLE)) + assert _level_of(app, first) == stems_list(app).tags.level(1, SUF_TABLE) + + def test_a_clicked_row_is_what_the_settings_card_edits(self, app: Application, tmp_path: Path) -> None: + """The whole wiring chain: a click on a row, a box on the card, and the row it settles. + + A recording joins holding the channels a run hands out, so the box the case ticks is one + of the two it starts without. + """ + first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + assert ChannelName.PULSE2 not in _row_of(app, second).channels + + _click_row(app, second) + _click_slot_box(SettingsField.CHANNELS, ChannelName.PULSE2) + + assert ChannelName.PULSE2 in _row_of(app, second).channels + assert ChannelName.PULSE2 not in _row_of(app, first).channels + + def _gather_folder(self, app: Application, tmp_path: Path, names: List[str]) -> List[Path]: + """Gathers a folder of recordings as one row, and answers what it holds.""" + root = tmp_path / "takes" + root.mkdir() + paths = [] + for name in names: + path = root / name + path.touch() + paths.append(path) + + converter_logic = app._main_tab._converter_logic + converter_logic.set_output(OutputKind.PER_RECORDING) + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + return paths + + def test_a_folder_arrives_closed_and_opens_onto_what_it_holds( + self, + app: Application, + tmp_path: Path, + ) -> None: + held = self._gather_folder(app, tmp_path, ["a.wav", "b.wav"]) + root = held[0].parent + name_tag = stems_list(app).tags.row(str(held[0]), SUF_TEXT) + assert not dpg.does_item_exist(name_tag) + + stems_list(app).toggle_folder(str(root)) + + assert dpg.does_item_exist(name_tag) + assert dpg.does_item_exist(stems_list(app).tags.region(str(root))) + + def test_a_recording_inside_an_open_folder_is_what_the_card_edits( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A reader who opens a folder answers for one of its recordings without breaking it up.""" + first, second = self._gather_folder(app, tmp_path, ["a.wav", "b.wav"]) + stems_list(app).toggle_folder(str(first.parent)) + assert ChannelName.PULSE2 not in _row_of(app, second).channels + + _click_row(app, second) + _click_slot_box(SettingsField.CHANNELS, ChannelName.PULSE2) + + assert ChannelName.PULSE2 in _row_of(app, second).channels + assert ChannelName.PULSE2 not in _row_of(app, first).channels + + def test_with_nothing_picked_the_card_edits_the_recordings_added_next( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A count set with no row picked is what the next recording gathered arrives with.""" + self._gather(app, tmp_path, ["a.wav"]) + assert dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT) == app.language_manager["main.source.label.new_recordings"] + + _click_step(1) + second = self._gather(app, tmp_path, ["b.wav"])[0] + _click_row(app, second) + + assert app._main_tab._converter_logic.source_settings_view.channel_cap == 1 + + def test_a_picked_row_names_the_card(self, app: Application, tmp_path: Path) -> None: + path = self._gather(app, tmp_path, ["a.wav"])[0] + + _click_row(app, path) + + assert dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT) == path.stem + + def test_a_released_drive_reaches_the_row_the_card_edits(self, app: Application, tmp_path: Path) -> None: + first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + + _click_row(app, second) + _release_drive(ChannelName.PULSE1, LOUD_DRIVE) + + drives = { + channel.channel: channel.drive for channel in app._main_tab._converter_logic.source_settings_view.channels + } + assert drives[ChannelName.PULSE1] == LOUD_DRIVE + _click_row(app, first) + assert dpg.get_value(ChannelSettingsRows.slider_tag(ChannelName.PULSE1)) == pytest.approx(UNIT_DRIVE) + + def test_the_order_arrives_with_the_second_recording_in_a_mix(self, app: Application, tmp_path: Path) -> None: + """One recording is its own order, so the choice of how levels take turns arrives with the second.""" + self._gather(app, tmp_path, ["a.wav"]) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_GROUP_ORDER)["show"] is False + + self._gather(app, tmp_path, ["b.wav"]) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_GROUP_ORDER)["show"] is True + + def test_the_order_explanation_leaves_with_the_control_it_belongs_to( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A tooltip left live over a hidden widget's rectangle explains whatever moved into it.""" + self._gather(app, tmp_path, ["a.wav", "b.wav"]) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is True + + app._main_tab._converter_logic.set_output(OutputKind.PER_RECORDING) + + 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).tags.row(str(path), SUF_TEXT) + assert dpg.does_item_exist(stems_list(app).tags.row(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/conftest.py b/tests/unit/sampletones_application/ui/elements/graphs/conftest.py new file mode 100644 index 000000000..76808c5a5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/conftest.py @@ -0,0 +1,18 @@ +import pytest + +from sampletones_application.layout.graphs import GraphsLayout +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource + + +@pytest.fixture +def graphs_layout() -> GraphsLayout: + """The graph settings the build ships, read where they are configured.""" + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source).graphs diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py new file mode 100644 index 000000000..54b2dfa7a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py @@ -0,0 +1,227 @@ +from typing import Final, List, Optional, Tuple +from unittest.mock import patch + +import numpy as np +import pytest + +from sampletones_application.ui.elements.graphs import bar as bar_module +from sampletones_application.ui.elements.graphs.bar import GUIBarGraph +from sampletones_application.ui.elements.graphs.layers.bar import BarLayer +from sampletones_application.utils.gui.press import LeftPress +from sampletones_application.utils.palette.colors.literal import LiteralColor + +BAND_SHARE: Final[float] = 0.2 +BAR_VALUES: Final[Tuple[int, ...]] = (5, 9, 12, 3) +BAR_WEIGHT: Final[float] = 0.8 +DATA_RANGE: Final[Tuple[int, int]] = (0, 15) +HOVER_TAG: Final[str] = "bar.hover" +LAYER_NAME: Final[str] = "volume" +PLOT_RANGE: Final[Tuple[float, float]] = (-2.0, 17.0) +PLOT_TAG: Final[str] = "bar.plot" +PRESSED_BAR: Final[int] = 1 +PRESSED_VALUE: Final[float] = 6.0 + + +def _graph() -> GUIBarGraph: + """A bar plot holding one dimension's values, built as the panel builds it.""" + graph = GUIBarGraph.__new__(GUIBarGraph) + graph.plot_tag = PLOT_TAG + graph.hover_bar_tag = HOVER_TAG + graph.data_range = DATA_RANGE + graph.y_axis_tag = "bar.y" + graph.x_axis_tag = "bar.x" + graph.x_range = (-0.5, float(len(BAR_VALUES))) + graph.y_range = PLOT_RANGE + graph._default_y_range = PLOT_RANGE + graph.current_data = np.array(BAR_VALUES) + graph.layers = { + LAYER_NAME: BarLayer( + data=np.array(BAR_VALUES), + name=LAYER_NAME, + color=LiteralColor(value=(255, 255, 255, 255)), + bar_weight=BAR_WEIGHT, + ) + } + graph._press = LeftPress() + graph.on_bar_point_clicked = None + graph.on_bar_point_hovered = None + return graph + + +def _reserve_band(graph: GUIBarGraph, share: float) -> Tuple[float, float]: + """The band the plot keeps beneath its bars, with the axes it locks left to DearPyGui.""" + with ( + patch.object(bar_module.dpg, "set_axis_limits"), + patch.object(bar_module.dpg, "set_axis_limits_constraints"), + ): + return graph.reserve_band(share) + + +def _point_at( + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """Stands the pointer at ``position`` in the plot. + + The plot's own items are DearPyGui's, so the reading answers that none of them is built and + the case reads the values a gesture leaves behind. + """ + monkeypatch.setattr(bar_module.dpg, "does_item_exist", lambda tag: False) + monkeypatch.setattr(bar_module, "dpg_is_item_hovered", lambda tag: True) + monkeypatch.setattr(bar_module, "dpg_configure_item", lambda tag, **kwargs: None) + monkeypatch.setattr(bar_module.dpg, "is_key_down", lambda key: False) + monkeypatch.setattr(bar_module.dpg, "get_plot_mouse_pos", lambda: position) + monkeypatch.setattr(bar_module.dpg, "is_mouse_button_down", lambda button: True) + + +def _press( + graph: GUIBarGraph, + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """A left press going down on the plot with the pointer standing at ``position``.""" + _point_at(monkeypatch, position) + graph._on_press(PLOT_TAG) + + +def _hover( + graph: GUIBarGraph, + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """One frame of the pointer standing at ``position``, as the hover and the move report it.""" + _point_at(monkeypatch, position) + graph._on_mouse_action(PLOT_TAG) + + +def _hover_with_the_button_up( + graph: GUIBarGraph, + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """One frame of the pointer standing at ``position`` with the left button up.""" + _point_at(monkeypatch, position) + monkeypatch.setattr(bar_module.dpg, "is_mouse_button_down", lambda button: False) + graph._on_mouse_action(PLOT_TAG) + + +def _with_bar(index: int, value: float) -> List[int]: + """The plotted values with one bar written.""" + expected = list(BAR_VALUES) + expected[index] = int(value) + return expected + + +class TestPressingTheBars: + """A press on the grid the bars stand on writes the value it lands at.""" + + def test_a_press_writes_the_value_it_lands_at(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + + def test_a_drag_from_the_press_writes_every_bar_it_crosses(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + + _press(graph, monkeypatch, (0.5, PRESSED_VALUE)) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == [int(PRESSED_VALUE)] * len(BAR_VALUES) + + +class TestAPressBelongsToThePlotItWentDownOn: + """A button held before the plot came under the pointer draws nothing, and a release ends a press. + + A double-click on a voice brings the plot forward while its second press is still down. + """ + + def test_a_button_held_as_the_plot_comes_under_the_pointer_draws_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + + _hover(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == list(BAR_VALUES) + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + + def test_a_button_held_carries_on_hovering_without_drawing(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The pointer still marks the bar it stands over, so a reader sees where a press would land.""" + graph = _graph() + hovered: List[Tuple[Optional[str], Optional[int]]] = [] + graph.on_bar_point_hovered = lambda name, index: hovered.append((name, index)) + + _hover(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert hovered == [(LAYER_NAME, PRESSED_BAR)] + + def test_a_release_ends_the_press(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + graph._on_release(PLOT_TAG) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + _press(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + expected = _with_bar(PRESSED_BAR, PRESSED_VALUE) + expected[last_bar] = int(PRESSED_VALUE) + assert list(graph.layers[LAYER_NAME].y_data) == expected + + def test_a_release_the_plot_missed_ends_the_press(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A button that reads up ends the press, so a button held again later draws nothing.""" + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + _hover_with_the_button_up(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + + +class TestPressingTheBandBeneathTheBars: + """A band reserved beneath the bars reads which stretch belongs to whom. + + The band stands inside the plot the bars are drawn in, so a press meant for what it shows + reaches the same handler; the values above it are the reader's and stay as they stand. + """ + + def test_a_press_inside_the_band_leaves_the_values_standing(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + band_low, band_high = _reserve_band(graph, BAND_SHARE) + + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, (band_low + band_high) / 2)) + + assert list(graph.layers[LAYER_NAME].y_data) == list(BAR_VALUES) + + def test_a_press_inside_the_band_still_reports_the_bar_it_stands_under( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + band_low, band_high = _reserve_band(graph, BAND_SHARE) + hovered: List[Tuple[Optional[str], Optional[int]]] = [] + graph.on_bar_point_hovered = lambda name, index: hovered.append((name, index)) + + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, (band_low + band_high) / 2)) + + assert hovered == [(LAYER_NAME, PRESSED_BAR)] + + def test_a_press_above_the_band_writes_as_it_did_before_the_band( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Reserving a band lowers what the plot spans and leaves the bars every value they reach.""" + graph = _graph() + _reserve_band(graph, BAND_SHARE) + + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_clock.py b/tests/unit/sampletones_application/ui/elements/graphs/test_clock.py new file mode 100644 index 000000000..da087596a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_clock.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_application.layout.graphs import GraphsLayout +from sampletones_application.layout.graphs.clock import ClockLayout +from sampletones_application.ui.elements.graphs.clock import ( + clock_decimals, + clock_step, + clock_ticks, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +SAMPLE_RATE: Final[float] = 44100.0 + + +@pytest.fixture(name="clock") +def clock_fixture(graphs_layout: GraphsLayout) -> ClockLayout: + """The steps the shipped configuration offers, read where they are configured.""" + return graphs_layout.clock + + +class TestTheStepAStretchIsReadAt(BaseTestSuite): + """The step is one a clock divides evenly into, and it grows with the stretch on screen.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + span_seconds: float + expected_labels: List[str] + + test_cases = ( + TestCase( + label="a whole song", + span_seconds=175.0, + expected_labels=["0:00", "0:30", "1:00", "1:30", "2:00", "2:30"], + ), + TestCase( + label="a minute", + span_seconds=60.0, + expected_labels=["0:00", "0:10", "0:20", "0:30", "0:40", "0:50", "1:00"], + ), + TestCase( + label="a couple of seconds", + span_seconds=2.0, + expected_labels=["0:00.0", "0:00.5", "0:01.0", "0:01.5", "0:02.0"], + ), + TestCase( + label="a handful of frames", + span_seconds=0.05, + expected_labels=["0:00.00", "0:00.01", "0:00.02", "0:00.03", "0:00.04", "0:00.05"], + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_marks_it_reads_with(self, test_case: TestCase, clock: ClockLayout) -> None: + ticks = clock_ticks(0.0, test_case.span_seconds, SAMPLE_RATE, clock) + + assert [label for label, _ in ticks] == test_case.expected_labels + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_step_is_one_the_configuration_offers(self, test_case: TestCase, clock: ClockLayout) -> None: + assert clock_step(test_case.span_seconds, clock) in clock.steps_seconds + + +class TestHowFinelyASecondIsDivided: + def test_a_step_of_seconds_reads_in_whole_seconds(self) -> None: + assert clock_decimals(1.0) == 0 + assert clock_decimals(30.0) == 0 + + def test_a_step_below_a_second_reads_in_tenths(self) -> None: + assert clock_decimals(0.5) == 1 + + def test_a_step_below_a_tenth_reads_in_hundredths(self) -> None: + assert clock_decimals(0.05) == 2 + + +class TestWhereTheMarksStand: + def test_a_mark_stands_at_a_whole_multiple_of_the_step(self, clock: ClockLayout) -> None: + ticks = clock_ticks(90.0, 98.0, SAMPLE_RATE, clock) + + assert [label for label, _ in ticks][:3] == ["1:30", "1:31", "1:32"] + + def test_a_mark_is_placed_where_its_moment_falls(self, clock: ClockLayout) -> None: + ticks = clock_ticks(0.0, 8.0, SAMPLE_RATE, clock) + + assert ticks[1] == ("0:01", SAMPLE_RATE) + + def test_a_view_of_no_width_carries_no_marks(self, clock: ClockLayout) -> None: + assert clock_ticks(5.0, 5.0, SAMPLE_RATE, clock) == [] + + def test_a_rate_of_nothing_carries_no_marks(self, clock: ClockLayout) -> None: + assert clock_ticks(0.0, 8.0, 0.0, clock) == [] diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_gesture.py b/tests/unit/sampletones_application/ui/elements/graphs/test_gesture.py new file mode 100644 index 000000000..4c4589dc2 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_gesture.py @@ -0,0 +1,246 @@ +import math +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_application.ui.elements.graphs import gesture as gesture_module +from sampletones_application.ui.elements.graphs.gesture import ( + DOUBLE_CLICK_SECONDS, + PlotClickGesture, +) +from sampletones_shared.types.callback import VoidCallback + +CLICK_TRAVEL: Final[float] = 4.0 +DOUBLE_CLICK: Final[int] = 2 +DOUBLE_CLICK_DISTANCE: Final[float] = 6.0 +PRESS_SAMPLE: Final[float] = 420.6 +PRESS_SCREEN: Final[Tuple[float, float]] = (300.0, 200.0) +TAP_SECONDS: Final[float] = 0.05 + + +class Mouse: + """The pointer and the clock as the gesture reads them, with the frames it waits on run by hand. + + Presses count up while each lands within the double-click window and distance of the one before + it, and the press counted second is the double-click, as ImGui recognizes one: a third press in + the same burst is none. + """ + + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + self.time = 10.0 + self.sample = PRESS_SAMPLE + self.screen = PRESS_SCREEN + self.clicks = 0 + self._last_press: Optional[Tuple[float, Tuple[float, float]]] = None + self._armed: List[VoidCallback] = [] + monkeypatch.setattr(gesture_module.dpg, "get_total_time", lambda: self.time) + monkeypatch.setattr(gesture_module.dpg, "get_plot_mouse_pos", lambda: (self.sample, 0.0)) + monkeypatch.setattr(gesture_module.dpg, "get_mouse_pos", lambda local: self.screen) + monkeypatch.setattr( + gesture_module.dpg, + "is_mouse_button_double_clicked", + lambda button: self.clicks == DOUBLE_CLICK, + ) + monkeypatch.setattr( + gesture_module.FrameCallbackManager, + "set_frame_callback", + lambda callback, frame_count=1: self._armed.append(callback), + ) + + def wait(self, seconds: float) -> None: + """Lets time pass, drawing the frames the gesture asked to be called back on.""" + self.time += seconds + armed, self._armed = self._armed, [] + for callback in armed: + callback() + + @property + def waiting(self) -> int: + """How many readings the gesture has asked the next frame for.""" + return len(self._armed) + + def press(self, gesture: PlotClickGesture) -> None: + within = ( + self._last_press is not None + and self.time - self._last_press[0] < DOUBLE_CLICK_SECONDS + and math.dist(self.screen, self._last_press[1]) < DOUBLE_CLICK_DISTANCE + ) + self.clicks = self.clicks + 1 if within else 1 + self._last_press = (self.time, self.screen) + gesture.press() + + def tap(self, gesture: PlotClickGesture) -> None: + self.press(gesture) + self.time += TAP_SECONDS + gesture.release() + + +@pytest.fixture +def mouse(monkeypatch: pytest.MonkeyPatch) -> Mouse: + return Mouse(monkeypatch) + + +@pytest.fixture +def clicked() -> List[float]: + return [] + + +@pytest.fixture +def gesture(clicked: List[float]) -> PlotClickGesture: + return PlotClickGesture(click_travel=CLICK_TRAVEL, on_clicked=clicked.append) + + +class TestAClickWaitsForTheDoubleClickWindow: + """A click names its sample once the press it began with can no longer start a double-click.""" + + def test_a_click_is_held_while_a_double_click_may_follow( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.tap(gesture) + mouse.wait(DOUBLE_CLICK_SECONDS / 2) + + assert clicked == [] + + def test_a_click_reports_the_sample_under_its_press_once_the_window_closes( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.tap(gesture) + mouse.wait(DOUBLE_CLICK_SECONDS / 2) + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [PRESS_SAMPLE] + + def test_a_press_held_past_the_window_reports_as_it_comes_up( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.press(gesture) + mouse.time += 2 * DOUBLE_CLICK_SECONDS + gesture.release() + + assert clicked == [PRESS_SAMPLE] + + def test_a_click_within_the_travel_reports_the_sample_under_its_press( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.press(gesture) + mouse.sample = PRESS_SAMPLE + 100 + mouse.screen = (PRESS_SCREEN[0] + CLICK_TRAVEL, PRESS_SCREEN[1]) + gesture.release() + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [PRESS_SAMPLE] + + def test_a_press_landing_as_a_separate_click_reports_the_one_before_it( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.tap(gesture) + mouse.sample = PRESS_SAMPLE + 300 + mouse.screen = (PRESS_SCREEN[0] + 10 * DOUBLE_CLICK_DISTANCE, PRESS_SCREEN[1]) + mouse.press(gesture) + + assert clicked == [PRESS_SAMPLE] + + +class TestADoubleClickReportsNothing: + """The double-click fits the view, so neither of its clicks puts the playhead anywhere.""" + + def test_neither_click_of_a_double_click_reports_a_sample( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.tap(gesture) + mouse.tap(gesture) + mouse.wait(DOUBLE_CLICK_SECONDS) + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [] + + def test_a_third_click_in_the_burst_reports_nothing( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + """ImGui counts the third press of a burst as no double-click, and it is still the burst.""" + mouse.tap(gesture) + mouse.tap(gesture) + mouse.tap(gesture) + mouse.wait(DOUBLE_CLICK_SECONDS) + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [] + + def test_a_click_after_a_double_click_reports_its_own_sample( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.tap(gesture) + mouse.tap(gesture) + mouse.wait(DOUBLE_CLICK_SECONDS) + + mouse.sample = PRESS_SAMPLE + 300 + mouse.tap(gesture) + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [PRESS_SAMPLE + 300] + + +class TestOneWaitStands: + def test_clicks_in_turn_leave_one_reading_waiting( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + """A second click arriving while the first waits reports the first and joins the same wait.""" + mouse.tap(gesture) + mouse.time += DOUBLE_CLICK_SECONDS + mouse.sample = PRESS_SAMPLE + 300 + mouse.tap(gesture) + + assert (clicked, mouse.waiting) == ([PRESS_SAMPLE], 1) + + +class TestADragReportsNothing: + def test_a_press_coming_up_past_the_travel_reports_nothing( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + mouse.press(gesture) + mouse.screen = (PRESS_SCREEN[0] - 3 * CLICK_TRAVEL, PRESS_SCREEN[1]) + gesture.release() + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [] + + def test_a_release_with_no_press_on_the_plot_reports_nothing( + self, + mouse: Mouse, + gesture: PlotClickGesture, + clicked: List[float], + ) -> None: + gesture.release() + mouse.wait(DOUBLE_CLICK_SECONDS) + + assert clicked == [] 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..e722d9f0d --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_graph.py @@ -0,0 +1,100 @@ +from typing import Dict, Final, Tuple +from unittest.mock import patch + +import pytest + +from sampletones_application.ui.elements.graphs import graph as graph_module +from sampletones_application.ui.elements.graphs.graph import GUIGraph +from sampletones_application.ui.elements.graphs.spectrum import GUISpectrumGraph +from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph +from sampletones_application.utils.gui.keyboard.modifiers import ALT, NO_MODIFIERS, SHIFT, ModifierSet + +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] + + +class TestWhichAxesTheHoverLocks: + """The wheel zooms the axis left free, so what the hover locks decides what a wheel does.""" + + @staticmethod + def _locks(modifiers: ModifierSet) -> Dict[str, Tuple[bool, bool]]: + graph = _graph() + locks: Dict[str, Tuple[bool, bool]] = {} + with ( + patch(f"{MODULE}.FrameCallbackManager"), + patch(f"{MODULE}.capture_modifiers", return_value=modifiers), + patch.object( + graph_module.dpg, + "configure_item", + side_effect=lambda axis, lock_min, lock_max: locks.update({axis: (lock_min, lock_max)}), + ), + ): + GUIGraph._on_hover(graph, 0, 0, None) + + return locks + + @pytest.mark.parametrize( + ("modifiers", "x_locked", "y_locked"), + [ + (NO_MODIFIERS, False, True), + (SHIFT, True, False), + (ALT, True, True), + (ALT | SHIFT, True, True), + ], + ids=["bare wheel zooms x", "shift zooms y", "alt pans x", "alt and shift pan y"], + ) + def test_the_hover_locks_every_axis_the_wheel_leaves_alone( + self, + modifiers: ModifierSet, + x_locked: bool, + y_locked: bool, + ) -> None: + locks = self._locks(modifiers) + + assert locks[X_AXIS] == (x_locked, x_locked) + assert locks[Y_AXIS] == (y_locked, y_locked) + + +class TestWhichGraphsPan: + def test_the_waveform_pans_with_the_wheel(self) -> None: + assert GUIWaveformGraph.pans_with_wheel + + def test_the_spectrum_keeps_its_view(self) -> None: + assert not GUISpectrumGraph.pans_with_wheel diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_pan.py b/tests/unit/sampletones_application/ui/elements/graphs/test_pan.py new file mode 100644 index 000000000..b933dd0e3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_pan.py @@ -0,0 +1,221 @@ +from typing import Dict, FrozenSet, List, Tuple +from unittest.mock import patch + +import pytest + +from sampletones_application.layout.graphs import GraphsLayout +from sampletones_application.ui.elements.graphs import pan as pan_module +from sampletones_application.ui.elements.graphs.pan import PlotWheelPan +from sampletones_application.utils.gui.keyboard.modifiers import ( + ALT, + CTRL_ALT_SHIFT, + NO_MODIFIERS, + SHIFT, + ModifierSet, +) + +PLOT: str = "graph.plot" +LANE: str = "graph.lane" +X_AXIS: str = "graph.x" +Y_AXIS: str = "graph.y" +X_BOUNDS: Tuple[float, float] = (0.0, 1000.0) +Y_BOUNDS: Tuple[float, float] = (-1.0, 1.0) +ALT_SHIFT: ModifierSet = ALT | SHIFT + + +class Scene: + """A plot standing at some limits, with the wheel handler its pan registered.""" + + def __init__( + self, + pan_factor: float, + *, + limits: Dict[str, Tuple[float, float]], + ) -> None: + self.limits = dict(limits) + self.hovered: FrozenSet[str] = frozenset({PLOT}) + self.modifiers: ModifierSet = NO_MODIFIERS + self.panned: List[None] = [] + self.pan = PlotWheelPan( + tag="graph", + plot_tags=(PLOT, LANE), + x_axis_tag=X_AXIS, + y_axis_tag=Y_AXIS, + pan_factor=pan_factor, + x_bounds=lambda: X_BOUNDS, + y_bounds=lambda: Y_BOUNDS, + on_panned=lambda: self.panned.append(None), + ) + + def scroll(self, wheel: float) -> None: + with ( + patch.object(pan_module, "capture_modifiers", return_value=self.modifiers), + patch.object(pan_module, "dpg_is_item_hovered", side_effect=lambda tag: tag in self.hovered), + patch.object(pan_module.dpg, "get_axis_limits", side_effect=lambda axis: self.limits[axis]), + patch.object(pan_module.dpg, "set_axis_limits", side_effect=self._set_limits), + ): + self.pan._on_wheel(0, wheel) + + def _set_limits(self, axis: str, low: float, high: float) -> None: + self.limits[axis] = (low, high) + + +@pytest.fixture +def scene(graphs_layout: GraphsLayout) -> Scene: + return Scene( + graphs_layout.waveform.pan_factor, + limits={X_AXIS: (400.0, 500.0), Y_AXIS: (-0.5, 0.5)}, + ) + + +class TestAltWithTheWheelPansAlongX: + def test_scrolling_down_moves_the_view_toward_larger_values( + self, + scene: Scene, + graphs_layout: GraphsLayout, + ) -> None: + scene.modifiers = ALT + + scene.scroll(-1.0) + + step = graphs_layout.waveform.pan_factor * 100.0 + assert scene.limits[X_AXIS] == pytest.approx((400.0 + step, 500.0 + step)) + + def test_scrolling_up_moves_the_view_toward_smaller_values( + self, + scene: Scene, + graphs_layout: GraphsLayout, + ) -> None: + scene.modifiers = ALT + + scene.scroll(1.0) + + step = graphs_layout.waveform.pan_factor * 100.0 + assert scene.limits[X_AXIS] == pytest.approx((400.0 - step, 500.0 - step)) + + def test_a_move_keeps_the_span_in_view(self, scene: Scene) -> None: + scene.modifiers = ALT + + scene.scroll(-3.0) + + low, high = scene.limits[X_AXIS] + assert high - low == pytest.approx(100.0) + + def test_the_y_axis_stays_where_it_was(self, scene: Scene) -> None: + scene.modifiers = ALT + + scene.scroll(-1.0) + + assert scene.limits[Y_AXIS] == (-0.5, 0.5) + + def test_the_graph_is_told_once_a_move_is_made(self, scene: Scene) -> None: + scene.modifiers = ALT + + scene.scroll(-1.0) + + assert len(scene.panned) == 1 + + +class TestAltAndShiftWithTheWheelPanAlongY: + def test_scrolling_up_moves_the_view_toward_larger_values( + self, + scene: Scene, + graphs_layout: GraphsLayout, + ) -> None: + scene.modifiers = ALT_SHIFT + + scene.scroll(1.0) + + step = graphs_layout.waveform.pan_factor * 1.0 + assert scene.limits[Y_AXIS] == pytest.approx((-0.5 + step, 0.5 + step)) + + def test_the_x_axis_stays_where_it_was(self, scene: Scene) -> None: + scene.modifiers = ALT_SHIFT + + scene.scroll(1.0) + + assert scene.limits[X_AXIS] == (400.0, 500.0) + + +class TestWhereTheWheelDoesNothing: + @pytest.mark.parametrize( + "modifiers", + [NO_MODIFIERS, SHIFT, CTRL_ALT_SHIFT - ALT], + ids=["bare", "shift", "no alt"], + ) + def test_a_wheel_without_alt_leaves_the_view_to_the_plot( + self, + scene: Scene, + modifiers: ModifierSet, + ) -> None: + scene.modifiers = modifiers + + scene.scroll(-1.0) + + assert scene.limits == {X_AXIS: (400.0, 500.0), Y_AXIS: (-0.5, 0.5)} + assert not scene.panned + + def test_a_wheel_away_from_the_plot_leaves_the_view_alone(self, scene: Scene) -> None: + scene.modifiers = ALT + scene.hovered = frozenset() + + scene.scroll(-1.0) + + assert scene.limits[X_AXIS] == (400.0, 500.0) + assert not scene.panned + + def test_a_view_showing_the_whole_range_has_nowhere_to_go(self, scene: Scene) -> None: + scene.modifiers = ALT + scene.limits[X_AXIS] = X_BOUNDS + + scene.scroll(-1.0) + + assert scene.limits[X_AXIS] == X_BOUNDS + + +class TestPlotsLinkedToTheGraph: + def test_the_wheel_over_a_linked_plot_pans_the_shared_axis(self, scene: Scene) -> None: + scene.modifiers = ALT + scene.hovered = frozenset({LANE}) + + scene.scroll(-1.0) + + assert scene.limits[X_AXIS][0] > 400.0 + + +class TestTheBoundsAMoveStopsAt: + @pytest.mark.parametrize( + ("limits", "wheel", "expected"), + [ + ((990.0, 1000.0), -50.0, (990.0, 1000.0)), + ((0.0, 10.0), 50.0, (0.0, 10.0)), + ((980.0, 990.0), -50.0, (990.0, 1000.0)), + ((10.0, 20.0), 50.0, (0.0, 10.0)), + ], + ids=["at the top", "at the bottom", "reaching the top", "reaching the bottom"], + ) + def test_the_view_stops_at_the_edge_of_the_range( + self, + scene: Scene, + limits: Tuple[float, float], + wheel: float, + expected: Tuple[float, float], + ) -> None: + scene.modifiers = ALT + scene.limits[X_AXIS] = limits + + scene.scroll(wheel) + + assert scene.limits[X_AXIS] == pytest.approx(expected) + + +class TestRegisteringTheHandler: + def test_the_wheel_handler_is_registered_under_the_graph(self, scene: Scene) -> None: + with ( + patch.object(pan_module.dpg, "handler_registry") as registry, + patch.object(pan_module.dpg, "add_mouse_wheel_handler") as handler, + ): + scene.pan.create() + + registry.assert_called_once() + handler.assert_called_once_with(callback=scene.pan._on_wheel) diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_ribbon.py b/tests/unit/sampletones_application/ui/elements/graphs/test_ribbon.py new file mode 100644 index 000000000..270ddbd46 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_ribbon.py @@ -0,0 +1,121 @@ +from typing import Final, List, Tuple + +import pytest + +from sampletones_application.layout.general.colors.stem import StemColors +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_application.view_model.shared.ownership import ( + OwnershipLaneViewModel, + OwnershipRibbonViewModel, + OwnershipRunViewModel, +) +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import ColorRGBA + +RECORDING_COLORS: Final[Tuple[ColorRGBA, ...]] = ( + (200, 80, 40, 255), + (80, 160, 220, 255), + (120, 200, 130, 255), +) +AUTHORED_COLOR: Final[ColorRGBA] = (180, 140, 240, 255) +REST_COLOR: Final[ColorRGBA] = (40, 40, 48, 255) +LEFT_OUT_FRACTION: Final[float] = 0.4 +FRAME_LENGTH: Final[int] = 4 + + +@pytest.fixture +def stem_colors() -> StemColors: + return StemColors( + recordings=tuple(LiteralColor(value) for value in RECORDING_COLORS), + authored=LiteralColor(AUTHORED_COLOR), + rest=LiteralColor(REST_COLOR), + left_out_fraction=LEFT_OUT_FRACTION, + ) + + +def _runs(*entries: Tuple[int, int, int]) -> Tuple[OwnershipRunViewModel, ...]: + return tuple( + OwnershipRunViewModel( + start_frame=start, + end_frame=end, + stem_id=stem_id, + heard=True, + ) + for start, end, stem_id in entries + ) + + +class TestTheColorARecordingIsKnownBy: + def test_a_recording_takes_the_color_of_the_id_it_was_converted_as(self, stem_colors: StemColors) -> None: + assert stem_colors.for_stem(1, heard=True).rgba == LiteralColor(RECORDING_COLORS[1]).rgba + + def test_the_list_starts_over_for_a_document_holding_more_recordings( + self, + stem_colors: StemColors, + ) -> None: + assert stem_colors.for_recording(len(RECORDING_COLORS)).rgba == stem_colors.for_recording(0).rgba + + def test_the_frames_the_reader_wrote_take_a_color_of_their_own(self, stem_colors: StemColors) -> None: + assert stem_colors.for_stem(AUTHORED_STEM_ID, heard=True).rgba == LiteralColor(AUTHORED_COLOR).rgba + + def test_a_resting_frame_shows_the_ground(self, stem_colors: StemColors) -> None: + assert stem_colors.for_stem(RESTING_STEM_ID, heard=True).rgba == LiteralColor(REST_COLOR).rgba + + +class TestHowSolidlyAStretchPaints: + """A recording left out keeps its color and carries it faded, so a stretch always names an owner.""" + + def test_a_recording_left_out_keeps_its_color(self, stem_colors: StemColors) -> None: + left_out = stem_colors.for_stem(1, heard=False).rgba + + assert left_out[:3] == LiteralColor(RECORDING_COLORS[1]).rgba[:3] + + def test_a_recording_left_out_carries_less_of_its_opacity(self, stem_colors: StemColors) -> None: + heard = stem_colors.for_stem(1, heard=True).rgba + + assert stem_colors.for_stem(1, heard=False).rgba[3] < heard[3] + + def test_the_frames_the_reader_wrote_fade_like_any_other(self, stem_colors: StemColors) -> None: + left_out = stem_colors.for_stem(AUTHORED_STEM_ID, heard=False).rgba + + assert left_out[:3] == LiteralColor(AUTHORED_COLOR).rgba[:3] + assert left_out[3] < LiteralColor(AUTHORED_COLOR).rgba[3] + + def test_a_resting_stretch_shows_the_ground_whoever_is_listening(self, stem_colors: StemColors) -> None: + """A rest answers to no recording, so every reader hears it.""" + assert stem_colors.for_stem(RESTING_STEM_ID, heard=False).rgba == LiteralColor(REST_COLOR).rgba + + +class TestWhatTheRibbonStandsFor: + @staticmethod + def _ribbon(lanes: List[OwnershipLaneViewModel], total_frames: int) -> OwnershipRibbonViewModel: + return OwnershipRibbonViewModel( + lanes=tuple(lanes), + frame_length=FRAME_LENGTH, + total_frames=total_frames, + ) + + def test_a_ribbon_with_lanes_is_drawn(self) -> None: + lane = OwnershipLaneViewModel(channel_name=ChannelName.PULSE1, runs=_runs((0, 4, 0))) + + assert self._ribbon([lane], 4).is_drawn + + def test_a_ribbon_with_no_lane_stands_down(self) -> None: + assert not self._ribbon([], 4).is_drawn + + def test_a_ribbon_covering_no_frame_stands_down(self) -> None: + lane = OwnershipLaneViewModel(channel_name=ChannelName.PULSE1, runs=()) + + assert not self._ribbon([lane], 0).is_drawn + + def test_the_span_it_covers_is_stated_in_the_samples_the_waveform_counts(self) -> None: + lane = OwnershipLaneViewModel(channel_name=ChannelName.PULSE1, runs=_runs((0, 6, 0))) + + assert self._ribbon([lane], 6).total_samples == 6 * FRAME_LENGTH + + def test_an_empty_ribbon_stands_for_nothing(self) -> None: + empty = OwnershipRibbonViewModel.empty() + + assert not empty.is_drawn + assert empty.total_samples == 0 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..f1c5f126d 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -1,12 +1,16 @@ from types import SimpleNamespace -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple, Union from unittest.mock import MagicMock +import numpy as np import pytest from sampletones_application.ui.elements.graphs import waveform as waveform_module from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.utils.palette.colors.written import LiteralColor +from sampletones_application.view_model.shared.waveform_data import WaveformData + +VOICE_SAMPLES = 64 class _FakeDPG: @@ -43,7 +47,8 @@ def get_item_children(self, tag: str, slot: int) -> List[int]: def get_item_alias(self, item_id: int) -> str: return self.id_to_alias.get(item_id, "") - def delete_item(self, item_id: int) -> None: + def delete_item(self, item: Union[int, str]) -> None: + item_id = self.alias_to_id.get(item, -1) if isinstance(item, str) else item self.deleted.append(item_id) for children in self.children.values(): if item_id in children: @@ -96,6 +101,9 @@ def tolist(self) -> List[float]: def _graph() -> GUIWaveformGraph: graph = GUIWaveformGraph.__new__(GUIWaveformGraph) graph.y_axis_tag = "axis" + graph.x_axis_tag = "time_axis" + graph._named_span = None + graph._sample_rate = 0 graph.position_indicator_tag = "indicator" graph.overlay_rectangle_tag = "overlay" graph.layers = {} @@ -103,6 +111,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 @@ -142,6 +152,136 @@ def test_preserves_position_indicator_across_updates(self, fake_dpg: _FakeDPG) - assert fake_dpg.alias_to_id["overlay"] not in fake_dpg.deleted +class TestWaveformDataUpdateRefit: + """A retune moves the audio's own length, so its update re-fits the view; an ordinary edit, + which changes nothing about the length, leaves the reader's view where it was.""" + + @staticmethod + def _waveform_data() -> WaveformData: + approximation = np.zeros(VOICE_SAMPLES, dtype=np.float32) + return WaveformData( + original_audio=None, + approximation=approximation, + approximations={}, + coefficient=1.0, + frame_length=1, + sample_rate=44100, + ) + + def test_a_refit_update_recomputes_the_axis_ranges( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + graph.current_data = self._waveform_data() + monkeypatch.setattr(graph, "_display_layers", lambda *_args, **_kwargs: [_Layer("Reconstruction")]) + ranges = MagicMock() + monkeypatch.setattr(graph, "_update_ranges", ranges) + + graph.update_waveform_data(self._waveform_data(), refit=True) + + ranges.assert_called_once_with() + + def test_an_ordinary_update_leaves_the_ranges_alone( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + graph.current_data = self._waveform_data() + monkeypatch.setattr(graph, "_display_layers", lambda *_args, **_kwargs: [_Layer("Reconstruction")]) + ranges = MagicMock() + monkeypatch.setattr(graph, "_update_ranges", ranges) + + graph.update_waveform_data(self._waveform_data()) + + ranges.assert_not_called() + + def test_a_graph_not_yet_holding_waveform_data_takes_no_update( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A voice's own waveform (`current_data` unset) has no length to lose, so a retune + elsewhere reaching this graph by mistake is a no-op rather than a crash.""" + graph = _graph() + graph.current_data = None + ranges = MagicMock() + monkeypatch.setattr(graph, "_update_ranges", ranges) + + graph.update_waveform_data(self._waveform_data(), refit=True) + + ranges.assert_not_called() + + +class TestAnUpdateDrawsTheLayersItsDataDisplays: + """An update draws what the fresh data displays, so a layer the data no longer carries leaves the plot.""" + + @staticmethod + def _drawing_both(fake_dpg: _FakeDPG) -> GUIWaveformGraph: + """A graph showing the original and the reconstruction, each with its series on the axis.""" + graph = _graph() + graph.current_data = TestWaveformDataUpdateRefit._waveform_data() + graph.layers = {"Original": _Layer("Original"), "Reconstruction": _Layer("Reconstruction")} + fake_dpg.set_children( + "axis", + [graph._series_tag("Original"), graph._series_tag("Reconstruction"), "indicator", "overlay"], + ) + return graph + + def test_a_layer_the_data_no_longer_displays_leaves_with_its_series( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = self._drawing_both(fake_dpg) + original_series = fake_dpg.alias_to_id[graph._series_tag("Original")] + monkeypatch.setattr(graph, "_display_layers", lambda *_args, **_kwargs: [_Layer("Reconstruction")]) + + graph.update_waveform_data(TestWaveformDataUpdateRefit._waveform_data()) + + assert (list(graph.layers), fake_dpg.deleted) == (["Reconstruction"], [original_series]) + + def test_layers_the_data_still_displays_stay( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = self._drawing_both(fake_dpg) + monkeypatch.setattr( + graph, + "_display_layers", + lambda *_args, **_kwargs: [_Layer("Original"), _Layer("Reconstruction")], + ) + + graph.update_waveform_data(TestWaveformDataUpdateRefit._waveform_data()) + + assert (list(graph.layers), fake_dpg.deleted) == (["Original", "Reconstruction"], []) + + def test_a_layer_joining_the_plot_is_drawn_in_the_layers_order( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The series already drawn is taken off and added back behind the one that joins, as ordered.""" + graph = _graph() + graph.current_data = TestWaveformDataUpdateRefit._waveform_data() + graph.layers = {"Reconstruction": _Layer("Reconstruction")} + fake_dpg.set_children("axis", [graph._series_tag("Reconstruction"), "indicator", "overlay"]) + added: List[str] = [] + monkeypatch.setattr(waveform_module.dpg, "add_line_series", lambda *args, **kwargs: added.append(kwargs["tag"])) + monkeypatch.setattr( + graph, + "_display_layers", + lambda *_args, **_kwargs: [_Layer("Reconstruction"), _Layer("Original")], + ) + + graph.update_waveform_data(TestWaveformDataUpdateRefit._waveform_data()) + + assert added == [graph._series_tag("Reconstruction"), graph._series_tag("Original")] + + class TestWaveformReconstructionDim: def test_series_color_is_untouched_when_not_dimmed(self) -> None: graph = _graph() @@ -149,7 +289,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 @@ -207,3 +347,95 @@ def test_set_dimmed_shows_then_clears_the_status_message(self) -> None: graph.set_reconstruction_dimmed(False) graph._status_bar.set.assert_called_with("") + + def test_new_data_keeps_the_reconstruction_dimmed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An edit landing while later ones are still on their way redraws the waveform still faded.""" + graph = _graph() + graph.set_reconstruction_dimmed(True) + monkeypatch.setattr(graph, "clear_layers", MagicMock()) + monkeypatch.setattr(graph, "_restate_clock_ticks", MagicMock()) + monkeypatch.setattr(graph, "_display_layers", MagicMock(return_value=[])) + graph._x_range = (0.0, 1.0) + waveform_data = WaveformData( + original_audio=None, + approximation=np.zeros(4), + approximations={}, + coefficient=1.0, + frame_length=1, + sample_rate=4, + ) + + graph.load_waveform_data(waveform_data) + + assert graph._reconstruction_dimmed is True + + +class TestAClickReportsASampleOfDrawnAudio: + """The graph reports the sample a click named while it draws the audio its player sounds.""" + + @staticmethod + def _graph_reporting_clicks(monkeypatch: pytest.MonkeyPatch) -> Tuple[GUIWaveformGraph, List[int]]: + graph = _graph() + graph.current_data = MagicMock() + graph._plays_what_it_draws = True + graph._default_x_range = (0.0, 1.0) + graph._default_y_range = (-1.0, 1.0) + graph._layout = SimpleNamespace(waveform=SimpleNamespace(max_display_points=VOICE_SAMPLES)) + monkeypatch.setattr(graph, "add_layer", lambda _layer: None) + monkeypatch.setattr(graph, "_update_display", lambda: None) + clicked: List[int] = [] + graph.on_position_clicked = clicked.append + return graph, clicked + + def test_a_click_reports_the_nearest_sample(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph, clicked = self._graph_reporting_clicks(monkeypatch) + + graph._on_plot_clicked(420.6) + + assert clicked == [421] + + def test_a_voice_drawn_in_place_of_the_audio_takes_no_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An instrument's waveform is none of the audio the tab's player sounds.""" + graph, clicked = self._graph_reporting_clicks(monkeypatch) + + graph.load_voice_waveform( + np.zeros(VOICE_SAMPLES, dtype=np.float32), + name="voice", + color=LiteralColor((255, 255, 255, 255)), + ) + graph._on_plot_clicked(420.6) + + assert clicked == [] + + def test_a_graph_emptied_takes_no_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph, clicked = self._graph_reporting_clicks(monkeypatch) + + graph.clear_layers() + graph._on_plot_clicked(420.6) + + assert clicked == [] + + +class TestClearingKeepsThePositionIndicator: + def test_the_indicator_is_drawn_again_after_the_axis_is_emptied( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + graph.current_position = 900 + graph.indicator_theme = MagicMock() + graph.overlay_theme = MagicMock() + _with_layout(graph) + graph._layout.graph = SimpleNamespace(min_y=-1.0, max_y=1.0) # type: ignore[attr-defined] + indicators: List[Dict[str, Any]] = [] + monkeypatch.setattr(waveform_module, "dpg_delete_children", lambda tag: fake_dpg.set_children(tag, [])) + monkeypatch.setattr(waveform_module.dpg, "add_inf_line_series", lambda x, **kwargs: indicators.append(kwargs)) + monkeypatch.setattr(waveform_module.dpg, "add_shade_series", lambda *args, **kwargs: None) + monkeypatch.setattr(graph, "clear_layers", lambda: None) + + graph.clear() + + assert [indicator["tag"] for indicator in indicators] == [graph.position_indicator_tag] + assert indicators[0]["show"] is False + assert graph.current_position == 0 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..aaacbb851 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_colors.py @@ -0,0 +1,82 @@ +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, + channel_colors=layout.general.colors.channels, + 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/graphs/test_waveform_lanes.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_lanes.py new file mode 100644 index 000000000..01f9f5490 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform_lanes.py @@ -0,0 +1,147 @@ +from typing import Generator, List, Tuple +from unittest.mock import MagicMock + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.context import channel_letter +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.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.graphs import waveform as waveform_module +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.source import PaletteSource +from sampletones_core.constants.enums import ChannelName + +LANGUAGE_MANAGER = LanguageManager(LANG_EN) + + +@pytest.fixture(name="layout") +def layout_fixture() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture(name="graph") +def graph_fixture(layout: LayoutConfig) -> Generator[GUIWaveformGraph, None, None]: + """A waveform graph on a live DearPyGui context, which is what holds the lane rows.""" + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + 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, + channel_colors=layout.general.colors.channels, + language_manager=LANGUAGE_MANAGER, + status_bar=MagicMock(), + ) + finally: + dpg.destroy_context() + + +class TestTheRowsTheLanesAreDrawnIn: + """Each channel keeps a row of its own beneath the waveform, so a lane carries its letter.""" + + def test_every_channel_keeps_a_row(self, graph: GUIWaveformGraph) -> None: + assert set(graph.lane_plot_tags) == set(ChannelName.items()) + assert all(dpg.does_item_exist(plot_tag) for plot_tag in graph.lane_plot_tags.values()) + + def test_a_row_carries_an_axis_of_its_own(self, graph: GUIWaveformGraph) -> None: + assert all(dpg.does_item_exist(axis_tag) for axis_tag in graph.lane_y_axis_tags.values()) + assert len(set(graph.lane_y_axis_tags.values())) == len(ChannelName.items()) + + def test_the_rows_stand_closed_until_a_lane_asks_for_room(self, graph: GUIWaveformGraph) -> None: + assert dpg.get_item_configuration(graph.subplots_tag)["height"] == graph.height + + def test_a_channel_with_a_lane_takes_the_room_it_asks_for(self, graph: GUIWaveformGraph) -> None: + graph.set_lane_heights({ChannelName.PULSE1: 11, ChannelName.TRIANGLE: 11}) + + assert dpg.get_item_configuration(graph.subplots_tag)["height"] == graph.height + 22 + + def test_a_channel_without_a_lane_keeps_no_room(self, graph: GUIWaveformGraph) -> None: + graph.set_lane_heights({ChannelName.PULSE1: 11, ChannelName.TRIANGLE: 11}) + graph.set_lane_heights({ChannelName.PULSE1: 11}) + + assert dpg.get_item_configuration(graph.subplots_tag)["height"] == graph.height + 11 + + def test_the_waveform_keeps_its_own_height_whatever_the_lanes_take(self, graph: GUIWaveformGraph) -> None: + graph.set_lane_heights({channel_name: 11 for channel_name in ChannelName.items()}) + + ratios = dpg.get_item_configuration(graph.subplots_tag)["row_ratios"] + + assert ratios[0] == float(graph.height) + assert ratios[1:] == [11.0] * len(ChannelName.items()) + + +class TestTheFaceALaneIsMarkedIn: + """A lane one bar high prints its letter in the smallest face, so the letter stands within it.""" + + def test_every_lane_takes_the_tiny_face(self, graph: GUIWaveformGraph) -> None: + tiny = FontRegistry.get_tag(Font.REGULAR_TINY) + + assert all(dpg.get_item_font(plot_tag) == tiny for plot_tag in graph.lane_plot_tags.values()) + + def test_the_letter_stands_shorter_than_the_lane_it_marks(self, layout: LayoutConfig) -> None: + """A letter taller than its lane would run into the lane above, so the face stays under it.""" + assert FontRegistry.get_size(Font.REGULAR_TINY) <= layout.graphs.ribbon.lane_height + + +class TestTheLetterFollowsWhetherTheLaneStands: + """A closed lane keeps a sliver of height, so its letter is cleared rather than left to crowd it.""" + + @staticmethod + def _ticks(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, Tuple[Tuple[str, float], ...]]]: + calls: List[Tuple[str, Tuple[Tuple[str, float], ...]]] = [] + monkeypatch.setattr(waveform_module.dpg, "set_axis_ticks", lambda tag, ticks: calls.append((tag, ticks))) + return calls + + def test_a_channel_gaining_a_lane_is_marked_with_its_letter( + self, + graph: GUIWaveformGraph, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls = self._ticks(monkeypatch) + + graph.set_lane_heights({ChannelName.PULSE1: 11}) + + letter = channel_letter(LANGUAGE_MANAGER, ChannelName.PULSE1) + assert (graph.lane_y_axis_tags[ChannelName.PULSE1], ((letter, 0.5),)) in calls + + def test_a_channel_without_a_lane_is_cleared( + self, + graph: GUIWaveformGraph, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls = self._ticks(monkeypatch) + + graph.set_lane_heights({ChannelName.PULSE1: 11}) + + assert (graph.lane_y_axis_tags[ChannelName.PULSE2], ()) in calls + + def test_a_lane_closed_after_standing_open_loses_its_letter( + self, + graph: GUIWaveformGraph, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph.set_lane_heights({ChannelName.PULSE1: 11}) + calls = self._ticks(monkeypatch) + + graph.set_lane_heights({}) + + assert (graph.lane_y_axis_tags[ChannelName.PULSE1], ()) in calls diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_centered.py b/tests/unit/sampletones_application/ui/elements/layout/test_centered.py new file mode 100644 index 000000000..5f68d5f7a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_centered.py @@ -0,0 +1,52 @@ +from typing import Final, Iterator, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.ui.elements.layout.centered import centered + +CONTENT_TAG: Final[str] = "test.centered.text.content" +COLUMN_SLOT: Final[int] = 0 +CELL_SLOT: Final[int] = 1 +MIDDLE: Final[int] = 1 + + +@pytest.fixture(name="dpg_context") +def dpg_context_fixture() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def build() -> int: + """Builds one centered item and returns the table standing around it.""" + with dpg.window(): + with centered(): + dpg.add_text("content", tag=CONTENT_TAG) + + group = dpg.get_item_parent(CONTENT_TAG) + row = dpg.get_item_parent(group) + return dpg.get_item_parent(row) + + +class TestCentered: + """``centered`` stands the block's content in a fitted column between two equal ones.""" + + def test_the_content_sits_in_the_middle_cell(self, dpg_context: None) -> None: + table = build() + row = dpg.get_item_children(table, CELL_SLOT)[0] + cells: List[int] = dpg.get_item_children(row, CELL_SLOT) + assert cells.index(dpg.get_item_parent(CONTENT_TAG)) == MIDDLE + + def test_the_middle_column_fits_its_content(self, dpg_context: None) -> None: + columns: List[int] = dpg.get_item_children(build(), COLUMN_SLOT) + assert dpg.get_item_configuration(columns[MIDDLE])["width_fixed"] + + def test_the_flanking_columns_share_the_rest_equally(self, dpg_context: None) -> None: + columns: List[int] = dpg.get_item_children(build(), COLUMN_SLOT) + left = dpg.get_item_configuration(columns[0]) + right = dpg.get_item_configuration(columns[-1]) + assert left["width_stretch"] and right["width_stretch"] + assert left["init_width_or_weight"] == right["init_width_or_weight"] diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py index 02af2b2b7..6daab48db 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py @@ -22,6 +22,10 @@ from sampletones_application.ui.themes.items import ThemeItems from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.hover import HOVER_RECHECK_FRAMES +from tests.suite.frames import Frames +from tests.suite.gestures import HOVERED, handler_of _EXPANDED_GLYPH = "v" _COLLAPSED_GLYPH = ">" @@ -34,6 +38,7 @@ _STRIP_PADDING = 8 +RESTING_FRAMES = 12 @pytest.fixture @@ -60,6 +65,7 @@ def rendered_strip_padding(monkeypatch: pytest.MonkeyPatch) -> None: def _glyphs() -> Glyphs: common = CommonGlyphs( tick=".", + swatch="#", favorite="*", expanded=_EXPANDED_GLYPH, collapsed=_COLLAPSED_GLYPH, @@ -108,6 +114,11 @@ def _build_card(controller: CollapseController) -> None: controller.set_collapsed(controller.collapsed, notify=False) +def _report_hover(controller: CollapseController) -> None: + """Land the report DearPyGui's hover handler makes for a frame the pointer rests on a bar.""" + dpg.get_item_callback(handler_of(controller.strip_handler_tag, HOVERED))() + + class TestCollapseControllerAttach: """``attach`` wires the header bar for interaction. A child window rejects a clicked handler, so the toggle must ride a global mouse-click handler while only the hover handler binds to the @@ -245,11 +256,50 @@ def test_hover_highlighting_covers_the_rail_as_well_as_the_strip( probed: List[str] = [] monkeypatch.setattr(dpg, "is_item_hovered", lambda tag: bool(probed.append(tag)) and False) - controller._on_bar_hover() + _report_hover(controller) assert controller.strip_tag in probed assert controller.rail_tag in probed + def test_a_pointer_resting_on_a_bar_keeps_one_recheck_waiting( + self, + dpg_context: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hover handler reports every frame the pointer rests on the bar, and the one re-check + catching the pointer leaving is all those reports wait on.""" + controller = _controller(CollapseAxis.VERTICAL) + _build_card(controller) + held = Frames() + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", held.hold) + monkeypatch.setattr(dpg, "is_item_hovered", lambda tag: True) + + for _ in range(RESTING_FRAMES): + _report_hover(controller) + held.render() + + assert held.pending == 1 + + def test_a_pointer_leaving_the_bar_settles_it_back_to_idle( + self, + dpg_context: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + controller = _controller(CollapseAxis.VERTICAL) + _build_card(controller) + held = Frames() + hovered = [True] + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", held.hold) + monkeypatch.setattr(dpg, "is_item_hovered", lambda tag: hovered[0]) + _report_hover(controller) + worn = dpg.get_item_theme(controller.strip_tag) + + hovered[0] = False + held.render(HOVER_RECHECK_FRAMES) + + assert worn != dpg.get_item_theme(controller.strip_tag) + assert held.pending == 0 + def test_toggle_announces_the_new_state(self, dpg_context: None) -> None: controller = _controller(CollapseAxis.HORIZONTAL_LEFT) _build_card(controller) diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_columns.py b/tests/unit/sampletones_application/ui/elements/layout/test_columns.py new file mode 100644 index 000000000..b703868b2 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_columns.py @@ -0,0 +1,171 @@ +from dataclasses import dataclass +from typing import Iterator, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_TABLE_COLUMN, SUF_TABLE_GAP +from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +_PANEL_GAP = 12 +_LEFT = "test.left" +_MIDDLE = "test.middle" +_RIGHT = "test.right" + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def _fill(parent: str) -> None: + dpg.add_text("card", parent=parent) + + +def _columns(*tags: str) -> Tuple[ColumnSpec, ...]: + return tuple(ColumnSpec(tag=tag, build=_fill) for tag in tags) + + +def _weight(tag: str) -> float: + return float(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_COLUMN))["init_width_or_weight"]) + + +def _gap(tag: str) -> float: + return float(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_GAP))["init_width_or_weight"]) + + +def _stands(tag: str) -> bool: + return bool(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_COLUMN))["enabled"]) + + +def _gap_stands(tag: str) -> bool: + return bool(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_GAP))["enabled"]) + + +class TestARowDeclaresWhatItsColumnsTake(BaseTestSuite): + """A row states each column's share rather than reading it back from the card inside it, so the + proportions hold whatever its cards draw and a column put away can come back at the share it + was declared with.""" + + def test_a_stretching_column_takes_a_share_of_its_own(self, dpg_context: None) -> None: + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=_columns(_LEFT, _RIGHT)) + + assert _weight(_LEFT) == _weight(_RIGHT) + assert _weight(_LEFT) > 0 + + def test_a_fixed_column_takes_the_width_it_names(self, dpg_context: None) -> None: + with dpg.window(): + TabColumns.row( + panel_gap=_PANEL_GAP, + columns=( + ColumnSpec(tag=_LEFT, build=_fill, width=240), + ColumnSpec(tag=_RIGHT, build=_fill), + ), + ) + + assert _weight(_LEFT) == 240 + + def test_a_gap_stands_between_neighbors(self, dpg_context: None) -> None: + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=_columns(_LEFT, _RIGHT)) + + assert _gap(_RIGHT) == _PANEL_GAP + + +class TestARowDividesItselfAmongTheColumnsStanding(BaseTestSuite): + """A card the reader puts away leaves its column nothing to hold, so the column leaves the row + and the columns still standing divide it, keeping one gap between each of them.""" + + @dataclass(frozen=True, kw_only=True) + class StandingCase(BaseRegularTestCase): + declared: Tuple[str, ...] + standing: Tuple[str, ...] + expected_columns: Tuple[bool, ...] + expected_gaps: Tuple[bool, ...] + + test_cases = ( + StandingCase( + label="both_stand", + declared=(_LEFT, _RIGHT), + standing=(_LEFT, _RIGHT), + expected_columns=(True, True), + expected_gaps=(True,), + ), + StandingCase( + label="the_last_is_put_away", + declared=(_LEFT, _RIGHT), + standing=(_LEFT,), + expected_columns=(True, False), + expected_gaps=(False,), + ), + StandingCase( + label="the_first_is_put_away", + declared=(_LEFT, _RIGHT), + standing=(_RIGHT,), + expected_columns=(False, True), + expected_gaps=(False,), + ), + StandingCase( + label="the_middle_is_put_away", + declared=(_LEFT, _MIDDLE, _RIGHT), + standing=(_LEFT, _RIGHT), + expected_columns=(True, False, True), + expected_gaps=(False, True), + ), + StandingCase( + label="the_first_two_are_put_away", + declared=(_LEFT, _MIDDLE, _RIGHT), + standing=(_RIGHT,), + expected_columns=(False, False, True), + expected_gaps=(False, False), + ), + StandingCase( + label="every_column_is_put_away", + declared=(_LEFT, _RIGHT), + standing=(), + expected_columns=(False, False), + expected_gaps=(False,), + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_row_holds_what_stands(self, case: StandingCase, dpg_context: None) -> None: + columns = _columns(*case.declared) + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) + + TabColumns.stand_columns(columns, frozenset(case.standing)) + + assert tuple(_stands(tag) for tag in case.declared) == case.expected_columns + assert tuple(_gap_stands(tag) for tag in case.declared[1:]) == case.expected_gaps + + def test_a_column_put_away_keeps_the_share_it_was_declared_with(self, dpg_context: None) -> None: + """The share stays declared while the column is away, so what stands divides the row by theirs.""" + columns = _columns(_LEFT, _RIGHT) + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) + declared = (_weight(_LEFT), _weight(_RIGHT), _gap(_RIGHT)) + + TabColumns.stand_columns(columns, frozenset({_LEFT})) + + assert (_weight(_LEFT), _weight(_RIGHT), _gap(_RIGHT)) == declared + + def test_a_column_comes_back_at_the_share_it_was_declared_with(self, dpg_context: None) -> None: + columns = _columns(_LEFT, _RIGHT) + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) + + TabColumns.stand_columns(columns, frozenset({_LEFT})) + TabColumns.stand_columns(columns, frozenset({_LEFT, _RIGHT})) + + assert (_stands(_LEFT), _stands(_RIGHT), _gap_stands(_RIGHT)) == (True, True, True) + assert _weight(_LEFT) == _weight(_RIGHT) + assert _gap(_RIGHT) == _PANEL_GAP diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py new file mode 100644 index 000000000..a3dab1e4b --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -0,0 +1,260 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.ui.elements.layout.geometry import ( + GEOMETRY_TOLERANCE, + MINIMUM_ROW_PITCH, + RowGeometry, + Window, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +OVERSCAN = 2 +PITCH = 20.0 +OPENING = 16.0 +REGION_HEIGHT = 100.0 +TOTAL_ROWS = 100 +READING_STEPS = 40 + + +def measured(*, overscan: int = OVERSCAN, pitch: float = PITCH) -> RowGeometry: + """A reading of the room one row takes, as a region that has drawn rows would hold it.""" + return RowGeometry(overscan=overscan, pitch=pitch, opening=OPENING) + + +class TestAnUnmeasuredGeometry(BaseTestSuite): + """A geometry that has yet to read a row works from the height its layout gives one, so a + first draw builds about the rows the region shows.""" + + def test_it_reports_no_reading(self) -> None: + assert not RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).measured + + def test_it_works_from_the_opening_it_was_given(self) -> None: + assert RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).room == OPENING + + def test_an_opening_under_the_floor_is_held_to_it(self) -> None: + """No theme draws a row that small, so a figure below the floor opens at the floor.""" + opened = RowGeometry.opening_at(overscan=OVERSCAN, opening=MINIMUM_ROW_PITCH / 2) + assert opened.room == MINIMUM_ROW_PITCH + + def test_its_window_covers_what_a_measured_one_covers(self) -> None: + """The opening stands close to what a row takes, so the first slice reaches the rows the + region shows rather than several times as many.""" + unmeasured = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert unmeasured.size(REGION_HEIGHT) >= measured().size(REGION_HEIGHT) + assert unmeasured.size(REGION_HEIGHT) < 2 * measured().size(REGION_HEIGHT) + + def test_it_still_holds_a_long_list_back(self) -> None: + assert RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).windows(height=REGION_HEIGHT, total=10_000) + + def test_a_short_list_is_drawn_whole(self) -> None: + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=3) == (0, 3) + + +class TestAMeasuredGeometry(BaseTestSuite): + """A reading taken from drawn rows is what every room the region reserves is worked out from.""" + + def test_the_room_is_the_reading(self) -> None: + assert measured().room == PITCH + + def test_it_reports_a_reading(self) -> None: + assert measured().measured + + +class TestWindowSize(BaseTestSuite): + """The window holds the rows the region shows and an overscan beyond each edge, so a scroll + in either direction meets rows already standing.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + height: float + pitch: float + overscan: int + expected: int + + test_cases = ( + TestCase(label="five_rows_fit", height=100.0, pitch=20.0, overscan=2, expected=10), + TestCase(label="a_partial_row_counts", height=110.0, pitch=20.0, overscan=2, expected=10), + TestCase(label="no_overscan_shows_what_fits", height=100.0, pitch=20.0, overscan=0, expected=6), + TestCase(label="a_region_shorter_than_a_row_holds_one", height=5.0, pitch=20.0, overscan=0, expected=1), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_size_counts_what_shows_and_the_overscan(self, test_case: TestCase) -> None: + geometry = measured(overscan=test_case.overscan, pitch=test_case.pitch) + assert geometry.size(test_case.height) == test_case.expected + + +class TestSliceOf(BaseTestSuite): + """Where a window opens is where the offset stands counted in the rooms the region reserves + by, so the top of the list is reachable at the top and the end of it at the end.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + offset: float + total: int + expected: Window + + test_cases = ( + TestCase(label="a_short_list_is_drawn_whole", offset=0.0, total=8, expected=(0, 8)), + TestCase(label="a_list_the_size_of_the_window_is_drawn_whole", offset=0.0, total=10, expected=(0, 10)), + TestCase(label="the_top_opens_at_the_first_row", offset=0.0, total=TOTAL_ROWS, expected=(0, 10)), + TestCase(label="a_row_down_carries_the_overscan", offset=PITCH, total=TOTAL_ROWS, expected=(0, 10)), + TestCase(label="the_middle_opens_halfway_down", offset=950.0, total=TOTAL_ROWS, expected=(45, 10)), + TestCase(label="the_end_opens_at_the_last_rows", offset=1900.0, total=TOTAL_ROWS, expected=(90, 10)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_slice_follows_the_rooms_the_offset_counts_out(self, test_case: TestCase) -> None: + window = measured().slice_of( + offset=test_case.offset, + height=REGION_HEIGHT, + total=test_case.total, + ) + assert window == test_case.expected + + def test_a_window_holds_the_same_rows_however_long_the_list(self) -> None: + """This is the claim a folder rests on: opening ten thousand costs what opening ten costs, + because the rows outside the window stand as reserved room rather than as widgets.""" + geometry = measured() + + _, few = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=TOTAL_ROWS) + _, many = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=100 * TOTAL_ROWS) + + assert few == many + + def test_the_end_of_the_list_is_reachable(self) -> None: + """A region scrolled to the end of its rows builds the rows at the end of its list.""" + total = 5_000 + geometry = measured() + start, count = geometry.slice_of(offset=total * PITCH, height=REGION_HEIGHT, total=total) + assert start + count == total + + @pytest.mark.parametrize("offset", range(0, 2001, 37)) + def test_a_window_stays_inside_the_list_at_any_offset(self, offset: int) -> None: + start, count = measured().slice_of( + offset=float(offset), + height=REGION_HEIGHT, + total=TOTAL_ROWS, + ) + assert start >= 0 + assert start + count <= TOTAL_ROWS + + def test_the_window_only_moves_forward_as_the_region_scrolls(self) -> None: + geometry = measured() + previous = 0 + for offset in range(0, 10_001, 13): + start, _ = geometry.slice_of(offset=float(offset), height=REGION_HEIGHT, total=500) + assert start >= previous + previous = start + + +class TestTheWindowCoversTheRegion(BaseTestSuite): + """The rows a window names stand across the region the offset it was chosen for is looking at. + + A window is placed by the room reserved above it and filled with rows of whatever height the + theme draws them at, so the two agree in length only while the reading is exact. The rows + have to reach across the region either way: a reading over the height a row really takes is + what a list whose rows are drawn in tables of their own leaves behind, and a window chosen + against it left the foot of an open folder blank and its last rows out of reach. + """ + + @pytest.mark.parametrize("drawn", (PITCH * 0.8, PITCH, PITCH * 1.3)) + def test_the_rows_it_names_reach_across_the_region(self, drawn: float) -> None: + geometry = measured() + for offset in self._offsets(geometry, drawn): + start, count = geometry.slice_of(offset=offset, height=REGION_HEIGHT, total=TOTAL_ROWS) + head = float(geometry.reserve(start)) + assert head <= offset + assert head + count * drawn >= offset + REGION_HEIGHT + + @pytest.mark.parametrize("drawn", (PITCH * 0.8, PITCH, PITCH * 1.3)) + def test_the_last_row_stands_at_the_foot(self, drawn: float) -> None: + geometry = measured() + start, count = geometry.slice_of( + offset=self._offsets(geometry, drawn)[-1], + height=REGION_HEIGHT, + total=TOTAL_ROWS, + ) + assert start + count == TOTAL_ROWS + + @staticmethod + def _offsets(geometry: RowGeometry, drawn: float) -> Tuple[float, ...]: + """Every position the reader can scroll a region whose rows are drawn ``drawn`` tall. + + What the region holds is the two reserves and the block of drawn rows, so a row drawn at + a height the reading overstates makes the content shorter than the rooms it was reserved + in, and the reader stops short of the travel the reserves alone would give. + """ + count = geometry.size(REGION_HEIGHT) + content = geometry.reserve(TOTAL_ROWS) - geometry.reserve(count) + count * drawn + travel = content - REGION_HEIGHT + return tuple(travel * step / READING_STEPS for step in range(READING_STEPS + 1)) + + +class TestReserve(BaseTestSuite): + """The rows a window passed over stand as the room they would have taken, which is what keeps + the scrollbar proportional to the whole list.""" + + @pytest.mark.parametrize("rows", (0, 1, 7, 5_000)) + def test_reserve_is_the_room_those_rows_take(self, rows: int) -> None: + assert measured().reserve(rows) == int(rows * PITCH) + + def test_an_unmeasured_geometry_reserves_by_its_opening(self) -> None: + rows = 100 + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert geometry.reserve(rows) == int(rows * OPENING) + + def test_the_reserves_and_the_drawn_rows_span_the_list(self) -> None: + geometry = measured() + total = 100 + start, count = geometry.slice_of(offset=400.0, height=REGION_HEIGHT, total=total) + spanned = geometry.reserve(start) + geometry.reserve(count) + geometry.reserve(total - start - count) + assert spanned == geometry.reserve(total) + + +class TestTake(BaseTestSuite): + """A reading is taken from the block of rows a region drew, so whatever a table lays around + its rows is carried by the same number that reserves room for them.""" + + def test_a_reading_gives_the_room_one_row_takes(self) -> None: + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + geometry.take(block=200.0, rows=10) + assert geometry.pitch == pytest.approx(20.0) + assert geometry.measured + + def test_a_first_reading_is_worth_redrawing(self) -> None: + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert geometry.take(block=200.0, rows=10) + + def test_a_reading_that_holds_asks_for_no_redraw(self) -> None: + geometry = measured() + assert not geometry.take(block=PITCH * 10, rows=10) + + def test_a_move_within_the_tolerance_asks_for_no_redraw(self) -> None: + geometry = measured() + drift = GEOMETRY_TOLERANCE / 2 + assert not geometry.take(block=(PITCH + drift) * 10, rows=10) + + def test_a_move_past_the_tolerance_is_worth_redrawing(self) -> None: + geometry = measured() + drift = GEOMETRY_TOLERANCE * 2 + assert geometry.take(block=(PITCH + drift) * 10, rows=10) + + @pytest.mark.parametrize("rows", (0, -1)) + def test_a_block_of_no_rows_leaves_the_reading_alone(self, rows: int) -> None: + geometry = measured() + assert not geometry.take(block=200.0, rows=rows) + assert geometry.pitch == PITCH + + @pytest.mark.parametrize("block", (0.0, -50.0, MINIMUM_ROW_PITCH * 10 - 1)) + def test_a_block_measured_while_the_rows_were_clipped_is_let_be(self, block: float) -> None: + """A block giving a row less than the floor is a region measured before its rows were + placed, and taking it would put every region sharing the reading out of step.""" + geometry = measured() + assert not geometry.take(block=block, rows=10) + assert geometry.pitch == PITCH diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py new file mode 100644 index 000000000..4f8db0e6a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -0,0 +1,620 @@ +from typing import Any, Iterator, List, Optional, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_LEAD +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import ( + NO_GUTTER, + NO_MARGIN, + NO_SCROLL, + LeadBuilder, + WindowedRegion, +) +from sampletones_application.ui.elements.layout.well import well +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 tests.suite.base import BaseTestSuite + +ROOT_TAG = "test_root" +REGION_TAG = "test.region" +PITCH = 20.0 +OPENING = 8.0 +OVERSCAN = 2 +CEILING = 100 +HEADING_TEXT = "channels" +STANDING_OFFSET = 300.0 +PADDING = 8 +GUTTER = 13 +MARGIN = 6 +LEAD_TAG = compose_tag(REGION_TAG, SUF_LEAD) +HEADING_HEIGHT = 50.0 +NO_HEADING = 0.0 +FITTING_ROWS = 4 +MEASURING_ROWS = 8 + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + """Stands up the context and themes a recessed region binds while it draws.""" + dpg.create_context() + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture +def region(dpg_context: None) -> WindowedRegion: + """A region whose reading of a row is already taken, as a list that has drawn rows leaves it.""" + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + +def heading(parent: str) -> None: + """A heading of the kind a list stands above its rows.""" + dpg.add_text(HEADING_TEXT, parent=parent) + + +def draw(region: WindowedRegion, total: int, *, lead: Optional[LeadBuilder] = None) -> List[Tuple[int, int]]: + """Draw a list of ``total`` rows, reporting the slice the region asked to be built.""" + asked: List[Tuple[int, int]] = [] + + def build(start: int, count: int) -> None: + asked.append((start, count)) + for index in range(start, start + count): + dpg.add_text(f"row {index}", parent=region.body) + + region.draw(total, build, lead=lead) + return asked + + +def block_of(height: float, *, lead: float = NO_HEADING) -> Any: + """Stands in for the rows a frame placed, and for the heading standing above them. + + A block is measured whole, so the heading's own room is part of what a region reads and comes + out of it again before a row is counted from what is left. + """ + + def measured(item: str, *_args: Any, **_kwargs: Any) -> List[float]: + return [0.0, lead if item == LEAD_TAG else height] + + return patch.object(dpg, "get_item_rect_size", side_effect=measured) + + +def reserves(region: WindowedRegion) -> Tuple[int, int]: + """The room standing above and below the rows the region drew.""" + spacers = [ + child + for child in dpg.get_item_children(region.body, 1) + if dpg.get_item_type(child) == "mvAppItemType::mvSpacer" + ] + return ( + int(dpg.get_item_configuration(spacers[0])["height"]), + int(dpg.get_item_configuration(spacers[-1])["height"]), + ) + + +class TestAShortList(BaseTestSuite): + """A list the region can show whole is built whole, with no room reserved either side.""" + + def test_every_row_is_built(self, region: WindowedRegion) -> None: + assert draw(region, 4) == [(0, 4)] + + def test_nothing_is_reserved(self, region: WindowedRegion) -> None: + draw(region, 4) + assert reserves(region) == (0, 0) + + def test_the_window_names_the_whole_list(self, region: WindowedRegion) -> None: + draw(region, 4) + assert region.window == (0, 4) + + +class TestALongList(BaseTestSuite): + """A list outgrowing the region is built a slice at a time, the rest standing as room.""" + + def test_only_the_slice_is_built(self, region: WindowedRegion) -> None: + assert draw(region, 500) == [(0, 10)] + + def test_the_rows_still_to_come_are_reserved(self, region: WindowedRegion) -> None: + draw(region, 500) + above, below = reserves(region) + assert above == 0 + assert below == int((500 - 10) * PITCH) + + def test_the_reserves_and_the_slice_span_the_whole_list(self, region: WindowedRegion) -> None: + draw(region, 500) + above, below = reserves(region) + _, count = region.window + assert above + int(count * PITCH) + below == int(500 * PITCH) + + +class TestAnUnmeasuredRegion(BaseTestSuite): + """A region with no reading of a row yet builds a first slice at its natural height and + reserves nothing, which is the run of rows a reading is then taken from.""" + + @pytest.fixture + def unmeasured(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + def test_it_builds_a_bounded_slice_of_a_long_list(self, unmeasured: WindowedRegion) -> None: + asked = draw(unmeasured, 5_000) + assert asked[0][0] == 0 + assert asked[0][1] < 5_000 + + def test_it_reserves_nothing_while_it_has_no_reading(self, unmeasured: WindowedRegion) -> None: + draw(unmeasured, 5_000) + assert reserves(unmeasured) == (0, 0) + + def test_a_short_list_is_still_built_whole(self, unmeasured: WindowedRegion) -> None: + assert draw(unmeasured, 4) == [(0, 4)] + + +class TestAReadingTheHeightHasYetToFollow(BaseTestSuite): + """A region reads what a row takes from the rows it drew, which is a frame after it was sized. + + The reading is what says how tall the whole list stands, so a region holding it is standing at + a height decided before it knew: :attr:`settling` is what asks for the pass that puts it right, + and without one a long list stands as tall as every row it drew. + """ + + @pytest.fixture + def unmeasured(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + def test_a_reading_asks_for_the_pass_that_holds_the_region_to_it(self, unmeasured: WindowedRegion) -> None: + drawn = draw(unmeasured, 500) + with block_of(drawn[0][1] * PITCH): + unmeasured.settle() + + assert unmeasured.settling + + def test_that_pass_holds_the_region_to_its_ceiling(self, unmeasured: WindowedRegion) -> None: + drawn = draw(unmeasured, 500) + with block_of(drawn[0][1] * PITCH): + unmeasured.settle() + + assert unmeasured.settle() + assert not unmeasured.natural + assert draw(unmeasured, 500)[0][1] < drawn[0][1] + + def test_it_comes_to_rest_once_the_height_follows_the_reading(self, unmeasured: WindowedRegion) -> None: + """A list the region shows whole holds nothing back, so the reading is the last thing due.""" + drawn = draw(unmeasured, 4) + with block_of(drawn[0][1] * PITCH): + unmeasured.settle() + + unmeasured.settle() + + assert not unmeasured.settling + + def test_a_region_taking_the_height_it_measures_asks_to_be_read_back( + self, + unmeasured: WindowedRegion, + ) -> None: + """The height a region opens at is not the one it measures, and a move is a move. + + Whatever the region is drawn inside measures it as it stands, so the height it takes to + read a row by asks for the same further pass a height taken from a reading does. + """ + draw(unmeasured, 4) + + unmeasured.settle() + + assert unmeasured.settling + + def test_a_region_with_nothing_to_read_comes_to_rest_where_it_stands( + self, + unmeasured: WindowedRegion, + ) -> None: + """A region drawn where no frame has placed its rows measures nothing, and settles anyway. + + The first pass moves it off the height it opened at, which is a move like any other and + asks to be read back. The pass that follows finds it standing where it already stood, so a + region with nothing to measure comes to rest rather than asking on every frame. + """ + draw(unmeasured, 4) + unmeasured.settle() + + unmeasured.settle() + + assert not unmeasured.settling + + +class TestAHeightTheFrameHasYetToShow(BaseTestSuite): + """A region that takes a new height stands at its old one until the frame after. + + Whatever the region is drawn inside measures it as it stands, so a region reporting itself at + rest the moment it resizes leaves the one around it holding a scrollbar over content that fits. + """ + + @pytest.fixture + def sizing(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + def test_a_region_that_shrank_below_its_ceiling_asks_for_another_pass( + self, + sizing: WindowedRegion, + ) -> None: + draw(sizing, 500) + sizing.settle() + + draw(sizing, 2) + sizing.settle() + + assert sizing.natural + assert sizing.settling + + def test_a_region_standing_where_it_stood_comes_to_rest(self, sizing: WindowedRegion) -> None: + draw(sizing, 2) + sizing.settle() + + sizing.settle() + + assert not sizing.settling + + +class TestRedrawing(BaseTestSuite): + """A region drawn again replaces what it held, so its rows stand once however often it is + rebuilt.""" + + def test_a_second_draw_builds_the_slice_once(self, region: WindowedRegion) -> None: + draw(region, 500) + draw(region, 500) + _, count = region.window + assert len(dpg.get_item_children(region.body, 1)) == count + 2 + + def test_a_shorter_list_reserves_less(self, region: WindowedRegion) -> None: + draw(region, 500) + draw(region, 20) + _, below = reserves(region) + assert below == int((20 - 10) * PITCH) + + +class TestALead(BaseTestSuite): + """A heading standing above the rows is built with them and scrolls with them.""" + + def test_the_heading_stands_before_the_rows(self, region: WindowedRegion) -> None: + draw(region, 4, lead=heading) + first = dpg.get_item_children(region.body, 1)[0] + + assert dpg.get_item_type(first) == "mvAppItemType::mvGroup" + + def test_the_rows_are_reserved_around_as_they_are_without_one(self, region: WindowedRegion) -> None: + draw(region, 500, lead=heading) + + assert reserves(region) == (0, int((500 - 10) * PITCH)) + + def test_a_region_drawn_again_holds_one_heading(self, region: WindowedRegion) -> None: + draw(region, 4, lead=heading) + draw(region, 4, lead=heading) + groups = [ + child + for child in dpg.get_item_children(region.body, 1) + if dpg.get_item_type(child) == "mvAppItemType::mvGroup" + ] + + assert len(groups) == 1 + + def test_the_room_the_rows_ask_for_counts_the_heading(self, region: WindowedRegion) -> None: + """Rows that stand inside the region alone outgrow it once a heading stands above them.""" + draw(region, FITTING_ROWS, lead=heading) + + with block_of(NO_HEADING, lead=HEADING_HEIGHT): + region.settle() + + assert not region.natural + + def test_the_same_rows_without_one_stand_inside_it(self, region: WindowedRegion) -> None: + """What puts the rows past the ceiling is the heading, so without one they fit.""" + draw(region, FITTING_ROWS, lead=heading) + + with block_of(NO_HEADING, lead=NO_HEADING): + region.settle() + + assert region.natural + + def test_the_reading_of_a_row_leaves_the_heading_out(self, dpg_context: None) -> None: + """A block is measured with the heading in it, so a row is counted from what is left.""" + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + built = WindowedRegion( + tag=REGION_TAG, + geometry=geometry, + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + drawn = draw(built, MEASURING_ROWS, lead=heading) + with block_of(drawn[0][1] * PITCH + HEADING_HEIGHT, lead=HEADING_HEIGHT): + built.settle() + + assert geometry.pitch == PITCH + + +class TestAWholeDraw(BaseTestSuite): + """Content that is more than a run of rows is built entire, and the region holds it to its + ceiling from there on.""" + + def test_everything_it_is_given_is_built(self, region: WindowedRegion) -> None: + region.draw_whole(lambda: [dpg.add_text(f"row {index}", parent=region.body) for index in range(30)], lead=None) + + assert len(dpg.get_item_children(region.body, 1)) == 30 + + def test_it_holds_back_no_rows(self, region: WindowedRegion) -> None: + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) + + assert not region.windowing + + def test_it_carries_its_heading_too(self, region: WindowedRegion) -> None: + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=heading) + first = dpg.get_item_children(region.body, 1)[0] + + assert dpg.get_item_type(first) == "mvAppItemType::mvGroup" + + def test_content_past_the_ceiling_is_held_at_it(self, region: WindowedRegion) -> None: + """What a region holds is measured rather than counted, since it is more than a run of rows.""" + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) + + with block_of(CEILING + PITCH): + region.settle() + + assert not region.natural + assert dpg.get_item_configuration(REGION_TAG)["height"] == CEILING + assert dpg.get_item_configuration(REGION_TAG)["auto_resize_y"] is False + + def test_content_inside_the_ceiling_sizes_the_region_to_itself(self, region: WindowedRegion) -> None: + """A region held at its ceiling follows what it holds back down once that fits again.""" + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) + with block_of(CEILING + PITCH): + region.settle() + + with block_of(CEILING - PITCH): + region.settle() + + assert region.natural + assert dpg.get_item_configuration(REGION_TAG)["auto_resize_y"] is True + assert dpg.get_item_configuration(REGION_TAG)["no_scrollbar"] is True + + +class TestWhereTheReaderStands(BaseTestSuite): + """A region redrawn as the reader scrolls leaves the scroll where they put it, and one built + in place of another opens where that one stood. + + The rows a region shows are replaced a frame after the wheel asked for them, by which time the + reader has scrolled on. A position written back then lands against the wheel and takes them + somewhere they never scrolled, which asks for the rows to be replaced again — a region that + never settles while a hand is on the wheel. + """ + + def test_a_draw_writes_no_scroll(self, region: WindowedRegion) -> None: + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + draw(region, 40) + + set_y_scroll.assert_not_called() + + def test_the_window_follows_where_the_reader_scrolled_to(self, region: WindowedRegion) -> None: + draw(region, 40) + region.settle() + + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + asked = draw(region, 40) + + assert asked[0][0] > 0 + + def test_a_redraw_the_scroll_asked_for_hands_nothing_back(self, region: WindowedRegion) -> None: + """By the frame the rows land the reader has scrolled on, so the region leaves them there.""" + draw(region, 40) + region.settle() + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + draw(region, 40) + + with ( + patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET + PITCH), + patch.object(dpg, "set_y_scroll") as set_y_scroll, + ): + region.settle() + + set_y_scroll.assert_not_called() + + +class TestARegionOpeningInPlaceOfAnother(BaseTestSuite): + """A region built where one a rebuild took down stood opens on the rows that one showed.""" + + def test_its_window_opens_where_the_one_before_it_stood(self, region: WindowedRegion) -> None: + region.opens_at(STANDING_OFFSET) + + asked = draw(region, 40) + + assert asked[0][0] > 0 + + def test_the_reader_is_put_back_once_the_rows_are_placed(self, region: WindowedRegion) -> None: + region.opens_at(STANDING_OFFSET) + draw(region, 40) + + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + region.settle() + + set_y_scroll.assert_called_once_with(REGION_TAG, STANDING_OFFSET) + + def test_a_region_opening_where_it_already_stands_writes_nothing(self, region: WindowedRegion) -> None: + """A folder opens at the top by default, which is where the region stands, so no scroll is written.""" + region.opens_at(NO_SCROLL) + draw(region, 40) + + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + settled = region.settle() + + set_y_scroll.assert_not_called() + assert settled is False + + def test_it_is_handed_back_once(self, region: WindowedRegion) -> None: + """A restored position is where the reader stands, so the next frame writes nothing.""" + region.opens_at(STANDING_OFFSET) + draw(region, 40) + with patch.object(dpg, "set_y_scroll"): + region.settle() + + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + region.settle() + + set_y_scroll.assert_not_called() + + +class TestARegionWhoseBodyHasGone(BaseTestSuite): + """A window closing takes the region's whole subtree down while the pass that settles it is + still armed, so the region answers for a body that is no longer there.""" + + def test_it_asks_for_nothing_more(self, region: WindowedRegion) -> None: + draw(region, 40) + dpg.delete_item(ROOT_TAG, children_only=True) + + assert region.settle() is False + assert region.settling is False + + def test_a_draw_reaching_it_builds_nothing(self, region: WindowedRegion) -> None: + """Building into a parent that has been freed is what a closed dialog would otherwise do.""" + draw(region, 40) + dpg.delete_item(ROOT_TAG, children_only=True) + + assert draw(region, 40) == [] + + +class TestTheGutterAScrollbarWillTake(BaseTestSuite): + """A region holds the scrollbar's room clear until the scrollbar itself takes it. + + A child window's scrollbar comes out of the room its content stands in, so a grid inside a + region that scrolls would stand narrower than the same grid inside one that fits. Holding the + room clear while no scrollbar stands keeps the content one width across the moment it starts + scrolling, which is what lines a folder's columns up with the columns around it. + """ + + @pytest.fixture(name="gutted") + def gutted_fixture(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), + ceiling=CEILING, + padding=PADDING, + margin=0, + gutter=GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + @staticmethod + def _inset(region: WindowedRegion) -> int: + """The room the body holds clear at its right, which a negative width states.""" + return -int(dpg.get_item_configuration(region.body)["width"]) + + def test_a_region_standing_whole_holds_the_room_clear(self, gutted: WindowedRegion) -> None: + draw(gutted, 4) + gutted.settle() + + assert self._inset(gutted) == PADDING + GUTTER + + def test_a_region_that_scrolls_gives_the_room_up(self, gutted: WindowedRegion) -> None: + """The scrollbar stands in that room itself, so the body would otherwise pay for it twice.""" + with block_of(PITCH * 500): + draw(gutted, 500) + gutted.settle() + + assert self._inset(gutted) == PADDING + + def test_a_region_falling_back_under_its_ceiling_holds_it_again(self, gutted: WindowedRegion) -> None: + with block_of(PITCH * 500): + draw(gutted, 500) + gutted.settle() + + draw(gutted, 4) + gutted.settle() + + assert self._inset(gutted) == PADDING + GUTTER + + +class TestTheGapAWellOpens(BaseTestSuite): + """A well opens a gap above its first row and below its last, or lays its rows flush. + + A well standing on a card of its own opens the gap so its body reads apart from the card's + edge; one nested inside a list lays its rows flush, since the list's own rhythm carries them. + """ + + @staticmethod + def _built(dpg_context: None, *, margin: int) -> str: + with dpg.window(tag=ROOT_TAG): + body = well(ROOT_TAG, REGION_TAG, padding=PADDING, margin=margin, width=-PADDING) + + return body + + def test_a_well_asked_for_a_gap_lays_a_spacer_either_side(self, dpg_context: None) -> None: + body = self._built(dpg_context, margin=MARGIN) + + laid = dpg.get_item_children(REGION_TAG, 1) + + assert len(laid) == 3 + assert laid[1] == dpg.get_alias_id(body) + assert dpg.get_item_configuration(laid[0])["height"] == MARGIN + assert dpg.get_item_configuration(laid[2])["height"] == MARGIN + + def test_a_well_asked_for_none_opens_its_rows_where_it_opens(self, dpg_context: None) -> None: + body = self._built(dpg_context, margin=NO_MARGIN) + + laid = dpg.get_item_children(REGION_TAG, 1) + + assert laid == [dpg.get_alias_id(body)] 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/conftest.py b/tests/unit/sampletones_application/ui/elements/stems/conftest.py new file mode 100644 index 000000000..da6dfc489 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from tests.suite.frames import DrawnFrames + + +@pytest.fixture +def frames() -> DrawnFrames: + """The frames a stems list settles on, which a case renders for itself. + + A frame a case renders is one the list stood on screen in, the way a list a reader is looking + at is drawn; a case standing a list away says so on the frames before rendering them. + """ + return DrawnFrames() diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py new file mode 100644 index 000000000..c7f14e62f --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -0,0 +1,288 @@ +from typing import Any, Dict, Iterator, List, Tuple +from unittest.mock import patch + +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 +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite + +ROOT_TAG = "test_root" +TABLE_TAG = "test_table" +GLYPH = "▸" +GLYPH_WIDTH = 9.0 +GLYPH_SIZE = [GLYPH_WIDTH, 20.0] +LABEL = "PULSE 1" +LABEL_WIDTH = 41.0 +LABEL_SIZE = [LABEL_WIDTH, 14.0] +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) + + +@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 and the faces a measurement is taken in.""" + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + try: + yield + finally: + dpg.destroy_context() + + +def columns( + layout_config: LayoutConfig, + *, + folders: bool, + master: bool = False, + swatch: bool = False, +) -> StemsColumns: + """The grid a list of gathered recordings declares.""" + return StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=master, + removable=True, + swatch=swatch, + folders=folders, + ) + + +def measured(size: object) -> object: + """What DearPyGui answers a text measurement with, which needs a drawn frame to be a size.""" + return patch.object(dpg, "get_text_size", return_value=size) + + +class TestWhereARowWithoutAMarkerOpens(BaseTestSuite): + """A folder's marker glyph stands in the middle of the room the marker is given, and a row + carrying no marker opens its name there, so the names read as one column.""" + + def test_the_name_opens_where_the_glyph_stands( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + + with measured(GLYPH_SIZE): + indent = columns(layout_config, folders=True).marker_indent(GLYPH, Font.ICON) + + assert indent == (stems.twisty_width - int(GLYPH_WIDTH)) // 2 + + def test_a_grid_holding_no_folder_opens_its_names_at_the_edge( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """No marker leads any row there, so there is nothing for a name to line up with.""" + with measured(GLYPH_SIZE): + indent = columns(layout_config, folders=False).marker_indent(GLYPH, Font.ICON) + + assert indent == 0 + + def test_a_glyph_no_frame_has_measured_yet_opens_at_the_edge( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A measurement waits on a drawn frame, and the next reading of the list settles it.""" + with measured(None): + indent = columns(layout_config, folders=True).marker_indent(GLYPH, Font.ICON) + + assert indent == 0 + + +class TestWhereAChannelNameStands(BaseTestSuite): + """The heading names each channel over the middle of the column its boxes stand in, so the + name reads as that column's however long the channel is called.""" + + def test_the_name_stands_over_the_middle_of_its_column( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + + with measured(LABEL_SIZE): + indent = columns(layout_config, folders=True).name_indent(LABEL, Font.BOLD_SMALL) + + assert indent == (stems.channel_column_width - int(LABEL_WIDTH)) // 2 + + def test_a_name_no_frame_has_measured_yet_opens_at_the_edge( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A measurement waits on a drawn frame, and the next reading of the heading settles it.""" + with measured(None): + indent = columns(layout_config, folders=True).name_indent(LABEL, Font.BOLD_SMALL) + + assert indent == 0 + + +class TestWhereABoxStands(BaseTestSuite): + """Every box stands in the middle of the column it belongs to, whichever column that is.""" + + def test_a_channel_box_is_centered_in_its_column( + self, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + + indent = columns(layout_config, folders=True).box_indent + + assert indent == (stems.channel_column_width - stems.channel_box_width) // 2 + + def test_the_box_beside_a_row_is_centered_in_its_own_column( + self, + layout_config: LayoutConfig, + ) -> None: + """The master column is narrower than a channel's, so it takes an indent of its own.""" + stems = layout_config.general.stems + + indent = columns(layout_config, folders=True, master=True).master_indent + + assert indent == (stems.master_column_width - stems.channel_box_width) // 2 + + def test_a_box_wider_than_its_column_opens_at_the_edge( + self, + layout_config: LayoutConfig, + ) -> None: + grid = columns(layout_config, folders=True) + wide = grid.layout.model_copy(update={"master_column_width": 1}) + + indent = StemsColumns( + layout=wide, + channels=CHANNELS, + master=True, + removable=True, + swatch=False, + folders=True, + ).master_indent + + assert indent == 0 + + +class TestTheRoomAFolderSpends(BaseTestSuite): + """A folder draws its recordings inside a region of its own, and the room that region spends + at its right is held clear across every table outside it, so the columns stand in one grid.""" + + def test_a_grid_holding_folders_spends_what_a_region_takes( + self, + layout_config: LayoutConfig, + ) -> None: + """The room is the layout's own figure, which is what a folder's region spends at its right.""" + assert columns(layout_config, folders=True).reserve == layout_config.general.stems.folder_reserve + + def test_a_grid_holding_none_spends_nothing( + self, + layout_config: LayoutConfig, + ) -> None: + assert columns(layout_config, folders=False).reserve == 0 + + +class TestTheColumnsAGridDeclares(BaseTestSuite): + """A grid declares its columns in one order at one set of widths, which is what stands every + table of a stems list — and the settings card's single row — in the same grid.""" + + @staticmethod + def _declared(grid: StemsColumns) -> List[Dict[str, Any]]: + """The columns one table comes out holding, as a reader would measure them.""" + with dpg.window(tag=ROOT_TAG): + with dpg.table(tag=TABLE_TAG): + grid.declare() + + return [dpg.get_item_configuration(column) for column in dpg.get_item_children(TABLE_TAG, 0)] + + def test_a_grid_holding_folders_ends_on_the_reserved_strip( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The strip stands where a folder's region spends its room, so the columns line up.""" + grid = columns(layout_config, folders=True) + + declared = self._declared(grid) + + assert declared[-1]["init_width_or_weight"] == grid.reserve_width + assert len(declared) == len(CHANNELS) + 3 + + def test_a_grid_holding_none_ends_on_the_removal_column( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A card drawing one row opens no region, so a strip held clear there would be negative.""" + stems = layout_config.general.stems + grid = columns(layout_config, folders=False) + + declared = self._declared(grid) + + assert grid.reserve_width < 0 + assert declared[-1]["init_width_or_weight"] == stems.remove_button_width + assert len(declared) == len(CHANNELS) + 2 + + def test_the_name_column_is_the_one_that_stretches( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A wider card spends its room on the recordings rather than on the boxes beside them.""" + declared = self._declared(columns(layout_config, folders=True, master=True)) + + assert [column["width_stretch"] for column in declared].count(True) == 1 + assert declared[1]["width_stretch"] is True + + def test_each_channel_takes_the_width_its_boxes_ask_for( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + grid = columns(layout_config, folders=True) + + declared = self._declared(grid) + + assert [column["init_width_or_weight"] for column in declared[1 : 1 + len(CHANNELS)]] == [ + stems.channel_column_width + ] * len(CHANNELS) + + def test_the_swatch_stands_between_the_box_beside_a_row_and_its_name( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The color leads the recording it names, so a row reads as one thing left to right.""" + stems = layout_config.general.stems + grid = columns(layout_config, folders=False, master=True, swatch=True) + + declared = self._declared(grid) + + assert declared[0]["init_width_or_weight"] == stems.master_column_width + assert declared[1]["init_width_or_weight"] == stems.swatch_size + assert declared[2]["width_stretch"] is True + + def test_a_grid_offering_no_swatch_declares_no_column_for_one( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + with_swatch = self._declared(columns(layout_config, folders=False, master=True, swatch=True)) + dpg.delete_item(ROOT_TAG) + without = self._declared(columns(layout_config, folders=False, master=True)) + + assert len(with_swatch) == len(without) + 1 diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_expansion.py b/tests/unit/sampletones_application/ui/elements/stems/test_expansion.py new file mode 100644 index 000000000..a675debc8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_expansion.py @@ -0,0 +1,50 @@ +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from tests.suite.base import BaseTestSuite + +FIRST = "/music/loops" +SECOND = "/music/drums" + + +class TestOpenFolders(BaseTestSuite): + """A folder stands as the reader last left it, and a folder that has left the list is + forgotten with it.""" + + def test_a_folder_arrives_closed(self) -> None: + assert not OpenFolders().stands_open(FIRST) + + def test_a_toggle_opens_it(self) -> None: + folders = OpenFolders() + assert folders.toggle(FIRST) + assert folders.stands_open(FIRST) + + def test_a_second_toggle_closes_it(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + assert not folders.toggle(FIRST) + assert not folders.stands_open(FIRST) + + def test_folders_stand_apart(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + assert folders.stands_open(FIRST) + assert not folders.stands_open(SECOND) + + def test_the_keys_name_what_stands_open(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + folders.toggle(SECOND) + assert folders.keys == {FIRST, SECOND} + + def test_a_memory_with_nothing_open_is_falsy(self) -> None: + folders = OpenFolders() + assert not folders + folders.toggle(FIRST) + assert folders + + def test_a_folder_that_left_the_list_is_forgotten(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + folders.toggle(SECOND) + folders.hold_to({SECOND}) + assert not folders.stands_open(FIRST) + assert folders.stands_open(SECOND) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py new file mode 100644 index 000000000..7ac6e1164 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -0,0 +1,893 @@ +from pathlib import Path +from typing import Any, Callable, Final, FrozenSet, Iterator, List, Optional, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +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_GROUP, + SUF_LEAD, + SUF_TEXT, + SUF_TWISTY, + TAG_GLOBAL_THEME_STEMS_GRID, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, + TAG_GLOBAL_THEME_STEMS_MARKER, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.columns import COLUMN_BORDER, StemsColumns +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import GATHERED_SOURCES +from sampletones_application.ui.elements.stems.tags import StemsTags +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 tests.suite.base import BaseTestSuite +from tests.suite.frames import DrawnFrames +from tests.suite.gestures import DOUBLE_CLICKED, click_row_name + +ROOT_TAG = "test_root" +PREFIX = "test.stems" +TAGS: Final[StemsTags] = StemsTags(prefix=PREFIX) +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +DEEP_FOLDER: Final[int] = 200 +STANDING_OFFSET: Final[float] = 700.0 +GLYPH_SIZE: Final[List[float]] = [9.0, 20.0] +NO_OFFSET: Final[float] = 0.0 +ROW_PITCH: Final[float] = 20.0 +HEADING_HEIGHT: Final[float] = 24.0 +REACHED_HELD: Final[int] = 40 +FRAMES_TO_FOLLOW: Final[int] = 2 +FRAMES_TO_SETTLE: Final[int] = 3 +FIRST_HELD: Final[int] = 0 +HOLDS_ONE_PAST_THE_CEILING: Final[int] = 13 + + +@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() + + +@pytest.fixture +def stems_list(dpg_context: None, layout_config: LayoutConfig) -> GUIStemsList: + """A converter's list of gathered sources, drawn into a window of its own.""" + built = GUIStemsList( + prefix=PREFIX, + layout=layout_config.general.stems, + ceiling=layout_config.general.stems.well_ceiling, + glyphs=layout_config.glyphs.common, + stem_colors=layout_config.general.colors.stems, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + offer=GATHERED_SOURCES, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + +def recording(path: Path, *, channels: FrozenSet[ChannelName] = frozenset(CHANNELS)) -> StemRowViewModel: + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + name=path.stem, + path=path, + held=(), + channels=channels, + partial_channels=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +def folder(name: str, *, holds: int) -> StemRowViewModel: + root = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + name=root.name, + path=root, + held=tuple(recording(root / f"take_{index}.wav") for index in range(holds)), + channels=frozenset(CHANNELS), + partial_channels=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +def view( + *rows: StemRowViewModel, + selected_key: Optional[str] = None, + live: bool = True, +) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=live, + collapse_levels=True, + selected_key=selected_key, + ) + + +def named(name: str, *, holds: int) -> str: + """How a folder's row reads: its own name, and how many recordings it holds.""" + return str(LanguageManager(LANG_EN)["global.stems.template.folder_row"].format(name=name, count=holds)) + + +def measured(size: List[float]) -> object: + """What DearPyGui answers a text measurement with, which needs a drawn frame to be a size.""" + return patch.object(dpg, "get_text_size", return_value=size) + + +def placed(rows: int) -> Any: + """Stands in for a frame having placed this many rows of one height under a region's heading. + + A region reads what a row takes from the block its rows were drawn into, which no suite + renders, so every region answers as the frame would have left it. + """ + + def sized(item: str, *_args: Any, **_kwargs: Any) -> List[float]: + block = rows * ROW_PITCH + HEADING_HEIGHT + return [0.0, HEADING_HEIGHT if str(item).endswith(f".{SUF_LEAD}") else block] + + return patch.object(dpg, "get_item_rect_size", side_effect=sized) + + +def press(tag: str) -> None: + """Press a widget the way DearPyGui would, with the user data it carries.""" + dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag)) + + +def twisty_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_TWISTY) + + +def region_of(row: StemRowViewModel) -> str: + return TAGS.region(row.key) + + +def name_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_TEXT) + + +def box_of(row: StemRowViewModel, channel_name: ChannelName) -> str: + return TAGS.channel(row.key, channel_name) + + +def table_of(row: StemRowViewModel) -> int: + """The grid one row stands in, which is what says whether two rows share a rhythm.""" + return dpg.get_item_parent(TAGS.row(row.key, SUF_GROUP)) + + +def theme_on(row: StemRowViewModel) -> str: + """The theme one row's line carries, which is what bands a group apart from its neighbors.""" + return dpg.get_item_alias(dpg.get_item_theme(TAGS.row(row.key, SUF_GROUP))) + + +def folder_without(row: StemRowViewModel, leaving: StemRowViewModel) -> StemRowViewModel: + """The folder as the model leaves it once one of its recordings is taken out.""" + held = tuple(standing for standing in row.held if standing.key != leaving.key) + return row.model_copy(update={"held": held}) + + +class TestAClosedFolder(BaseTestSuite): + """A folder arrives closed, standing as one row that names how many recordings it brought in.""" + + def test_it_draws_no_region(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + assert not dpg.does_item_exist(region_of(sources)) + + def test_it_draws_none_of_the_recordings_it_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + for held in sources.held: + assert not dpg.does_item_exist(name_of(held)) + + def test_it_carries_a_marker_to_open_it_by(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + assert dpg.does_item_exist(twisty_of(sources)) + + def test_a_recording_carries_no_marker(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(folder("sources", holds=1), bass)) + assert not dpg.does_item_exist(twisty_of(bass)) + + +class TestOpeningAFolder(BaseTestSuite): + """The marker beside a folder's name puts its recordings in view, and puts them away again.""" + + def test_the_marker_opens_a_region(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + assert dpg.does_item_exist(region_of(sources)) + + def test_an_open_folder_draws_the_recordings_it_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + for held in sources.held: + assert dpg.does_item_exist(name_of(held)) + + def test_the_marker_rests_while_the_list_is_inert(self, stems_list: GUIStemsList) -> None: + """A run holds the list still, so a folder stays as it was drawn and the pick inside it stays.""" + sources = folder("sources", holds=3) + stems_list.update_view(view(sources, live=False)) + + press(twisty_of(sources)) + + assert (dpg.get_item_configuration(twisty_of(sources))["enabled"], dpg.does_item_exist(region_of(sources))) == ( + False, + False, + ) + + def test_the_marker_closes_it_again(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + press(twisty_of(sources)) + assert not dpg.does_item_exist(region_of(sources)) + assert not dpg.does_item_exist(name_of(sources.held[0])) + + def test_the_folder_row_stands_through_it(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + assert dpg.does_item_exist(name_of(sources)) + + def test_folders_open_apart(self, stems_list: GUIStemsList) -> None: + first = folder("loops", holds=2) + second = folder("drums", holds=2) + stems_list.update_view(view(first, second)) + press(twisty_of(first)) + assert dpg.does_item_exist(region_of(first)) + assert not dpg.does_item_exist(region_of(second)) + + def test_a_folder_stays_open_across_a_new_reading(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + stems_list.update_view(view(sources)) + assert dpg.does_item_exist(region_of(sources)) + + +class TestARecordingInsideAFolder(BaseTestSuite): + """A reader who opened a folder answers for one of its recordings without leaving the list.""" + + def test_it_draws_a_box_on_every_channel_it_offers(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + for channel_name in CHANNELS: + assert dpg.does_item_exist(box_of(sources.held[0], channel_name)) + + def test_its_box_reports_the_recording_it_belongs_to(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + settled: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list.on_channels_changed = lambda key, channels: settled.append((key, channels)) + + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + held = sources.held[0] + box = box_of(held, ChannelName.PULSE1) + dpg.set_value(box, False) + dpg.get_item_callback(box)(box, False, dpg.get_item_user_data(box)) + + assert settled == [(held.key, frozenset({ChannelName.TRIANGLE}))] + + +class TestThePickInsideAFolder(BaseTestSuite): + """A folder closing over the row picked out reports the pick as gone, which is what keeps the + list, the settings card and the keys naming one row. + + The reading holds the pick, so every other way the rows are drawn leaves it where it is; this + is the one gesture that takes the picked row off the list without landing on it. + """ + + def test_a_recording_the_folder_shows_is_the_row_a_key_acts_on(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + held = sources.held[FIRST_HELD] + + stems_list.update_view(view(sources, selected_key=held.key)) + press(twisty_of(sources)) + + assert stems_list.picked_key == held.key + + def test_closing_the_folder_over_the_picked_row_reports_the_pick_as_gone( + self, + stems_list: GUIStemsList, + ) -> None: + cleared: List[bool] = [] + stems_list.on_selection_cleared = lambda: cleared.append(True) + sources = folder("sources", holds=2) + stems_list.update_view(view(sources, selected_key=sources.held[FIRST_HELD].key)) + press(twisty_of(sources)) + + press(twisty_of(sources)) + + assert cleared == [True] + + def test_closing_a_folder_the_pick_stands_outside_leaves_it_alone( + self, + stems_list: GUIStemsList, + ) -> None: + """The recordings the folder takes away are its own, so a pick elsewhere is untouched.""" + cleared: List[bool] = [] + stems_list.on_selection_cleared = lambda: cleared.append(True) + sources = folder("sources", holds=2) + bass = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(sources, bass, selected_key=bass.key)) + press(twisty_of(sources)) + + press(twisty_of(sources)) + + assert cleared == [] + assert stems_list.picked_key == bass.key + + def test_opening_a_folder_over_the_picked_row_leaves_the_pick_alone( + self, + stems_list: GUIStemsList, + ) -> None: + """Opening puts recordings on the list rather than taking them off it.""" + cleared: List[bool] = [] + stems_list.on_selection_cleared = lambda: cleared.append(True) + sources = folder("sources", holds=2) + held = sources.held[FIRST_HELD] + stems_list.update_view(view(sources, selected_key=held.key)) + + press(twisty_of(sources)) + + assert cleared == [] + assert stems_list.picked_key == held.key + + +class TestDoubleClick(BaseTestSuite): + """A double-click opens what it landed on: a folder shows what it holds, a recording sounds.""" + + def test_a_double_clicked_folder_opens(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + stems_list.update_view(view(sources)) + double_click(sources) + assert dpg.does_item_exist(region_of(sources)) + + def test_a_double_clicked_recording_is_reported(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + opened: List[str] = [] + stems_list.on_row_opened = opened.append + + stems_list.update_view(view(bass)) + double_click(bass) + + assert opened == [bass.key] + + def test_a_double_clicked_folder_sounds_nothing(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + opened: List[str] = [] + stems_list.on_row_opened = opened.append + + stems_list.update_view(view(sources)) + double_click(sources) + + assert opened == [] + + +class TestAFolderThatLeaves(BaseTestSuite): + """A folder taken out of the list is forgotten with it, so its name arriving again is closed.""" + + def test_a_folder_that_left_the_list_comes_back_closed(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + stems_list.update_view(view()) + stems_list.update_view(view(sources)) + assert not dpg.does_item_exist(region_of(sources)) + + +def double_click(row: StemRowViewModel) -> None: + """Double-click one row's name the way DearPyGui reports the gesture.""" + click_row_name(TAGS, row.key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) + + +class TestARecordingThatLeavesAFolder(BaseTestSuite): + """A recording taken out from inside an open folder leaves it the way a loose one leaves.""" + + @staticmethod + def _opened(stems_list: GUIStemsList, sources: StemRowViewModel) -> None: + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + + def test_its_remove_button_is_live(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + + button = TAGS.row(sources.held[0].key, SUF_BUTTON) + + assert dpg.get_item_configuration(button)["enabled"] is True + + def test_one_of_them_leaving_draws_the_folder_again(self, stems_list: GUIStemsList) -> None: + """The region holds a row apiece, so it is built afresh once the folder holds one fewer.""" + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + leaving = sources.held[0] + + stems_list.update_view(view(folder_without(sources, leaving))) + + assert not dpg.does_item_exist(name_of(leaving)) + + def test_the_ones_that_stay_are_still_drawn(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + leaving = sources.held[0] + + stems_list.update_view(view(folder_without(sources, leaving))) + + for held in sources.held[1:]: + assert dpg.does_item_exist(name_of(held)) + + def test_the_rows_around_the_folder_keep_the_widgets_they_stand_as(self, stems_list: GUIStemsList) -> None: + """A recording leaving a folder is answered inside it, so the list around it stands.""" + sources = folder("sources", holds=3) + bass = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(bass, sources)) + press(twisty_of(sources)) + standing = dpg.get_alias_id(name_of(bass)) + + stems_list.update_view(view(bass, folder_without(sources, sources.held[0]))) + + assert dpg.get_alias_id(name_of(bass)) == standing + + def test_the_folder_row_keeps_the_widget_it_stands_as(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + standing = dpg.get_alias_id(name_of(sources)) + + stems_list.update_view(view(folder_without(sources, sources.held[0]))) + + assert dpg.get_alias_id(name_of(sources)) == standing + + def test_the_folder_reads_out_how_many_it_now_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + + stems_list.update_view(view(folder_without(sources, sources.held[0]))) + + assert dpg.get_item_label(name_of(sources)) == named("sources", holds=2) + + def test_a_closed_folder_reads_out_how_many_it_now_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + + stems_list.update_view(view(folder_without(sources, sources.held[0]))) + + assert dpg.get_item_label(name_of(sources)) == named("sources", holds=2) + + @staticmethod + def _settled(stems_list: GUIStemsList, frames: DrawnFrames, *, holds: int) -> StemRowViewModel: + """An open folder standing at the height its recordings ask for, as a run of frames leaves it. + + A region reads what it holds back the frame after the rows are placed and sizes itself to + that, so the readings settle over the first few frames and the folder stands still from + there on. + """ + sources = folder("sources", holds=holds) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + for _ in range(FRAMES_TO_SETTLE): + with placed(holds): + frames.render() + + assert frames.pending == 0 + return sources + + def test_one_of_them_leaving_asks_for_the_frame_that_reads_the_folder_back( + self, + stems_list: GUIStemsList, + frames: DrawnFrames, + ) -> None: + """The region is filled again where the recording stood, so what room its rows now ask for + is read back once the frame that placed them has been rendered.""" + sources = self._settled(stems_list, frames, holds=HOLDS_ONE_PAST_THE_CEILING) + + stems_list.update_view(view(folder_without(sources, sources.held[FIRST_HELD]))) + + assert frames.pending == 1 + + def test_the_folder_comes_down_to_the_room_its_recordings_now_ask_for( + self, + stems_list: GUIStemsList, + frames: DrawnFrames, + ) -> None: + """A folder whose recordings outgrow its region stands at its ceiling and scrolls them; + with one fewer they fit, and the frame that reads them back is what stands it at their + own height again.""" + sources = self._settled(stems_list, frames, holds=HOLDS_ONE_PAST_THE_CEILING) + assert dpg.get_item_configuration(region_of(sources))["auto_resize_y"] is False + + stems_list.update_view(view(folder_without(sources, sources.held[FIRST_HELD]))) + with placed(HOLDS_ONE_PAST_THE_CEILING - 1): + frames.render() + + assert dpg.get_item_configuration(region_of(sources))["auto_resize_y"] is True + + def test_a_row_arriving_draws_the_list_again(self, stems_list: GUIStemsList) -> None: + """A row the list did not hold is met by the tables, so those are what is built again.""" + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + standing = dpg.get_alias_id(name_of(sources)) + + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert dpg.get_alias_id(name_of(sources)) != standing + + +def taken_down_at(offset: float) -> Callable[[str], float]: + """How DearPyGui reads a region a rebuild replaces: the one standing reports where the reader + scrolled it to, and the one built in its place stands at its top.""" + standing = [offset] + + def read(_tag: str) -> float: + return standing.pop() if standing else NO_OFFSET + + return read + + +class TestWhereAnOpenFolderStands(BaseTestSuite): + """A rebuild takes an open folder's region down, and the one built in its place opens on the + rows the reader had scrolled to.""" + + @staticmethod + def _deep(stems_list: GUIStemsList) -> StemRowViewModel: + sources = folder("sources", holds=DEEP_FOLDER) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + return sources + + def test_a_folder_at_its_top_comes_back_at_its_top(self, stems_list: GUIStemsList) -> None: + sources = self._deep(stems_list) + + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert dpg.does_item_exist(name_of(sources.held[0])) + + def test_a_folder_scrolled_into_comes_back_where_it_stood(self, stems_list: GUIStemsList) -> None: + sources = self._deep(stems_list) + + with patch.object(dpg, "get_y_scroll", taken_down_at(STANDING_OFFSET)): + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert not dpg.does_item_exist(name_of(sources.held[0])) + + def test_it_draws_the_recordings_that_position_reaches(self, stems_list: GUIStemsList) -> None: + sources = self._deep(stems_list) + + with patch.object(dpg, "get_y_scroll", taken_down_at(STANDING_OFFSET)): + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert any(dpg.does_item_exist(name_of(held)) for held in sources.held) + + +class TestTheRhythmAFolderStandsIn(BaseTestSuite): + """A folder's own row is a row like any other, so the list keeps one rhythm down its length. + + A grid gives every row it holds the same height, and a table of its own would give a folder a + chrome of its own on top of it. So the folder's row stands in the grid of the rows around it, + and only the region an open folder opens onto breaks the run. + """ + + def test_a_closed_folder_stands_in_the_grid_of_the_rows_around_it(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + sources = folder("sources", holds=3) + lead = recording(Path("/audio/lead.wav")) + + stems_list.update_view(view(bass, sources, lead)) + + assert table_of(sources) == table_of(bass) == table_of(lead) + + def test_the_marker_carries_the_theme_that_centers_its_glyph(self, stems_list: GUIStemsList) -> None: + """The marker's own theme is what spends no padding around the glyph and centers it, which + is what holds a folder's row to the height of the rows around it.""" + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_alias(dpg.get_item_theme(twisty_of(sources))) == TAG_GLOBAL_THEME_STEMS_MARKER + + def test_the_marker_stands_as_tall_as_the_name_it_leads( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + marker = dpg.get_item_configuration(twisty_of(sources)) + assert marker["height"] == layout_config.general.stems.name_height + assert marker["width"] == layout_config.general.stems.twisty_width + + def test_an_open_folder_breaks_the_run_so_its_region_stands_between(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + sources = folder("sources", holds=3) + lead = recording(Path("/audio/lead.wav")) + stems_list.update_view(view(bass, sources, lead)) + + press(twisty_of(sources)) + + assert table_of(sources) == table_of(bass) + assert table_of(lead) != table_of(sources) + + def test_a_folder_closed_again_rejoins_the_run(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + sources = folder("sources", holds=3) + lead = recording(Path("/audio/lead.wav")) + stems_list.update_view(view(bass, sources, lead)) + press(twisty_of(sources)) + + press(twisty_of(sources)) + + assert table_of(sources) == table_of(bass) == table_of(lead) + + +class TestWhereTheNamesOpen(BaseTestSuite): + """A folder opens at its marker and a recording at that marker's glyph, so they read as one + column of names however the two kinds of row stand beside each other.""" + + @staticmethod + def _grid(layout_config: LayoutConfig) -> StemsColumns: + """The columns the list declares for a run of gathered sources holding folders.""" + return StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=GATHERED_SOURCES.master_box, + removable=GATHERED_SOURCES.removal, + swatch=GATHERED_SOURCES.swatch, + folders=True, + ) + + @staticmethod + def _indent(row: StemRowViewModel) -> int: + return int(dpg.get_item_configuration(name_of(row))["indent"]) + + def test_a_folder_opens_at_its_marker(self, stems_list: GUIStemsList) -> None: + """The marker leads the row, so the name that follows it opens where the marker ends.""" + sources = folder("sources", holds=3) + + with measured(GLYPH_SIZE): + stems_list.update_view(view(sources)) + + assert self._indent(sources) == 0 + + def test_a_recording_beside_it_opens_at_the_marker_s_glyph( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + """A measured glyph puts the name in from the edge, which is what lines the column up.""" + bass = recording(Path("/audio/bass.wav")) + + with measured(GLYPH_SIZE): + stems_list.update_view(view(folder("sources", holds=1), bass)) + expected = self._grid(layout_config).marker_indent( + layout_config.glyphs.common.collapsed, + Font.ICON, + ) + + assert expected > 0 + assert self._indent(bass) == expected + + +class TestTheOneGridAFolderStandsIn(BaseTestSuite): + """The room a table outside a folder holds clear is the room that folder's region spends. + + The two are computed apart — one as a column at the end of every table, one as the inset a + region draws its body at — so the columns line up only while both read one figure. + """ + + def test_the_reserve_is_the_room_the_open_folder_s_region_takes( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + + press(twisty_of(sources)) + + body = compose_tag(region_of(sources), SUF_GROUP) + inset = -int(dpg.get_item_configuration(body)["width"]) + assert inset == layout_config.general.stems.folder_reserve + + def test_every_row_opens_a_cell_for_the_strip_its_table_declares( + self, + stems_list: GUIStemsList, + ) -> None: + """A row short of a cell would let the columns after it slide left, so the row opens one + wherever the grid holds the strip clear.""" + sources = folder("sources", holds=3) + loose = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(sources, loose)) + + declared = len(dpg.get_item_children(table_of(loose), 0)) + assert len(dpg.get_item_children(TAGS.row(loose.key, SUF_GROUP), 1)) == declared + + def test_the_strip_a_table_declares_comes_out_the_width_of_that_room( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + """The column ending every table and the inset a region draws at are one figure. + + A column takes its own width plus its cell padding and the rule beside it, so what the + strip holds clear is what the region beside it spends. + """ + stems = layout_config.general.stems + sources = folder("sources", holds=3) + loose = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(sources, loose)) + press(twisty_of(sources)) + + declared = dpg.get_item_children(table_of(loose), 0) + strip = int(dpg.get_item_configuration(declared[-1])["init_width_or_weight"]) + body = compose_tag(region_of(sources), SUF_GROUP) + + held_clear = strip + 2 * stems.cell_padding + COLUMN_BORDER + assert held_clear == -int(dpg.get_item_configuration(body)["width"]) + + +class TestTheBandAGroupReadsBy(BaseTestSuite): + """A group takes a band of its own behind its row, which is what sets it apart from a recording. + + The band is drawn as the row's background rather than as space around it, so a folder reads + apart while the list keeps the one rhythm every row stands in. + """ + + def test_a_folder_carries_the_band(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert theme_on(sources) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def test_a_recording_carries_none(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + + stems_list.update_view(view(folder("sources", holds=1), bass)) + + assert theme_on(bass) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def test_a_recording_inside_a_folder_carries_none(self, stems_list: GUIStemsList) -> None: + """What a folder holds are recordings, so the band names the folder alone.""" + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + + press(twisty_of(sources)) + + assert all(theme_on(held) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW for held in sources.held) + + +class TestWhatMakesTheBandVisible(BaseTestSuite): + """A band is a row background, so the grid draws one and states its own rows clear. + + The theme on a folder's row paints nothing unless its table draws row backgrounds at all, and + every other row would take the default alternation if the grid left it unstated. + """ + + def test_the_grid_draws_row_backgrounds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_configuration(table_of(sources))["row_background"] is True + + def test_the_grid_carries_the_theme_that_states_its_own_rows_clear(self, stems_list: GUIStemsList) -> None: + """Every row would otherwise take DearPyGui's own alternation behind the band.""" + bass = recording(Path("/audio/bass.wav")) + + stems_list.update_view(view(folder("sources", holds=1), bass)) + + assert dpg.get_item_alias(dpg.get_item_theme(table_of(bass))) == TAG_GLOBAL_THEME_STEMS_GRID + + +class TestAFolderFollowingItsReader(BaseTestSuite): + """An open folder reads its rows back the frame after they are placed, and refills itself. + + A folder answers for its own length inside its region, so a scroll into one is answered there: + the list settles the folder's region and redraws the rows that position now reaches, leaving + the list around it alone. + """ + + @staticmethod + def _opened(stems_list: GUIStemsList) -> StemRowViewModel: + sources = folder("sources", holds=DEEP_FOLDER) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + return sources + + @staticmethod + def _built(sources: StemRowViewModel) -> int: + """How many of the folder's recordings stand as widgets, which is the block a frame places.""" + return sum(1 for held in sources.held if dpg.does_item_exist(name_of(held))) + + def test_a_scroll_into_one_brings_the_recordings_it_reaches_in( + self, + stems_list: GUIStemsList, + frames: DrawnFrames, + ) -> None: + """The folder's own region follows the reader, so the list around it keeps its widgets.""" + sources = self._opened(stems_list) + with placed(self._built(sources)): + frames.render() + + with placed(self._built(sources)), patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + frames.render(FRAMES_TO_FOLLOW) + + assert dpg.does_item_exist(name_of(sources.held[REACHED_HELD])) + assert not dpg.does_item_exist(name_of(sources.held[FIRST_HELD])) + + def test_the_folder_s_own_row_stays_where_it_stood( + self, + stems_list: GUIStemsList, + frames: DrawnFrames, + ) -> None: + """A scroll inside a folder is answered inside it, so the rows around it are left be.""" + sources = self._opened(stems_list) + with placed(self._built(sources)): + frames.render() + standing = dpg.get_alias_id(name_of(sources)) + + with placed(self._built(sources)), patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + frames.render(FRAMES_TO_FOLLOW) + + assert dpg.get_alias_id(name_of(sources)) == standing 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..e60d6d532 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -0,0 +1,1562 @@ +from pathlib import Path +from typing import Any, Final, FrozenSet, Iterator, List, Optional, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +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_CHECKBOX, + SUF_HEADING, + SUF_LEAD, + SUF_STRIP, + SUF_SWATCH, + SUF_TABLE, + SUF_TEXT, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, + 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.layout.geometry import MINIMUM_ROW_PITCH +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +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.colors.base import BaseColor +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 +from tests.suite.base import BaseTestSuite +from tests.suite.frames import DrawnFrames +from tests.suite.gestures import CLICKED, DOUBLE_CLICKED, HOVERED, click_row_name, handler_of + +ROOT_TAG = "test_root" +PREFIX = "test.stems" +TAGS: Final[StemsTags] = StemsTags(prefix=PREFIX) +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +DRAG_PAYLOAD_SLOT: Final[int] = 3 +LONG_LIST: Final[int] = 200 +FULL_CHANNEL: Final[float] = 255.0 +ROW_PITCH: Final[float] = 20.0 +HEADING_HEIGHT: Final[float] = 24.0 +DEEP_SCROLL: Final[float] = 2000.0 +EARLY_ROW: Final[int] = 0 +REACHED_ROW: Final[int] = 100 +SETTLING_FRAMES: Final[int] = 2 + + +@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, + *, + dragging: bool = True, + removal: bool = True, + keeps_last_row: bool = False, + master_box: bool = False, + picking: bool = False, + swatch: bool = False, +) -> GUIStemsList: + stems_list = GUIStemsList( + prefix=PREFIX, + layout=layout_config.general.stems, + ceiling=layout_config.general.stems.well_ceiling, + glyphs=layout_config.glyphs.common, + stem_colors=layout_config.general.colors.stems, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + offer=StemsListOffer( + master_box=master_box, + removal=removal, + keeps_last_row=keeps_last_row, + dragging=dragging, + picking=picking, + swatch=swatch, + ), + ) + 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, + stem_id: Optional[int] = None, + kind: SourceKind = SourceKind.RECORDING, + level_size: int = 1, + level_count: int = 1, +) -> StemRowViewModel: + path = Path(f"/audio/{name}.wav") + return StemRowViewModel( + kind=kind, + held=(), + partial_channels=frozenset(), + key=str(path), + name=path.stem, + path=path, + channels=channels, + offered_channels=offered_channels, + available=available, + level=level, + position=position, + stem_id=stem_id, + level_size=level_size, + level_count=level_count, + ) + + +def view( + *rows: StemRowViewModel, + live: bool = True, + muted_channels: FrozenSet[ChannelName] = frozenset(), + picked_keys: FrozenSet[str] = frozenset(), + collapse_levels: bool = False, + selected_key: Optional[str] = None, + channels_in_play: Tuple[ChannelName, ...] = CHANNELS, +) -> StemsListViewModel: + return StemsListViewModel( + selected_key=selected_key, + rows=rows, + channels_in_play=channels_in_play, + muted_channels=muted_channels, + picked_keys=picked_keys, + picking_room=None, + live=live, + collapse_levels=collapse_levels, + ) + + +def placed(rows: int) -> Any: + """Stands in for a frame having placed this many rows of one height under the heading. + + A region reads what a row takes from the block its rows were drawn into, which no suite + renders, so the block answers as a frame would have left it. + """ + lead_tag = compose_tag(TAGS.well, SUF_LEAD) + + def sized(item: str, *_args: Any, **_kwargs: Any) -> List[float]: + return [0.0, HEADING_HEIGHT if item == lead_tag else rows * ROW_PITCH + HEADING_HEIGHT] + + return patch.object(dpg, "get_item_rect_size", side_effect=sized) + + +def row_tag(entry: StemRowViewModel, suffix: str) -> str: + return TAGS.row(entry.key, suffix) + + +def channel_tag(entry: StemRowViewModel, channel_name: ChannelName) -> str: + return TAGS.channel(entry.key, channel_name) + + +def hover_handler(suffix: str) -> Callback: + """The hover callback a row widget of that kind shares, as DearPyGui would call it.""" + return dpg.get_item_callback(handler_of(TAGS.handlers(suffix), HOVERED)) + + +def click_on(entry: StemRowViewModel, kind: str, button: int) -> None: + """Land a mouse gesture on a row's name the way DearPyGui reports one.""" + click_row_name(TAGS, entry.key, kind=kind, button=button) + + +def select_name(entry: StemRowViewModel, value: bool) -> None: + """Click a row's name the way DearPyGui does: the widget moves, then the callback runs.""" + name_tag = row_tag(entry, SUF_TEXT) + dpg.set_value(name_tag, value) + dpg.get_item_callback(name_tag)(name_tag, value, entry.key) + + +def folder_row( + name: str, + *, + holds: int = 2, + channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + partial_channels: FrozenSet[ChannelName] = frozenset(), +) -> StemRowViewModel: + """A row standing for the recordings gathered below a folder.""" + path = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(path), + kind=SourceKind.FOLDER, + name=path.name, + path=path, + held=tuple(row(f"{name}/held_{index}") for index in range(holds)), + channels=channels, + partial_channels=partial_channels, + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +class TestFolderRows(BaseTestSuite): + """A folder is one row answering for the recordings below it.""" + + def test_a_folder_names_itself_and_how_many_it_holds(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources", holds=3) + named = LanguageManager(LANG_EN)["global.stems.template.folder_row"].format(name="sources", count=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_label(row_tag(sources, SUF_TEXT)) == named + + def test_a_channel_every_recording_holds_reads_ticked(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources") + + stems_list.update_view(view(sources)) + + assert dpg.get_value(channel_tag(sources, ChannelName.PULSE1)) is True + + def test_a_channel_they_differ_on_reads_clear_in_the_softer_tone( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A tick would state an answer the folder has yet to give, so a divided reading is clear.""" + stems_list = build(layout_config) + sources = folder_row( + "sources", + channels=frozenset(), + partial_channels=frozenset({ChannelName.PULSE1}), + ) + + stems_list.update_view(view(sources)) + + box = channel_tag(sources, ChannelName.PULSE1) + assert dpg.get_value(box) is False + assert dpg.get_item_alias(dpg.get_item_theme(box)) == TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL + + def test_a_channel_none_of_them_holds_reads_clear(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources", channels=frozenset()) + + stems_list.update_view(view(sources)) + + assert dpg.get_value(channel_tag(sources, ChannelName.PULSE1)) is False + + def test_a_folders_box_reports_the_channel_it_settles(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources") + toggled: List[Tuple[str, ChannelName]] = [] + stems_list.on_channel_toggled = lambda key, channel: toggled.append((key, channel)) + + stems_list.update_view(view(sources)) + box = channel_tag(sources, ChannelName.PULSE1) + dpg.get_item_callback(box)(box, False, dpg.get_item_user_data(box)) + + assert toggled == [(sources.key, ChannelName.PULSE1)] + + +class TestRows(BaseTestSuite): + def test_a_row_names_its_recording_and_offers_every_channel_in_play( + self, dpg_context: None, layout_config: LayoutConfig + ) -> 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: LayoutConfig) -> 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: LayoutConfig, + ) -> 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: LayoutConfig) -> 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: LayoutConfig) -> 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: LayoutConfig) -> 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 + + def test_the_reading_the_list_already_stands_at_keeps_the_widgets_it_drew( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The same reading twice asks for a repaint, so the reader keeps the row they are over.""" + stems_list = build(layout_config) + bass = row("bass") + reading = view(bass) + stems_list.update_view(reading) + standing = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + + stems_list.update_view(reading) + + assert dpg.get_alias_id(row_tag(bass, SUF_TEXT)) == standing + + +class TestRowsNamedAlike(BaseTestSuite): + """Two recordings a tag part spells the same way stand on widgets of their own. + + A tag part lowercases its name and collapses whitespace, so paths differing only in case or + spacing reach one segment. Each row carries the identity of its own key beside the name. + """ + + def test_two_paths_differing_in_case_carry_their_own_names( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config) + lower, upper = row("kick"), row("Kick") + + stems_list.update_view(view(lower, upper)) + + assert row_tag(lower, SUF_TEXT) != row_tag(upper, SUF_TEXT) + assert dpg.get_item_label(row_tag(lower, SUF_TEXT)) == "kick" + assert dpg.get_item_label(row_tag(upper, SUF_TEXT)) == "Kick" + + def test_a_channel_ticked_on_one_leaves_the_other_alone( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config) + spaced, scored = row("my song"), row("my_song") + + stems_list.update_view( + view( + spaced, + scored, + selected_key=None, + ), + ) + dpg.set_value(channel_tag(spaced, ChannelName.PULSE1), False) + + assert dpg.get_value(channel_tag(scored, ChannelName.PULSE1)) is True + + def test_the_reading_reported_names_the_row_it_was_ticked_on( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The boxes report through the key their row carries, so one row's tick is its own.""" + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config) + stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) + lower, upper = row("kick"), row("Kick") + stems_list.update_view(view(lower, upper)) + + box = channel_tag(upper, ChannelName.PULSE1) + dpg.set_value(box, False) + dpg.get_item_callback(box)(box, False, dpg.get_item_user_data(box)) + + assert reported == [(upper.key, frozenset({ChannelName.TRIANGLE}))] + + +class TestLevels(BaseTestSuite): + def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config: LayoutConfig) -> 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)) + + caption = LanguageManager(LANG_EN)["global.stems.template.level_caption"] + assert dpg.get_value(TAGS.level(0, SUF_TEXT)) == caption.format(1).upper() + assert dpg.get_value(TAGS.level(1, SUF_TEXT)) == caption.format(2).upper() + + def test_a_row_moving_to_another_band_is_built_into_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A band is a table of its own, so a row picking on another level stands as a widget of + that table rather than as the one it was drawn into.""" + stems_list = build(layout_config) + bass = row("bass", level=0, level_count=2) + stems_list.update_view(view(bass, row("drums", level=1, level_count=2))) + standing = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + + stems_list.update_view(view(row("bass", level=1, level_count=2), row("drums", level=1, level_count=2))) + + assert dpg.get_alias_id(row_tag(bass, SUF_TEXT)) != standing + + 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(TAGS.level(position, SUF_STRIP)) + + +class TestAffordances(BaseTestSuite): + def test_a_draggable_list_makes_the_row_itself_the_thing_you_drag( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=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: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=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(TAGS.level(0, SUF_STRIP)) + + def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, removal=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: LayoutConfig) -> None: + stems_list = build(layout_config, removal=False) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert not dpg.does_item_exist(row_tag(bass, SUF_BUTTON)) + + +class TestRetainedLastRow(BaseTestSuite): + def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, keeps_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: LayoutConfig + ) -> None: + stems_list = build(layout_config, keeps_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: LayoutConfig) -> None: + stems_list = build(layout_config, keeps_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_the_rule_the_button_reads_is_the_one_every_way_out_reads( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + """A key press and a menu item reach removal past the row's own button, and each asks the + list rather than the widget, so the last row a list holds on to stays wherever it is asked + from.""" + stems_list = build(layout_config, keeps_last_row=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + alone = stems_list.lets_a_row_go + stems_list.update_view(view(bass, row("lead"))) + + assert alone is False + assert stems_list.lets_a_row_go is True + + def test_the_reader_s_own_frames_leave_the_last_recording_in_place( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The frames an edit wrote stand as a row of their own, and no removal takes them out.""" + stems_list = build(layout_config, keeps_last_row=True) + bass = row("bass") + edits = row("edits", stem_id=None, kind=SourceKind.EDITS) + + stems_list.update_view(view(bass, edits)) + + assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + assert stems_list.lets_a_row_go is False + + def test_a_list_that_keeps_no_row_lets_the_last_one_go( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, keeps_last_row=False) + bass = row("bass") + + stems_list.update_view(view(bass)) + + assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) + + +class TestGestures(BaseTestSuite): + def test_unticking_a_channel_reports_the_row_and_what_it_keeps( + self, dpg_context: None, layout_config: LayoutConfig + ) -> 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: LayoutConfig) -> 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(BaseTestSuite): + def test_a_list_that_is_not_live_disables_every_control_it_drew( + self, dpg_context: None, layout_config: LayoutConfig + ) -> 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: LayoutConfig) -> 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(BaseTestSuite): + """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: LayoutConfig) -> 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_a_click_naming_a_row_that_went_is_let_be(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A gesture is answered a frame after it landed, by which time a rebuild may have run.""" + stems_list = build(layout_config) + bass = row("bass") + reported: List[str] = [] + stems_list.on_menu_requested = reported.append + stems_list.on_row_picked = reported.append + stems_list.update_view(view(bass)) + clicked = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + stems_list.update_view(view()) + + callback = dpg.get_item_callback(handler_of(TAGS.handlers(SUF_TEXT), CLICKED)) + callback(row_tag(bass, SUF_TEXT), (dpg.mvMouseButton_Right, clicked)) + + assert reported == [] + + def test_a_channel_settled_while_a_box_is_gone_keeps_what_the_row_held( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A box that is no longer drawn stands for the reading it last showed, not for an empty one.""" + stems_list = build(layout_config) + bass = row("bass") + settled: List[FrozenSet[ChannelName]] = [] + stems_list.on_channels_changed = lambda _key, channels: settled.append(channels) + stems_list.update_view(view(bass)) + dpg.delete_item(channel_tag(bass, ChannelName.TRIANGLE)) + + dpg.get_item_callback(channel_tag(bass, ChannelName.PULSE1))( + channel_tag(bass, ChannelName.PULSE1), + True, + (bass.key, ChannelName.PULSE1), + ) + + assert settled == [frozenset(CHANNELS)] + + def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> 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(BaseTestSuite): + def test_a_row_draws_a_box_only_on_the_channels_it_offers( + self, dpg_context: None, layout_config: LayoutConfig + ) -> 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: LayoutConfig) -> 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_channel_coming_into_play_draws_the_rows_again( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The columns every table declares are the channels in play, so one arriving is met by + the tables themselves rather than by the boxes already drawn.""" + stems_list = build(layout_config) + bass = row("bass") + stems_list.update_view(view(bass, channels_in_play=(ChannelName.PULSE1,))) + + stems_list.update_view(view(bass)) + + assert dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) + + def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_config: LayoutConfig) -> 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(BaseTestSuite): + def test_a_master_box_opens_where_the_grid_puts_it(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """The box is centered in its column, and the indent the grid works out is what draws it there.""" + stems_list = build(layout_config, master_box=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + + indent = int(dpg.get_item_configuration(row_tag(bass, SUF_CHECKBOX))["indent"]) + assert ( + indent + == StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=True, + removable=True, + swatch=False, + folders=False, + ).master_indent + ) + + def test_a_master_box_reads_whether_the_row_holds_a_channel( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, master_box=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: LayoutConfig, + ) -> None: + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config, master_box=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: LayoutConfig + ) -> None: + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config, master_box=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: LayoutConfig, + ) -> None: + stems_list = build(layout_config, master_box=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: LayoutConfig) -> 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(BaseTestSuite): + def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config: LayoutConfig) -> 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: LayoutConfig + ) -> 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: LayoutConfig + ) -> 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 TestTheToneAChannelNameTakes(BaseTestSuite): + """The heading tones each channel's name the way its boxes are toned, so the name and the + column of boxes under it read as one thing.""" + + @staticmethod + def _name_tag(channel_name: ChannelName) -> str: + return compose_tag(PREFIX, SUF_HEADING, channel_name, SUF_TEXT) + + def test_a_muted_channel_is_named_in_the_muted_tone(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("bass"), muted_channels=frozenset({ChannelName.TRIANGLE}))) + + muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag + assert dpg.get_item_theme(self._name_tag(ChannelName.TRIANGLE)) == muted + assert dpg.get_item_theme(self._name_tag(ChannelName.PULSE1)) != muted + + def test_a_channel_in_play_is_named_in_its_own_color(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("bass"))) + + expected = ThemeRegistry.get(CHANNEL_THEME_TAGS[ChannelName.PULSE1]).tag + assert dpg.get_item_theme(self._name_tag(ChannelName.PULSE1)) == expected + + def test_a_channel_switched_back_on_is_named_in_its_own_color_again( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config) + stems_list.update_view(view(row("bass"), muted_channels=frozenset({ChannelName.TRIANGLE}))) + + stems_list.update_view(view(row("bass"))) + + muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag + assert dpg.get_item_theme(self._name_tag(ChannelName.TRIANGLE)) != muted + + +class TestCollapsedLevels(BaseTestSuite): + def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, dragging=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.tags.table) + assert not dpg.does_item_exist(TAGS.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: LayoutConfig) -> None: + stems_list = build(layout_config, dragging=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.tags.table) + assert dpg.does_item_exist(TAGS.level(0, SUF_TEXT)) + assert dpg.does_item_exist(TAGS.level(1, SUF_TEXT)) + + +class TestActivation(BaseTestSuite): + def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config: LayoutConfig) -> None: + activated: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = activated.append + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + + assert activated == [bass.key] + + def test_a_right_click_picks_the_row_its_menu_stands_over( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The menu prints the key that takes a row out, so both name the row the menu stands over.""" + activated: List[str] = [] + asked: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = activated.append + stems_list.on_menu_requested = asked.append + bass = row("bass") + lead = row("lead") + stems_list.update_view(view(bass, lead, selected_key=bass.key)) + + click_on(lead, CLICKED, dpg.mvMouseButton_Right) + + assert activated == [lead.key] + assert asked == [lead.key] + + def test_a_row_no_reading_picks_out_reads_plain( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A list whose owner answers a click without recording one stands its rows as the view + holds them, so the click leaves the look the last reading wrote.""" + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = lambda _key: None + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + def test_the_second_click_of_a_double_click_names_the_same_row( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """DearPyGui reports a selectable once per click, so a double-click reports its row twice + rather than picking it out and letting it go again.""" + activated: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = activated.append + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + select_name(bass, False) + + assert activated == [bass.key, bass.key] + + def test_the_list_names_the_row_a_key_press_acts_on(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + lead = row("lead") + + stems_list.update_view(view(bass, lead, selected_key=lead.key)) + + assert stems_list.picked_key == lead.key + + def test_a_list_holding_nothing_picked_out_names_no_row( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, dragging=False) + + stems_list.update_view(view(row("bass"))) + + assert stems_list.picked_key is None + + def test_the_view_says_which_row_reads_as_picked_out(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A click is answered by whoever owns the list, so the next view decides what is selected.""" + stems_list = build(layout_config, dragging=False) + bass = row("bass") + lead = row("lead") + + stems_list.update_view(view(bass, lead, selected_key=lead.key)) + + assert dpg.get_value(row_tag(lead, SUF_TEXT)) is True + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + +class TestGesturesTheOwnerLeavesUnanswered(BaseTestSuite): + """A list hands a gesture on where it has an owner for it, and lets the rest be. + + A click still moves the widget it lands on, so a list answering no click puts the row back the + way its view holds it — a reader picking through such a list would otherwise leave a trail of + rows reading as picked that no reading of the list ever wrote. + """ + + def test_a_click_leaves_the_row_reading_as_the_view_holds_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + def test_the_row_the_view_holds_picked_out_keeps_reading_that_way( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A click that reaches nobody puts the row back where the view stands it, either way.""" + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass, selected_key=bass.key)) + + select_name(bass, False) + + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is True + + def test_a_right_click_is_let_be_where_the_owner_puts_no_menu_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + with patch.object(stems_list, "call") as handed_on: + click_on(bass, CLICKED, dpg.mvMouseButton_Right) + + assert stems_list.has_menu is False + handed_on.assert_not_called() + + def test_a_right_click_names_its_row_where_the_owner_puts_one_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + asked: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_menu_requested = asked.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, CLICKED, dpg.mvMouseButton_Right) + + assert asked == [bass.key] + + def test_a_double_click_is_let_be_where_the_owner_sounds_nothing( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + with patch.object(stems_list, "call") as handed_on: + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Left) + + assert stems_list.playable is False + handed_on.assert_not_called() + + def test_a_double_click_sounds_its_row_where_the_owner_answers( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + opened: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_opened = opened.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Left) + + assert opened == [bass.key] + + +class TestEachGestureAnswersItsOwnButton(BaseTestSuite): + """Both handlers report every button, so each reads the one its own gesture is made with.""" + + def test_a_left_click_puts_no_menu_up(self, dpg_context: None, layout_config: LayoutConfig) -> None: + asked: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_menu_requested = asked.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, CLICKED, dpg.mvMouseButton_Left) + + assert asked == [] + + def test_a_right_double_click_sounds_nothing(self, dpg_context: None, layout_config: LayoutConfig) -> None: + opened: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_opened = opened.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Right) + + assert opened == [] + + +class TestTheHeading(BaseTestSuite): + """The channels are named once above the rows, whatever shape the list takes below it.""" + + def test_a_plain_list_names_them(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), collapse_levels=True)) + + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_HEADING, ChannelName.PULSE1, SUF_TEXT)) + + def test_a_banded_list_names_them_too(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"))) + + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_HEADING, ChannelName.PULSE1, SUF_TEXT)) + + +class TestThePickALongListHolds(BaseTestSuite): + """A long list builds the rows a window reaches, and the pick is the reading's rather than the + widgets', so scrolling away from the picked row leaves it the row a key acts on.""" + + def test_a_row_scrolled_out_of_the_window_is_still_the_row_a_key_acts_on( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + picked = rows[EARLY_ROW] + stems_list.update_view(view(*rows, collapse_levels=True, selected_key=picked.key)) + + with placed(LONG_LIST), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render(SETTLING_FRAMES) + + assert not dpg.does_item_exist(row_tag(picked, SUF_TEXT)) + assert stems_list.picked_key == picked.key + + +class TestTheRulesTheGridDraws(BaseTestSuite): + """A rule runs around the grid's own edges and between its rows, so the run of rows an open + folder breaks reads as one ruled list down its whole length.""" + + def test_the_grid_rules_the_rows_off_from_the_heading(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """The heading draws no rule of its own, so the grid below it is what divides the two.""" + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), collapse_levels=True)) + + assert dpg.get_item_configuration(TAGS.table)["borders_outerH"] is True + + def test_the_heading_draws_no_rule_of_its_own(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """One line divides the names from the rows, so the heading leaves the drawing of it to + the grid rather than adding a second beside it.""" + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), collapse_levels=True)) + + heading = compose_tag(PREFIX, SUF_HEADING, SUF_TABLE) + assert dpg.get_item_configuration(heading)["borders_outerH"] is False + + def test_the_grid_rules_between_the_rows_it_holds(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), row("snare"), collapse_levels=True)) + + assert dpg.get_item_configuration(TAGS.table)["borders_innerH"] is True + + +class TestTheWell(BaseTestSuite): + """The well keeps the card's shape: where its rows are recordings alone it builds the ones it + shows and reserves the room for the rest, and it holds every row otherwise.""" + + def test_a_long_run_of_recordings_builds_the_rows_it_shows( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + built = sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) + assert 0 < built < LONG_LIST + + def test_the_first_slice_is_sized_by_the_height_the_layout_gives_a_row( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """No frame has measured a row yet, so the well opens at the layout's own figure: it + builds about the rows the ceiling holds rather than the several times as many a floor of + eight pixels a row would reach.""" + stems = layout_config.general.stems + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + built = sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) + assert built < stems.well_ceiling / MINIMUM_ROW_PITCH + assert built >= stems.well_ceiling / stems.name_height + + def test_a_short_run_of_recordings_builds_them_all(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + rows = (row("kick"), row("snare")) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + assert all(dpg.does_item_exist(row_tag(entry, SUF_TEXT)) for entry in rows) + + def test_a_list_holding_a_folder_builds_every_row(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A folder answers for its own length inside its region, so the well holds the rest whole.""" + stems_list = build(layout_config) + rows = (folder_row("sources"), *(row(f"take_{index}") for index in range(LONG_LIST))) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + assert all(dpg.does_item_exist(row_tag(entry, SUF_TEXT)) for entry in rows) + + def test_a_banded_list_builds_every_row(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """Captions and strips stand among banded rows, so there is no one row to reserve room by.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows)) + + assert all(dpg.does_item_exist(row_tag(entry, SUF_TEXT)) for entry in rows) + + +class TestTheListSettlingItsWell(BaseTestSuite): + """A region reads its rows back a frame after they are placed, and refills itself from there. + + The reading a row is measured by, and the rows a scroll has moved on to, both belong to the + frame after a draw, so the list asks for that frame and keeps asking for as long as a region + still holds rows it has yet to build. + """ + + @staticmethod + def _built(rows: Tuple[StemRowViewModel, ...]) -> int: + """How many of the list's rows stand as widgets, which is the block a frame would place.""" + return sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) + + @classmethod + def _drawn(cls, rows: Tuple[StemRowViewModel, ...], index: int) -> bool: + return dpg.does_item_exist(row_tag(rows[index], SUF_TEXT)) + + def test_a_well_holding_rows_back_asks_for_another_frame( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + """The rows outside the window are built as the reader reaches them, so the pass goes on.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + + with placed(self._built(rows)): + frames.render() + + assert frames.pending == 1 + + def test_readings_arriving_before_the_frame_ask_for_the_one_frame( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + """One pass answers for whatever the list has drawn by the time it runs, so a run of + readings between two frames leaves one pass rather than one apiece.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows, collapse_levels=True)) + stems_list.update_view(view(*rows[:-1], collapse_levels=True)) + stems_list.update_view(view(*rows[:-2], collapse_levels=True)) + + assert frames.pending == 1 + + def test_the_pass_renews_itself_rather_than_piling_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + + for _ in range(SETTLING_FRAMES): + with placed(self._built(rows)): + frames.render() + + assert frames.pending == 1 + + def test_a_well_holding_everything_it_drew_comes_to_rest( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + """A list short enough to stand whole has nothing left to build, so it stops watching.""" + stems_list = build(layout_config) + rows = (row("kick"), row("snare")) + stems_list.update_view(view(*rows, collapse_levels=True)) + + with placed(self._built(rows)): + frames.render(SETTLING_FRAMES) + + assert frames.pending == 0 + + def test_a_scroll_brings_the_rows_it_reaches_in( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + """The window follows the reader, so rows they scrolled to are built and the ones behind go.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + with placed(self._built(rows)): + frames.render() + + with placed(self._built(rows)), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render() + + assert self._drawn(rows, REACHED_ROW) + assert not self._drawn(rows, EARLY_ROW) + + def test_a_list_put_away_keeps_its_watch_and_does_no_work( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + """A list holding rows back settles on no frame while its card is collapsed or its tab is in + the back, and its watch stands waiting for the frame it comes back in.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + frames.on_screen = False + + with placed(self._built(rows)), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render(SETTLING_FRAMES) + + assert frames.pending == 1 + assert self._drawn(rows, EARLY_ROW) + assert not self._drawn(rows, REACHED_ROW) + + def test_a_list_drawn_again_takes_its_pass_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: DrawnFrames, + ) -> None: + """The first frame the list is drawn in again follows the scroll made while it was away.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + with placed(self._built(rows)): + frames.render() + frames.on_screen = False + with placed(self._built(rows)), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render() + + frames.on_screen = True + with placed(self._built(rows)), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render() + + assert self._drawn(rows, REACHED_ROW) + + +class TestTheColorARowIsKnownBy(BaseTestSuite): + """A row leads with the color its recording is painted in, so the ribbon answers to a name.""" + + @staticmethod + def _fill(entry: StemRowViewModel) -> Tuple[float, ...]: + """The color the square is filled with, as DearPyGui holds a drawn color.""" + return tuple(dpg.get_item_configuration(row_tag(entry, SUF_SWATCH))["fill"]) + + @staticmethod + def _drawn(color: BaseColor) -> Any: + """A token as DearPyGui reports it back, which is each channel over its full scale.""" + return pytest.approx([value / FULL_CHANNEL for value in color.rgba]) + + def test_a_row_takes_the_color_its_recordings_id_picks( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, swatch=True) + bass = row("bass", stem_id=1) + + stems_list.update_view(view(bass)) + + assert self._fill(bass) == self._drawn(layout_config.general.colors.stems.for_recording(1)) + + def test_two_recordings_read_apart(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, swatch=True) + bass = row("bass", stem_id=0) + lead = row("lead", stem_id=1) + + stems_list.update_view(view(bass, lead)) + + assert self._fill(bass) != self._fill(lead) + + def test_the_frames_a_reader_wrote_take_the_color_of_their_own( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, swatch=True) + edits = row("edits", stem_id=None, kind=SourceKind.EDITS) + + stems_list.update_view(view(edits)) + + assert self._fill(edits) == self._drawn(layout_config.general.colors.stems.authored) + + def test_a_row_answering_to_no_entry_draws_no_square( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, swatch=True) + gathered = row("gathered", stem_id=None) + + stems_list.update_view(view(gathered)) + + assert not dpg.does_item_exist(row_tag(gathered, SUF_SWATCH)) + + def test_a_list_offering_no_swatch_draws_none(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + bass = row("bass", stem_id=0) + + stems_list.update_view(view(bass)) + + assert not dpg.does_item_exist(row_tag(bass, SUF_SWATCH)) + + def test_the_square_keeps_its_color_through_a_repaint( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A view redrawn onto the widgets already standing leaves the color the row is known by.""" + stems_list = build(layout_config, swatch=True) + bass = row("bass", stem_id=2) + + stems_list.update_view(view(bass)) + stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.PULSE1}))) + + assert self._fill(bass) == self._drawn(layout_config.general.colors.stems.for_recording(2)) + + def test_the_square_stands_in_the_middle_of_the_row_it_leads( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A row stands as tall as its name and the padding a cell keeps, and the square centers on it.""" + stems = layout_config.general.stems + stems_list = build(layout_config, swatch=True) + bass = row("bass", stem_id=0) + + stems_list.update_view(view(bass)) + + configuration = dpg.get_item_configuration(row_tag(bass, SUF_SWATCH)) + top, bottom = configuration["pmin"][1], configuration["pmax"][1] + assert bottom - top == stems.swatch_size + assert top + bottom == stems.name_height + 2 * stems.cell_padding + + def test_the_square_fills_the_column_it_stands_in( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The column is the square's own width, so the cell's padding is what centers it across.""" + stems = layout_config.general.stems + stems_list = build(layout_config, swatch=True) + bass = row("bass", stem_id=0) + + stems_list.update_view(view(bass)) + + configuration = dpg.get_item_configuration(row_tag(bass, SUF_SWATCH)) + assert configuration["pmin"][0] == 0 + assert configuration["pmax"][0] == stems.swatch_size diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_messages.py b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py new file mode 100644 index 000000000..1d02b6fcb --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py @@ -0,0 +1,374 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, FrozenSet, Tuple + +import pytest + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +from sampletones_application.paths import LANG_EN +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +NAME: Final[str] = "kick" +FOLDER_NAME: Final[str] = "sources" +HOLDS: Final[int] = 3 + + +@dataclass(frozen=True) +class Host: + """What the list answers about the gestures its owner takes, as ``StemsListHost`` states them.""" + + activatable: bool = False + playable: bool = False + revealable: bool = False + has_menu: bool = False + + +def offer(*, dragging: bool = False) -> StemsListOffer: + return StemsListOffer( + master_box=False, + removal=True, + keeps_last_row=False, + dragging=dragging, + picking=False, + swatch=False, + ) + + +def recording( + name: str = NAME, + *, + channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + available: bool = True, +) -> StemRowViewModel: + path = Path(f"/audio/{name}.wav") + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + name=path.stem, + path=path, + held=(), + channels=channels, + partial_channels=frozenset(), + offered_channels=offered_channels, + available=available, + level=0, + position=0, + stem_id=0, + level_size=1, + level_count=1, + ) + + +def folder(name: str = FOLDER_NAME, *, holds: int = HOLDS) -> StemRowViewModel: + root = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + name=root.name, + path=root, + held=tuple(recording(f"{name}/take_{index}") for index in range(holds)), + channels=frozenset(CHANNELS), + partial_channels=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +def view( + *rows: StemRowViewModel, + collapse_levels: bool, + muted_channels: FrozenSet[ChannelName] = frozenset(), +) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=muted_channels, + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=collapse_levels, + selected_key=None, + ) + + +def messages( + *rows: StemRowViewModel, + collapse_levels: bool, + dragging: bool = False, + host: Host = Host(), + open_folders: OpenFolders = OpenFolders(), + muted_channels: FrozenSet[ChannelName] = frozenset(), +) -> StemsMessages: + """The answers one list would give, reading the view it is drawing.""" + answering = StemsMessages( + LANGUAGE_MANAGER, + offer=offer(dragging=dragging), + open_folders=open_folders, + host=host, + ) + answering.reads(view(*rows, collapse_levels=collapse_levels, muted_channels=muted_channels)) + return answering + + +def template(key: str) -> str: + return LANGUAGE_MANAGER[f"global.stems.message.{key}"] + + +class TestWhatARowSaysAboutDragging(BaseTestSuite): + """A list drags its rows where it bands them, since a drag moves a recording between levels. + + What the row offers and what it says it offers are one answer, so a list drawing its rows in + one run neither takes a drag nor speaks of one. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + dragging: bool + collapse_levels: bool + + test_cases = ( + TestCase(label="a_banded_list_that_drags_says_so", dragging=True, collapse_levels=False, expected=True), + TestCase(label="one_run_of_rows_takes_no_drag", dragging=True, collapse_levels=True, expected=False), + TestCase(label="a_list_that_never_drags_stays_quiet", dragging=False, collapse_levels=False, expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_hover_names_the_drag_where_the_row_takes_one(self, test_case: TestCase) -> None: + kick = recording() + + explanation = messages( + kick, + collapse_levels=test_case.collapse_levels, + dragging=test_case.dragging, + ).row_explanation(kick) + + assert (template("drag_tooltip") in explanation) is test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_status_line_names_the_drag_where_the_row_takes_one(self, test_case: TestCase) -> None: + kick = recording() + + line = messages( + kick, + collapse_levels=test_case.collapse_levels, + dragging=test_case.dragging, + ).name(user_data=kick.key) + + drag = template("status_row_drag").format(name=kick.name) + assert (drag in line) is test_case.expected + + +class TestWhatARowSaysAboutTheDoubleClickItTakes(BaseTestSuite): + """A double-click reaches the owner wherever one answers, so the line naming it stands beside + the drag rather than behind it.""" + + def test_a_list_drawing_one_run_of_rows_names_the_gesture(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True, dragging=True, host=Host(revealable=True)).name(user_data=kick.key) + + assert template("status_row_reveal").format(name=kick.name) in line + + def test_a_banded_list_names_the_drag_and_the_gesture_together(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=False, dragging=True, host=Host(revealable=True)).name(user_data=kick.key) + + assert template("status_row_drag").format(name=kick.name) in line + assert template("status_row_reveal").format(name=kick.name) in line + + def test_a_list_no_owner_answers_reads_as_the_name_alone(self) -> None: + """A double-click is the one gesture nothing on the row draws, so a list that neither + sounds nor reveals has nothing to offer beyond what the row is called.""" + kick = recording() + + line = messages(kick, collapse_levels=True).name(user_data=kick.key) + + assert line == kick.name + + def test_a_list_that_sounds_a_row_says_so(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True, host=Host(playable=True)).name(user_data=kick.key) + + assert template("status_row_play").format(name=kick.name) in line + + +class TestWhatARowExplainsOnHover(BaseTestSuite): + """The hover names where the recording is, and why it is grayed out where it contributes + nothing, so a reader meets the reason beside the row it belongs to.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: StemRowViewModel + key: str + + test_cases = ( + TestCase(label="a_folder_stands_for_what_it_holds", row=folder(), key="folder_tooltip"), + TestCase( + label="a_recording_off_disk_says_so", + row=recording(available=False), + key="missing_tooltip", + ), + TestCase( + label="a_recording_holding_no_frames_says_so", + row=recording(offered_channels=frozenset()), + key="unoffered_tooltip", + ), + TestCase( + label="a_recording_on_no_channel_says_what_would_bring_it_in", + row=recording(channels=frozenset()), + key="inert_tooltip", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_hover_names_the_reading_the_row_stands_at(self, test_case: TestCase) -> None: + explanation = messages(test_case.row, collapse_levels=True).row_explanation(test_case.row) + + assert template(test_case.key) in explanation + + def test_the_hover_opens_on_where_the_recording_is(self) -> None: + kick = recording() + + explanation = messages(kick, collapse_levels=True).row_explanation(kick) + + assert explanation.startswith(str(kick.path)) + + def test_a_recording_in_play_explains_itself_by_its_path_alone(self) -> None: + kick = recording() + + explanation = messages(kick, collapse_levels=True).row_explanation(kick) + + assert explanation == str(kick.path) + + +class TestWhatAFolderSays(BaseTestSuite): + """A folder stands for the recordings gathered below it, so its lines name the group rather + than any one recording in it.""" + + def test_its_row_reads_out_how_many_it_holds(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).name(user_data=sources.key) + + assert line == template("status_folder_row").format(name=sources.name, count=HOLDS) + + def test_its_box_reaches_every_recording_in_it(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).channel(user_data=(sources.key, ChannelName.PULSE1)) + + assert line == template("status_folder_channel").format( + channel=channel_label(LANGUAGE_MANAGER, ChannelName.PULSE1), + name=sources.name, + ) + + def test_its_button_takes_the_recordings_with_it(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).remove(user_data=sources.key) + + assert line == template("status_folder_remove").format(name=sources.name) + + +class TestWhatTheMarkerSays(BaseTestSuite): + """The marker beside a folder's name offers the move it would make from where it now stands, + so the line follows the folder rather than stating one of the two readings.""" + + def test_a_closed_folder_offers_to_show_what_it_holds(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).twisty(user_data=sources.key) + + assert line == template("status_folder_open").format(name=sources.name) + + def test_an_open_folder_offers_to_hide_it_again(self) -> None: + sources = folder() + open_folders = OpenFolders() + open_folders.toggle(sources.key) + + line = messages(sources, collapse_levels=True, open_folders=open_folders).twisty(user_data=sources.key) + + assert line == template("status_folder_close").format(name=sources.name) + + +class TestWhatABoxSays(BaseTestSuite): + """A box names the channel it answers for, and a channel switched off everywhere says that + the row stays quiet on it however the box reads.""" + + def test_a_channel_in_play_names_what_the_box_does(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True).channel(user_data=(kick.key, ChannelName.PULSE1)) + + assert line == template("status_channel").format( + channel=channel_label(LANGUAGE_MANAGER, ChannelName.PULSE1), + name=kick.name, + ) + + def test_a_muted_channel_says_the_row_stays_quiet_on_it(self) -> None: + kick = recording() + + line = messages( + kick, + collapse_levels=True, + muted_channels=frozenset({ChannelName.PULSE1}), + ).channel(user_data=(kick.key, ChannelName.PULSE1)) + + assert line == template("status_channel_muted").format( + channel=channel_label(LANGUAGE_MANAGER, ChannelName.PULSE1), + name=kick.name, + ) + + def test_the_box_beside_a_row_reaches_every_channel(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True).master(user_data=kick.key) + + assert line == template("status_master").format(name=kick.name) + + def test_the_button_takes_the_recording_off_the_list(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True).remove(user_data=kick.key) + + assert line == template("status_remove").format(name=kick.name) + + +class TestARowTheReadingHasLetGo(BaseTestSuite): + """A hover is answered a frame after it landed, by which time the list may have moved on, so + every answer is read from the reading standing now.""" + + @pytest.mark.parametrize( + "answer", + ("name", "master", "remove", "twisty"), + ) + def test_a_widget_naming_a_row_that_went_explains_nothing(self, answer: str) -> None: + answering = messages(recording(), collapse_levels=True) + + assert getattr(answering, answer)(user_data="/audio/gone.wav") == "" + + def test_a_box_naming_a_row_that_went_explains_nothing(self) -> None: + answering = messages(recording(), collapse_levels=True) + + assert answering.channel(user_data=("/audio/gone.wav", ChannelName.PULSE1)) == "" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_shape.py b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py new file mode 100644 index 000000000..0fde51fad --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py @@ -0,0 +1,209 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.ui.elements.stems.shape import ListShape, Reshape, RowPlacement +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) + + +def placed( + key: str, + *, + level: int = 0, + opened: bool = False, + held: Tuple[str, ...] = (), + offered: Tuple[ChannelName, ...] = CHANNELS, +) -> RowPlacement: + """One row as a shape records it.""" + return RowPlacement( + key=key, + level=level, + offered=frozenset(offered), + opened=opened, + held=held, + ) + + +def shaped(*rows: RowPlacement, columns: Tuple[ChannelName, ...] = CHANNELS, collapsed: bool = True) -> ListShape: + """The shape a list of these rows amounts to.""" + return ListShape(columns=columns, collapsed=collapsed, rows=rows) + + +class TestWhatAReadingAsksFor(BaseTestSuite): + """A shape says whether the widgets standing can be brought to a new reading, and how far. + + The whole list is drawn again where the tables are built around what changed — the columns, + the banding, which rows stand and where. A recording leaving the folder it was gathered under + reaches that folder's own region alone, which is what lets the rows around it keep the widgets + they stand as and the reader keep the scroll they left. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + standing: ListShape + incoming: ListShape + expected: Reshape + + test_cases = ( + TestCase( + label="the_same_shape_asks_for_nothing", + standing=shaped(placed("bass"), placed("drums", held=("one", "two"))), + incoming=shaped(placed("bass"), placed("drums", held=("one", "two"))), + expected=Reshape.within(()), + ), + TestCase( + label="a_recording_leaving_a_folder_reaches_that_folder", + standing=shaped(placed("bass"), placed("drums", held=("one", "two"))), + incoming=shaped(placed("bass"), placed("drums", held=("one",))), + expected=Reshape.within(("drums",)), + ), + TestCase( + label="two_folders_changing_at_once_name_them_both", + standing=shaped(placed("drums", held=("one", "two")), placed("keys", held=("three", "four"))), + incoming=shaped(placed("drums", held=("one",)), placed("keys", held=("three",))), + expected=Reshape.within(("drums", "keys")), + ), + TestCase( + label="a_row_arriving_reaches_the_whole_list", + standing=shaped(placed("bass")), + incoming=shaped(placed("bass"), placed("lead")), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_leaving_reaches_the_whole_list", + standing=shaped(placed("bass"), placed("lead")), + incoming=shaped(placed("bass")), + expected=Reshape.everything(), + ), + TestCase( + label="a_folder_opening_reaches_the_whole_list", + standing=shaped(placed("drums", held=("one",))), + incoming=shaped(placed("drums", opened=True, held=("one",))), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_changing_band_reaches_the_whole_list", + standing=shaped(placed("bass", level=0)), + incoming=shaped(placed("bass", level=1)), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_offering_another_channel_reaches_the_whole_list", + standing=shaped(placed("bass", offered=(ChannelName.PULSE1,))), + incoming=shaped(placed("bass", offered=CHANNELS)), + expected=Reshape.everything(), + ), + TestCase( + label="a_column_arriving_reaches_the_whole_list", + standing=shaped(placed("bass"), columns=(ChannelName.PULSE1,)), + incoming=shaped(placed("bass"), columns=CHANNELS), + expected=Reshape.everything(), + ), + TestCase( + label="the_banding_changing_reaches_the_whole_list", + standing=shaped(placed("bass"), collapsed=True), + incoming=shaped(placed("bass"), collapsed=False), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_that_both_moves_and_loses_a_recording_reaches_the_whole_list", + standing=shaped(placed("drums", level=0, held=("one", "two"))), + incoming=shaped(placed("drums", level=1, held=("one",))), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_taking_another_rows_place_reaches_the_whole_list", + standing=shaped(placed("bass"), placed("lead")), + incoming=shaped(placed("lead"), placed("bass")), + expected=Reshape.everything(), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_what_the_incoming_shape_asks_of_the_standing_one(self, test_case: TestCase) -> None: + assert test_case.incoming.against(test_case.standing) == test_case.expected + + +class TestWhetherAnythingIsDrawn(BaseTestSuite): + """Whoever answers a reshape settles the regions once widgets have been built.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + reshape: Reshape + + test_cases = ( + TestCase(label="naming_no_folder_draws_nothing", reshape=Reshape.within(()), expected=False), + TestCase(label="the_whole_list_draws", reshape=Reshape.everything(), expected=True), + TestCase(label="one_folder_draws", reshape=Reshape.within(("drums",)), expected=True), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_whether_widgets_were_built(self, test_case: TestCase) -> None: + assert test_case.reshape.redraws is test_case.expected + + +class TestWhereARowStands(BaseTestSuite): + """A placement answers whether two readings put the same row in the same place. + + What a folder holds is answered separately, so it plays no part in where the folder's own + row stands; the band it sits in and whether it stands open both do. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + standing: RowPlacement + incoming: RowPlacement + + test_cases = ( + TestCase( + label="what_a_folder_holds_leaves_it_where_it_was", + standing=placed("drums", held=("one",)), + incoming=placed("drums", held=("one", "two")), + expected=True, + ), + TestCase( + label="a_row_that_moved_band_stands_elsewhere", + standing=placed("drums", level=1), + incoming=placed("drums", level=0), + expected=False, + ), + TestCase( + label="a_folder_that_opened_stands_elsewhere", + standing=placed("drums"), + incoming=placed("drums", opened=True), + expected=False, + ), + TestCase( + label="a_row_offered_other_channels_stands_elsewhere", + standing=placed("drums", offered=(ChannelName.PULSE1,)), + incoming=placed("drums", offered=CHANNELS), + expected=False, + ), + TestCase( + label="another_row_stands_elsewhere", + standing=placed("drums"), + incoming=placed("bass"), + expected=False, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_whether_two_readings_stand_the_row_the_same_way(self, test_case: TestCase) -> None: + assert test_case.incoming.stands_where(test_case.standing) is test_case.expected diff --git a/tests/unit/sampletones_application/ui/elements/test_context_menu.py b/tests/unit/sampletones_application/ui/elements/test_context_menu.py new file mode 100644 index 000000000..f7221c129 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_context_menu.py @@ -0,0 +1,67 @@ +from typing import Final, Iterator, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_CONTEXT_WINDOW +from sampletones_application.ui.elements.context_menu import context_menu + +GESTURES: Final[int] = 5 + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def _popups() -> List[int]: + return [window for window in dpg.get_windows() if dpg.get_item_configuration(window)["popup"]] + + +class TestTheApplicationHoldsOneContextMenu: + """A dismissed menu leaves nothing behind, since the next one takes its place.""" + + def test_menus_raised_in_turn_leave_one_popup_standing(self, dpg_context: None) -> None: + for gesture in range(GESTURES): + with context_menu(): + dpg.add_menu_item(label=f"item {gesture}") + + assert len(_popups()) == 1 + + def test_the_popup_standing_holds_the_latest_menu(self, dpg_context: None) -> None: + with context_menu(): + dpg.add_menu_item(label="earlier") + + with context_menu(): + dpg.add_menu_item(label="latest") + + items = dpg.get_item_children(TAG_GLOBAL_CONTEXT_WINDOW, 1) + assert [dpg.get_item_label(item) for item in items] == ["latest"] + + def test_a_dismissed_popup_opens_again_for_the_next_menu(self, dpg_context: None) -> None: + with context_menu(): + dpg.add_menu_item(label="earlier") + dpg.configure_item(TAG_GLOBAL_CONTEXT_WINDOW, show=False) + + with context_menu(): + dpg.add_menu_item(label="latest") + + assert dpg.get_item_configuration(TAG_GLOBAL_CONTEXT_WINDOW)["show"] is True + + def test_a_menu_takes_the_place_of_one_still_open(self, dpg_context: None) -> None: + """A menu raised over one still standing builds into the popup already shown.""" + with context_menu(): + dpg.add_menu_item(label="earlier") + standing = dpg.get_alias_id(TAG_GLOBAL_CONTEXT_WINDOW) + + with context_menu(): + dpg.add_menu_item(label="latest") + + assert (dpg.get_alias_id(TAG_GLOBAL_CONTEXT_WINDOW), dpg.get_item_configuration(standing)["show"]) == ( + standing, + True, + ) 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_plus_minus_buttons.py b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py index 4eb1a879e..967d39e26 100644 --- a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py +++ b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py @@ -1,9 +1,19 @@ -from typing import List +from dataclasses import dataclass +from typing import Callable, List, Optional +from unittest.mock import MagicMock + +import dearpygui.dearpygui as dpg +import pytest from sampletones_application.layout.general.plus_minus_buttons import ( PlusMinusButtonsLayout, ) -from sampletones_application.ui.elements.plus_minus_buttons import GUIPlusMinusButtons +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.plus_minus_buttons import ( + HOLD_INITIAL_DELAY_FACTOR, + GUIPlusMinusButtons, +) +from sampletones_application.utils.gui.press import LeftPress LAYOUT = PlusMinusButtonsLayout( button_width=30, @@ -11,30 +21,66 @@ hold_delay=0.075, ) +FIRST_REPEAT = HOLD_INITIAL_DELAY_FACTOR * LAYOUT.hold_delay +FRAME = LAYOUT.hold_delay / 10 +DECREMENT_TAG = "decrement" +INCREMENT_TAG = "increment" + + +@dataclass +class Pointer: + """Which of the pair's buttons the pointer stands on, as their hover reads it, and whether the left + button is down. + """ + + on_increment: bool = False + on_decrement: bool = False + down: bool = True + -def _buttons() -> GUIPlusMinusButtons: +def _button(hovered: Callable[[], bool]) -> GUIButton: + button = MagicMock(spec=GUIButton) + button.is_item_hovered.side_effect = hovered + return button + + +@pytest.fixture +def pointer() -> Pointer: + return Pointer() + + +@pytest.fixture +def buttons(pointer: Pointer, monkeypatch: pytest.MonkeyPatch) -> GUIPlusMinusButtons: """A pair carrying only the state the tested methods touch, bypassing the DearPyGui-dependent - constructor.""" - buttons = GUIPlusMinusButtons.__new__(GUIPlusMinusButtons) - buttons.on_increment = None - buttons.on_decrement = None - buttons._layout = LAYOUT - buttons._hold_direction = None - buttons._hold_timer = None - return buttons + constructor, with the left button read from ``pointer``.""" + monkeypatch.setattr(dpg, "is_mouse_button_down", lambda button: pointer.down) + monkeypatch.setattr(dpg, "does_item_exist", lambda tag: True) + pair = GUIPlusMinusButtons.__new__(GUIPlusMinusButtons) + pair.on_increment = None + pair.on_decrement = None + pair._layout = LAYOUT + pair._decrement_button_tag = DECREMENT_TAG + pair._increment_button_tag = INCREMENT_TAG + pair._press = LeftPress() + pair._increment_button = _button(lambda: pointer.on_increment) + pair._decrement_button = _button(lambda: pointer.on_decrement) + return pair + + +def _frame(buttons: GUIPlusMinusButtons, seconds: float) -> Optional[int]: + """One frame of the left button down, answering the direction it repeats in, if any.""" + return buttons._advance_hold(buttons._held_button_hovered(), seconds) class TestStep: - def test_step_up_calls_increment(self) -> None: - buttons = _buttons() + def test_step_up_calls_increment(self, buttons: GUIPlusMinusButtons) -> None: calls: List[str] = [] buttons.on_increment = lambda: calls.append("increment") buttons.on_decrement = lambda: calls.append("decrement") buttons._step(1) assert calls == ["increment"] - def test_step_down_calls_decrement(self) -> None: - buttons = _buttons() + def test_step_down_calls_decrement(self, buttons: GUIPlusMinusButtons) -> None: calls: List[str] = [] buttons.on_increment = lambda: calls.append("increment") buttons.on_decrement = lambda: calls.append("decrement") @@ -42,25 +88,86 @@ def test_step_down_calls_decrement(self) -> None: assert calls == ["decrement"] -class TestHoldTimer: - def test_first_press_arms_timer_without_stepping(self) -> None: - buttons = _buttons() - assert buttons._update_hold_timer(False, True, 0.0) is None - assert buttons._hold_timer is not None +class TestAHoldBelongsToTheButtonItWentDownOn: + """A press on a button repeats while held over it, and a press carried in from elsewhere steps nothing.""" - def test_repeats_once_the_delay_elapses(self) -> None: - buttons = _buttons() - buttons._update_hold_timer(False, True, 0.0) - buttons._hold_timer = 0.0 - assert buttons._update_hold_timer(False, True, 0.01) == 1 + def test_a_press_waits_the_longer_delay_before_its_first_repeat( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() - def test_no_button_pressed_returns_none(self) -> None: - buttons = _buttons() - assert buttons._update_hold_timer(False, False, 0.05) is None + assert _frame(buttons, FIRST_REPEAT - FRAME) is None + assert _frame(buttons, 2 * FRAME) == 1 + + def test_each_later_repeat_follows_the_shorter_delay( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_decrement = True + buttons._on_decrement_pressed() + _frame(buttons, FIRST_REPEAT) + + assert _frame(buttons, LAYOUT.hold_delay - FRAME) is None + assert _frame(buttons, 2 * FRAME) == -1 + + def test_a_press_carried_onto_a_button_steps_nothing( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + + assert [_frame(buttons, FIRST_REPEAT) for _ in range(3)] == [None, None, None] + buttons._on_increment_pressed() + assert _frame(buttons, FIRST_REPEAT) == 1 + + def test_a_press_slid_onto_the_other_button_steps_nothing( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() + pointer.on_increment = False + pointer.on_decrement = True + + assert _frame(buttons, FIRST_REPEAT) is None + pointer.on_decrement = False + pointer.on_increment = True + assert _frame(buttons, FIRST_REPEAT) == 1 + + def test_a_release_ends_the_hold( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() - def test_release_clears_hold_state(self) -> None: - buttons = _buttons() - buttons._update_hold_timer(True, False, 0.0) buttons._on_mouse_release(0, None, None) - assert buttons._hold_timer is None - assert buttons._hold_direction is None + + assert buttons._press.held() is None + assert _frame(buttons, FIRST_REPEAT) is None + + def test_a_release_the_buttons_missed_ends_the_hold( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + """A mouse move with the button up ends the hold, so a press later carried onto the button steps + nothing. + """ + pointer.on_increment = True + buttons._on_increment_pressed() + + pointer.on_increment = False + pointer.down = False + buttons._on_mouse_move(0, None, None) + pointer.down = True + pointer.on_increment = True + + assert [_frame(buttons, FIRST_REPEAT) for _ in range(3)] == [None, None, None] diff --git a/tests/unit/sampletones_application/ui/elements/test_seeded.py b/tests/unit/sampletones_application/ui/elements/test_seeded.py new file mode 100644 index 000000000..c62981a40 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_seeded.py @@ -0,0 +1,87 @@ +from typing import Final, Iterator, List +from unittest.mock import MagicMock, patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.primitives import DialogGeometry +from sampletones_application.ui.elements.seeded import GUISeededDialogWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from tests.suite.frames import Frames, held_frames + +__all__ = ["held_frames"] + +MODULE: Final[str] = "sampletones_application.ui.elements.window" +TAG: Final[str] = "test.dialog.window.seeded" +STANDING_TAG: Final[str] = "test.dialog.window.standing" +FIRST_SEED: Final[str] = "first" +NEWER_SEED: Final[str] = "newer" +PROBE_WIDTH: Final[int] = 200 +PROBE_HEIGHT: Final[int] = 100 +VIEWPORT_WIDTH: Final[int] = 800 +VIEWPORT_HEIGHT: Final[int] = 600 + + +class ProbeSeededWindow(GUISeededDialogWindow[str]): + """A dialog drawing one word, which records every word it drew.""" + + def __init__(self, router: KeyRouter) -> None: + self.drawn: List[str] = [] + super().__init__( + TAG, + DialogGeometry(width=PROBE_WIDTH, height=PROBE_HEIGHT), + subject="probe", + key_router=router, + shortcut_source=MagicMock(spec=ShortcutSource), + ) + + def create_window(self) -> None: + with self.dialog_window(label="probe", on_close=None): + dpg.add_text(self.view_model) + + self.drawn.append(self.view_model) + + def _render(self) -> None: + self.drawn.append(self.view_model) + + +@pytest.fixture(name="dpg_context") +def dpg_context_fixture() -> Iterator[None]: + dpg.create_context() + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + try: + yield + finally: + dpg.destroy_context() + + +class TestASeededWindowWaitingForTheScreen: + """A dialog opened while another modal stands keeps the newest seed until the screen is free.""" + + @pytest.fixture(name="window") + def window_fixture(self, dpg_context: None) -> ProbeSeededWindow: + ModalQueue.open(STANDING_TAG, lambda: None) + window = ProbeSeededWindow(KeyRouter()) + window.open(FIRST_SEED) + return window + + def test_a_new_seed_draws_nothing_while_the_window_waits(self, window: ProbeSeededWindow) -> None: + window.update_view(NEWER_SEED) + + assert not window.drawn + assert not dpg.does_item_exist(TAG) + + def test_the_window_opens_on_the_newest_seed(self, window: ProbeSeededWindow, held_frames: Frames) -> None: + window.update_view(NEWER_SEED) + + ModalQueue.leave(STANDING_TAG) + held_frames.render() + + assert window.drawn == [NEWER_SEED] diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py index 127570864..8847651c1 100644 --- a/tests/unit/sampletones_application/ui/elements/test_window.py +++ b/tests/unit/sampletones_application/ui/elements/test_window.py @@ -4,24 +4,37 @@ import dearpygui.dearpygui as dpg import pytest +from sampletones_application.layout.primitives import DEARPYGUI_MAXIMUM_WINDOW_SIZE, DialogGeometry from sampletones_application.ui.elements.window import GUIWindow from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames, held_frames MODULE: Final[str] = "sampletones_application.ui.elements.window" TAG: Final[str] = "test.dialog.window.probe" +OTHER_TAG: Final[str] = "test.dialog.window.other" +PROMPT_TAG: Final[str] = "test.dialog.window.prompt" +REPORT_TAG: Final[str] = "test.dialog.window.report" STATED_WIDTH: Final[int] = 460 -CONTENT_HEIGHT: Final[int] = 0 +STATED_HEIGHT: Final[int] = 200 +VIEWPORT_WIDTH: Final[int] = 1280 +VIEWPORT_HEIGHT: Final[int] = 800 + +__all__ = ["held_frames"] class ProbeWindow(GUIWindow): """A dialog whose content stretches across the window, the shape a stated width has to hold.""" - def __init__(self, on_close: Optional[VoidCallback]) -> None: + def __init__( + self, + on_close: Optional[VoidCallback], + geometry: Optional[DialogGeometry] = None, + tag: str = TAG, + ) -> None: self._on_close = on_close super().__init__( - tag=TAG, - width=STATED_WIDTH, - height=CONTENT_HEIGHT, + tag=tag, + geometry=geometry if geometry is not None else DialogGeometry(width=STATED_WIDTH, height=STATED_HEIGHT), ) def prepare(self, *_args: Any, **_kwargs: Any) -> None: @@ -35,6 +48,15 @@ def create_window(self) -> None: dpg.add_combo(items=["a", "b"], width=-1) +class ReportingWindow(ProbeWindow): + """A window reporting work under way, which leaves the rest of the interface live beside it.""" + + _claims_the_screen = False + + def __init__(self) -> None: + super().__init__(on_close=None, tag=REPORT_TAG) + + @pytest.fixture(name="dpg_context") def dpg_context_fixture() -> Iterator[None]: dpg.create_context() @@ -50,11 +72,79 @@ def test_the_window_holds_the_width_it_states(self, dpg_context: None) -> None: assert dpg.get_item_configuration(TAG)["width"] == STATED_WIDTH - def test_the_window_takes_no_size_from_its_content(self, dpg_context: None) -> None: - """A window measuring itself against stretched content loses a pixel of width every frame.""" + def test_the_window_holds_its_stated_width_both_ways(self, dpg_context: None) -> None: + """A window free to widen, holding content measured against its width, widens every frame. + + The probe's combo stretches across the window, so the width it asks for follows the width + the window has; holding the window to one width is what leaves the two agreeing. + """ + ProbeWindow(on_close=None).create_window() + + configuration = dpg.get_item_configuration(TAG) + assert configuration["autosize"] is True + assert configuration["min_size"][0] == STATED_WIDTH + assert configuration["max_size"][0] == STATED_WIDTH + + def test_the_window_leaves_its_height_to_what_it_holds(self, dpg_context: None) -> None: + """A prompt wrapping over several lines grows down, so nothing caps the height.""" ProbeWindow(on_close=None).create_window() - assert dpg.get_item_configuration(TAG)["autosize"] is False + assert dpg.get_item_configuration(TAG)["max_size"][1] == DEARPYGUI_MAXIMUM_WINDOW_SIZE + + def test_the_window_opens_at_the_height_it_states(self, dpg_context: None) -> None: + """A dialog holding more than it states grows, so the stated height is where it starts.""" + ProbeWindow(on_close=None).create_window() + + assert dpg.get_item_configuration(TAG)["min_size"][1] == STATED_HEIGHT + + def test_a_window_stating_no_height_leaves_its_content_to_settle_it(self, dpg_context: None) -> None: + ProbeWindow(on_close=None, geometry=DialogGeometry(width=STATED_WIDTH)).create_window() + + assert dpg.get_item_configuration(TAG)["min_size"][1] == 0 + + +class TestWhereADialogOpens: + """A dialog stating a height is placed before it is drawn, so it never appears off center.""" + + @staticmethod + def _shown(geometry: DialogGeometry) -> Any: + window = ProbeWindow(on_close=None, geometry=geometry) + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + window.show() + + return dpg.get_item_pos(TAG) + + def test_a_window_stating_a_height_stands_at_its_center(self, dpg_context: None) -> None: + position = self._shown(DialogGeometry(width=STATED_WIDTH, height=STATED_HEIGHT)) + + assert position == [ + (VIEWPORT_WIDTH - STATED_WIDTH) // 2, + (VIEWPORT_HEIGHT - STATED_HEIGHT) // 2, + ] + + def test_a_window_taller_than_the_viewport_keeps_its_title_bar_reachable(self, dpg_context: None) -> None: + position = self._shown(DialogGeometry(width=STATED_WIDTH, height=VIEWPORT_HEIGHT * 2)) + + assert position[1] == 0 + + def test_a_window_settling_its_own_height_is_left_to_the_correction(self, dpg_context: None) -> None: + """Nothing states where it goes until a frame has measured it, so the pass does the placing.""" + window = ProbeWindow(on_close=None, geometry=DialogGeometry(width=STATED_WIDTH)) + + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled") as center_when_settled, + patch.object(dpg, "set_item_pos") as set_item_pos, + ): + window.show() + + set_item_pos.assert_not_called() + center_when_settled.assert_called_once_with(TAG) class TestCloseAffordance: @@ -72,47 +162,195 @@ def test_a_dialog_answering_for_no_close_omits_the_button(self, dpg_context: Non class TestModalHandOff: """DearPyGui carries one modal at a time, so a dialog raising another has to step aside first.""" - def test_yielding_takes_the_window_off_screen(self, dpg_context: None) -> None: - window = ProbeWindow(on_close=None) - window.create_window() + @pytest.fixture(name="window") + def window_fixture(self, dpg_context: None) -> Iterator[ProbeWindow]: + """A dialog raised the way every dialog is, holding the screen.""" + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + window = ProbeWindow(on_close=None) + window.show() + yield window - with patch(f"{MODULE}.FrameCallbackManager"): - window.yield_to(MagicMock()) + def test_yielding_takes_the_window_off_screen(self, window: ProbeWindow, held_frames: Frames) -> None: + window.yield_to(MagicMock()) assert dpg.get_item_configuration(TAG)["show"] is False - def test_the_modal_is_raised_a_frame_after_the_hand_off(self, dpg_context: None) -> None: + def test_the_modal_is_raised_a_frame_after_the_hand_off(self, window: ProbeWindow, held_frames: Frames) -> None: """A modal built while this window still holds the screen opens where nobody can reach it.""" - window = ProbeWindow(on_close=None) - window.create_window() raise_modal = MagicMock() - with patch(f"{MODULE}.FrameCallbackManager") as frame: - window.yield_to(raise_modal) - + window.yield_to(raise_modal) raise_modal.assert_not_called() - frame.set_frame_callback.assert_called_once_with(raise_modal) + held_frames.render() - def test_resuming_waits_a_frame_before_taking_the_screen_back(self, dpg_context: None) -> None: - window = ProbeWindow(on_close=None) - window.create_window() - with patch(f"{MODULE}.FrameCallbackManager"): - window.yield_to(MagicMock()) + raise_modal.assert_called_once_with() - with patch(f"{MODULE}.FrameCallbackManager") as frame: - window.resume() + def test_resuming_waits_a_frame_before_taking_the_screen_back( + self, + window: ProbeWindow, + held_frames: Frames, + ) -> None: + window.yield_to(MagicMock()) + held_frames.render() + window.resume() assert dpg.get_item_configuration(TAG)["show"] is False - frame.set_frame_callback.assert_called_once() - frame.set_frame_callback.call_args.args[0]() + held_frames.render() + assert dpg.get_item_configuration(TAG)["show"] is True - def test_the_widget_tree_survives_the_hand_off(self, dpg_context: None) -> None: + def test_the_widget_tree_survives_the_hand_off(self, window: ProbeWindow, held_frames: Frames) -> None: """Whatever is being edited has to still be there when the dialog comes back.""" + window.yield_to(MagicMock()) + + assert dpg.get_item_children(TAG, 1) + + def test_leaving_deletes_the_window(self, window: ProbeWindow, held_frames: Frames) -> None: + window._leave_then(MagicMock()) + + assert not dpg.does_item_exist(TAG) + + def test_the_answer_runs_a_frame_after_the_window_left(self, window: ProbeWindow, held_frames: Frames) -> None: + answer = MagicMock() + + window._leave_then(answer) + answer.assert_not_called() + held_frames.render() + + answer.assert_called_once_with() + + def test_a_window_that_already_left_answers_nothing(self, window: ProbeWindow, held_frames: Frames) -> None: + """A second click reaches a window gone from the screen, and only the first one answers.""" + first = MagicMock() + second = MagicMock() + + window._leave_then(first) + window._leave_then(second) + held_frames.render() + + first.assert_called_once_with() + second.assert_not_called() + + +class TestOneModalAtATime: + """A modal asked for while another conversation holds the screen opens once that one has ended.""" + + @pytest.fixture(name="viewport", autouse=True) + def viewport_fixture(self) -> Iterator[None]: + with ( + patch(f"{MODULE}.ThemeRegistry"), + patch(f"{MODULE}.center_when_settled"), + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + yield + + def test_a_window_asked_for_while_another_stands_waits_for_it_to_leave( + self, + dpg_context: None, + held_frames: Frames, + ) -> None: + standing = ProbeWindow(on_close=None) + waiting = ProbeWindow(on_close=None, tag=OTHER_TAG) + standing.show() + + waiting.show() + held_frames.render() + assert not dpg.does_item_exist(OTHER_TAG) + + standing.hide() + held_frames.render() + + assert dpg.does_item_exist(OTHER_TAG) + + def test_what_an_answer_raises_opens_ahead_of_a_waiting_window( + self, + dpg_context: None, + held_frames: Frames, + ) -> None: + standing = ProbeWindow(on_close=None) + waiting = ProbeWindow(on_close=None, tag=OTHER_TAG) + prompt = ProbeWindow(on_close=None, tag=PROMPT_TAG) + standing.show() + waiting.show() + + standing._leave_then(prompt.show) + held_frames.render() + + assert dpg.does_item_exist(PROMPT_TAG) + assert not dpg.does_item_exist(OTHER_TAG) + + def test_a_window_reporting_work_under_way_opens_beside_a_modal(self, dpg_context: None) -> None: + """A window leaving the rest of the interface live is not a modal, so it waits for nothing.""" + ProbeWindow(on_close=None).show() + + ReportingWindow().show() + + assert dpg.does_item_exist(REPORT_TAG) + + def test_showing_a_standing_window_again_rebuilds_it_a_frame_later( + self, + dpg_context: None, + held_frames: Frames, + ) -> None: window = ProbeWindow(on_close=None) - window.create_window() + window.show() + + window.show() + assert not dpg.does_item_exist(TAG) + held_frames.render() + + assert dpg.does_item_exist(TAG) + - with patch(f"{MODULE}.FrameCallbackManager"): - window.yield_to(MagicMock()) +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.""" + + @pytest.fixture(name="viewport", autouse=True) + def viewport_fixture(self) -> Iterator[None]: + """Stands in for the viewport a window is centered against, which a suite draws none of.""" + with ( + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + yield + + def test_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..a353d027a 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 @@ -1,12 +1,16 @@ from pathlib import Path -from typing import Final, List +from typing import Final, List, Tuple import pytest +from sampletones_application.layout.general.colors.stem import StemColors from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_application.view_model.shared.recording import NamedRecordingViewModel from sampletones_core.configs import Config from sampletones_core.configs.display import format_frequencies, format_sample_rate, short_hash +from sampletones_core.constants.enums import DEFAULT_CHANNELS from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( ConfigGroupNode, @@ -16,9 +20,10 @@ ) from sampletones_core.structures.tree.type import NodeType from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from sampletones_shared.types.application import ColorRGBA from tests.suite.language import FakeLanguageManager -CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) +CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions") / CONFIG_FIELDS.directory_name RECONSTRUCTION_PATH: Final[Path] = CONFIG_DIRECTORY / f"song{EXT_FILE_RECONSTRUCTION}" @@ -28,21 +33,52 @@ "spectrum_method", "transformation_gamma", "window_size", - "generators", + "channels", "configuration", + "stems", ] +RECORDING_COLORS: Final[Tuple[ColorRGBA, ...]] = ( + (200, 80, 40, 255), + (80, 160, 220, 255), +) +AUTHORED_COLOR: Final[ColorRGBA] = (180, 140, 240, 255) +REST_COLOR: Final[ColorRGBA] = (40, 40, 48, 255) +LEFT_OUT_FRACTION: Final[float] = 0.4 + +RECORDINGS: Final[Tuple[NamedRecordingViewModel, ...]] = ( + NamedRecordingViewModel(stem_id=0, name="Drums"), + NamedRecordingViewModel(stem_id=2, name="Bass"), +) +ONE_RECORDING: Final[Tuple[NamedRecordingViewModel, ...]] = (NamedRecordingViewModel(stem_id=0, name="Neurostem"),) + @pytest.fixture -def panel() -> GUISequencerBrowserPanel: +def stem_colors() -> StemColors: + return StemColors( + recordings=tuple(LiteralColor(value) for value in RECORDING_COLORS), + authored=LiteralColor(AUTHORED_COLOR), + rest=LiteralColor(REST_COLOR), + left_out_fraction=LEFT_OUT_FRACTION, + ) + + +@pytest.fixture +def panel(stem_colors: StemColors) -> GUISequencerBrowserPanel: """Builds a browser panel without its DearPyGui-dependent constructor. - Resolving a node's detail items reads only the language-resolved detail labels, so the pieces + Resolving a node's detail items reads only the language-resolved detail labels, the colors a + recording is known by and what the panel holds about the row under the pointer, so the pieces the constructor would build around a running GUI context are unnecessary here. A concrete browser stands in for the base because the configuration font is a browser-level opt-in. """ instance = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) instance._language_manager = FakeLanguageManager() + instance._stem_colors = stem_colors + instance._detail_document = None + instance._detail_recordings = () + instance._detail_tooltip_owner_tag = None + instance.on_recordings_requested = None for label in DETAIL_LABELS: setattr(instance, f"_lbl_detail_{label}", label) @@ -82,6 +118,16 @@ def config_variant_node() -> ConfigNode: ) +def reconstruction_file_node(parent: TreeNode) -> FileSystemNode: + """A reconstruction as the configuration branch lists it: a file under the folder holding it.""" + return FileSystemNode( + "song", + node_type=NodeType.FILE, + filepath=RECONSTRUCTION_PATH, + parent=parent, + ) + + class TestConfigDetailItems: def test_config_directory_states_its_configuration( self, @@ -115,6 +161,94 @@ def test_group_states_nothing( assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == [] +class TestAReconstructionFileRow: + def test_it_states_the_configuration_of_the_directory_holding_it( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """A configuration states its fields in a directory name, which the file inherits by sitting there.""" + directory = config_directory_node() + + items = panel._node_detail_items(reconstruction_file_node(directory)) + + assert items == panel._node_detail_items(directory) + + def test_a_file_outside_every_configuration_directory_states_none( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._node_detail_items(reconstruction_file_node(plain_directory_node())) == [] + + +class TestTheRecordingsARowLists: + def test_a_row_naming_a_document_asks_what_it_holds( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + asked: List[Path] = [] + panel.on_recordings_requested = asked.append + + panel._node_detail_recordings(config_variant_node()) + + assert asked == [RECONSTRUCTION_PATH] + + def test_a_row_naming_no_document_asks_for_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + asked: List[Path] = [] + panel.on_recordings_requested = asked.append + + assert panel._node_detail_recordings(config_directory_node()) == () + assert asked == [] + + def test_the_recordings_read_in_record_order( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + panel._node_detail_recordings(config_variant_node()) + panel.update_recordings(RECONSTRUCTION_PATH, RECORDINGS) + + swatches = panel._node_detail_recordings(config_variant_node()) + + assert tuple(swatch.name for swatch in swatches) == tuple(recording.name for recording in RECORDINGS) + + def test_each_recording_takes_the_color_of_the_id_it_was_converted_as( + self, + panel: GUISequencerBrowserPanel, + stem_colors: StemColors, + ) -> None: + """A recording keeps its color once another leaves the record, so the list skips no color it names.""" + panel._node_detail_recordings(config_variant_node()) + panel.update_recordings(RECONSTRUCTION_PATH, RECORDINGS) + + swatches = panel._node_detail_recordings(config_variant_node()) + + assert [swatch.color.rgba for swatch in swatches] == [ + stem_colors.for_recording(recording.stem_id).rgba for recording in RECORDINGS + ] + + def test_a_document_naming_one_recording_lists_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """One recording says what the row already says, so the list is left to the documents holding several.""" + panel._node_detail_recordings(config_variant_node()) + panel.update_recordings(RECONSTRUCTION_PATH, ONE_RECORDING) + + assert panel._node_detail_recordings(config_variant_node()) == () + + def test_a_reading_of_another_document_is_left_where_it_is( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + panel._node_detail_recordings(config_variant_node()) + + panel.update_recordings(CONFIG_DIRECTORY / f"other{EXT_FILE_RECONSTRUCTION}", RECORDINGS) + + assert panel._node_detail_recordings(config_variant_node()) == () + + class TestConfigurationFont: """Every row whose label is configuration text reads in one font, whatever kind of row it is.""" @@ -148,3 +282,13 @@ def test_heading_the_disk_names_reads_in_the_name_font( panel: GUISequencerBrowserPanel, ) -> None: assert panel._resolve_node_name_font(TreeNode("Amen Breaks", NodeType.GROUP)) == Font.REGULAR_SMALL + + def test_row_gathering_a_plain_name_reads_in_the_name_font( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """A sample folded into its one variant hands the row an audio's name, which reads as such.""" + variant = config_variant_node() + variant.gathered_plain_name = True + + assert panel._resolve_node_name_font(variant) == Font.REGULAR_SMALL diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 8dcfa73ce..145a2aeef 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -273,7 +273,7 @@ def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: Brows class TestFollowingTheReader: - """A click on a row is how it folds, and the browser reads what it stands as afterwards.""" + """A click on a row is how it folds, and the browser reads what it stands as afterward.""" def test_a_click_reads_the_row_the_frame_after_it_landed( self, 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/elements/tree/test_status_messages.py b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py index 6b9d9e893..bd0982316 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py @@ -1,8 +1,21 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg import pytest from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.structures.tree import NodeType, TreeNode from tests.suite.language import FakeLanguageManager +from tests.suite.status import RecordedStatusBar + + +@pytest.fixture +def context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() def _panel() -> GUISequencerBrowserPanel: @@ -29,3 +42,20 @@ def test_the_message_names_what_the_row_holds( panel = _panel() assert panel._expandable_node_message(TreeNode("row", node_type=node_type)) == key + + +@pytest.mark.usefixtures("context") +class TestAHoverReportedAfterTheRowWent: + def test_a_row_a_rebuild_removed_leaves_the_status_bar_as_it_was(self) -> None: + """The hover arrives a frame late, and a row the rebuilt tree no longer holds says nothing.""" + status_bar = RecordedStatusBar() + panel = _panel() + panel._status_bar = status_bar + hovered = panel._create_hover_callback(lambda *_args, **_kwargs: "row") + with dpg.window(): + row = dpg.add_text("row") + dpg.delete_item(row) + + hovered(0, row) + + assert status_bar.messages == [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py b/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py index 514f516d7..053072347 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py @@ -1,4 +1,5 @@ from typing import Iterator +from unittest.mock import patch import dearpygui.dearpygui as dpg import pytest @@ -16,6 +17,9 @@ from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource +from tests.suite.frames import held_frames + +__all__ = ["held_frames"] @pytest.fixture @@ -36,3 +40,14 @@ def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: finally: ThemeRegistry.clear() dpg.destroy_context() + + +@pytest.fixture(autouse=True) +def viewport(layout_config: LayoutConfig) -> Iterator[None]: + """Stands in for the viewport a dialog is centered against, which a suite draws none of.""" + window = layout_config.general.window + with ( + patch.object(dpg, "get_viewport_client_width", return_value=window.width), + patch.object(dpg, "get_viewport_client_height", return_value=window.height), + ): + yield diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py new file mode 100644 index 000000000..88d05af4b --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py @@ -0,0 +1,123 @@ +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.settings import TAG_SETTINGS_AUDIO_WINDOW +from sampletones_application.ui.panels.dialogs.audio_settings import GUIAudioSettingsWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.audio_settings import ( + AudioDeviceItem, + AudioSettingsViewModel, +) +from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE, DEFAULT_SAMPLE_RATE, BufferSize, SampleRate +from sampletones_shared.constants.audio import UNITY_GAIN +from tests.suite.frames import Frames +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +DEVICE_INDEX: Final[int] = 3 + +Committed = Tuple[int, SampleRate, BufferSize] + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIAudioSettingsWindow: + return GUIAudioSettingsWindow( + layout=layout_config.settings, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +@pytest.fixture(name="committed") +def committed_fixture(window: GUIAudioSettingsWindow) -> List[Committed]: + """The settings the owner heard, from a window built the way ``open`` builds it.""" + committed: List[Committed] = [] + window.on_commit = lambda device_index, sample_rate, buffer_size: committed.append( + (device_index, sample_rate, buffer_size) + ) + window._seed( + AudioSettingsViewModel( + devices=( + AudioDeviceItem( + device_index=DEVICE_INDEX, + name="Speakers", + sample_rates=(DEFAULT_SAMPLE_RATE,), + default_sample_rate=DEFAULT_SAMPLE_RATE, + ), + ), + current_device_index=DEVICE_INDEX, + current_sample_rate=DEFAULT_SAMPLE_RATE, + buffer_size=DEFAULT_BUFFER_SIZE, + master_gain=UNITY_GAIN, + ) + ) + window.create_window() + return committed + + +class TestApplying: + """Applying can raise a playback error, which opens alone once the settings have left.""" + + def test_the_window_leaves_before_the_owner_hears_it( + self, + window: GUIAudioSettingsWindow, + committed: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + + assert not dpg.does_item_exist(TAG_SETTINGS_AUDIO_WINDOW) + assert committed == [] + + def test_the_owner_hears_what_the_window_held( + self, + window: GUIAudioSettingsWindow, + committed: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + held_frames.render() + + assert committed == [(DEVICE_INDEX, DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE)] + + +class TestApplyingWithNoDevice: + """Apply on a machine offering no output device closes the window and leaves the settings as they + are: the owner hears nothing, even once the frames after it have run.""" + + @pytest.fixture(name="nothing_offered") + def nothing_offered_fixture(self, window: GUIAudioSettingsWindow) -> List[Committed]: + """The settings the owner heard, from a window built over a machine offering no device.""" + committed: List[Committed] = [] + window.on_commit = lambda device_index, sample_rate, buffer_size: committed.append( + (device_index, sample_rate, buffer_size) + ) + window._seed( + AudioSettingsViewModel( + devices=(), + current_device_index=None, + current_sample_rate=None, + buffer_size=DEFAULT_BUFFER_SIZE, + master_gain=UNITY_GAIN, + ) + ) + window.create_window() + return committed + + def test_the_window_closes_and_the_owner_hears_nothing( + self, + window: GUIAudioSettingsWindow, + nothing_offered: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + held_frames.render() + + assert not dpg.does_item_exist(TAG_SETTINGS_AUDIO_WINDOW) + assert nothing_offered == [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py index ccc1306a0..dd3ad29ab 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py @@ -11,6 +11,7 @@ TAG_SETTINGS_DISPLAY_BUTTON_OK, TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN, + TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE, TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE, TAG_SETTINGS_DISPLAY_COMBO_PALETTE, @@ -51,6 +52,7 @@ def view_model(*, fullscreen: bool = False) -> DisplaySettingsViewModel: ), vsync=True, frame_rate=60, + show_frame_rate=True, ), resolutions=RESOLUTIONS, frame_rates=FRAME_RATES, @@ -105,6 +107,7 @@ def test_the_switches_show_the_state_in_force(self, window: GUIDisplaySettingsWi assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) is True assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS) is False assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN) is False + assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE) is True def test_a_windowed_window_offers_its_size_and_frame(self, window: GUIDisplaySettingsWindow) -> None: render(window) @@ -184,6 +187,18 @@ def test_switching_vsync_reports_it( assert reported[-1].vsync is False + def test_switching_the_frame_rate_reading_off_reports_it( + self, + window: GUIDisplaySettingsWindow, + reported: List[DisplaySettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE)( + TAG_SETTINGS_DISPLAY_CHECKBOX_SHOW_FRAME_RATE, + False, + ) + + assert reported[-1].show_frame_rate is False + def test_picking_the_unlimited_rate_reports_it( self, window: GUIDisplaySettingsWindow, 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..0c400037b --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -0,0 +1,155 @@ +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, TAG_GLOBAL_THEME_DANGER_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: + """Raises the window on the given state, the way the coordinator running the export does.""" + window.open( + 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_traveling_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 + assert shown(TAG_SETTINGS_EXPORT_TEXT_FIGURE) + + def test_a_stage_with_nothing_to_state_leaves_the_indicator_alone(self, window: GUIExportWindow) -> None: + render(window, traveling=False, figure="") + assert not shown(TAG_SETTINGS_EXPORT_TEXT_FIGURE) + + +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.CANCELING) + assert not dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"] + + def test_cancel_carries_the_tone_of_an_action_that_undoes_the_run(self, window: GUIExportWindow) -> None: + render(window, phase=ExportPhase.EXPORTING) + + theme = dpg.get_item_theme(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON)) + + assert dpg.get_item_alias(theme) == TAG_GLOBAL_THEME_DANGER_BUTTON + + 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.CANCELING) + 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..c04bb2a5a 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py @@ -62,7 +62,7 @@ def group_tag(category: str) -> str: def view_model( *, selected: Optional[str] = None, - combination: str = "", + keys: str = "", message: str = "", ) -> KeybindingsViewModel: return KeybindingsViewModel( @@ -71,20 +71,20 @@ def view_model( category="application", label="Application", rows=( - KeybindingRow(action=SAVE_PROJECT, label="Save project", combination="Ctrl+S"), - KeybindingRow(action=ABOUT_DIALOG, label="About", combination=""), + KeybindingRow(action=SAVE_PROJECT, label="Save project", keys="Ctrl+S"), + KeybindingRow(action=ABOUT_DIALOG, label="About", keys=""), ), ), KeybindingGroup( category="tracker", label="Tracker", - rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", combination="Down"),), + rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", keys="Down"),), ), ), schemes=SCHEMES, scheme="default", selected=selected, - combination=combination, + keys=keys, message=message, ) @@ -201,20 +201,20 @@ def test_a_later_view_re_reads_the_rows_already_built(self, harness: Harness) -> category="application", label="Application", rows=( - KeybindingRow(action=SAVE_PROJECT, label="Save project", combination="Ctrl+Alt+B"), - KeybindingRow(action=ABOUT_DIALOG, label="About", combination=""), + KeybindingRow(action=SAVE_PROJECT, label="Save project", keys="Ctrl+Alt+B"), + KeybindingRow(action=ABOUT_DIALOG, label="About", keys=""), ), ), KeybindingGroup( category="tracker", label="Tracker", - rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", combination="Down"),), + rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", keys="Down"),), ), ), schemes=SCHEMES, scheme="default", selected=None, - combination="", + keys="", message="", ) ) @@ -293,7 +293,7 @@ def test_the_selected_row_reads_as_selected(self, harness: Harness) -> None: assert dpg.get_value(action_tag(ABOUT_DIALOG)) is False def test_the_entry_box_shows_the_selected_action_keys(self, harness: Harness) -> None: - harness.render(view_model(selected=SAVE_PROJECT, combination="Ctrl+S")) + harness.render(view_model(selected=SAVE_PROJECT, keys="Ctrl+S")) assert dpg.get_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT) == "Ctrl+S" @@ -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) @@ -320,6 +320,28 @@ def test_a_cancelled_capture_leaves_the_cell_reading_its_keys(self, harness: Har assert harness.captured == [] assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S" + def test_a_captured_press_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None: + """The capture ends with the press it reads, so the cell stops asking for one.""" + harness.render(view_model(selected=SAVE_PROJECT)) + harness.click_shortcut(SAVE_PROJECT) + harness.press(dpg.mvKey_G, CTRL_ALT) + + assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S" + + def test_listening_again_asks_for_a_press_and_takes_the_next_one(self, harness: Harness) -> None: + harness.render(view_model(selected=SAVE_PROJECT)) + harness.click_shortcut(SAVE_PROJECT) + harness.press(dpg.mvKey_G, CTRL_ALT) + + harness.window.listen_again() + + assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == CAPTURING_MESSAGE + harness.press(dpg.mvKey_B, CTRL_ALT) + assert harness.captured == [ + KeyCombination(dpg.mvKey_G, CTRL_ALT), + KeyCombination(dpg.mvKey_B, CTRL_ALT), + ] + def test_clicking_an_action_listens_for_nothing(self, harness: Harness) -> None: """The name cell selects the row, which leaves the keyboard where it was.""" harness.render(view_model(selected=SAVE_PROJECT)) @@ -381,3 +403,23 @@ def test_a_button_pressed_mid_capture_stops_listening(self, harness: Harness) -> harness.press(dpg.mvKey_G, CTRL) assert harness.captured == [] + + @pytest.mark.parametrize( + "tag", + [ + TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET, + TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, + ], + ids=["reset", "cancel"], + ) + def test_a_button_pressed_mid_capture_leaves_the_cell_reading_its_keys( + self, + harness: Harness, + tag: str, + ) -> None: + """Reset and Cancel ask a question first, and the row behind it reads what it holds.""" + harness.render(view_model(selected=SAVE_PROJECT)) + harness.click_shortcut(SAVE_PROJECT) + harness.press_button(tag) + + assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S" diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_nsf.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_nsf.py new file mode 100644 index 000000000..b2a09c26d --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_nsf.py @@ -0,0 +1,306 @@ +from pathlib import Path +from typing import Final, List + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_NSF_BUTTON_BROWSE, + TAG_SETTINGS_NSF_BUTTON_CANCEL, + TAG_SETTINGS_NSF_BUTTON_EXPORT, + TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, + TAG_SETTINGS_NSF_COMBO_REPEAT, + TAG_SETTINGS_NSF_COMBO_SCHEME, + TAG_SETTINGS_NSF_GROUP_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_ARTIST, + TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, + TAG_SETTINGS_NSF_INPUT_TITLE, + TAG_SETTINGS_NSF_PATH_DESTINATION, + TAG_SETTINGS_NSF_TEXT_FRAME_COUNT, + TAG_SETTINGS_NSF_TEXT_LENGTH, + TAG_SETTINGS_NSF_TEXT_NO_CHANNEL, + TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION, + TAG_SETTINGS_NSF_TEXT_TITLE_SIZE, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.dialogs.nsf import GUINSFExportWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.nsf.information import fit_field +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from sampletones_shared.utils.system.paths import shorten_path +from tests.suite.nsf import PROGRAM_ARTIST, PROGRAM_TITLE, project_offer, sample_offer, standing_choices +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +DESTINATION: Final[Path] = Path("/home/user/programs/chiptune.nsf") +SONG_TICKS: Final[int] = 10800 +NES_FREQUENCY: Final[int] = 60 +LOOP_FRAME: Final[int] = 2 +OVERLONG_TITLE: Final[str] = "Überraschungsmelodie für Konsolen" + + +def export_view(offer: NSFExportOffer, choices: NSFExportChoices) -> NSFExportViewModel: + return NSFExportViewModel( + offer=offer, + choices=choices, + destination=DESTINATION, + ticks=SONG_TICKS, + nes_frequency=NES_FREQUENCY, + ) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUINSFExportWindow: + return GUINSFExportWindow( + layout=layout_config.settings, + path_colors=layout_config.general.colors.paths, + text_colors=layout_config.general.colors.text, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + status_bar=GUIStatusBar(display_time=1.0), + ) + + +def draw(window: GUINSFExportWindow, view_model: NSFExportViewModel) -> None: + """Builds the widget tree for the given state, the way ``open`` does without a live frame.""" + window.update_view(view_model) + window.create_window() + + +def draw_project(window: GUINSFExportWindow) -> NSFExportViewModel: + offer = project_offer() + view_model = export_view(offer, standing_choices(offer)) + draw(window, view_model) + return view_model + + +def channel_tag(channel: ChannelName) -> str: + return compose_tag(TAG_SETTINGS_NSF_CHECKBOX_CHANNEL, channel.value) + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +def edit(tag: str, value: object) -> None: + """Reports an edit the way DearPyGui does, handing the user data to a control that carries some.""" + user_data = dpg.get_item_user_data(tag) + if user_data is None: + dpg.get_item_callback(tag)(tag, value) + else: + dpg.get_item_callback(tag)(tag, value, user_data) + + +class TestTheProgramText: + def test_each_field_shows_its_text(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert dpg.get_value(TAG_SETTINGS_NSF_INPUT_TITLE) == PROGRAM_TITLE + assert dpg.get_value(TAG_SETTINGS_NSF_INPUT_ARTIST) == PROGRAM_ARTIST + + def test_each_field_states_the_bytes_its_text_takes(self, window: GUINSFExportWindow) -> None: + view_model = draw_project(window) + + expected = view_model.text_size_label(PROGRAM_TITLE, LANGUAGE_MANAGER["settings.nsf.template.text_size"]) + assert dpg.get_value(TAG_SETTINGS_NSF_TEXT_TITLE_SIZE) == expected + + def test_a_typed_title_is_reported_as_the_header_holds_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_INPUT_TITLE, OVERLONG_TITLE * 2) + + assert reported[-1].information.title == fit_field(OVERLONG_TITLE * 2) + assert len(reported[-1].information.title.encode()) <= STRING_TEXT_SIZE + + +class TestTheChannels: + def test_a_channel_the_source_sounds_takes_a_tick(self, window: GUINSFExportWindow) -> None: + offer = sample_offer() + draw(window, export_view(offer, standing_choices(offer))) + + for channel in ChannelName.items(): + configuration = dpg.get_item_configuration(channel_tag(channel)) + assert configuration["enabled"] == (channel in offer.channels) + assert dpg.get_value(channel_tag(channel)) == (channel in offer.channels) + + def test_each_channel_is_named_as_everywhere_else(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + for channel in ChannelName.items(): + label = channel_label(LANGUAGE_MANAGER, channel) + assert dpg.get_item_configuration(channel_tag(channel))["label"] == label + + def test_unticking_a_channel_reports_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(channel_tag(ChannelName.NOISE), False) + + assert ChannelName.NOISE not in reported[-1].channels + + def test_a_program_sounding_nothing_says_why_it_waits(self, window: GUINSFExportWindow) -> None: + offer = project_offer() + choices = standing_choices(offer).model_copy(update={"channels": frozenset()}) + draw(window, export_view(offer, choices)) + + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_BUTTON_EXPORT)["enabled"] + + def test_a_sounding_program_says_nothing(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_TEXT_NO_CHANNEL)["show"] + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_BUTTON_EXPORT)["enabled"] + + +class TestThePlayback: + def test_the_repeats_offered_are_the_sources(self, window: GUINSFExportWindow) -> None: + offer = sample_offer() + draw(window, export_view(offer, standing_choices(offer))) + + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_COMBO_REPEAT)["items"] == [ + LANGUAGE_MANAGER["settings.nsf.label.repeat_once"], + LANGUAGE_MANAGER["settings.nsf.label.repeat_from_start"], + ] + + def test_the_frame_is_asked_for_once_the_repeat_returns_to_one(self, window: GUINSFExportWindow) -> None: + offer = project_offer() + choices = standing_choices(offer).with_repeat(NSFRepeat.FROM_FRAME, offer).with_loop_frame(LOOP_FRAME, offer) + view_model = export_view(offer, choices) + draw(window, view_model) + + assert dpg.get_item_configuration(TAG_SETTINGS_NSF_GROUP_LOOP_FRAME)["show"] + assert dpg.get_value(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME) == view_model.loop_frame_label( + LANGUAGE_MANAGER["settings.nsf.template.frame"] + ) + assert dpg.get_value(TAG_SETTINGS_NSF_TEXT_FRAME_COUNT) == view_model.frame_count_label( + LANGUAGE_MANAGER["settings.nsf.template.frame_count"] + ) + + def test_a_repeat_from_the_start_leaves_the_frame_out(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_GROUP_LOOP_FRAME)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME)["enabled"] + + def test_the_length_is_stated(self, window: GUINSFExportWindow) -> None: + view_model = draw_project(window) + + expected = view_model.length_label(LANGUAGE_MANAGER["settings.nsf.template.length"]) + assert dpg.get_value(TAG_SETTINGS_NSF_TEXT_LENGTH) == expected + + def test_picking_a_repeat_reports_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_COMBO_REPEAT, LANGUAGE_MANAGER["settings.nsf.label.repeat_from_frame"]) + + assert reported[-1].repeat == NSFRepeat.FROM_FRAME + + def test_a_typed_frame_is_read_in_hexadecimal(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, "0A") + + assert reported[-1].loop_frame == 10 + + def test_a_field_typed_empty_reports_nothing(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_INPUT_LOOP_FRAME, "") + + assert not reported + + +class TestTheCompression: + def test_the_schemes_offered_are_the_sources(self, window: GUINSFExportWindow) -> None: + offer = sample_offer() + draw(window, export_view(offer, standing_choices(offer))) + + assert LANGUAGE_MANAGER["settings.nsf.label.scheme_instruments"] not in ( + dpg.get_item_configuration(TAG_SETTINGS_NSF_COMBO_SCHEME)["items"] + ) + + def test_the_chosen_scheme_is_described(self, window: GUINSFExportWindow) -> None: + offer = project_offer() + draw(window, export_view(offer, standing_choices(offer).with_scheme(CompressionScheme.RUNS, offer))) + + assert dpg.get_value(TAG_SETTINGS_NSF_COMBO_SCHEME) == LANGUAGE_MANAGER["settings.nsf.label.scheme_runs"] + assert ( + dpg.get_value(TAG_SETTINGS_NSF_TEXT_SCHEME_DESCRIPTION) + == LANGUAGE_MANAGER["settings.nsf.message.scheme_runs"] + ) + + def test_picking_a_scheme_reports_it(self, window: GUINSFExportWindow) -> None: + reported: List[NSFExportChoices] = [] + window.on_choices_changed = reported.append + draw_project(window) + + edit(TAG_SETTINGS_NSF_COMBO_SCHEME, LANGUAGE_MANAGER["settings.nsf.label.scheme_none"]) + + assert reported[-1].scheme == CompressionScheme.NONE + + +class TestTheActions: + def test_the_file_is_shown(self, window: GUINSFExportWindow) -> None: + draw_project(window) + + assert dpg.get_value(TAG_SETTINGS_NSF_PATH_DESTINATION) == shorten_path(DESTINATION) + + def test_the_browse_button_asks_for_a_file(self, window: GUINSFExportWindow) -> None: + asked: List[None] = [] + window.on_browse = lambda: asked.append(None) + draw_project(window) + + press(TAG_SETTINGS_NSF_BUTTON_BROWSE) + + assert asked + + def test_the_export_button_hands_the_export_over(self, window: GUINSFExportWindow) -> None: + exported: List[None] = [] + window.on_export = lambda: exported.append(None) + draw_project(window) + + press(TAG_SETTINGS_NSF_BUTTON_EXPORT) + + assert exported + + def test_a_program_sounding_nothing_is_kept_from_the_export(self, window: GUINSFExportWindow) -> None: + exported: List[None] = [] + window.on_export = lambda: exported.append(None) + offer = project_offer() + draw(window, export_view(offer, standing_choices(offer).model_copy(update={"channels": frozenset()}))) + + press(TAG_SETTINGS_NSF_BUTTON_EXPORT) + + assert not exported + + def test_the_cancel_button_closes_the_dialog(self, window: GUINSFExportWindow) -> None: + closed: List[None] = [] + window.on_close = lambda: closed.append(None) + draw_project(window) + + press(TAG_SETTINGS_NSF_BUTTON_CANCEL) + + assert closed diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py index 0dd8e62f5..8ea9a41f4 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py @@ -15,6 +15,7 @@ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, TAG_SETTINGS_PROPERTIES_INPUT_TITLE, + TAG_SETTINGS_PROPERTIES_WINDOW, ) from sampletones_application.ui.panels.dialogs.project_properties import ( GUIProjectPropertiesWindow, @@ -24,6 +25,7 @@ ProjectPropertiesViewModel, ) from sampletones_shared.constants.project import MAX_HIGHLIGHT, MIN_HIGHLIGHT +from tests.suite.frames import Frames from tests.suite.shortcuts import shipped_source LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) @@ -85,7 +87,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 @@ -113,7 +115,10 @@ def test_both_actions_are_offered(self, window: GUIProjectPropertiesWindow) -> N class TestCommit: - """Confirming reports the whole form at once, so the owner applies one undoable gesture.""" + """Confirming reports the whole form at once, so the owner applies one undoable gesture. + + The form leaves the screen before the owner hears it, so the values are read as it goes. + """ @pytest.fixture(name="committed") def committed_fixture(self, window: GUIProjectPropertiesWindow) -> List[Committed]: @@ -124,15 +129,17 @@ 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], + held_frames: Frames, ) -> None: dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, 3) dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, 9) window._commit() + held_frames.render() assert committed == [("Chiptune", "Composer", "A note to self", 3, 9)] @@ -140,23 +147,27 @@ def test_a_highlight_past_the_range_arrives_clamped( self, window: GUIProjectPropertiesWindow, committed: List[Committed], + held_frames: Frames, ) -> None: """The project rejects a highlight outside its bounds, so the dialog reports one inside.""" dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, MAX_HIGHLIGHT + 1) dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, MIN_HIGHLIGHT - 1) window._commit() + held_frames.render() assert committed[-1][3:] == (MAX_HIGHLIGHT, MIN_HIGHLIGHT) - def test_the_metre_carries_the_info_with_it( + def test_the_meter_carries_the_info_with_it( self, window: GUIProjectPropertiesWindow, committed: List[Committed], + held_frames: Frames, ) -> None: dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, "Another song") window._commit() + held_frames.render() assert committed[-1] == ( "Another song", @@ -165,3 +176,14 @@ def test_the_metre_carries_the_info_with_it( FIRST_HIGHLIGHT, SECOND_HIGHLIGHT, ) + + def test_the_form_leaves_before_the_owner_hears_it( + self, + window: GUIProjectPropertiesWindow, + committed: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + + assert not dpg.does_item_exist(TAG_SETTINGS_PROPERTIES_WINDOW) + assert committed == [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py index 9b8cece34..dc239c616 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py @@ -8,7 +8,7 @@ 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.general import SUF_BUTTON, TAG_GLOBAL_THEME_DANGER_BUTTON from sampletones_application.tags.settings import ( TAG_SETTINGS_RENDER_BUTTON_BROWSE, TAG_SETTINGS_RENDER_BUTTON_CANCEL, @@ -176,7 +176,7 @@ def test_a_control_off_screen_takes_no_focus(self, window: GUIRenderWindow) -> N assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] def test_a_render_already_stopping_takes_no_further_stop(self, window: GUIRenderWindow) -> None: - render(window, phase=RenderPhase.CANCELLING) + render(window, phase=RenderPhase.CANCELING) assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] @@ -283,6 +283,13 @@ def test_the_stop_button_stops_a_running_render(self, window: GUIRenderWindow) - assert stopped + def test_the_stop_button_carries_the_tone_of_an_action_that_undoes_the_run(self, window: GUIRenderWindow) -> None: + render(window, phase=RenderPhase.RENDERING) + + theme = dpg.get_item_theme(compose_tag(TAG_SETTINGS_RENDER_BUTTON_CANCEL, SUF_BUTTON)) + + assert dpg.get_item_alias(theme) == TAG_GLOBAL_THEME_DANGER_BUTTON + def test_leaving_the_setup_closes_the_dialog(self, window: GUIRenderWindow) -> None: closed: List[None] = [] stopped: List[None] = [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py new file mode 100644 index 000000000..9136e95f2 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py @@ -0,0 +1,101 @@ +from pathlib import Path +from typing import Final, List + +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, TAG_GLOBAL_THEME_DANGER_BUTTON +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_WINDOW_SCAN, +) +from sampletones_application.ui.panels.dialogs.scanning import GUIScanWindow +from tests.suite.base import BaseTestSuite + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +OPENING: Final[str] = LANGUAGE_MANAGER["main.converter.message.scan_opening"] +PROGRESS: Final[str] = LANGUAGE_MANAGER["main.converter.template.scan_progress"] +ROOT: Final[Path] = Path("/music/takes") +FOUND: Final[int] = 128 + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIScanWindow: + return GUIScanWindow( + layout=layout_config.tabs.main.converter, + language_manager=LANGUAGE_MANAGER, + ) + + +def open_on(window: GUIScanWindow, root: Path) -> None: + """Names the folder and builds the tree, the way ``open`` does without a live frame.""" + window.open(root) + + +def press_stop() -> None: + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON))() + + +class TestWhatTheWindowSays(BaseTestSuite): + """The reader is told which folder is being read and how far the walk has got.""" + + def test_it_opens_on_the_folder_it_was_given(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER) == OPENING.format(name=ROOT.name) + + def test_it_counts_what_the_walk_has_met(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + window.report(FOUND) + + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER) == PROGRESS.format(count=FOUND, name=ROOT.name) + + +class TestHowStopReads(BaseTestSuite): + """Stop gives up on a reading the reader asked for, so it carries the tone the interface + gives an action that undoes what is under way.""" + + def test_stop_carries_the_tone_of_the_action_it_is(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + theme = dpg.get_item_theme(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON)) + + assert dpg.get_item_alias(theme) == TAG_GLOBAL_THEME_DANGER_BUTTON + + +class TestGivingUp(BaseTestSuite): + """Stop is answered by the window itself, so it closes however the reading ends. + + The window carries no close of its own, and a walk that dies partway reports nothing, so a + Stop that waited for the walk's next word would leave the reader a window with no way out. + """ + + def test_stop_takes_the_window_away(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + press_stop() + + assert dpg.does_item_exist(TAG_MAIN_CONVERTER_WINDOW_SCAN) is False + + def test_stop_asks_the_walk_to_give_up(self, window: GUIScanWindow) -> None: + asked: List[bool] = [] + window.on_stop = lambda: asked.append(True) + open_on(window, ROOT) + + press_stop() + + assert asked == [True] + + def test_a_walk_ending_after_stop_leaves_the_window_gone(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + press_stop() + + window.close() + + assert dpg.does_item_exist(TAG_MAIN_CONVERTER_WINDOW_SCAN) is False diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py new file mode 100644 index 000000000..ba1701bbe --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -0,0 +1,394 @@ +from pathlib import Path +from typing import Final, List, Optional, Sequence + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.sources import SourceKind +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_CHECKBOX, SUF_TEXT +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, + TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.panels.dialogs.stem_selection import Answer, GUIStemSelectionWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.frames import Frames +from tests.suite.gestures import DOUBLE_CLICKED, click_row_name +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +GATHERED: Final[int] = MAX_STEM_SOURCES + 4 +TAGS: Final[StemsTags] = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIStemSelectionWindow: + return GUIStemSelectionWindow( + layout=layout_config.tabs.main.converter, + stems_layout=layout_config.general.stems, + glyphs=layout_config.glyphs.common, + stem_colors=layout_config.general.colors.stems, + language_manager=LANGUAGE_MANAGER, + status_bar=GUIStatusBar(), + title=LANGUAGE_MANAGER["main.converter.title.stem_selection_dialog"], + message=LANGUAGE_MANAGER["main.converter.message.stem_selection_prompt"], + limit_template=LANGUAGE_MANAGER["main.converter.template.stem_selection_limit"], + add_label=LANGUAGE_MANAGER["main.converter.label.add_stems_button"], + cancel_label=LANGUAGE_MANAGER["global.dialog.label.cancel"], + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def recording_row(path: Path) -> StemRowViewModel: + """One gathered recording, as the converter's list draws it.""" + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + name=path.stem, + path=path, + held=(), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +def folder_row(root: Path, held: Sequence[Path]) -> StemRowViewModel: + """A gathered folder standing for the recordings below it.""" + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + name=root.name, + path=root, + held=tuple(recording_row(path) for path in held), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +def paths(count: int = GATHERED) -> List[Path]: + return [Path(f"/audio/take_{index}.wav") for index in range(count)] + + +def candidates(count: int = GATHERED) -> List[StemRowViewModel]: + return [recording_row(path) for path in paths(count)] + + +def render( + window: GUIStemSelectionWindow, + offered: Sequence[StemRowViewModel], + answer: Optional[Answer] = None, +) -> None: + """Builds the widget tree for what was offered, the way ``open`` does without a live frame.""" + window.open(offered, MAX_STEM_SOURCES, answer if answer is not None else discard) + + +def discard(_picked: List[Path]) -> None: + """The answer a case makes no use of.""" + + +def box_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_CHECKBOX) + + +def pick(row: StemRowViewModel) -> None: + """Click one row's box the way DearPyGui reports a checkbox.""" + tag = box_of(row) + dpg.get_item_callback(tag)(tag, not dpg.get_value(tag), dpg.get_item_user_data(tag)) + + +def name_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_TEXT) + + +def sound(row: StemRowViewModel) -> None: + """Double-click one row's name the way DearPyGui reports the gesture.""" + click_row_name(TAGS, row.key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) + + +def click_name(row: StemRowViewModel, value: bool) -> None: + """Click one row's name the way DearPyGui does: the widget moves, then the callback runs.""" + name_tag = name_of(row) + dpg.set_value(name_tag, value) + dpg.get_item_callback(name_tag)(name_tag, value, row.key) + + +def add(held_frames: Frames) -> None: + """Presses Add and lets the frame go by that carries the answer, once the question has left.""" + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + held_frames.render() + + +def add_enabled() -> bool: + return bool(dpg.get_item_configuration(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))["enabled"]) + + +class TestWhatIsOffered(BaseTestSuite): + """Everything gathered is offered, with as many as a mix holds arriving ticked.""" + + def test_every_recording_gathered_gets_a_box(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + for row in offered: + assert dpg.does_item_exist(box_of(row)) + + def test_the_ones_a_mix_holds_arrive_ticked(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + assert [dpg.get_value(box_of(row)) for row in offered[:MAX_STEM_SOURCES]] == [True] * MAX_STEM_SOURCES + + def test_the_rest_arrive_clear(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + assert not any(dpg.get_value(box_of(row)) for row in offered[MAX_STEM_SOURCES:]) + + def test_a_recording_past_the_room_waits(self, window: GUIStemSelectionWindow) -> None: + """A mix is built from a fixed number of recordings, so a full pick answers what it holds.""" + offered = candidates() + render(window, offered) + + assert dpg.get_item_configuration(box_of(offered[MAX_STEM_SOURCES]))["enabled"] is False + + def test_the_ones_it_holds_still_answer(self, window: GUIStemSelectionWindow) -> None: + """Letting a recording go is how the reader makes room for another.""" + offered = candidates() + render(window, offered) + + assert dpg.get_item_configuration(box_of(offered[0]))["enabled"] is True + + +class TestSettlingTheMix(BaseTestSuite): + """The mix is settled once the pick fits, and the line above says where the pick stands.""" + + def test_a_pick_that_fits_settles( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + offered = candidates() + answered: List[List[Path]] = [] + + render(window, offered, answered.append) + add(held_frames) + + assert answered == [paths()[:MAX_STEM_SOURCES]] + + def test_the_question_leaves_before_the_mix_hears_the_answer( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + """What the answer opens next, such as an error, stands alone once the question has gone.""" + answered: List[List[Path]] = [] + render(window, candidates(), answered.append) + + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert not dpg.does_item_exist(TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION) + assert answered == [] + + def test_swapping_one_for_another_keeps_it_settling( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + offered = candidates() + answered: List[List[Path]] = [] + + render(window, offered, answered.append) + pick(offered[0]) + pick(offered[MAX_STEM_SOURCES]) + add(held_frames) + + assert answered == [paths()[1 : MAX_STEM_SOURCES + 1]] + + def test_a_pick_past_the_room_leaves_the_mix_as_it_was( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + offered = candidates() + answered: List[List[Path]] = [] + + render(window, offered, answered.append) + pick(offered[MAX_STEM_SOURCES]) + add(held_frames) + + assert answered == [paths()[:MAX_STEM_SOURCES]] + + def test_a_pick_of_nothing_waits(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + for row in offered[:MAX_STEM_SOURCES]: + pick(row) + + assert add_enabled() is False + + def test_letting_one_go_opens_the_room_for_another(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + beyond = offered[MAX_STEM_SOURCES] + + pick(offered[0]) + assert dpg.get_item_configuration(box_of(beyond))["enabled"] is True + pick(beyond) + + assert dpg.get_value(box_of(beyond)) is True + assert add_enabled() is True + + def test_the_line_reads_what_stands_picked(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + opening = dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) + + pick(offered[0]) + + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) != opening + + def test_a_list_a_mix_already_holds_arrives_settling(self, window: GUIStemSelectionWindow) -> None: + offered = candidates(MAX_STEM_SOURCES) + render(window, offered) + assert add_enabled() is True + + +class TestAFolderInTheQuestion(BaseTestSuite): + """A folder stands as the row it stands as in the card, answering for what it holds.""" + + def test_the_folder_stands_as_one_row(self, window: GUIStemSelectionWindow) -> None: + held = paths(3) + render(window, [folder_row(Path("/audio/takes"), held)]) + + assert dpg.does_item_exist(box_of(folder_row(Path("/audio/takes"), held))) + + def test_it_counts_the_recordings_it_holds(self, window: GUIStemSelectionWindow) -> None: + """The line above counts recordings, so a folder counts as what it brings in.""" + held = paths(3) + render(window, [folder_row(Path("/audio/takes"), held)]) + + assert "3" in dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) + + def test_one_click_lets_the_whole_folder_go(self, window: GUIStemSelectionWindow) -> None: + held = paths(3) + folder = folder_row(Path("/audio/takes"), held) + render(window, [folder]) + assert dpg.get_value(box_of(folder)) is True + + pick(folder) + + assert add_enabled() is False + + def test_a_folder_larger_than_the_room_takes_as_many_as_fit( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + """A folder settles either way, so it lets go of a full mix and takes what fits again.""" + answered: List[List[Path]] = [] + held = paths(GATHERED) + folder = folder_row(Path("/audio/takes"), held) + render(window, [folder], answered.append) + assert add_enabled() is True + + pick(folder) + assert add_enabled() is False + + pick(folder) + + assert add_enabled() is True + add(held_frames) + assert answered == [held[:MAX_STEM_SOURCES]] + + def test_what_it_holds_is_what_the_mix_takes( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + held = paths(3) + answered: List[List[Path]] = [] + render(window, [folder_row(Path("/audio/takes"), held)], answered.append) + + add(held_frames) + + assert answered == [held] + + +class TestAskingTwice(BaseTestSuite): + """The window takes its whole tree down between one opening and the next, so a second asking + draws the rows rather than repainting the ones that are gone. The frame the window left in still + draws it, so the second asking opens a frame later.""" + + def test_the_rows_are_drawn_again(self, window: GUIStemSelectionWindow, held_frames: Frames) -> None: + offered = candidates() + render(window, offered) + window.hide() + + render(window, offered) + held_frames.render() + + for row in offered: + assert dpg.does_item_exist(box_of(row)) + + def test_the_pick_still_settles( + self, + window: GUIStemSelectionWindow, + held_frames: Frames, + ) -> None: + offered = candidates() + answered: List[List[Path]] = [] + render(window, offered) + window.hide() + + render(window, offered, answered.append) + held_frames.render() + add(held_frames) + + assert answered == [paths()[:MAX_STEM_SOURCES]] + + +class TestHearingWhatARowStandsFor(BaseTestSuite): + """A reader decides by ear, so the question sounds a recording the way the card's list does.""" + + def test_a_double_clicked_recording_is_named_to_sound(self, window: GUIStemSelectionWindow) -> None: + played: List[Path] = [] + offered = candidates() + window.on_source_played = played.append + render(window, offered) + + sound(offered[0]) + + assert played == [offered[0].path] + + def test_clicking_a_name_leaves_the_row_reading_as_it_stood(self, window: GUIStemSelectionWindow) -> None: + """The pick is made by the boxes, so a click on a name marks no row as picked out.""" + offered = candidates() + render(window, offered) + + click_name(offered[0], True) + + assert dpg.get_value(name_of(offered[0])) is False 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..421151dc6 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/instruction/test_library_generator_nodes.py @@ -0,0 +1,146 @@ +from typing import Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.tags.instructions import ( + TAG_INSTRUCTIONS_LIBRARY_THEME, + TAG_INSTRUCTIONS_LIBRARY_THEME_OUTDATED, +) +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +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, NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + +STATUS_KEY: Final[str] = "instructions.library.message.status_node_generator" +STATUS_TEXT: Final[str] = "{generator} of {library_key}" +OUTDATED_KEY: Final[str] = "instructions.library.template.library_node_outdated_template" +OUTDATED_TEXT: Final[str] = "[!] {}" +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(), outdated=False) + 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, OUTDATED_KEY: OUTDATED_TEXT}) + return panel + + +class FakeLibraryLogic: + current_library_key: Optional[InstructionLibraryKey] = None + + +def _build_specs(root: TreeNode, monkeypatch: pytest.MonkeyPatch) -> List[NodeSpec]: + """Collects the rows a tree refresh would emit, which the traversal resolves off the main thread.""" + panel = _panel() + panel._search_visibility = None + panel._favorites_visibility = None + panel._expansion = RowExpansionMemory(set()) + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + monkeypatch.setattr(panel, "_library_logic", FakeLibraryLogic(), raising=False) + monkeypatch.setattr(panel, "_generate_node_tag", lambda node: f"row.{node.name}", raising=False) + + panel._build_tree_node(root, TreeNodeState(parent="tree")) + return panel._pending_specs + + +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)] + + +class TestTheRowOfALibrary: + """A library another version built reads as out of date, while its name, which the row's + tag and remembered expansion are built from, stays as it is.""" + + def test_a_library_another_version_built_reads_as_out_of_date(self) -> None: + node = LibraryNode("Library", library_key=_library_key(), outdated=True) + + assert (_panel()._node_label(node), node.name) == ("[!] Library", "Library") + + def test_a_library_this_build_reads_reads_as_its_name(self) -> None: + node = LibraryNode("Library", library_key=_library_key(), outdated=False) + + assert _panel()._node_label(node) == "Library" + + @pytest.mark.parametrize( + ("outdated", "theme"), + [(True, TAG_INSTRUCTIONS_LIBRARY_THEME_OUTDATED), (False, TAG_INSTRUCTIONS_LIBRARY_THEME)], + ids=["built by another version", "read by this build"], + ) + def test_a_library_row_takes_the_theme_of_its_standing(self, outdated: bool, theme: str) -> None: + node = LibraryNode("Library", library_key=_library_key(), outdated=outdated) + + assert _panel()._resolve_node_theme_tag(node) == theme + + +class TestTheRowsTheTreeDraws: + def test_a_row_holding_nothing_is_drawn_as_a_leaf(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A library another version built lists no generators, so its row stands as a leaf.""" + root = TreeNode("Libraries", node_type=NodeType.ROOT) + LibraryNode("Outdated", library_key=_library_key(), outdated=True, parent=root) + current = LibraryNode("Current", library_key=_library_key(), outdated=False, parent=root) + GeneratorNode("Pulse", generator_name=GeneratorName.PULSE, parent=current) + + specs = _build_specs(root, monkeypatch) + + assert {spec.node.name: spec.leaf for spec in specs} == {"Outdated": True, "Current": False, "Pulse": True} diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py new file mode 100644 index 000000000..2a1a6db81 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -0,0 +1,973 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +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.output import OutputKind +from sampletones_application.constants.sources import SourceKind +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 +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_GROUP_CONVERT, + TAG_MAIN_CONVERTER_GROUP_INPUT, + TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_GROUP_SUMMARY, + TAG_MAIN_CONVERTER_RADIO_MODE, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_STEMS, +) +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.main.converter import menus as menus_module +from sampletones_application.ui.panels.main.converter.panel import GUIConverterPanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyEvent, KeyRouter, focus +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.main.converter import ( + ConversionPhase, + ConverterViewModel, +) +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.gestures import CLICKED, click_row_name +from tests.suite.shortcuts import rebound_source, shipped_source + +ROOT_TAG = "test_root" +SETUP_CHOICES = ( + TAG_MAIN_CONVERTER_RADIO_MODE, + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, +) +LANGUAGE_MANAGER = LanguageManager(LANG_EN) +ACTION_LABEL = "Convert 2 recordings" +STATUS_TEXT = "No tasks in progress." +RECORDING = Path("/audio/kick.wav") +REBOUND_REMOVAL = "Ctrl+Shift+K" +SEPARATOR = "separator" + + +@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 header geometry the card draws under.""" + 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() + + +def row( + name: str, + *, + 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), + kind=SourceKind.RECORDING, + name=path.stem, + path=path, + held=(), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=level, + position=position, + stem_id=None, + level_size=level_size, + level_count=level_count, + ) + + +def folder(name: str, *, holds: int) -> StemRowViewModel: + """A row standing for everything gathered below one folder.""" + root = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + name=root.name, + path=root, + held=tuple(row(f"{name}/take_{index}") for index in range(holds)), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=0, + position=0, + stem_id=None, + level_size=1, + level_count=1, + ) + + +def view( + *rows: StemRowViewModel, + output: OutputKind = OutputKind.PER_RECORDING, + phase: ConversionPhase = ConversionPhase.IDLE, + input_path: Optional[Path] = None, + output_path: Optional[Path] = None, + selected_key: Optional[str] = None, +) -> ConverterViewModel: + return ConverterViewModel( + phase=phase, + status_text=STATUS_TEXT, + action_label=ACTION_LABEL, + progress=0.0, + input_path=input_path, + output_path=output_path, + is_file=True, + other_operation_active=False, + output=output, + stem_sources=rows, + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + max_sources=8, + selected_key=selected_key, + ) + + +def build( + layout_config: LayoutConfig, + *, + key_router: Optional[KeyRouter] = None, + tab_active: ActivePredicate = lambda: True, + shortcut_source: Optional[ShortcutSource] = None, + collapsed: bool = False, +) -> Tuple[GUIConverterPanel, List[OutputKind]]: + """The card as the application builds it, over the output switch it reports. + + ``collapsed`` opens the card put away, which is how a reader who collapsed it last session + meets it. + """ + panel = GUIConverterPanel( + layout=layout_config.tabs.main.converter, + stems_layout=layout_config.general.stems, + inputs=layout_config.general.inputs, + path_colors=layout_config.general.colors.paths, + stem_colors=layout_config.general.colors.stems, + initial_collapsed=collapsed, + language_manager=LANGUAGE_MANAGER, + status_bar=GUIStatusBar(), + key_router=key_router if key_router is not None else KeyRouter(), + shortcut_source=shipped_source() if shortcut_source is None else shortcut_source, + tab_active=tab_active, + ) + reported: List[OutputKind] = [] + panel.on_output_changed = reported.append + with dpg.window(tag=ROOT_TAG): + panel.create_panel(ROOT_TAG) + + panel.update_view(view()) + return panel, reported + + +@pytest.fixture +def registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: + """The items a menu registers, in the order a reader meets them, the rules between them included.""" + items: List[Dict[str, Any]] = [] + monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: items.append(kwargs) or 0) + monkeypatch.setattr( + menus_module.dpg, + "add_separator", + lambda **_kwargs: items.append({"label": SEPARATOR}) or 0, + ) + return items + + +def right_click(panel: GUIConverterPanel, entry: StemRowViewModel) -> None: + """Land a right-click on the row's name, which is what puts that row's menu up.""" + click_row_name(panel.stems_list.tags, entry.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + +def shows(tag: str) -> bool: + return bool(dpg.get_item_configuration(tag)["show"]) + + +class TestTheOutputSwitch: + """The card opens on what the run writes, which the button below it repeats.""" + + def test_it_offers_both_kinds_of_run(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + offered = dpg.get_item_configuration(TAG_MAIN_CONVERTER_RADIO_MODE)["items"] + + assert offered == [ + LANGUAGE_MANAGER["main.converter.label.mode_each"], + LANGUAGE_MANAGER["main.converter.label.mode_mixed"], + ] + + def test_it_stands_above_the_button(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + body = dpg.get_item_children(dpg.get_item_parent(TAG_MAIN_CONVERTER_GROUP_CONVERT), 1) + switch = dpg.get_item_parent(TAG_MAIN_CONVERTER_RADIO_MODE) + + assert body.index(switch) < body.index(dpg.get_alias_id(TAG_MAIN_CONVERTER_GROUP_CONVERT)) + + def test_it_reads_what_the_view_names(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"), output=OutputKind.MIXED)) + + assert dpg.get_value(TAG_MAIN_CONVERTER_RADIO_MODE) == LANGUAGE_MANAGER["main.converter.label.mode_mixed"] + + def test_a_reader_turning_it_reports_the_kind(self, dpg_context: None, layout_config: LayoutConfig) -> None: + _panel, reported = build(layout_config) + + callback = dpg.get_item_callback(TAG_MAIN_CONVERTER_RADIO_MODE) + callback(TAG_MAIN_CONVERTER_RADIO_MODE, LANGUAGE_MANAGER["main.converter.label.mode_mixed"]) + + assert reported == [OutputKind.MIXED] + + +def action_button() -> str: + """The button widget the action group holds, which is where its label is drawn.""" + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ACTION, SUF_BUTTON) + + +class TestTheActionButton: + """The button says what the run writes, which the logic composes and the card renders.""" + + def test_it_reads_the_label_the_view_carries(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"))) + + assert dpg.get_item_label(action_button()) == ACTION_LABEL + + def test_nothing_listed_leaves_it_waiting(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert dpg.get_item_configuration(action_button())["enabled"] is False + + +class TestTheRunControls: + """The order answers for a mix of several recordings, so it arrives with the second of them.""" + + def test_it_stands_away_until_something_is_listed(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert not shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + def test_the_order_arrives_with_the_second_recording_of_a_mix( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), output=OutputKind.MIXED)) + assert not shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + panel.update_view(view(row("kick"), row("snare"), output=OutputKind.MIXED)) + assert shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + def test_a_run_writing_one_apiece_has_no_order_to_take( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"))) + + assert not shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + def test_every_choice_rests_while_a_run_holds_the_setup( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"), output=OutputKind.MIXED, phase=ConversionPhase.RUNNING)) + + assert {tag: dpg.get_item_configuration(tag)["enabled"] for tag in SETUP_CHOICES} == dict.fromkeys( + SETUP_CHOICES, + False, + ) + + def test_it_stands_below_the_list(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + body = dpg.get_item_children(dpg.get_item_parent(TAG_MAIN_CONVERTER_WINDOW_STEMS), 1) + + assert body.index(dpg.get_alias_id(TAG_MAIN_CONVERTER_WINDOW_STEMS)) < body.index( + dpg.get_alias_id(TAG_MAIN_CONVERTER_GROUP_ORDER) + ) + + +class TestTheList: + """The gathered recordings are what a run converts either way, so the list always stands.""" + + def test_it_stands_whichever_run_the_switch_names(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + assert shows(TAG_MAIN_CONVERTER_WINDOW_STEMS) + + panel.update_view(view(row("kick"), output=OutputKind.MIXED)) + + assert shows(TAG_MAIN_CONVERTER_WINDOW_STEMS) + + def test_an_empty_list_says_how_to_fill_it(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert shows(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) == ( + LANGUAGE_MANAGER["main.converter.message.stems_empty_hint"] + ) + + def test_the_hint_leaves_with_the_first_recording(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"))) + + assert not shows(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) + + def test_an_empty_list_stands_away_so_the_hint_has_the_room( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A list holding nothing draws a heading and its rules over empty room, so it stands away.""" + panel, _reported = build(layout_config) + + panel.update_view(view()) + + assert not shows(panel.stems_list.tag) + + def test_the_list_comes_back_with_the_first_recording( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"))) + + assert shows(panel.stems_list.tag) + + +class TestTheKeysTheListClaims: + """A row picked out puts the list on the keyboard, and everything else is left to travel on.""" + + @staticmethod + def _press(router: KeyRouter, shortcut_id: ShortcutId) -> bool: + """Offer the press the shipped scheme gives ``shortcut_id`` to the scopes, as the router does.""" + combination = shipped_source().shortcut(shortcut_id).combination + assert combination is not None + return router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + def test_a_press_rests_while_no_row_is_picked_out( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + panel.update_view(view(row("kick"))) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_a_press_rests_while_a_field_holds_the_keys( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A reader typing a path keeps the plain keys, so Del edits the field rather than the list.""" + monkeypatch.setattr(focus, "is_field_focused", lambda: True) + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_a_press_rests_while_another_tab_is_in_front( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router, tab_active=lambda: False) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_a_press_rests_while_the_card_stands_away( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A card put away keeps the row it holds picked out, and lets the key travel on.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router, collapsed=True) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert (removed, panel.stems_list.picked_key) == ([], kick.key) + + def test_the_channel_keys_read_the_card_standing_away( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The channel keys reach the pick from the application's shortcuts, on the list's terms.""" + panel, _reported = build(layout_config, collapsed=True) + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert panel.keys_active is False + + def test_a_press_rests_while_the_list_stands_inert( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A run holds the list still, so the key answers to the rule the row's own button reads.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key, phase=ConversionPhase.RUNNING)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_it_removes_the_recording_picked_out(self, dpg_context: None, layout_config: LayoutConfig) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True + assert removed == [kick.path] + + def test_it_removes_the_folder_picked_out_and_everything_it_holds( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A folder is taken out as a folder, so the recordings below it leave with it.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + folders: List[Path] = [] + panel.on_source_removed = removed.append + panel.on_folder_removed = folders.append + sources = folder("sources", holds=3) + panel.update_view(view(sources, selected_key=sources.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True + assert folders == [sources.path] + assert removed == [] + + def test_a_recording_picked_out_leaves_on_its_own( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A recording standing beside a folder is taken out as a recording.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + folders: List[Path] = [] + panel.on_source_removed = removed.append + panel.on_folder_removed = folders.append + kick = row("kick") + panel.update_view(view(folder("sources", holds=2), kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True + assert removed == [kick.path] + assert folders == [] + + def test_a_press_it_has_no_action_for_travels_on(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """The list yields whatever its category leaves unnamed, so the shortcuts still hear it.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SAVE_PROJECT) is False + + +class TestTheDestination: + """Where a run writes is on screen whatever the card is doing; what it reads is not.""" + + def test_it_stands_with_nothing_listed(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert shows(TAG_MAIN_CONVERTER_GROUP_SUMMARY) + + def test_the_input_line_waits_for_a_run(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), input_path=RECORDING)) + + assert not shows(TAG_MAIN_CONVERTER_GROUP_INPUT) + + def test_the_input_line_names_the_recording_a_run_is_reading( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view( + view( + row("kick"), + phase=ConversionPhase.RUNNING, + input_path=RECORDING, + ) + ) + + assert shows(TAG_MAIN_CONVERTER_GROUP_INPUT) + assert panel.input_path_text.path == RECORDING + + +class TestTheMenuARightClickPutsUp: + """A right-click on a row raises the menu the card draws for it, over the row it landed on.""" + + def test_a_right_click_raises_the_row_s_menu( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The list reports the gesture and the card answers it, which is what puts a menu up.""" + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + click_row_name(panel.stems_list.tags, kick.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + assert LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] in [item["label"] for item in registered] + + def test_a_rule_divides_sounding_a_recording_from_moving_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """Play sounds the recording where it stands; everything under the rule moves it or takes + it off the list, so the two readings of the menu are kept apart.""" + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + right_click(panel, kick) + + labels = [item["label"] for item in registered] + assert labels[labels.index(LANGUAGE_MANAGER["global.context.label.play"]) + 1] == SEPARATOR + + def test_the_item_a_folder_offers_opens_it_on_the_list( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The item does what the marker beside the folder's name does, so a reader reaching for + the menu meets the same folder open.""" + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + + right_click(panel, sources) + opening = next( + item for item in registered if item["label"] == LANGUAGE_MANAGER["main.converter.label.context_open_folder"] + ) + opening["callback"]() + + assert panel.stems_list.stands_open(sources.key) + + def test_a_right_click_picks_the_row_it_stands_over( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The menu prints the key that removes a row, so both name the row the menu stands over.""" + panel, _reported = build(layout_config) + selected: List[Tuple[Path, SourceKind]] = [] + panel.on_row_selected = lambda path, kind: selected.append((path, kind)) + kick, snare = row("kick"), row("snare") + panel.update_view(view(kick, snare, selected_key=kick.key)) + + click_row_name(panel.stems_list.tags, snare.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + assert selected == [(snare.path, SourceKind.RECORDING)] + + def test_a_folder_offers_what_reaches_everything_below_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + + click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + labels = [item["label"] for item in registered] + assert LANGUAGE_MANAGER["main.converter.label.context_open_folder"] in labels + assert LANGUAGE_MANAGER["main.converter.label.context_remove_folder"] in labels + assert LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] not in labels + + def test_an_open_folder_offers_to_close_again( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + panel.stems_list.toggle_folder(sources.key) + + click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + assert LANGUAGE_MANAGER["main.converter.label.context_close_folder"] in [item["label"] for item in registered] + + def test_a_folder_removed_from_its_menu_takes_everything_it_holds( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + folders: List[Path] = [] + panel.on_folder_removed = folders.append + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + + click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + removal = next( + item + for item in registered + if item["label"] == LANGUAGE_MANAGER["main.converter.label.context_remove_folder"] + ) + removal["callback"]() + + assert folders == [sources.path] + + +class TestTheMovesAMixOffers(BaseTestSuite): + """A run mixing its recordings orders them, so a row's menu offers the moves that reorder it.""" + + @staticmethod + def _label(element: ConverterStemMoveElements) -> str: + return LANGUAGE_MANAGER[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] + + @classmethod + def _offered( + cls, + panel: GUIConverterPanel, + entry: StemRowViewModel, + registered: List[Dict[str, Any]], + ) -> Dict[str, Dict[str, Any]]: + """The move items the row's menu registers, each under the label it prints.""" + right_click(panel, entry) + offered = {cls._label(element) for element in ConverterStemMoveElements} + return {item["label"]: item for item in registered if item["label"] in offered} + + @classmethod + def _moves( + cls, + panel: GUIConverterPanel, + entry: StemRowViewModel, + registered: List[Dict[str, Any]], + ) -> Dict[str, bool]: + """The moves the row's menu offers and whether each stands live, in the order it lists them.""" + offered = cls._offered(panel, entry, registered) + return {label: bool(item.get("enabled", True)) for label, item in offered.items()} + + def test_a_run_writing_one_reconstruction_apiece_offers_removal_alone( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + moves = self._moves(panel, kick, registered) + + assert list(moves) == [self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM)] + + def test_a_mix_of_one_recording_offers_removal_alone( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """One recording is its own order, so the list draws no levels and the menu names no move + over them.""" + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, output=OutputKind.MIXED)) + + moves = self._moves(panel, kick, registered) + + assert list(moves) == [self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM)] + + def test_a_running_mix_offers_no_move_it_would_take( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), output=OutputKind.MIXED, phase=ConversionPhase.RUNNING)) + + moves = self._moves(panel, kick, registered) + + assert not any(moves.values()) + + def test_a_mix_offers_every_move_a_row_can_make( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), output=OutputKind.MIXED)) + + moves = self._moves(panel, kick, registered) + + assert list(moves) == [ + self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP), + self._label(ConverterStemMoveElements.CONTEXT_MOVE_DOWN), + self._label(ConverterStemMoveElements.CONTEXT_JOIN_ABOVE), + self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW), + self._label(ConverterStemMoveElements.CONTEXT_ISOLATE), + self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM), + ] + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + """One row's placement among the levels, and the moves that placement leaves it.""" + + rows: Tuple[StemRowViewModel, ...] + subject: int + expected: Dict[ConverterStemMoveElements, bool] + + test_cases = ( + TestCase( + label="alone_on_the_first_of_two_levels", + rows=(row("kick", level=0, level_count=2), row("snare", level=1, level_count=2)), + subject=0, + expected={ + ConverterStemMoveElements.CONTEXT_MOVE_UP: False, + ConverterStemMoveElements.CONTEXT_MOVE_DOWN: False, + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE: False, + ConverterStemMoveElements.CONTEXT_JOIN_BELOW: True, + ConverterStemMoveElements.CONTEXT_ISOLATE: False, + }, + ), + TestCase( + label="second_on_the_last_of_two_levels", + rows=( + row("hat", level=0, level_count=2), + row("kick", level=1, position=0, level_size=2, level_count=2), + row("snare", level=1, position=1, level_size=2, level_count=2), + ), + subject=2, + expected={ + ConverterStemMoveElements.CONTEXT_MOVE_UP: True, + ConverterStemMoveElements.CONTEXT_MOVE_DOWN: False, + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE: True, + ConverterStemMoveElements.CONTEXT_JOIN_BELOW: False, + ConverterStemMoveElements.CONTEXT_ISOLATE: True, + }, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_each_move_stands_live_where_the_row_has_room_for_it( + self, + test_case: TestCase, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + panel.update_view(view(*test_case.rows, output=OutputKind.MIXED)) + + moves = self._moves(panel, test_case.rows[test_case.subject], registered) + + expected = {self._label(element): live for element, live in test_case.expected.items()} + assert {label: moves[label] for label in expected} == expected + + def test_each_move_reports_the_direction_it_prints( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """An offset is added to the row's place, so moving earlier reports -1 and later reports 1. + + The item is fired rather than read, since a move standing under the right label still + reorders the wrong way while the offset behind it is the mirror of what the label says. + """ + panel, _reported = build(layout_config) + moved: List[Tuple[Path, int]] = [] + joined: List[Tuple[Path, int]] = [] + isolated: List[Path] = [] + panel.on_source_moved = lambda path, offset: moved.append((path, offset)) + panel.on_source_level_joined = lambda path, offset: joined.append((path, offset)) + panel.on_source_isolated = isolated.append + kick = row("kick", level=1, position=0, level_size=2, level_count=3) + snare = row("snare", level=1, position=1, level_size=2, level_count=3) + panel.update_view(view(row("hat", level=0, level_count=3), kick, snare, output=OutputKind.MIXED)) + + offered = self._offered(panel, snare, registered) + for element in ( + ConverterStemMoveElements.CONTEXT_MOVE_UP, + ConverterStemMoveElements.CONTEXT_MOVE_DOWN, + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE, + ConverterStemMoveElements.CONTEXT_JOIN_BELOW, + ConverterStemMoveElements.CONTEXT_ISOLATE, + ): + offered[self._label(element)]["callback"]() + + assert moved == [(snare.path, -1), (snare.path, 1)] + assert joined == [(snare.path, -1), (snare.path, 1)] + assert isolated == [snare.path] + + +class TestTheRemovalItemInTheMenu: + """Taking a row out is one action, so the item and the key print and reach the same thing.""" + + @staticmethod + def _removal( + panel: GUIConverterPanel, + entry: StemRowViewModel, + registered: List[Dict[str, Any]], + ) -> Dict[str, Any]: + """The removal item the row's menu prints, reached the way a reader reaches it.""" + label = LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] + right_click(panel, entry) + return next(item for item in registered if item["label"] == label) + + def test_it_takes_the_recording_it_stands_over_and_nothing_else( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The item is fired, since a removal printing the right key still reaches nothing.""" + panel, _reported = build(layout_config) + removed: List[Path] = [] + folders: List[Path] = [] + panel.on_source_removed = removed.append + panel.on_folder_removed = folders.append + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + self._removal(panel, kick, registered)["callback"]() + + assert removed == [kick.path] + assert folders == [] + + def test_it_prints_the_key_that_does_the_same_thing( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + removal = self._removal(panel, kick, registered) + + assert removal["shortcut"] == shipped_source().display(ShortcutId.SOURCES_REMOVE_SOURCE) + + def test_it_prints_whatever_the_scheme_in_place_gives_the_action( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """A rebind reaches the menu, which is what tells the printed key from a written one.""" + source = rebound_source(ShortcutId.SOURCES_REMOVE_SOURCE, REBOUND_REMOVAL) + panel, _reported = build(layout_config, shortcut_source=source) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + removal = self._removal(panel, kick, registered) + + assert removal["shortcut"] == REBOUND_REMOVAL + assert removal["shortcut"] != shipped_source().display(ShortcutId.SOURCES_REMOVE_SOURCE) + + def test_it_stands_inert_while_a_run_holds_the_list( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), phase=ConversionPhase.RUNNING)) + + removal = self._removal(panel, kick, registered) + + assert removal["enabled"] is False 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..cc123d23d 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 @@ -1,6 +1,7 @@ from pathlib import Path from typing import List, Set, Tuple +import dearpygui.dearpygui as dpg import pytest from sampletones_application.ui.elements.tree import tree as tree_module @@ -183,7 +184,7 @@ def test_the_rows_fold_while_the_model_still_states_them( assert music_tag in {tag for tag, _ in folded} assert all(not expanded for _, expanded in folded) - def test_the_folders_the_model_held_are_dropped_afterwards( + def test_the_folders_the_model_held_are_dropped_afterward( self, folded: List[Tuple[str, bool]], ) -> None: @@ -196,3 +197,69 @@ 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) -> 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.panel._logic = self.autoplay # type: ignore[assignment] + self.panel.on_file_add_requested = self.gathered.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) + + def double_click(self) -> None: + self.panel._on_file_node_double_clicked( + 0, + (dpg.mvMouseButton_Left, 0), + (self.node, 0), + ) + + +class TestClickingARecording: + """A plain click plays a recording; Ctrl and a double-click each ask to gather it as a stem. + + The two gathering gestures always reach the converter, which answers for whether its list takes + the recording, so they ask the way Add as stem asks. + """ + + def test_a_plain_click_plays_the_recording_and_gathers_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + clicked = RecordingClick() + + clicked.click(monkeypatch, holding_ctrl=False) + + 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() + + clicked.click(monkeypatch, holding_ctrl=True) + + assert clicked.gathered == [MUSIC / "song.wav"] + assert clicked.autoplay.played == [] + + def test_a_double_click_gathers_the_recording(self) -> None: + clicked = RecordingClick() + + clicked.double_click() + + assert clicked.gathered == [MUSIC / "song.wav"] diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py deleted file mode 100644 index ae07e0514..000000000 --- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py +++ /dev/null @@ -1,157 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, FrozenSet, List, Tuple - -import pytest - -from sampletones_application.tags.main import TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE -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 tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase - -DRIVE = 1.5 - -ALL_GENERATORS = frozenset(GeneratorName) - - -class Harness: - """The panel over its checkboxes as DearPyGui holds them, without a window to hold them in.""" - - def __init__( - self, - checked: FrozenSet[GeneratorName], - monkeypatch: pytest.MonkeyPatch, - ) -> None: - self.values: Dict[str, bool] = { - GUIReconstructorPanel._get_generator_checkbox_tag(generator): generator in checked - for generator in GeneratorName - } - self.reported: List[GenerationSettingsUpdate] = [] - - monkeypatch.setattr(reconstructor_module.dpg, "get_value", self.values.__getitem__) - monkeypatch.setattr(reconstructor_module, "dpg_set_value", self.values.__setitem__) - monkeypatch.setattr(reconstructor_module, "clamp_widget_value", self._drive) - - self.panel = GUIReconstructorPanel.__new__(GUIReconstructorPanel) - self.panel.on_generation_settings_changed = self.reported.append - - @staticmethod - def _drive(tag: str) -> float: - assert tag == TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE - return DRIVE - - def checked(self) -> FrozenSet[GeneratorName]: - return frozenset( - generator - for generator in GeneratorName - if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(generator)] - ) - - -class TestToggleGenerator(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] - - test_cases = ( - TestCase( - label="switching one off leaves the rest", - checked=ALL_GENERATORS, - generator=GeneratorName.TRIANGLE, - expected=ALL_GENERATORS - {GeneratorName.TRIANGLE}, - ), - TestCase( - label="switching one on adds it alone", - checked=frozenset(), - generator=GeneratorName.PULSE1, - expected=frozenset({GeneratorName.PULSE1}), - ), - TestCase( - label="the last one switched off leaves nothing selected", - checked=frozenset({GeneratorName.NOISE}), - generator=GeneratorName.NOISE, - expected=frozenset(), - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_set_the_checkboxes_show( - self, - test_case: TestCase, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - harness = Harness(test_case.checked, monkeypatch) - - harness.panel.toggle_generator(test_case.generator) - - assert harness.checked() == test_case.expected - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_settings_the_panel_reports( - self, - test_case: TestCase, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """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) - - assert harness.reported == [ - GenerationSettingsUpdate( - drive=DRIVE, - generators=[generator for generator in GeneratorName if generator in test_case.expected], - ) - ] - - def test_switching_a_generator_twice_returns_the_set_it_started_from( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - harness = Harness(ALL_GENERATORS, monkeypatch) - - harness.panel.toggle_generator(GeneratorName.PULSE2) - harness.panel.toggle_generator(GeneratorName.PULSE2) - - assert harness.checked() == ALL_GENERATORS - - 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.panel.toggle_generator(GeneratorName.TRIANGLE) - - assert self._generators(harness.reported) == [ - GeneratorName.PULSE1, - GeneratorName.TRIANGLE, - GeneratorName.NOISE, - ] - - @staticmethod - def _generators(reported: List[GenerationSettingsUpdate]) -> List[GeneratorName]: - return list(reported[-1].generators) - - -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 - ) - - assert len(set(tags)) == len(tags) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_source.py b/tests/unit/sampletones_application/ui/panels/main/test_source.py new file mode 100644 index 000000000..8956bbbba --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_source.py @@ -0,0 +1,388 @@ +from typing import Final, FrozenSet, Iterator, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SettingsField, SourceKind +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_HANDLER_REGISTRY, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_CHANNEL_PULSE1, + TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, + TAG_GLOBAL_THEME_STEP_BUTTON, + TAG_GLOBAL_THEME_STEP_BUTTON_DIM, + TAG_GLOBAL_THEME_STEP_BUTTON_LIT, + TAG_GLOBAL_THEME_STEP_BUTTON_PARTIAL, +) +from sampletones_application.tags.main import ( + TAG_MAIN_SOURCE_TABLE_CHANNELS, + TAG_MAIN_SOURCE_TEXT_SUBJECT, + TAG_MAIN_SOURCE_TOOLTIP_SUBJECT, +) +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.main.source.panel import GUISourceSettingsPanel +from sampletones_application.ui.panels.main.source.rows import ChannelSettingsRows +from sampletones_application.ui.panels.main.source.steps import ChannelCapSteps +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.main.source import ( + CHANNEL_CAP_STEPS, + ChannelSettingsViewModel, + InspectedSourceViewModel, + SourceSettingsPanelViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName + +ROOT_TAG: Final[str] = "test_root" +LOUD_DRIVE: Final[float] = 1.5 +DRAGGED_DRIVE: Final[float] = 2.3456 +HELD_RECORDINGS: Final[int] = 1939 + + +@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 header geometry the card draws under.""" + 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() + + +class Reports: + """What the card handed on, in the order a reader's gestures reached it.""" + + def __init__(self) -> None: + self.slots: List[Tuple[SettingsField, ChannelName]] = [] + self.drives: List[Tuple[ChannelName, float]] = [] + self.caps: List[int] = [] + + +def line( + channel_name: ChannelName, + *, + use: Agreement = Agreement.ALL, + bend: Agreement = Agreement.NONE, + drive: Optional[float] = UNIT_DRIVE, +) -> ChannelSettingsViewModel: + return ChannelSettingsViewModel(channel=channel_name, use=use, bend=bend, drive=drive) + + +def view( + *changed: ChannelSettingsViewModel, + subject: Optional[InspectedSourceViewModel] = None, + caps: FrozenSet[int] = frozenset({len(ChannelName)}), + channels_used: int = len(ChannelName), + live: bool = True, +) -> SourceSettingsPanelViewModel: + """A card reading every channel as used at unit drive, but for the lines ``changed`` names.""" + lines = {channel_name: line(channel_name) for channel_name in ChannelName.items()} + lines.update({item.channel: item for item in changed}) + return SourceSettingsPanelViewModel( + subject=subject, + channels=tuple(lines.values()), + caps=caps, + channels_used=channels_used, + live=live, + ) + + +def recording(name: str) -> InspectedSourceViewModel: + return InspectedSourceViewModel(name=name, kind=SourceKind.RECORDING, holds=1) + + +def folder(name: str, holds: int) -> InspectedSourceViewModel: + return InspectedSourceViewModel(name=name, kind=SourceKind.FOLDER, holds=holds) + + +def build( + layout_config: LayoutConfig, + initial: SourceSettingsPanelViewModel, +) -> Tuple[GUISourceSettingsPanel, Reports]: + """The card as the application builds it, over the choices it reports.""" + panel = GUISourceSettingsPanel( + initial, + layout=layout_config.tabs.main.source, + inputs=layout_config.general.inputs, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + ) + reports = Reports() + panel.on_slot_toggled = lambda field, channel: reports.slots.append((field, channel)) + panel.on_drive_changed = lambda channel, drive: reports.drives.append((channel, drive)) + panel.on_channel_cap_changed = reports.caps.append + with dpg.window(tag=ROOT_TAG): + panel.create_panel(ROOT_TAG) + + return panel, reports + + +def theme_of(tag: str) -> str: + return str(dpg.get_item_alias(dpg.get_item_theme(tag))) + + +def enabled(tag: str) -> bool: + return bool(dpg.get_item_configuration(tag)["enabled"]) + + +def release(slider_tag: str) -> None: + """Let go of a slider the way DearPyGui reports it: through the handler bound to it.""" + registry = compose_tag(TAG_MAIN_SOURCE_TABLE_CHANNELS, SUF_HANDLER_REGISTRY) + [handler] = dpg.get_item_children(registry, 1) + dpg.get_item_callback(handler)(handler, slider_tag) + + +class TestWhatTheCardNames: + def test_a_recording_reads_its_own_name(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(subject=recording("bass"))) + + assert dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT) == "bass" + + def test_a_folder_reads_how_many_it_stands_for(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(subject=folder("VEH2 Loops", HELD_RECORDINGS))) + + named = dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT) + assert named.startswith("VEH2 Loops") + assert str(HELD_RECORDINGS) in named + + def test_with_nothing_picked_it_names_the_recordings_added_next( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + build(layout_config, view()) + + assert ( + dpg.get_value(TAG_MAIN_SOURCE_TEXT_SUBJECT) == LanguageManager(LANG_EN)["main.source.label.new_recordings"] + ) + + def test_only_the_recordings_added_next_explain_themselves( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A picked row names itself; the settings new recordings start with need the words.""" + panel, _reports = build(layout_config, view()) + explained = dpg.get_item_configuration(TAG_MAIN_SOURCE_TOOLTIP_SUBJECT)["show"] + + panel.update_view(view(subject=recording("bass"))) + + assert (explained, dpg.get_item_configuration(TAG_MAIN_SOURCE_TOOLTIP_SUBJECT)["show"]) == (True, False) + + +class TestTheChannelLines: + def test_every_channel_holds_a_line(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + for channel_name in ChannelName.items(): + assert dpg.does_item_exist(ChannelSettingsRows.name_tag(channel_name)) + assert dpg.does_item_exist(ChannelSettingsRows.box_tag(channel_name, SettingsField.CHANNELS)) + assert dpg.does_item_exist(ChannelSettingsRows.slider_tag(channel_name)) + + def test_a_bend_stands_only_where_its_channel_reads_one( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + build(layout_config, view()) + + bent = { + channel_name + for channel_name in ChannelName.items() + if dpg.does_item_exist(ChannelSettingsRows.box_tag(channel_name, SettingsField.BENDS)) + } + assert bent == TONE_CHANNELS + + def test_a_channel_every_recording_uses_reads_ticked(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(line(ChannelName.PULSE2, use=Agreement.NONE))) + + assert dpg.get_value(ChannelSettingsRows.box_tag(ChannelName.PULSE1, SettingsField.CHANNELS)) is True + assert dpg.get_value(ChannelSettingsRows.box_tag(ChannelName.PULSE2, SettingsField.CHANNELS)) is False + + def test_a_channel_the_folder_half_uses_reads_clear_and_softened( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A tick would state an answer the folder has yet to give, so a divided reading is clear.""" + panel, _reports = build(layout_config, view()) + + panel.update_view(view(line(ChannelName.PULSE1, use=Agreement.SOME), subject=folder("takes", 2))) + + box = ChannelSettingsRows.box_tag(ChannelName.PULSE1, SettingsField.CHANNELS) + assert dpg.get_value(box) is False + assert theme_of(box) == TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL + + def test_a_channel_no_recording_uses_reads_muted_and_takes_no_bend_or_drive( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(line(ChannelName.PULSE1, use=Agreement.NONE))) + + assert theme_of(ChannelSettingsRows.name_tag(ChannelName.PULSE1)) == TAG_GLOBAL_THEME_CHANNEL_MUTED + assert enabled(ChannelSettingsRows.box_tag(ChannelName.PULSE1, SettingsField.CHANNELS)) is True + assert enabled(ChannelSettingsRows.box_tag(ChannelName.PULSE1, SettingsField.BENDS)) is False + assert enabled(ChannelSettingsRows.slider_tag(ChannelName.PULSE1)) is False + + def test_a_channel_in_use_wears_its_own_color(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + assert theme_of(ChannelSettingsRows.name_tag(ChannelName.PULSE1)) == TAG_GLOBAL_THEME_CHANNEL_PULSE1 + + def test_a_running_conversion_holds_every_control(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(live=False)) + + assert not enabled(ChannelSettingsRows.box_tag(ChannelName.PULSE1, SettingsField.CHANNELS)) + assert not enabled(ChannelSettingsRows.slider_tag(ChannelName.PULSE1)) + assert not any(enabled(ChannelCapSteps.step_tag(step)) for step in CHANNEL_CAP_STEPS) + + +class TestTheDrive: + def test_the_slider_reads_the_drive(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(line(ChannelName.TRIANGLE, drive=LOUD_DRIVE))) + + assert dpg.get_value(ChannelSettingsRows.slider_tag(ChannelName.TRIANGLE)) == pytest.approx(LOUD_DRIVE) + + def test_a_drive_the_recordings_differ_on_reads_mixed_at_the_calibrated_level( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(line(ChannelName.TRIANGLE, drive=None), subject=folder("takes", 2))) + + slider = ChannelSettingsRows.slider_tag(ChannelName.TRIANGLE) + assert dpg.get_item_configuration(slider)["format"] == LanguageManager(LANG_EN)["main.source.label.drive_mixed"] + assert dpg.get_value(slider) == pytest.approx(UNIT_DRIVE) + + def test_a_settled_drive_reads_its_value_again(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reports = build(layout_config, view(line(ChannelName.TRIANGLE, drive=None))) + + panel.update_view(view(line(ChannelName.TRIANGLE, drive=LOUD_DRIVE))) + + slider = ChannelSettingsRows.slider_tag(ChannelName.TRIANGLE) + assert dpg.get_item_configuration(slider)["format"] == layout_config.tabs.main.source.drive_format + + +class TestTheSteps: + @pytest.mark.parametrize( + ("caps", "channels_used", "expected"), + [ + (frozenset({2}), 4, (TAG_GLOBAL_THEME_STEP_BUTTON, TAG_GLOBAL_THEME_STEP_BUTTON_LIT)), + (frozenset({1, 2}), 4, (TAG_GLOBAL_THEME_STEP_BUTTON_PARTIAL, TAG_GLOBAL_THEME_STEP_BUTTON_PARTIAL)), + ], + ids=["one_count_lit", "differing_counts_half_lit"], + ) + def test_the_counts_held_light_their_steps( + self, + caps: FrozenSet[int], + channels_used: int, + expected: Tuple[str, str], + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(caps=caps, channels_used=channels_used)) + + assert (theme_of(ChannelCapSteps.step_tag(1)), theme_of(ChannelCapSteps.step_tag(2))) == expected + + def test_a_step_past_the_channels_used_reads_dim_and_still_answers( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reports = build(layout_config, view()) + + panel.update_view(view(caps=frozenset({1}), channels_used=2)) + + step = ChannelCapSteps.step_tag(3) + assert (theme_of(step), enabled(step)) == (TAG_GLOBAL_THEME_STEP_BUTTON_DIM, True) + + +class TestTheChoicesTheCardReports: + """The card settles nothing itself: it names the choice a reader made and hands it on.""" + + @pytest.mark.parametrize("field", list(SettingsField)) + def test_a_box_names_the_choice_and_the_channel_it_stands_on( + self, + field: SettingsField, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + _panel, reports = build(layout_config, view()) + box = ChannelSettingsRows.box_tag(ChannelName.TRIANGLE, field) + + dpg.get_item_callback(box)(box, True, dpg.get_item_user_data(box)) + + assert reports.slots == [(field, ChannelName.TRIANGLE)] + + def test_a_released_slider_names_its_channel_and_the_drive_it_shows( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The widget carries its value in single precision, so the drive reported is the one printed.""" + _panel, reports = build(layout_config, view()) + slider = ChannelSettingsRows.slider_tag(ChannelName.NOISE) + dpg.set_value(slider, DRAGGED_DRIVE) + + release(slider) + + decimals = layout_config.tabs.main.source.drive_decimals + assert reports.drives == [(ChannelName.NOISE, round(DRAGGED_DRIVE, decimals))] + + @pytest.mark.parametrize("step", CHANNEL_CAP_STEPS) + def test_a_step_names_its_count(self, step: int, dpg_context: None, layout_config: LayoutConfig) -> None: + _panel, reports = build(layout_config, view()) + button = ChannelCapSteps.step_tag(step) + + dpg.get_item_callback(button)(button, None, dpg.get_item_user_data(button)) + + assert reports.caps == [step] 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..722eeaedb --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py @@ -0,0 +1,234 @@ +from typing import Iterator, List, 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.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.tags.reconstructions import ( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, +) +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.rate import RateLock +from sampletones_application.view_model.reconstruction.reconstruction import ( + ReconstructionViewModel, +) +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, NTSC_FREQUENCY, 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], + *, + rate_lock: Optional[RateLock], +) -> ReconstructionViewModel: + empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()) + loaded = nes_frequency is not None + file_state = ReconstructionPathState.AVAILABLE if rate_lock is None else ReconstructionPathState.NOT_APPLICABLE + return ReconstructionViewModel( + reconstruction_loaded=loaded, + playing_channels=frozenset(), + selected_channels=frozenset(), + reconstruction_file=(ReconstructionPathViewModel(state=file_state, paths=()) if loaded else empty_path), + original_audio=empty_path, + nes_frequency=nes_frequency, + rate_lock=rate_lock, + ) + + +def _lock_words() -> str: + """What the locked rate's hint reads.""" + return str(dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED)) + + +def _commit(rendered_panel: GUIReconstructionAudioPanel, value: int) -> None: + """Types a value into the field and finishes the edit the way DearPyGui reports it.""" + dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, value) + handler = dpg.get_item_children( + compose_tag(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, SUF_HANDLER_REGISTRY), + 1, + )[0] + dpg.get_item_callback(handler)(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, value) + + +class TestEngineRateField: + """The rate the card states for the open reconstruction, which a reader can change.""" + + def test_a_loaded_reconstruction_states_its_rate( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=None)) + + assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) == PAL_FREQUENCY + + def test_an_empty_tab_shows_no_field( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=None)) + rendered_panel.update_view(_view_model(None, rate_lock=None)) + + assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) + + def test_the_rate_reads_as_a_monospaced_figure( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + field = dpg.get_item_info(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) + + assert field["font"] == FontRegistry.get_tag(Font.MONO) + + def test_a_document_with_a_file_takes_a_new_rate( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=None)) + + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + + @pytest.mark.parametrize( + ("rate_lock", "words_key"), + [ + (RateLock.PROJECT_SAMPLE, "reconstructions.reconstruction.tooltip.nes_frequency_locked"), + (RateLock.NO_FILE, "reconstructions.reconstruction.tooltip.nes_frequency_no_file"), + ], + ids=lambda value: str(value), + ) + def test_a_locked_document_locks_the_field_and_says_why( + self, + rendered_panel: GUIReconstructionAudioPanel, + rate_lock: RateLock, + words_key: str, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=rate_lock)) + + assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + assert _lock_words() == LanguageManager(LANG_EN)[words_key] + + def test_a_document_with_no_file_says_nothing_of_a_project( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + """A sample of a project opened first leaves no project words behind for the next document.""" + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.PROJECT_SAMPLE)) + project_words = _lock_words() + + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.NO_FILE)) + + assert _lock_words() != project_words + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.PROJECT_SAMPLE)) + assert _lock_words() == project_words + + def test_the_lock_lifts_when_a_document_with_a_file_opens( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.NO_FILE)) + rendered_panel.update_view(_view_model(NTSC_FREQUENCY, rate_lock=None)) + + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] + assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] + assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + + def test_a_finished_edit_reports_the_rate_typed( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + reported: List[int] = [] + rendered_panel.on_nes_frequency_changed = reported.append + rendered_panel.update_view(_view_model(NTSC_FREQUENCY, rate_lock=None)) + + _commit(rendered_panel, PAL_FREQUENCY) + + assert reported == [PAL_FREQUENCY] + + def test_a_rate_out_of_range_is_reported_at_the_nearest_bound( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + reported: List[int] = [] + rendered_panel.on_nes_frequency_changed = reported.append + rendered_panel.update_view(_view_model(NTSC_FREQUENCY, rate_lock=None)) + + _commit(rendered_panel, MAX_NES_FREQUENCY + 1) + + assert reported == [MAX_NES_FREQUENCY] 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..d4fbf250c 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 @@ -1,7 +1,9 @@ from dataclasses import dataclass -from typing import Dict, Final, List, cast +from typing import Dict, Final, FrozenSet, 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 @@ -24,40 +26,94 @@ 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.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.modifiers import SHIFT +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_application.view_model.shared.ownership import ( + OwnershipLaneViewModel, + OwnershipRunViewModel, +) +from sampletones_core.constants.enums import ChannelName, FeatureKey, GeneratorName +from sampletones_core.constants.general import PITCH_BEND_MAX, PITCH_BEND_MIN +from sampletones_core.exporters.feature import Features +from sampletones_core.exports.ceilings import FORMAT_STORED_LENGTHS +from sampletones_core.exports.format import ExportFormat +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 SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" +SIZE_TEMPLATE_KEY: Final[str] = "global.context.template.size_bytes" +KEPT_KEYS: Final[Dict[ExportFormat, str]] = { + ExportFormat.FAMITRACKER: "reconstructions.instruments.template.kept_famitracker", + ExportFormat.BITPHASE: "reconstructions.instruments.template.kept_bitphase", + ExportFormat.BITPHASE_PRESET: "reconstructions.instruments.template.kept_bitphase_preset", +} +PAST_A_SEQUENCE: Final[int] = 300 +PAST_A_MACRO: Final[int] = 600 LARGEST_PULSE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=768) LARGEST_TRIANGLE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=512) SILENT_INSTRUMENT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=3, sequence_bytes=0) +PLOTTED_AXIS: Final[str] = "instruments.plot.y" +PLOTTED_BAND: Final[tuple] = (-5.0, -2.0) +PLOTTED_CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +PLOTTED_PITCH: Final[int] = 60 +PLOTTED_LANE: Final[OwnershipLaneViewModel] = OwnershipLaneViewModel( + channel_name=PLOTTED_CHANNEL, + runs=( + OwnershipRunViewModel(start_frame=0, end_frame=4, stem_id=0, heard=True), + OwnershipRunViewModel(start_frame=4, end_frame=10, stem_id=1, heard=True), + ), +) + 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,22 +159,37 @@ 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, feature_colors=layout_config.general.colors.features, + stem_colors=layout_config.general.colors.stems, layout_graphs=layout_config.graphs, language_manager=LanguageManager(LANG_EN), status_bar=MagicMock(), + key_router=key_router, + tab_active=lambda: True, ) @@ -134,7 +205,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 +213,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 +221,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 +233,214 @@ 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 TestTheBandBeneathADimension: + """A dimension's band stands over the frames that dimension draws, and no further. + + A channel's readings trim to their own lengths, so a lane handed whole to each of them paints + a stretch over frames that reading never drew. + """ + + @staticmethod + def _features(volume_items: int, duty_items: int) -> Features: + """One instrument whose volume and duty cycle write different numbers of frames.""" + return Features( + initial_pitch=PLOTTED_PITCH, + volume=sequence(volume_items), + arpeggio=sequence(volume_items), + pitch=sequence(volume_items), + hi_pitch=sequence(volume_items), + duty_cycle=sequence(duty_items), + ) + + def _painted( + self, + panel: GUIReconstructionInstrumentsPanel, + monkeypatch: pytest.MonkeyPatch, + features: Features, + feature_key: FeatureKey, + ) -> Dict[str, object]: + """What the panel hands the ownership painter for one dimension of a two-recording lane.""" + plot = MagicMock() + plot.y_axis_tag = PLOTTED_AXIS + plot.reserve_band.return_value = PLOTTED_BAND + panel.channel_plots[PLOTTED_CHANNEL] = {feature_key: plot} + painted: Dict[str, object] = {} + + def paint(y_axis_tag: str, runs: object, **kwargs: object) -> None: + painted["runs"] = runs + + monkeypatch.setattr(panel._ownership, "paint", paint) + panel._update_generator_feature_display(PLOTTED_CHANNEL, features, feature_key, PLOTTED_LANE) + painted["share"] = plot.reserve_band.call_args.args[0] + return painted + + def test_a_dimension_reaching_the_whole_lane_carries_every_stretch( + self, + panel: GUIReconstructionInstrumentsPanel, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + painted = self._painted(panel, monkeypatch, self._features(10, 10), FeatureKey.VOLUME) + + assert painted["runs"] == PLOTTED_LANE.runs + + def test_a_trimmed_dimension_ends_its_stretches_where_it_ends( + self, + panel: GUIReconstructionInstrumentsPanel, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + painted = self._painted(panel, monkeypatch, self._features(10, 1), FeatureKey.DUTY_CYCLE) + + assert painted["runs"] == (PLOTTED_LANE.runs[0].model_copy(update={"end_frame": 1}),) + + def test_a_dimension_writing_nothing_gives_the_band_to_the_bars( + self, + panel: GUIReconstructionInstrumentsPanel, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + painted = self._painted(panel, monkeypatch, self._features(10, 0), FeatureKey.DUTY_CYCLE) + + assert painted["runs"] == () + assert painted["share"] == 0.0 + + def test_a_dimension_the_lane_reaches_keeps_its_band( + self, + panel: GUIReconstructionInstrumentsPanel, + monkeypatch: pytest.MonkeyPatch, + layout_config: LayoutConfig, + ) -> None: + painted = self._painted(panel, monkeypatch, self._features(10, 10), FeatureKey.VOLUME) + + assert painted["share"] == layout_config.graphs.bar_plot.ownership_band + + +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), + ) + + 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), + ) + + 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), + ) + + field_tag = panel._get_feature_text_tag(ChannelName.PULSE1, FeatureKey.VOLUME) + assert written[field_tag] == "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 +449,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 +460,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,39 +478,110 @@ 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 +class TestTheExportsASequenceStatusNames(BaseTestSuite): + """Every tracker export stores a bounded number of items, so the status line names each export + that shortens the dimension beside what it keeps, and leaves out the ones that store it whole. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + feature_key: FeatureKey + item_count: int + expected: FrozenSet[ExportFormat] + + test_cases = ( + TestCase( + feature_key=FeatureKey.VOLUME, + item_count=PAST_A_SEQUENCE, + expected=frozenset({ExportFormat.FAMITRACKER}), + label="volume_past_a_sequence", + ), + TestCase( + feature_key=FeatureKey.VOLUME, + item_count=PAST_A_MACRO, + expected=frozenset( + { + ExportFormat.FAMITRACKER, + ExportFormat.BITPHASE, + ExportFormat.BITPHASE_PRESET, + } + ), + label="volume_past_a_macro", + ), + TestCase( + feature_key=FeatureKey.ARPEGGIO, + item_count=PAST_A_MACRO, + expected=frozenset( + { + ExportFormat.FAMITRACKER, + ExportFormat.BITPHASE_PRESET, + } + ), + label="arpeggio_past_a_macro", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_status_names_each_export_that_shortens_the_dimension( + self, + panel: GUIReconstructionInstrumentsPanel, + test_case: TestCase, + ) -> None: + envelope = sequence(test_case.item_count) + panel._show_sequence(ChannelName.PULSE1, test_case.feature_key, envelope) + + message = panel._sequence_status_message(ChannelName.PULSE1, test_case.feature_key) + + named = { + export_format + for export_format, stored_length in FORMAT_STORED_LENGTHS.items() + if panel._language_manager[KEPT_KEYS[export_format]].format( + limit=stored_length(test_case.feature_key, envelope), + ) + in message + } + assert named == test_case.expected + assert str(test_case.item_count) in message + + +def size_text(panel: GUIReconstructionInstrumentsPanel, byte_count: int) -> str: + """A byte figure as the shipped template prints it.""" + return panel._language_manager[SIZE_TEMPLATE_KEY].format(bytes=byte_count) + + class TestSizeFields(BaseTestSuite): """The two read-only byte figures: the sample's above the tabs, each channel's inside its tab.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): - channel_footprints: Dict[GeneratorName, InstrumentFootprint] - expected: str + channel_footprints: Dict[ChannelName, InstrumentFootprint] + expected_bytes: int test_cases = ( TestCase( label="a single channel spends what its instrument does", - channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE}, - expected="777 B", + channel_footprints={ChannelName.PULSE1: LARGEST_PULSE}, + expected_bytes=777, ), 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", + expected_bytes=2073, ), TestCase( label="a silent channel spends the instrument definition alone", - channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT}, - expected="3 B", + channel_footprints={ChannelName.TRIANGLE: SILENT_INSTRUMENT}, + expected_bytes=3, ), ) @@ -270,7 +593,7 @@ def test_the_sample_size_sums_its_channels( test_case: TestCase, ) -> None: panel.update_view(build_view_model(test_case.channel_footprints)) - assert written[panel.sample_size_tag] == test_case.expected + assert written[panel.sample_size_tag] == size_text(panel, test_case.expected_bytes) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_each_channel_states_its_own_size( @@ -281,11 +604,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: size_text(panel, footprint.total_bytes) + 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 +621,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: size_text(panel, 0) + for channel_name in ChannelName.items() + if channel_name not in test_case.channel_footprints } @@ -320,46 +643,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 +763,150 @@ def test_no_reconstruction_states_no_figures( ) -> None: panel.update_view(NOT_LOADED) assert written == {} + + +class TestTheAuditionSelector: + """The generator an instrument 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 == [] + + @pytest.mark.parametrize("typed", ["Ctrl+Z", "Ctrl+S", "Ctrl+Shift+Z", "Alt+Z", "Super+Z"]) + def test_a_combination_ending_in_a_note_key_is_left_to_the_shortcuts( + self, + panel: GUIReconstructionInstrumentsPanel, + typed: str, + ) -> None: + """Undo, Save and the rest reach their shortcuts while an instrument is open.""" + sounded: List[int] = [] + panel.on_audition_requested = sounded.append + panel.update_view(ONE_INSTRUMENT) + combination = KeyCombination.parse(typed) + + assert panel._on_key_pressed(KeyEvent(key=combination.key, modifiers=combination.modifiers)) is False + assert sounded == [] + + def test_a_note_key_under_shift_sounds_its_note( + 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=SHIFT)) is True + assert sounded == [PIANO_KEYS[dpg.mvKey_Z]] + + 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, + stem_colors=layout_config.general.colors.stems, + 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 + + def test_a_rail_put_away_keeps_the_keys_from_the_panel( + self, + layout_config: LayoutConfig, + key_router: MagicMock, + ) -> None: + """A rail collapsed to its edge keeps the instrument it had open, and sounds no note.""" + 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, + stem_colors=layout_config.general.colors.stems, + layout_graphs=layout_config.graphs, + initial_collapsed=True, + language_manager=LanguageManager(LANG_EN), + status_bar=MagicMock(), + key_router=key_router, + tab_active=lambda: True, + ) + 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..24f547dcd 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,46 @@ from dataclasses import dataclass -from typing import Dict, FrozenSet, List +from types import SimpleNamespace +from typing import Any, Dict, FrozenSet, List +from unittest.mock import MagicMock +import numpy as np import pytest +from sampletones_application.layout.general.colors.channel import ChannelColors +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY, PALETTES_DIRECTORY +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.catalog import PaletteCatalog +from sampletones_application.utils.palette.colors.written import LiteralColor +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.constants.enums import GeneratorName +from sampletones_application.view_model.reconstruction.waveform import ( + InstrumentWaveformViewModel, +) +from sampletones_application.view_model.shared.ownership import ( + OwnershipLaneViewModel, + OwnershipRibbonViewModel, + OwnershipRunViewModel, +) +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 +55,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 +82,40 @@ 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, + rate_lock=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 +123,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 +135,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 +217,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 +238,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 +250,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 +264,220 @@ 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]] = [] + self.lane_heights: List[int] = [] + + def load_voice_waveform(self, audio: np.ndarray, *, name: str, color: object) -> None: + self.drawn.append({"audio": audio, "name": name, "color": color}) + + def set_lane_heights(self, heights: Dict[ChannelName, int]) -> None: + self.lane_heights.append(sum(heights.values())) + + +class _RibbonRecorder: + """Stands in for the ownership ribbon, giving each drawn channel a row of its own. + + The room a lane takes is read from the layout the application draws under, so the stand-in + follows the configured height rather than restating one of its own. + """ + + def __init__(self, lane_height: int) -> None: + self.lane_height = lane_height + self.painted: List[OwnershipRibbonViewModel] = [] + + def update_view(self, view_model: OwnershipRibbonViewModel) -> None: + self.painted.append(view_model) + + @property + def lane_heights(self) -> Dict[ChannelName, int]: + drawn = [lane.channel_name for lane in self.painted[-1].lanes] if self.painted else [] + return {channel_name: self.lane_height if channel_name in drawn else 0 for channel_name in ChannelName.items()} + + +def _configured_lane_height() -> int: + """The room one lane takes, read from the layout the application draws under.""" + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source).graphs.ribbon.lane_height + + +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.ribbon = _RibbonRecorder(_configured_lane_height()) + 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 + self.panel.ownership_ribbon = self.ribbon + self.panel._ownership = OwnershipRibbonViewModel.empty() + + def _configure(self, tag: str, **kwargs: object) -> None: + show = kwargs.get("show") + if isinstance(show, bool): + self.shown[tag] = show + + +def _ribbon(*channels: ChannelName) -> OwnershipRibbonViewModel: + """A ribbon standing for one lane per named channel, each holding a single stretch.""" + return OwnershipRibbonViewModel( + lanes=tuple( + OwnershipLaneViewModel( + channel_name=channel_name, + runs=(OwnershipRunViewModel(start_frame=0, end_frame=2, stem_id=0, heard=True),), + ) + for channel_name in channels + ), + frame_length=FRAME_LENGTH, + total_frames=2, + ) + + +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 TestTheLanesUnderWhatTheCardDraws: + """The lanes describe a recording, so they stand only while the card shows one.""" + + def test_the_lanes_stand_down_for_a_voice(self, monkeypatch: pytest.MonkeyPatch) -> None: + harness = InstrumentHarness(monkeypatch) + harness.panel.update_ownership(_ribbon(ChannelName.PULSE1, ChannelName.TRIANGLE)) + + harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1)) + + assert harness.ribbon.painted[-1].lanes == () + assert harness.waveform.lane_heights[-1] == 0 + + def test_the_lanes_return_with_the_recording(self, monkeypatch: pytest.MonkeyPatch) -> None: + harness = InstrumentHarness(monkeypatch) + ribbon = _ribbon(ChannelName.PULSE1, ChannelName.TRIANGLE) + harness.panel.update_ownership(ribbon) + harness.panel.update_instrument_view(_waveform(ChannelName.PULSE1)) + + harness.panel.update_instrument_view(None) + + assert harness.ribbon.painted[-1] == ribbon + assert harness.waveform.lane_heights[-1] == harness.waveform.lane_heights[0] + + +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.shown == { + AUTOSCALE_TAG: True, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_CHANNELS: True, + } + assert len(harness.waveform.drawn) == 1 + + +class TestWaveformClicks: + """A click on the waveform leaves the card as the sample it pointed at.""" + + def test_the_sample_a_click_names_reaches_the_panel_hook(self, monkeypatch: pytest.MonkeyPatch) -> None: + graphs: List[SimpleNamespace] = [] + + def graph(**_kwargs: Any) -> SimpleNamespace: + graphs.append(SimpleNamespace(on_position_clicked=None)) + return graphs[-1] + + monkeypatch.setattr(plot_module, "GUIWaveformGraph", graph) + panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel) + panel._layout_graphs = MagicMock() + panel._channel_colors = CHANNEL_COLORS + panel._language_manager = MagicMock() + panel._status_bar = MagicMock() + monkeypatch.setattr(GUIReconstructionPlotPanel, "_body_container", "body", raising=False) + clicked: List[int] = [] + panel.on_position_clicked = clicked.append + + panel._create_waveform_display() + graphs[0].on_position_clicked(420) - assert harness.selected() == ALL_GENERATORS + assert clicked == [420] 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..dc4a24b0a --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -0,0 +1,473 @@ +from pathlib import Path +from typing import Any, Dict, FrozenSet, Iterator, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +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 import stems_menu as menu_module +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 +from tests.suite.gestures import CLICKED, click_row_name + +ROOT_TAG = "test_root" +SEPARATOR = "separator" +LANGUAGE_MANAGER = LanguageManager(LANG_EN) +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, + stem_colors=layout_config.general.colors.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( + kind=SourceKind.RECORDING, + held=(), + partial_channels=frozenset(), + key=str(stem_id), + name=name, + path=Path(f"/audio/{name}.wav"), + channels=channels, + offered_channels=offered_channels, + available=True, + level=level, + position=position, + stem_id=stem_id, + 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( + selected_key=None, + rows=rows, + channels_in_play=CHANNELS if rows else (), + muted_channels=muted_channels, + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=False, + ), + hierarchy_mode=hierarchy_mode, + ) + + +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.tags.row("0", SUF_TEXT)) == "kick" + assert dpg.get_item_label(stems_list.tags.row("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.tags.channel("0", ChannelName.PULSE1)) + assert not dpg.does_item_exist(stems_list.tags.channel("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.tags.row("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.tags.row("0", SUF_TEXT)) + assert dpg.does_item_exist(stems_list.tags.row("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.tags.channel("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.tags.row("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.tags.row("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.tags.row("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.tags.table) + assert dpg.does_item_exist(panel.stems_list.tags.row("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.tags.table) + + +class TestStemsPanelHeader: + def test_the_card_leads_with_the_glyph_of_stems( + self, + panel: GUIReconstructionStemsPanel, + layout_config: LayoutConfig, + ) -> None: + render(panel) + + texts = [ + dpg.get_value(item) for item in dpg.get_all_items() if dpg.get_item_type(item) == "mvAppItemType::mvText" + ] + + assert layout_config.glyphs.headers.stems in texts + assert layout_config.glyphs.headers.stems != layout_config.glyphs.headers.source + + +class TestStemsPanelStates: + def test_the_setup_line_states_the_mode(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) + + 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) + + +@pytest.fixture +def registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: + """The items a menu registers, in the order a reader meets them, the rules between them included.""" + items: List[Dict[str, Any]] = [] + monkeypatch.setattr(menu_module.dpg, "add_menu_item", lambda **kwargs: items.append(kwargs) or 0) + monkeypatch.setattr( + menu_module.dpg, + "add_separator", + lambda **_kwargs: items.append({"label": SEPARATOR}) or 0, + ) + return items + + +def right_click(panel: GUIReconstructionStemsPanel, stem_id: int) -> None: + click_row_name(panel.stems_list.tags, str(stem_id), kind=CLICKED, button=dpg.mvMouseButton_Right) + + +def item(registered: List[Dict[str, Any]], key: str) -> Dict[str, Any]: + label = LANGUAGE_MANAGER[key] + return next(entry for entry in registered if entry["label"] == label) + + +class TestTheMenuARightClickPutsUp: + """A right-click on a recording offers to mute or solo it, beside the file items.""" + + def test_a_right_click_offers_what_a_recording_can_be_told( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 0) + + labels = [entry["label"] for entry in registered] + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_mute"] in labels + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_solo"] in labels + assert LANGUAGE_MANAGER["global.context.label.open_in_explorer"] in labels + + def test_a_row_heard_offers_to_mute_and_reports_what_it_keeps( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> 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"), _row(1, name="snare"))) + + right_click(panel, 0) + item(registered, "reconstructions.reconstruction.label.stem_mute")["callback"]() + + assert reported == [(0, frozenset())] + + def test_a_row_muted_offers_to_unmute_across_every_channel_it_offers( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> 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", channels=frozenset()), _row(1, name="snare"))) + + right_click(panel, 0) + item(registered, "reconstructions.reconstruction.label.stem_unmute")["callback"]() + + assert reported == [(0, frozenset(CHANNELS))] + + def test_solo_reports_the_recording_it_landed_on( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + soloed: List[int] = [] + panel.on_stem_solo_requested = soloed.append + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 1) + item(registered, "reconstructions.reconstruction.label.stem_solo")["callback"]() + + assert soloed == [1] + + def test_a_recording_heard_alone_offers_to_unsolo( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare", channels=frozenset()))) + + right_click(panel, 0) + + labels = [entry["label"] for entry in registered] + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_unsolo"] in labels + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_solo"] not in labels + + def test_a_lone_recording_has_none_to_solo_against( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"))) + + right_click(panel, 0) + + assert item(registered, "reconstructions.reconstruction.label.stem_solo")["enabled"] is False + + def test_a_row_standing_for_the_readers_frames_offers_no_file_items( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + edits = _row(0, name="edits").model_copy(update={"kind": SourceKind.EDITS, "path": None}) + render(panel) + panel.update_view(_view_model(edits, _row(1, name="snare"))) + + right_click(panel, 0) + + labels = [entry["label"] for entry in registered] + assert LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_mute"] in labels + assert LANGUAGE_MANAGER["global.context.label.open_in_explorer"] not in labels + + def test_a_rule_divides_what_a_recording_is_told_from_its_file( + self, + panel: GUIReconstructionStemsPanel, + registered: List[Dict[str, Any]], + ) -> None: + render(panel) + panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) + + right_click(panel, 0) + + labels = [entry["label"] for entry in registered] + solo = labels.index(LANGUAGE_MANAGER["reconstructions.reconstruction.label.stem_solo"]) + assert labels[solo + 1] == SEPARATOR 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/grid/surface/test_clipboard.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py index 5d606de1f..7d61cb546 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Callable, List +from typing import Any, Callable, Dict, List, Tuple import pytest @@ -26,8 +26,11 @@ class RecordedItem: class _MenuRecorder: + """The items a menu registered, each named by its place, and every change made to one since.""" + def __init__(self) -> None: self.items: List[RecordedItem] = [] + self.changes: List[Tuple[int, Dict[str, Any]]] = [] def add_menu_item(self, **kwargs: Any) -> int: self.items.append( @@ -38,13 +41,17 @@ def add_menu_item(self, **kwargs: Any) -> int: callback=kwargs["callback"], ) ) - return 0 + return len(self.items) - 1 + + def configure_item(self, item: int, **kwargs: Any) -> None: + self.changes.append((item, kwargs)) @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: recorded = _MenuRecorder() monkeypatch.setattr(clipboard_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(clipboard_module, "dpg_configure_item", recorded.configure_item) return recorded @@ -96,3 +103,17 @@ def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: assert not recorder.items[PASTE_ITEM].enabled assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) + + def test_paste_follows_the_answer_its_opening_asked_for(self, recorder: _MenuRecorder) -> None: + """The clipboard answers in its own time, so the item stands on the last answer until then.""" + grid = Grid(can_paste=False) + grid.clipboard_items().add_items(CLICKED_TARGET) + + assert not recorder.items[PASTE_ITEM].enabled + assert recorder.changes == [] + + grid.can_paste = True + for answered in grid.refreshes: + answered() + + assert recorder.changes == [(PASTE_ITEM, {"enabled": True})] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py index 348eaeefa..742ad6bbd 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py @@ -4,6 +4,7 @@ import pytest from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_shared.types.callback import VoidCallback Gestures = BlockGestures[str, str] @@ -29,6 +30,11 @@ def __init__(self, *, can_paste: bool = True) -> None: self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + self.refresh_paste_block: Optional[Callable[[VoidCallback], None]] = self._answer_at_once + + def _answer_at_once(self, then: VoidCallback) -> None: + self.events.append("refresh") + then() @dataclass(frozen=True) @@ -90,3 +96,21 @@ def test_a_grid_awaiting_its_wiring_offers_no_paste(self) -> None: grid.can_paste_block = None assert BlockGestures(grid=grid).can_paste() is False + + def test_a_refresh_asks_the_grid_and_follows_its_answer(self) -> None: + grid = _Grid() + answered: List[str] = [] + + BlockGestures(grid=grid).refresh_paste(lambda: answered.append("answered")) + + assert grid.events == ["refresh"] + assert answered == ["answered"] + + def test_a_grid_awaiting_its_wiring_leaves_the_offer_as_it_stands(self) -> None: + grid = _Grid() + grid.refresh_paste_block = None + answered: List[str] = [] + + BlockGestures(grid=grid).refresh_paste(lambda: answered.append("answered")) + + assert answered == [] 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..699a3e130 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 @@ -61,11 +61,11 @@ def test_a_later_extend_keeps_the_anchor_it_began_on(self) -> None: assert extended.anchor == _Cell(2, 1) assert extended.region == _Block(first_row=2, last_row=6, first_column=1, last_column=5) - def test_extending_backwards_names_the_same_region_as_forwards(self) -> None: - backwards = _state(row=4, column=3).extend_to(_Cell(2, 1)).region - forwards = _state(row=2, column=1).extend_to(_Cell(4, 3)).region + def test_extending_backward_names_the_same_region_as_forward(self) -> None: + backward = _state(row=4, column=3).extend_to(_Cell(2, 1)).region + forward = _state(row=2, column=1).extend_to(_Cell(4, 3)).region - assert backwards == forwards + assert backward == forward def test_extending_leaves_nothing_pending(self) -> None: assert _state(pending="5").extend_to(_Cell(4, 3)).pending == "" @@ -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..1fa28c135 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 @@ -1,9 +1,17 @@ -from typing import Optional +from dataclasses import dataclass +from typing import Optional, Tuple +import pytest + +from sampletones_application.ui.panels.sequencer.input.edit import EditAction 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 +from sampletones_core.constants.general import MAX_TRANSPOSE, MIN_TRANSPOSE +from sampletones_core.project.patterns.pitch import Step +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase ROW_COUNT = 64 @@ -12,23 +20,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 @@ -39,59 +47,104 @@ def test_minus_in_transpose_is_a_sign_not_note_off(self) -> None: assert new_state.pending.startswith("-") +class TestTypingAStep(BaseTestSuite): + """Digits type a decimal step, a sign before or among them sets its direction, and two digits commit it.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + keys: str + expected: Optional[int] + pending: str = "" + + test_cases: Tuple["TestTypingAStep.TestCase", ...] = ( + TestCase(label="two digits commit a step up", keys="31", expected=31), + TestCase(label="a leading minus goes down", keys="-12", expected=-12), + TestCase(label="a later sign flips the step in place", keys="1-2", expected=-12), + TestCase(label="a plus restates the direction", keys="-1+2", expected=12), + TestCase(label="one digit waits for the second", keys="3", expected=None, pending="+3"), + TestCase(label="a sign alone waits for digits", keys="-", expected=None, pending="-"), + TestCase(label="a hex letter leaves the entry as it stands", keys="3A", expected=None, pending="+3"), + TestCase(label="a step past the range is held at its top", keys="99", expected=MAX_TRANSPOSE), + TestCase(label="a step below the range is held at its bottom", keys="-99", expected=MIN_TRANSPOSE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_what_the_keys_commit(self, test_case: TestCase) -> None: + state = _state(SubColumn.TRANSPOSE) + action: Optional[EditAction] = None + for key in test_case.keys: + state, typed = state.type_char(key) + action = typed if typed is not None else action + + if test_case.expected is None: + assert action is None + assert state.pending == test_case.pending + else: + assert action is not None + assert action.pitch == Step(value=test_case.expected) + assert state.pending == "" + + def test_a_partial_entry_commits_the_digit_it_holds(self) -> None: + state, action = _state(SubColumn.TRANSPOSE, pending="+3").commit_partial() + + assert action is not None + assert action.pitch == Step(value=3) + assert state.pending == "" + + 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 assert (region.first_row, region.last_row) == (4, 5) - def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: + def test_extending_upward_names_the_same_region_as_downward(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 + upward = _state(SubColumn.VOICE, row=5).extend_row(-1, ROW_COUNT).region + downward = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).region - assert upwards == downwards + assert upward == downward 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 +163,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 +172,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 +190,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 +216,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..dc1cee4a4 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,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 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.band import TrackerRows +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 +27,8 @@ ) 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_application.view_model.sequencer.tracker import NO_REACH +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 +42,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 +54,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 +67,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 +80,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 +89,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._current_row_count = ROW_COUNT + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn)) + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=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 +112,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 +156,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 +196,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 +236,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 +267,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 +277,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 +309,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 +357,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 +397,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..9c91ead45 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,12 @@ ) 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.band import TrackerRows +from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, @@ -24,7 +32,8 @@ ) 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_application.view_model.sequencer.tracker import NO_REACH +from sampletones_core.constants.enums import ChannelName from tests.suite.grid import ( ORDER_BLOCK_SHORTCUTS, TRACKER_BLOCK_SHORTCUTS, @@ -47,7 +56,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 +106,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 +137,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, *, @@ -149,8 +170,14 @@ def _tracker_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel.refresh_paste_block = None 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 @@ -170,8 +197,14 @@ def _order_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel.refresh_paste_block = None 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 +237,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,11 +252,11 @@ 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._current_row_count = ROW_COUNT + panel._input_state = TrackerInputState(cursor=_tracker_cell(ChannelName.PULSE1)) + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) - monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None) + monkeypatch.setattr(panel, "_center_followed_row", lambda: None) return states @@ -232,7 +265,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 +273,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 +291,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 +305,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 +313,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 +340,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 +349,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 +365,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 +382,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 +405,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 +415,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 +435,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 +459,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 +471,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 +484,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 +499,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 +515,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 +529,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 +546,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 +563,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 +578,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 +595,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 +611,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 +621,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 +633,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..f5b533b19 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_columns.py @@ -2,62 +2,39 @@ from sampletones_application.ui.panels.sequencer.columns import ( DIVIDER_TABLE_COLUMN, - HEADER_TABLE_ROW, - HEADER_TABLE_ROWS, SAMPLE_TABLE_COLUMN, TRACKER_TABLE_COLUMNS, 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)] - def test_sample_column_directly_precedes_the_divider() -> None: assert tracker_table_column(None) == SAMPLE_TABLE_COLUMN == 2 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 - - -@pytest.mark.parametrize("row_index, expected_row", _PATTERN_ROWS) -def test_pattern_rows_sit_one_slot_past_the_header(row_index: int, expected_row: int) -> None: - assert tracker_table_row(row_index) == expected_row - - -def test_no_pattern_row_lands_on_the_header() -> None: - assert HEADER_TABLE_ROW == 0 - assert tracker_table_row(0) == HEADER_TABLE_ROWS - assert HEADER_TABLE_ROW not in {tracker_table_row(row_index) for row_index, _ in _PATTERN_ROWS} - - -def test_row_mapping_preserves_order_and_spacing() -> None: - mapped = [tracker_table_row(row_index) for row_index in range(8)] - - assert mapped == sorted(mapped) - assert {second - first for first, second in zip(mapped, mapped[1:])} == {1} 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..b95cce1bb 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: @@ -135,11 +135,16 @@ def test_a_hex_key_types_into_the_cell_under_the_cursor(self, order: OrderPanelF assert order.panel._on_key_pressed(_press("A")) is True assert order.states[-1].pending == "A" - def test_a_modified_hex_key_reaches_the_application(self, order: OrderPanelFixture) -> None: + @pytest.mark.parametrize("written", ["Ctrl+D", "Alt+D", "Super+D"]) + def test_a_modified_hex_key_reaches_the_application(self, order: OrderPanelFixture, written: str) -> None: """Ctrl+D opens the display settings, so cell entry keeps the plain key alone.""" - assert order.panel._on_key_pressed(_press("Ctrl+D")) is False + assert order.panel._on_key_pressed(_press(written)) is False assert order.states == [] + def test_a_hex_key_under_shift_types_as_a_capital(self, order: OrderPanelFixture) -> None: + assert order.panel._on_key_pressed(_press("Shift+A")) is True + assert order.states[-1].pending == "A" + def test_the_clear_cell_key_empties_the_cell_and_moves_on(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("Del")) is True assert order.entries == [(CURSOR_POSITION, None)] 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..8c30fd910 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,11 +8,13 @@ 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.band import TrackerRows +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 +from sampletones_application.view_model.sequencer.tracker import NO_REACH from tests.suite.shortcuts import shipped_source @@ -26,14 +28,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) @@ -44,10 +46,10 @@ def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyP """A selection is state the grid holds, so Escape takes it down before it reaches Stop.""" panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() - panel._current_row_count = 64 + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=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..00643d0fc 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 @@ -3,15 +3,29 @@ import pytest +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_ORDER_PANEL, + TAG_SEQUENCER_TRACKER_PANEL, + TAG_SEQUENCER_VOICES_PANEL, +) +from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, 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.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.sequencer.subcolumn import SubColumn from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -19,7 +33,7 @@ SequencerPanel = Union[ GUISequencerTrackerPanel, GUISequencerOrderPanel, - GUISequencerSamplesPanel, + GUISequencerVoicesPanel, ] SELECTED_ID = "bass-id" @@ -34,38 +48,62 @@ def no_focused_field(monkeypatch: pytest.MonkeyPatch) -> None: def _tracker(tab_active: ActivePredicate) -> GUISequencerTrackerPanel: """A tracker grid holding a cursor, which is what it keeps across a move to another tab.""" panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + GUIPanel.__init__(panel, tag=TAG_SEQUENCER_TRACKER_PANEL) 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 def _order(tab_active: ActivePredicate) -> GUISequencerOrderPanel: """An order table holding a cursor, which is what it keeps across a move to another tab.""" panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + GUIPanel.__init__(panel, tag=TAG_SEQUENCER_ORDER_PANEL) panel._router = KeyRouter() panel._tab_active = tab_active panel._input_state = OrderInputState(cursor=OrderCursor(None, 0)) 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) + GUIPanel.__init__(panel, tag=TAG_SEQUENCER_VOICES_PANEL) 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._focused = True + 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 +def _collapse_card(panel: SequencerPanel) -> None: + """Put the panel's card away, the way a reader clicking its header bar does. + + A card's collapse is its controller's to hold, and the controller is built from the header + geometry and the glyphs the application ships, so a case standing a card away reads both from + the shipped layout rather than naming sizes of its own. + """ + layout = load_layout_config( + LAYOUT_DIRECTORY, + BEHAVIOR_DIRECTORY, + PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default), + ) + GUIPanel.configure_section_header( + layout.glyphs, + layout.general.section_header, + layout.general.collapse, + ) + panel._enable_vertical_collapse(initial_collapsed=True) + + class TestPanelKeysFollowTheTabInFront(BaseTestSuite): """A sequencer panel answers the keyboard while the Sequencer is the tab in front. @@ -103,3 +141,34 @@ def test_a_panel_answers_while_its_own_tab_is_in_front(self, test_case: TestCase panel = test_case.build(lambda: True) assert panel._keys_active() is True + + +class TestPanelKeysRestWhileTheCardIsPutAway(BaseTestSuite): + """A sequencer panel answers the keyboard while its card stands open. + + Each panel keeps its cursor or selection through a collapse, since expanding the card brings + the reader back to where they left off; what rests meanwhile is the panel's claim on the keys, + so a press goes on to the application's shortcuts. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + build: Callable[[ActivePredicate], SequencerPanel] + + test_cases = ( + TestCase(label="the tracker grid holds a cursor", build=_tracker), + TestCase(label="the order table holds a cursor", build=_order), + TestCase(label="the samples panel holds a selection", build=_samples), + TestCase(label="the samples panel is mid-rename", build=_renaming_samples), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_panel_stands_down_while_its_card_is_collapsed(self, test_case: TestCase) -> None: + panel = test_case.build(lambda: True) + _collapse_card(panel) + + assert panel._keys_active() is False 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_samples_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py deleted file mode 100644 index e787b42a3..000000000 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_selection.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import Optional, Tuple - -from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel -from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel - -ENTRIES: Tuple[SampleEntryViewModel, ...] = ( - SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False), - SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True), -) - - -def _panel( - selected_sample_id: Optional[str], - selected_row: Optional[int], - entries: Tuple[SampleEntryViewModel, ...] = ENTRIES, -) -> GUISequencerSamplesPanel: - """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._entries = entries - panel._selected_sample_id = selected_sample_id - panel._selected_row = selected_row - return panel - - -class TestSelectionAccessor: - def test_reports_the_highlighted_sample(self) -> None: - panel = _panel("bass-id", 1) - - selection = panel.selection - - assert selection is not None - assert selection.sample_id == "bass-id" - assert selection.position == 1 - assert selection.name == "Bass" - assert selection.label == "01: Bass" - - def test_absent_without_a_selection(self) -> None: - assert _panel(None, None).selection is None - - def test_absent_once_the_selected_sample_leaves_the_pool(self) -> None: - panel = _panel("removed-id", 0) - - assert panel.selection is 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),) - - selection = panel.selection - - assert selection is not None - assert selection.label == "00: Thump" 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..c794d71f6 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,22 @@ 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.band import TrackerRows +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_application.view_model.sequencer.tracker import NO_REACH +from sampletones_core.constants.enums import ChannelName ROW_COUNT = 64 +CONTEXT_REACH = 3 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) @@ -84,7 +87,7 @@ def _tracker( ) -> Tuple[GUISequencerTrackerPanel, List[TrackerInputState]]: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._input_state = TrackerInputState() - panel._current_row_count = ROW_COUNT + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) panel._editable_cells = EditableCells() panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) panel._selection = TableSelection( @@ -97,7 +100,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 +124,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 +155,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 +169,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 +188,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 +226,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 +243,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) @@ -262,7 +265,7 @@ def test_each_row_answers_for_its_own_band( ) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = sequencer_layout - panel._current_row_count = ROW_COUNT + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) height = sequencer_layout.tracker.row_height @@ -278,7 +281,7 @@ def test_a_pointer_past_an_edge_reads_as_the_edge( ) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = sequencer_layout - panel._current_row_count = ROW_COUNT + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) assert panel._row_at(-500.0) == 0 @@ -291,7 +294,7 @@ def test_a_grid_awaiting_its_rows_answers_nothing( ) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = sequencer_layout - panel._current_row_count = 0 + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=0) monkeypatch.setattr(panel, "_row_top", lambda index: None) assert panel._row_at(100.0) is None @@ -307,7 +310,7 @@ def test_the_tracker_band_runs_from_the_first_row( ) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = sequencer_layout - panel._current_row_count = ROW_COUNT + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) assert panel._travel_band() == TravelBand( @@ -316,6 +319,24 @@ def test_the_tracker_band_runs_from_the_first_row( cell_count=ROW_COUNT, ) + def test_the_tracker_band_takes_in_the_rows_either_side_of_the_frame( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + """The rows of the song beside the frame scroll with it, so the band runs from the first of them.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._rows_layout = TrackerRows(reach=CONTEXT_REACH, frame_rows=ROW_COUNT) + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + row_height = sequencer_layout.tracker.row_height + + assert panel._travel_band() == TravelBand( + first_edge=100.0 - CONTEXT_REACH * row_height, + cell_extent=row_height, + cell_count=ROW_COUNT + 2 * CONTEXT_REACH, + ) + def test_a_tracker_awaiting_its_rows_states_no_band( self, monkeypatch: pytest.MonkeyPatch, @@ -323,7 +344,7 @@ def test_a_tracker_awaiting_its_rows_states_no_band( ) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = sequencer_layout - panel._current_row_count = ROW_COUNT + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) monkeypatch.setattr(panel, "_row_top", lambda index: None) assert panel._travel_band() is None @@ -335,7 +356,7 @@ def test_an_empty_frame_states_no_band( ) -> None: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = sequencer_layout - panel._current_row_count = 0 + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=0) monkeypatch.setattr(panel, "_row_top", lambda index: 100.0) assert panel._travel_band() is None @@ -377,7 +398,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 +412,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 +442,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..bf66001df 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,16 @@ 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.band import TrackerRows +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_application.view_model.sequencer.tracker import NO_REACH +from sampletones_core.constants.enums import ChannelName from tests.suite.shortcuts import shipped_source ROW_COUNT = 64 @@ -31,20 +33,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._current_row_count = ROW_COUNT + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, channel, subcolumn)) + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=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 @@ -56,7 +58,7 @@ def _tracker_states( """The states a gesture applies, with the scroll a jump asks for left out.""" states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) - monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None) + monkeypatch.setattr(panel, "_center_followed_row", lambda: None) return states @@ -101,8 +103,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 +155,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 +197,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 +212,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 +220,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 +233,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_band.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_band.py new file mode 100644 index 000000000..a40e28f71 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_band.py @@ -0,0 +1,176 @@ +from types import SimpleNamespace +from typing import Dict, Final, List, Tuple +from unittest.mock import MagicMock + +import pytest + +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_application.tags.sequencer import TAG_SEQUENCER_TRACKER_GROUP, TAG_SEQUENCER_TRACKER_WINDOW +from sampletones_application.ui.panels.sequencer.tracker import panel as tracker +from sampletones_application.ui.panels.sequencer.tracker.band import UNMEASURED_BAND, TrackerBand, TrackerRows +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_application.view_model.sequencer.tracker import NO_REACH, SequencerTrackerViewModel + +ROW_HEIGHT: Final[float] = 29.0 +HEADER_HEIGHT: Final[float] = 30.0 +WINDOW_TOP: Final[float] = 100.0 +HEADER_LABEL: Final[str] = "header_label" +FRAME_ROWS: Final[int] = 4 +SHOWN_FRAME: Final[int] = 0 + + +class Geometry: + """The rectangles DearPyGui reports for the grid's window, the group it opens in and the header.""" + + def __init__(self) -> None: + self.band_rows: float = 7.0 + self.header_height: float = HEADER_HEIGHT + + def rect_size(self, item: str) -> Tuple[float, float]: + sizes: Dict[str, Tuple[float, float]] = { + TAG_SEQUENCER_TRACKER_WINDOW: (800.0, self.header_height + self.band_rows * ROW_HEIGHT), + HEADER_LABEL: (30.0, self.header_height), + } + return sizes[item] + + def rect_min(self, item: str) -> Tuple[float, float]: + tops: Dict[str, Tuple[float, float]] = { + TAG_SEQUENCER_TRACKER_GROUP: (0.0, WINDOW_TOP), + HEADER_LABEL: (0.0, WINDOW_TOP), + } + return tops[item] + + +@pytest.fixture +def geometry(monkeypatch: pytest.MonkeyPatch) -> Geometry: + instance = Geometry() + monkeypatch.setattr(tracker.dpg, "does_item_exist", lambda _item: True) + monkeypatch.setattr(tracker.dpg, "get_item_rect_size", instance.rect_size) + monkeypatch.setattr(tracker.dpg, "get_item_rect_min", instance.rect_min) + return instance + + +@pytest.fixture +def reported() -> List[int]: + return [] + + +@pytest.fixture +def panel(reported: List[int]) -> GUISequencerTrackerPanel: + """A panel that has raised its header, with the grid's band still unmeasured.""" + instance = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + instance._layout = SimpleNamespace(tracker=SimpleNamespace(row_height=ROW_HEIGHT)) + instance._header_label = HEADER_LABEL + instance._band = TrackerBand(height=UNMEASURED_BAND, row_height=ROW_HEIGHT) + instance._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=FRAME_ROWS) + instance.on_reach_changed = reported.append + return instance + + +def _grid() -> SequencerTrackerViewModel: + """The grid of a new project's only frame, :data:`FRAME_ROWS` rows tall.""" + controller = ProjectController(ProjectManager()) + controller.set_rows_per_pattern(FRAME_ROWS) + return SequencerTrackerLogic(controller).build_grid() + + +class TestMeasuringTheBand: + """The band is what the window holds below the header, and a new reach is reported once.""" + + @pytest.mark.usefixtures("geometry") + def test_a_measured_band_reports_the_rows_above_its_center( + self, + panel: GUISequencerTrackerPanel, + reported: List[int], + ) -> None: + panel._measure_band() + + assert reported == [3] + assert panel._band.height == pytest.approx(7.0 * ROW_HEIGHT) + + @pytest.mark.usefixtures("geometry") + def test_the_same_reach_is_reported_once(self, panel: GUISequencerTrackerPanel, reported: List[int]) -> None: + panel._measure_band() + panel._measure_band() + + assert reported == [3] + + def test_a_taller_band_reports_its_new_reach( + self, + panel: GUISequencerTrackerPanel, + reported: List[int], + geometry: Geometry, + ) -> None: + panel._measure_band() + geometry.band_rows = 11.0 + panel._measure_band() + + assert reported == [3, 5] + + def test_a_grid_awaiting_its_layout_reports_nothing( + self, + panel: GUISequencerTrackerPanel, + reported: List[int], + geometry: Geometry, + ) -> None: + geometry.header_height = 0.0 + + panel._measure_band() + + assert reported == [] + + def test_a_grid_without_its_header_reports_nothing( + self, + panel: GUISequencerTrackerPanel, + reported: List[int], + ) -> None: + panel._header_label = None + + panel._measure_band() + + assert reported == [] + + +class TestTheReachShapesTheTable: + """A grid arriving under a new reach is built again, and one under the same reach is filled in place.""" + + @pytest.fixture + def rebuilt(self, panel: GUISequencerTrackerPanel, monkeypatch: pytest.MonkeyPatch) -> List[TrackerRows]: + layouts: List[TrackerRows] = [] + monkeypatch.setattr( + panel, + "_rebuild_table", + lambda _view_model, layout, _values, _kinds: layouts.append(layout), + ) + panel._displayed_frame = SHOWN_FRAME + panel._context = MagicMock() + panel._editable_cells = MagicMock() + panel._cell_kinds = {} + monkeypatch.setattr(panel, "_reconcile_cell_kinds", lambda _kinds: None) + monkeypatch.setattr(panel, "_paint_context", lambda: None) + return layouts + + def test_a_new_reach_builds_the_rows_either_side( + self, + panel: GUISequencerTrackerPanel, + rebuilt: List[TrackerRows], + ) -> None: + panel._band = TrackerBand(height=7.0 * ROW_HEIGHT, row_height=ROW_HEIGHT) + + panel.update_tracker(_grid()) + + assert rebuilt == [TrackerRows(reach=3, frame_rows=FRAME_ROWS)] + + def test_the_same_reach_fills_the_rows_in_place( + self, + panel: GUISequencerTrackerPanel, + rebuilt: List[TrackerRows], + ) -> None: + grid = _grid() + + panel.update_tracker(grid) + + assert rebuilt == [] + panel._context.fill.assert_called_once_with(grid.lead, grid.trail) 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..7dd3754a8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_cell_themes.py @@ -0,0 +1,312 @@ +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.band import TrackerRows +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 ( + NO_REACH, + 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._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=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, lead=(), trail=()) + + +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), + pitch=None, + level=None, + command=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, + pitch=None, + level=None, + command=None, + ) + if kind is VoiceKind.SAMPLE: + sample_channels = frozenset({channel}) + + return SequencerRowViewModel( + index=index, + cells=cells, + sample_channels=sample_channels, + carried_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..384a708a0 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,15 @@ 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.band import TrackerRows +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 +23,10 @@ SequencerChannelsViewModel, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName +from sampletones_application.view_model.sequencer.tracker import NO_REACH +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 +45,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 +79,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 +107,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._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) + 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 +153,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 +167,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 +206,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 +232,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 +292,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 +347,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 +359,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 +379,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_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py deleted file mode 100644 index 457758d15..000000000 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py +++ /dev/null @@ -1,397 +0,0 @@ -import contextlib -from typing import Any, Dict, FrozenSet, Iterator, List, Optional, Tuple - -import dearpygui.dearpygui as dpg -import pytest - -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.utils.gui.keyboard.modifiers import Modifier -from sampletones_application.view_model.sequencer.channels import ( - SequencerChannelsViewModel, -) -from sampletones_core.constants.enums import GeneratorName -from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import VoidCallback - -SENDER_WIDGET_ID: Sender = 8100 -"""A stand-in for the handler-registry id DearPyGui passes as the callback's sender.""" - -STALE_WIDGET_ID: Sender = 8999 -"""A header id a rebuild has already replaced, which the live map no longer names.""" - -LABEL_MUTE = "Mute" -LABEL_UNMUTE = "Unmute" -LABEL_SOLO = "Solo" -LABEL_UNSOLO = "Unsolo" -LABEL_MUTE_ALL = "Mute all channels" -LABEL_UNMUTE_ALL = "Unmute all channels" -ALL_CHANNEL_LABELS = [LABEL_MUTE_ALL, LABEL_UNMUTE_ALL] - -COLUMN_LABELS: Dict[Optional[GeneratorName], str] = { - None: "Sample", - GeneratorName.PULSE1: "Pulse 1", - GeneratorName.PULSE2: "Pulse 2", - GeneratorName.TRIANGLE: "Triangle", - GeneratorName.NOISE: "Noise", -} - -HEADER_WIDGETS: Dict[Optional[GeneratorName], Sender] = { - column: 200 + index for index, column in enumerate(COLUMN_LABELS) -} - -FULL_MIX: FrozenSet[GeneratorName] = frozenset() -EVERY_CHANNEL: FrozenSet[GeneratorName] = frozenset(GeneratorName.items()) - - -def _others(generator: GeneratorName) -> FrozenSet[GeneratorName]: - """The mute set a solo of ``generator`` leaves behind.""" - return EVERY_CHANNEL - {generator} - - -class _MenuRecorder: - """Captures the items a header menu builds, standing in for the DearPyGui popup.""" - - def __init__(self) -> None: - self.titles: List[str] = [] - self.items: List[Tuple[str, bool]] = [] - self.callbacks: Dict[str, VoidCallback] = {} - - def add_menu_item(self, **kwargs: Any) -> int: - label = kwargs["label"] - self.items.append((label, kwargs.get("enabled", True))) - self.callbacks[label] = kwargs["callback"] - return 0 - - def add_text(self, message: str, **kwargs: Any) -> int: - self.titles.append(message) - return 0 - - def add_separator(self, **kwargs: Any) -> int: - return 0 - - @property - def labels(self) -> List[str]: - return [label for label, _ in self.items] - - def is_enabled(self, label: str) -> bool: - return dict(self.items)[label] - - def click(self, label: str) -> None: - """Fires a recorded item the way DearPyGui fires a zero-argument callback.""" - self.callbacks[label]() - - -def _panel(muted: FrozenSet[GeneratorName]) -> 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 - column, so those are wired directly and the rest of the panel is left out. The switch behind - 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. - """ - 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)) - 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) - - @contextlib.contextmanager - def _popup() -> Iterator[None]: - yield - - monkeypatch.setattr(tracker_module, "context_menu", _popup) - return instance - - -def _right_click(panel: GUISequencerTrackerPanel, column: Optional[GeneratorName]) -> None: - panel._on_header_right_clicked( - SENDER_WIDGET_ID, - (dpg.mvMouseButton_Right, HEADER_WIDGETS[column]), - ) - - -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) - - assert recorder.titles == [COLUMN_LABELS[GeneratorName.TRIANGLE]] - - def test_the_sample_header_opens_the_whole_mix_menu(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - - _right_click(panel, None) - - assert recorder.titles == [COLUMN_LABELS[None]] - assert recorder.labels == ALL_CHANNEL_LABELS - - @pytest.mark.parametrize( - "mouse_button", - [dpg.mvMouseButton_Left, dpg.mvMouseButton_Middle], - ids=["left", "middle"], - ) - def test_only_the_right_button_opens_the_menu(self, recorder: _MenuRecorder, mouse_button: int) -> None: - panel = _panel(FULL_MIX) - - panel._on_header_right_clicked( - SENDER_WIDGET_ID, - (mouse_button, HEADER_WIDGETS[GeneratorName.NOISE]), - ) - - assert not recorder.titles - - def test_a_header_a_rebuild_replaced_opens_nothing(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - - panel._on_header_right_clicked(SENDER_WIDGET_ID, (dpg.mvMouseButton_Right, STALE_WIDGET_ID)) - - assert not recorder.titles - - 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) - - assert recorder.titles == [COLUMN_LABELS[generator] for generator in GeneratorName.items()] - - -class TestChannelItemLabels: - @pytest.mark.parametrize( - "muted, expected", - [ - (FULL_MIX, LABEL_MUTE), - (frozenset({GeneratorName.PULSE1}), LABEL_UNMUTE), - ], - ids=["audible", "silenced"], - ) - def test_the_mute_item_names_the_change_it_makes( - self, - recorder: _MenuRecorder, - muted: FrozenSet[GeneratorName], - expected: str, - ) -> None: - panel = _panel(muted) - - _right_click(panel, GeneratorName.PULSE1) - - assert expected in recorder.labels - - @pytest.mark.parametrize( - "muted, expected", - [ - (FULL_MIX, LABEL_SOLO), - (frozenset({GeneratorName.PULSE2}), LABEL_SOLO), - (_others(GeneratorName.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], - expected: str, - ) -> None: - panel = _panel(muted) - - _right_click(panel, GeneratorName.PULSE1) - - assert expected in recorder.labels - - def test_each_column_reads_its_own_state(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.PULSE1})) - - _right_click(panel, GeneratorName.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)) - - _right_click(panel, GeneratorName.PULSE2) - - assert LABEL_UNMUTE in recorder.labels - assert LABEL_SOLO in recorder.labels - - def test_a_channel_menu_carries_both_blocks(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - - _right_click(panel, GeneratorName.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})) - - _right_click(panel, None) - - assert LABEL_MUTE not in recorder.labels - assert LABEL_UNMUTE not in recorder.labels - assert LABEL_SOLO not in recorder.labels - - -class TestAllChannelItems: - @pytest.mark.parametrize( - "muted", - [FULL_MIX, frozenset({GeneratorName.NOISE})], - ids=["full mix", "one silenced"], - ) - def test_muting_everything_is_offered_while_a_channel_sounds( - self, - recorder: _MenuRecorder, - muted: FrozenSet[GeneratorName], - ) -> None: - panel = _panel(muted) - - _right_click(panel, None) - - assert recorder.is_enabled(LABEL_MUTE_ALL) - - def test_muting_everything_is_withheld_in_full_silence(self, recorder: _MenuRecorder) -> None: - panel = _panel(EVERY_CHANNEL) - - _right_click(panel, None) - - assert not recorder.is_enabled(LABEL_MUTE_ALL) - - @pytest.mark.parametrize( - "muted", - [frozenset({GeneratorName.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], - ) -> None: - panel = _panel(muted) - - _right_click(panel, None) - - assert recorder.is_enabled(LABEL_UNMUTE_ALL) - - def test_restoring_everything_is_withheld_in_the_full_mix(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - - _right_click(panel, None) - - assert not recorder.is_enabled(LABEL_UNMUTE_ALL) - - def test_both_menus_end_with_the_whole_mix_items(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - - _right_click(panel, GeneratorName.PULSE1) - channel_menu = recorder.labels[-2:] - _right_click(panel, None) - - assert channel_menu == ALL_CHANNEL_LABELS - assert recorder.labels[-2:] == ALL_CHANNEL_LABELS - - -class TestHeaderMenuActions: - def test_the_mute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - toggled: List[GeneratorName] = [] - panel.on_channel_mute_toggled = toggled.append - - _right_click(panel, GeneratorName.TRIANGLE) - recorder.click(LABEL_MUTE) - - assert toggled == [GeneratorName.TRIANGLE] - - def test_the_unmute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.TRIANGLE})) - toggled: List[GeneratorName] = [] - panel.on_channel_mute_toggled = toggled.append - - _right_click(panel, GeneratorName.TRIANGLE) - recorder.click(LABEL_UNMUTE) - - assert toggled == [GeneratorName.TRIANGLE] - - def test_the_solo_item_solos_the_clicked_channel(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - soloed: List[GeneratorName] = [] - panel.on_channel_soloed = soloed.append - - _right_click(panel, GeneratorName.NOISE) - recorder.click(LABEL_SOLO) - - assert soloed == [GeneratorName.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.on_channel_soloed = soloed.append - - _right_click(panel, GeneratorName.NOISE) - recorder.click(LABEL_UNSOLO) - - assert soloed == [GeneratorName.NOISE] - - def test_muting_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.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") - - _right_click(panel, None) - recorder.click(LABEL_MUTE_ALL) - - assert muted == [None] - - def test_restoring_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None: - panel = _panel(frozenset({GeneratorName.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") - - _right_click(panel, None) - recorder.click(LABEL_UNMUTE_ALL) - - assert unmuted == [None] - - def test_a_channel_menu_reaches_the_whole_mix_too(self, recorder: _MenuRecorder) -> None: - panel = _panel(FULL_MIX) - muted: List[None] = [] - panel.on_channels_muted = lambda: muted.append(None) - - _right_click(panel, GeneratorName.PULSE2) - recorder.click(LABEL_MUTE_ALL) - - assert muted == [None] - - -class TestHeaderTooltips: - @pytest.fixture - def panel(self) -> GUISequencerTrackerPanel: - instance = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) - instance._load_header_tooltips(LanguageManager(LANG_EN)) - return instance - - def test_the_channel_tooltip_names_the_solo_modifier(self, panel: GUISequencerTrackerPanel) -> None: - assert Modifier.CTRL.value in panel._tooltip_header_channel - - def test_the_channel_tooltip_leaves_no_placeholder_behind(self, panel: GUISequencerTrackerPanel) -> None: - assert "{" not in panel._tooltip_header_channel - - def test_both_headers_explain_their_click(self, panel: GUISequencerTrackerPanel) -> None: - assert panel._tooltip_header_channel - assert panel._tooltip_header_sample - assert panel._tooltip_header_channel != panel._tooltip_header_sample 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..7d2753afb 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,14 +4,18 @@ 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.band import UNMEASURED_BAND, TrackerBand, TrackerRows +from sampletones_application.ui.panels.sequencer.tracker.context import ContextRows +from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes 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 from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.tracker import NO_REACH from sampletones_core.project.song_position import SongPosition from sampletones_shared.types.callback import VoidCallback from tests.suite.shortcuts import shipped_source @@ -19,10 +23,9 @@ PAGE_SIZE = 16 CURSOR_ROW = 5 ROW_COUNT = 65 -SCROLL_MAX = 640.0 -ROW_PITCH = 20.0 -BAND_TOP = 100.0 -LAST_HEADING_ROW = 32 +ROW_HEIGHT = 20.0 +BAND_ROWS = 9.5 +PLAYING_ROW = 12 SHOWN_FRAME = 3 OTHER_FRAME = 4 @@ -32,16 +35,19 @@ 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)) + panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE, row_height=ROW_HEIGHT)) + panel._band = TrackerBand(height=BAND_ROWS * ROW_HEIGHT, row_height=ROW_HEIGHT) panel._displayed_frame = SHOWN_FRAME panel._playing_frame = None panel._playing_row = None panel._painted_row = None panel._follows_playing_row = False - panel._current_row_count = ROW_COUNT + panel._rows_layout = TrackerRows(reach=panel._band.reach, frame_rows=ROW_COUNT) + panel._context = ContextRows(layout=panel._layout, themes=TrackerThemes(panel._layout), subcolumn_widths={}) + panel._settings = SimpleNamespace(first_highlight=4, second_highlight=16) panel._rows = {row_index: f"row_{row_index}" for row_index in range(ROW_COUNT)} return panel @@ -57,152 +63,168 @@ def _press(text: str) -> KeyEvent: return KeyEvent(key=combination.key, modifiers=combination.modifiers) -class TestGridPageNavigation: - """PageUp and PageDown jump the cursor a page of rows, matching the key codes DearPyGui delivers, - and reveal the row they land on.""" +def _record_scrolls(monkeypatch: pytest.MonkeyPatch) -> List[float]: + """The scrolls the grid is asked for, in the order it is asked for them.""" + scrolls: List[float] = [] + monkeypatch.setattr(tracker.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(tracker.dpg, "set_y_scroll", lambda tag, value: scrolls.append(value)) + return scrolls - def test_page_up_moves_up_one_page_and_scrolls(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel = _panel() - moves: List[int] = [] - scrolls: List[None] = [] - monkeypatch.setattr(panel, "_move_row", moves.append) - monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: scrolls.append(None)) - assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_UP, modifiers=NO_MODIFIERS)) is True - assert moves == [-PAGE_SIZE] - assert scrolls == [None] +def _take_states(monkeypatch: pytest.MonkeyPatch, panel: GUISequencerTrackerPanel) -> None: + """Has the panel take each input state a key leads to, leaving out the highlights it paints.""" + + def take(state: TrackerInputState) -> None: + panel._input_state = state + + monkeypatch.setattr(panel, "_apply_state", take) - def test_page_down_moves_down_one_page_and_scrolls(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel = _panel() - moves: List[int] = [] - scrolls: List[None] = [] - monkeypatch.setattr(panel, "_move_row", moves.append) - monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: scrolls.append(None)) - assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_DOWN, modifiers=NO_MODIFIERS)) is True - assert moves == [PAGE_SIZE] - assert scrolls == [None] +def _middle_off_center(panel: GUISequencerTrackerPanel, row_index: int, scroll: float) -> float: + """How far a row's middle stands from the band's middle once the grid is scrolled to ``scroll``.""" + top = panel._rows_layout.body_row(row_index) * ROW_HEIGHT - scroll + return top + ROW_HEIGHT / 2 - panel._band.height / 2 + + +def _scroll_max(panel: GUISequencerTrackerPanel) -> float: + """How far the grid scrolls: every row below the header, less the band showing them.""" + return panel._rows_layout.body_rows * ROW_HEIGHT - panel._band.height @dataclass(frozen=True) -class RowPlacementCase: - """A row of the frame, and the scroll that places it.""" +class KeyLanding: + """A key, and the row it carries the cursor to from :data:`CURSOR_ROW`.""" + press: str row_index: int - scroll: float -ROW_PLACEMENTS = [ - RowPlacementCase(row_index=0, scroll=0.0), - RowPlacementCase(row_index=(ROW_COUNT - 1) // 2, scroll=SCROLL_MAX / 2), - RowPlacementCase(row_index=ROW_COUNT - 1, scroll=SCROLL_MAX), +KEY_LANDINGS = [ + KeyLanding(press="Down", row_index=CURSOR_ROW + 1), + KeyLanding(press="Up", row_index=CURSOR_ROW - 1), + KeyLanding(press="PageDown", row_index=CURSOR_ROW + PAGE_SIZE), + KeyLanding(press="PageUp", row_index=0), + KeyLanding(press="Home", row_index=0), + KeyLanding(press="End", row_index=ROW_COUNT - 1), + KeyLanding(press="Shift+End", row_index=ROW_COUNT - 1), ] -BAND_TOP_PLACEMENTS = [ - RowPlacementCase(row_index=0, scroll=0.0), - RowPlacementCase(row_index=10, scroll=10 * ROW_PITCH), - RowPlacementCase(row_index=LAST_HEADING_ROW, scroll=SCROLL_MAX), - RowPlacementCase(row_index=LAST_HEADING_ROW + 8, scroll=SCROLL_MAX), - RowPlacementCase(row_index=ROW_COUNT - 1, scroll=SCROLL_MAX), -] +class TestTheCursorStandsAtTheCenter: + """A key carrying the cursor to another row brings that row to the band's center.""" -def _row_top(tag: str) -> List[float]: - """Where a laid-out row stands, the rows stacked one pitch apart below the band's top.""" - return [0.0, BAND_TOP + int(tag.removeprefix("row_")) * ROW_PITCH] + @pytest.mark.parametrize("landing", KEY_LANDINGS, ids=lambda landing: landing.press) + def test_the_row_a_key_lands_on_is_centered( + self, + landing: KeyLanding, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = _panel() + _take_states(monkeypatch, panel) + scrolls = _record_scrolls(monkeypatch) + assert panel._on_key_pressed(_press(landing.press)) is True -def _record_scrolls(monkeypatch: pytest.MonkeyPatch, scroll_max: float) -> List[float]: - """The scrolls a placement asks of a laid-out grid, in the order it asks for them.""" - scrolls: List[float] = [] - monkeypatch.setattr(tracker.dpg, "does_item_exist", lambda tag: True) - monkeypatch.setattr(tracker.dpg, "get_y_scroll_max", lambda tag: scroll_max) - monkeypatch.setattr(tracker.dpg, "get_item_rect_min", _row_top) - monkeypatch.setattr(tracker.dpg, "set_y_scroll", lambda tag, value: scrolls.append(value)) - return scrolls + cursor = panel._input_state.cursor + assert cursor is not None + assert cursor.row == landing.row_index + assert len(scrolls) == 1 + assert _middle_off_center(panel, landing.row_index, scrolls[0]) == pytest.approx(0.0) + assert 0.0 <= scrolls[0] <= _scroll_max(panel) + + def test_a_key_keeping_the_row_scrolls_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _panel() + _take_states(monkeypatch, panel) + scrolls = _record_scrolls(monkeypatch) + + panel._on_key_pressed(_press("Right")) + + assert scrolls == [] + + def test_a_band_awaiting_its_measurement_scrolls_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _panel() + panel._band = TrackerBand(height=UNMEASURED_BAND, row_height=ROW_HEIGHT) + _take_states(monkeypatch, panel) + scrolls = _record_scrolls(monkeypatch) + + panel._on_key_pressed(_press("Down")) + + assert scrolls == [] + + def test_a_followed_playhead_keeps_the_band_on_the_sounding_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + """While the grid follows playback, the song holds the center and a key only moves the cursor.""" + panel = _panel() + panel.set_row_following(True) + panel._playing_frame = SHOWN_FRAME + panel._playing_row = PLAYING_ROW + _take_states(monkeypatch, panel) + scrolls = _record_scrolls(monkeypatch) + + panel._on_key_pressed(_press("Down")) + + assert len(scrolls) == 1 + assert _middle_off_center(panel, PLAYING_ROW, scrolls[0]) == pytest.approx(0.0) class TestPlayheadFollowing: - """The grid carries the sounding row to the head of the band for as long as it follows the - playhead.""" + """The grid keeps the sounding row at the band's center for as long as it follows the playhead.""" - def test_a_followed_row_is_revealed(self, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("row_index", [0, PLAYING_ROW, ROW_COUNT - 1]) + def test_a_followed_row_is_centered(self, row_index: int, monkeypatch: pytest.MonkeyPatch) -> None: + """The first and the last row reach the center too, the rows of the song either side holding the room.""" panel = _panel() - revealed: List[int] = [] monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) - monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) + scrolls = _record_scrolls(monkeypatch) panel.set_row_following(True) - panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) + panel.set_playing_position(_playhead(SHOWN_FRAME, row_index)) - assert revealed == [12] + assert len(scrolls) == 1 + assert _middle_off_center(panel, row_index, scrolls[0]) == pytest.approx(0.0) + assert 0.0 <= scrolls[0] <= _scroll_max(panel) def test_an_unfollowed_row_stays_where_the_reader_left_it(self, monkeypatch: pytest.MonkeyPatch) -> None: panel = _panel() - revealed: List[int] = [] monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) - monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) + scrolls = _record_scrolls(monkeypatch) panel.set_row_following(False) - panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) + panel.set_playing_position(_playhead(SHOWN_FRAME, PLAYING_ROW)) - assert revealed == [] + assert scrolls == [] def test_a_row_of_another_frame_holds_the_grid_where_it_is(self, monkeypatch: pytest.MonkeyPatch) -> None: """A row belongs to its own pattern, so the grid travels to it once that frame is shown.""" panel = _panel() - revealed: List[int] = [] monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) - monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) + scrolls = _record_scrolls(monkeypatch) panel.set_row_following(True) - panel.set_playing_position(_playhead(OTHER_FRAME, 12)) + panel.set_playing_position(_playhead(OTHER_FRAME, PLAYING_ROW)) - assert revealed == [] + assert scrolls == [] def test_a_cleared_playhead_leaves_the_scroll_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: """Stopping drops the mark, and the grid keeps the position it was scrolled to.""" panel = _panel() - revealed: List[int] = [] monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) - monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) + scrolls = _record_scrolls(monkeypatch) panel.set_row_following(True) - panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) + panel.set_playing_position(_playhead(SHOWN_FRAME, PLAYING_ROW)) panel.set_playing_position(None) - assert revealed == [12] - - @pytest.mark.parametrize("case", BAND_TOP_PLACEMENTS, ids=lambda case: f"row_{case.row_index}") - def test_a_row_is_carried_to_the_head_of_the_band( - self, - case: RowPlacementCase, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """Each row heads the band by the height of the rows above it, as far as the grid scrolls.""" - panel = _panel() - scrolls = _record_scrolls(monkeypatch, SCROLL_MAX) - - panel._scroll_row_to_band_top(case.row_index) + assert len(scrolls) == 1 - assert scrolls == [pytest.approx(case.scroll)] - - def test_a_frame_that_fits_the_band_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: - """A frame shorter than the band shows every row already, so the grid holds still.""" + def test_a_grid_awaiting_its_band_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A band is measured once the grid has laid out, and nothing is centered before.""" panel = _panel() - scrolls = _record_scrolls(monkeypatch, 0.0) - - panel._scroll_row_to_band_top(4) - - assert scrolls == [] - - def test_a_grid_awaiting_its_layout_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Rows reach the grid a frame before they are placed, and measure nothing until they are.""" - panel = _panel() - panel._rows = {} - scrolls = _record_scrolls(monkeypatch, SCROLL_MAX) + panel._band = TrackerBand(height=UNMEASURED_BAND, row_height=ROW_HEIGHT) + monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) + scrolls = _record_scrolls(monkeypatch) - panel._scroll_row_to_band_top(4) + panel.set_row_following(True) + panel.set_playing_position(_playhead(SHOWN_FRAME, PLAYING_ROW)) assert scrolls == [] @@ -283,33 +305,6 @@ def paint() -> None: return painted, paint -class TestCursorPlacement: - """A cursor jump places the row across the band, from its top on the first row to its bottom on - the last.""" - - @pytest.mark.parametrize("case", ROW_PLACEMENTS, ids=lambda case: f"row_{case.row_index}") - def test_a_row_is_placed_across_the_band( - self, - case: RowPlacementCase, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - panel = _panel() - scrolls = _record_scrolls(monkeypatch, SCROLL_MAX) - - panel._scroll_row_into_view(case.row_index) - - assert scrolls == [pytest.approx(case.scroll)] - - def test_the_cursor_is_placed_by_that_rule(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel = _panel() - revealed: List[int] = [] - monkeypatch.setattr(panel, "_scroll_row_into_view", revealed.append) - - panel._scroll_cursor_into_view() - - assert revealed == [CURSOR_ROW] - - class TestGridColumnNavigation: """Tab steps to the next channel column and Shift+Tab back, each its own action in the scheme.""" @@ -339,11 +334,24 @@ def test_a_note_key_types_into_the_cell_under_the_cursor(self, monkeypatch: pyte assert panel._on_key_pressed(_press("C")) is True assert states[-1].pending == "C" - def test_a_modified_key_reaches_the_application(self, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("written", ["Ctrl+D", "Alt+D", "Super+D"]) + def test_a_modified_key_reaches_the_application( + self, + monkeypatch: pytest.MonkeyPatch, + written: str, + ) -> None: """Ctrl+D opens the display settings, so cell entry keeps the plain hex key alone.""" panel = _panel() states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) - assert panel._on_key_pressed(_press("Ctrl+D")) is False + assert panel._on_key_pressed(_press(written)) is False assert states == [] + + def test_a_key_under_shift_types_as_a_capital(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _panel() + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + + assert panel._on_key_pressed(_press("Shift+C")) is True + assert states[-1].pending == "C" 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..8840284e1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py @@ -0,0 +1,175 @@ +from dataclasses import dataclass, field +from typing import Final, List, Optional + +import pytest + +from sampletones_application.constants.tracker import DEFAULT_OCTAVE +from sampletones_application.ui.panels.sequencer.input.edit import EditAction +from sampletones_application.ui.panels.sequencer.input.tracker import ( + TrackerCursor, + TrackerInputState, +) +from sampletones_application.ui.panels.sequencer.tracker.band import TrackerRows +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_application.view_model.sequencer.tracker import NO_REACH +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PITCH, NUM_PERIODS +from sampletones_core.project.patterns.pitch import Note +from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES + +ROW: Final[int] = 3 +ROW_COUNT: Final[int] = 64 +TOP_OCTAVE: Final[int] = 7 + + +@dataclass +class PianoFixture: + panel: GUISequencerTrackerPanel + actions: List[EditAction] = 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, its edits recorded in place of committed.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._octave = DEFAULT_OCTAVE + panel._rows_layout = TrackerRows(reach=NO_REACH, frame_rows=ROW_COUNT) + panel._input_state = TrackerInputState(cursor=TrackerCursor(ROW, channel, subcolumn)) + + fixture = PianoFixture(panel=panel) + monkeypatch.setattr(panel, "_handle_edit_action", fixture.actions.append) + 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 + + +def _typed(channel: Optional[ChannelName], note: Note) -> EditAction: + return EditAction(row=ROW, channel=channel, sample_index=None, pitch=note, volume=None) + + +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.actions == [_typed(ChannelName.PULSE1, Note(value=_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.actions == [_typed(ChannelName.PULSE1, Note(value=_pitch(DEFAULT_OCTAVE + 1, 0)))] + + def test_the_top_rows_white_keys_skip_the_black_ones(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + fixture.panel._type_note(_press("W")) + + assert fixture.actions == [_typed(ChannelName.PULSE1, Note(value=_pitch(DEFAULT_OCTAVE + 1, 2)))] + + 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.actions == [_typed(ChannelName.PULSE1, Note(value=_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.actions == [_typed(ChannelName.PULSE1, Note(value=_pitch(DEFAULT_OCTAVE - 1, 0)))] + + def test_a_note_above_the_highest_is_held_at_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + fixture.panel._octave = TOP_OCTAVE + + fixture.panel._type_note(_press("U")) + + assert fixture.actions == [_typed(ChannelName.PULSE1, Note(value=MAX_PITCH))] + + 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 TestWhereTheNoteLands: + """A note reaches every column: the noise channel takes it as a period, and the sample column as + the pitch it names, which each channel the column reaches reads its own way. + """ + + def test_the_noise_channel_takes_the_period_the_pitch_names(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.NOISE) + + assert fixture.panel._type_note(_press("S")) is True + assert fixture.actions == [ + _typed(ChannelName.NOISE, Note(value=_pitch(DEFAULT_OCTAVE, 1) % NUM_PERIODS)), + ] + + def test_the_sample_column_takes_the_pitch(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, None) + + assert fixture.panel._type_note(_press("Z")) is True + assert fixture.actions == [_typed(None, Note(value=_pitch(DEFAULT_OCTAVE, 0)))] + + +class TestWhereTheNoteKeysStayOut: + 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.actions == [] + + def test_a_digit_types_a_step_and_no_note(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press("2")) is False + assert fixture.actions == [] + + 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.actions == [] + + @pytest.mark.parametrize("written", ["Ctrl+Z", "Alt+Z", "Super+Z"]) + def test_a_combination_ending_in_a_note_key_is_left_to_the_shortcuts( + self, + monkeypatch: pytest.MonkeyPatch, + written: str, + ) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press(written)) is False + assert fixture.actions == [] + + def test_a_note_key_under_shift_types_its_note(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press("Shift+Z")) is True + assert fixture.actions == [_typed(ChannelName.PULSE1, Note(value=_pitch(DEFAULT_OCTAVE, 0)))] 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..5bd18f3c4 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,25 +3,30 @@ 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.band import TrackerRows +from sampletones_application.ui.panels.sequencer.tracker.context import ContextRows +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.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_application.view_model.sequencer.tracker import NO_REACH +from sampletones_core.constants.enums import ChannelName from sampletones_core.project.song_position import SongPosition from sampletones_shared.types.application import ColorRGBA PATTERN_ROWS = 4 HEADER_AND_PATTERN_ROWS = PATTERN_ROWS + 1 +CONTEXT_REACH = 2 +TABLE_ROWS = TrackerRows(reach=NO_REACH, frame_rows=PATTERN_ROWS) SHOWN_FRAME = 3 OTHER_FRAME = 4 @@ -78,7 +83,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, @@ -105,7 +110,8 @@ def _panel() -> GUISequencerTrackerPanel: ), ), ) - panel._current_row_count = PATTERN_ROWS + panel._rows_layout = TABLE_ROWS + panel._context = ContextRows(layout=panel._layout, themes=TrackerThemes(panel._layout), subcolumn_widths={}) panel._highlighted_row = None panel._displayed_frame = SHOWN_FRAME panel._playing_frame = None @@ -124,11 +130,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="", ) @@ -163,6 +169,14 @@ def test_an_unbuilt_table_reports_no_pattern_rows(self, recorder: _TableRecorder assert panel._live_row_count() == 0 + def test_the_rows_either_side_of_the_frame_are_left_out(self, recorder: _TableRecorder) -> None: + """The table holds the song's rows beside the frame as well, which no pattern row index names.""" + panel = _panel() + panel._rows_layout = TrackerRows(reach=CONTEXT_REACH, frame_rows=PATTERN_ROWS) + recorder.row_children = list(range(HEADER_AND_PATTERN_ROWS + 2 * CONTEXT_REACH)) + + assert panel._live_row_count() == PATTERN_ROWS + class TestRowGrouping: def test_the_rows_opening_a_bar_and_a_beat_take_their_shades(self, recorder: _TableRecorder) -> None: @@ -171,8 +185,8 @@ def test_the_rows_opening_a_bar_and_a_beat_take_their_shades(self, recorder: _Ta panel._apply_row_backgrounds() assert recorder.highlighted_rows == { - tracker_table_row(0): BAR_ROW, - tracker_table_row(2): BEAT_ROW, + TABLE_ROWS.table_row(0): BAR_ROW, + TABLE_ROWS.table_row(2): BEAT_ROW, } def test_the_rows_between_them_are_left_to_the_stripe(self, recorder: _TableRecorder) -> None: @@ -180,7 +194,7 @@ def test_the_rows_between_them_are_left_to_the_stripe(self, recorder: _TableReco panel._apply_row_backgrounds() - assert recorder.unhighlighted_rows == [tracker_table_row(row) for row in PLAIN_ROWS] + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(row) for row in PLAIN_ROWS] def test_the_header_row_takes_no_row_background(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -190,7 +204,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() @@ -198,10 +212,10 @@ def test_an_edited_metre_retints_the_rows_at_once(self, recorder: _TableRecorder panel.update_settings(_settings(first_highlight=1, second_highlight=PATTERN_ROWS)) assert recorder.highlighted_rows == { - tracker_table_row(0): BAR_ROW, - tracker_table_row(1): BEAT_ROW, - tracker_table_row(2): BEAT_ROW, - tracker_table_row(3): BEAT_ROW, + TABLE_ROWS.table_row(0): BAR_ROW, + TABLE_ROWS.table_row(1): BEAT_ROW, + TABLE_ROWS.table_row(2): BEAT_ROW, + TABLE_ROWS.table_row(3): BEAT_ROW, } def test_a_row_past_the_live_table_never_reaches_dearpygui(self, recorder: _TableRecorder) -> None: @@ -221,11 +235,11 @@ 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} + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(row_index): CURSOR_ROW} @pytest.mark.parametrize("row_index", BAR_ROWS + BEAT_ROWS) def test_the_cursor_on_a_group_row_carries_both_shades( @@ -234,20 +248,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)] + painted = recorder.highlighted_rows[TABLE_ROWS.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 = (TABLE_ROWS.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: @@ -264,15 +278,15 @@ def test_removing_the_cursor_clears_the_cell_and_the_plain_row(self, recorder: _ panel._remove_cell_highlight(1, None) - assert recorder.unhighlighted_rows == [tracker_table_row(1)] - assert recorder.unhighlighted_cells == [(tracker_table_row(1), tracker_table_column(None))] + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(1)] + assert recorder.unhighlighted_cells == [(TABLE_ROWS.table_row(1), tracker_table_column(None))] def test_a_group_row_keeps_its_shade_once_the_cursor_leaves(self, recorder: _TableRecorder) -> None: panel = _panel() panel._remove_cell_highlight(0, None) - assert recorder.highlighted_rows == {tracker_table_row(0): BAR_ROW} + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(0): BAR_ROW} class TestHoverHighlight: @@ -281,14 +295,14 @@ def test_hover_lands_on_the_mapped_table_row(self, recorder: _TableRecorder) -> panel.highlight_row(3) - assert recorder.highlighted_rows == {tracker_table_row(3): PATTERN_HIGHLIGHT} + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(3): PATTERN_HIGHLIGHT} def test_hover_on_a_group_row_carries_both_shades(self, recorder: _TableRecorder) -> None: panel = _panel() panel.highlight_row(0) - painted = recorder.highlighted_rows[tracker_table_row(0)] + painted = recorder.highlighted_rows[TABLE_ROWS.table_row(0)] assert painted[3] > PATTERN_HIGHLIGHT[3] def test_moving_the_hover_returns_the_row_it_left(self, recorder: _TableRecorder) -> None: @@ -297,8 +311,8 @@ def test_moving_the_hover_returns_the_row_it_left(self, recorder: _TableRecorder panel.highlight_row(1) panel.highlight_row(3) - assert recorder.unhighlighted_rows == [tracker_table_row(1)] - assert recorder.highlighted_rows == {tracker_table_row(3): PATTERN_HIGHLIGHT} + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(1)] + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(3): PATTERN_HIGHLIGHT} def test_moving_the_hover_off_a_group_row_gives_it_its_shade_back(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -306,7 +320,7 @@ def test_moving_the_hover_off_a_group_row_gives_it_its_shade_back(self, recorder panel.highlight_row(0) panel.highlight_row(3) - assert recorder.highlighted_rows[tracker_table_row(0)] == BAR_ROW + assert recorder.highlighted_rows[TABLE_ROWS.table_row(0)] == BAR_ROW def test_dropping_the_hover_clears_the_mapped_row(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -314,7 +328,7 @@ def test_dropping_the_hover_clears_the_mapped_row(self, recorder: _TableRecorder panel.highlight_row(None) - assert recorder.unhighlighted_rows == [tracker_table_row(3)] + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(3)] class TestPlayingRowHighlight: @@ -328,7 +342,7 @@ def test_the_playhead_lands_on_the_mapped_table_row( panel.set_playing_position(_playhead(SHOWN_FRAME, row_index)) - assert recorder.highlighted_rows == {tracker_table_row(row_index): PLAYBACK_ROW} + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(row_index): PLAYBACK_ROW} @pytest.mark.parametrize("row_index", BAR_ROWS + BEAT_ROWS) def test_the_playhead_over_a_group_row_carries_both_shades( @@ -340,16 +354,16 @@ def test_the_playhead_over_a_group_row_carries_both_shades( panel.set_playing_position(_playhead(SHOWN_FRAME, row_index)) - painted = recorder.highlighted_rows[tracker_table_row(row_index)] + painted = recorder.highlighted_rows[TABLE_ROWS.table_row(row_index)] assert painted[3] > PLAYBACK_ROW[3] 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)) - assert recorder.highlighted_rows == {tracker_table_row(1): PLAYBACK_ROW} + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(1): PLAYBACK_ROW} def test_the_last_pattern_row_is_still_within_the_table(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -371,7 +385,7 @@ def test_advancing_the_playhead_returns_the_row_it_left(self, recorder: _TableRe panel.set_playing_position(_playhead(SHOWN_FRAME, 1)) panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) - assert recorder.unhighlighted_rows == [tracker_table_row(1)] + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(1)] def test_advancing_past_a_group_row_gives_it_its_shade_back(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -379,7 +393,7 @@ def test_advancing_past_a_group_row_gives_it_its_shade_back(self, recorder: _Tab panel.set_playing_position(_playhead(SHOWN_FRAME, 0)) panel.set_playing_position(_playhead(SHOWN_FRAME, 1)) - assert recorder.highlighted_rows[tracker_table_row(0)] == BAR_ROW + assert recorder.highlighted_rows[TABLE_ROWS.table_row(0)] == BAR_ROW def test_stopping_clears_the_mapped_row(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -387,7 +401,7 @@ def test_stopping_clears_the_mapped_row(self, recorder: _TableRecorder) -> None: panel.set_playing_position(None) - assert recorder.unhighlighted_rows == [tracker_table_row(3)] + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(3)] class TestPlayheadFrame: @@ -407,7 +421,7 @@ def test_showing_another_frame_returns_the_marked_row(self, recorder: _TableReco panel._show_frame(OTHER_FRAME) - assert recorder.unhighlighted_rows == [tracker_table_row(3)] + assert recorder.unhighlighted_rows == [TABLE_ROWS.table_row(3)] def test_returning_to_the_sounding_frame_marks_its_row_again(self, recorder: _TableRecorder) -> None: panel = _panel() @@ -416,17 +430,17 @@ def test_returning_to_the_sounding_frame_marks_its_row_again(self, recorder: _Ta panel._show_frame(SHOWN_FRAME) - assert recorder.highlighted_rows == {tracker_table_row(3): PLAYBACK_ROW} + assert recorder.highlighted_rows == {TABLE_ROWS.table_row(3): PLAYBACK_ROW} 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) - assert recorder.highlighted_rows[tracker_table_row(3)] == CURSOR_ROW + assert recorder.highlighted_rows[TABLE_ROWS.table_row(3)] == CURSOR_ROW class TestHeaderRowBackground: @@ -459,7 +473,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..1a7b01110 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_typed_voice.py @@ -0,0 +1,327 @@ +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, + VoiceSelection, +) +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.patterns.pitch import Note, RowPitch +from sampletones_core.utils.display import display_id, display_pitch, display_volume + +SAMPLE_INDEX = 0 +INSTRUMENT_INDEX = 1 +STORED_LABEL = display_id(None) +STORED_VOLUME = display_volume(None) +TYPED_VOLUME = 10 +TYPED_NOTE = Note(value=60) +STORED_PITCH = display_pitch(None) +MARKS = { + SAMPLE_INDEX: VoiceSelection(voice_id="lead-id", position=SAMPLE_INDEX, name="lead", kind=VoiceKind.SAMPLE), + INSTRUMENT_INDEX: VoiceSelection( + voice_id="pad-id", + position=INSTRUMENT_INDEX, + name="pad", + kind=VoiceKind.INSTRUMENT, + ), +} + +Write = Tuple[int, Optional[ChannelName], Optional[str]] +Offset = Tuple[int, Optional[ChannelName], Optional[RowPitch], Optional[int]] + + +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, the rows the sample column takes a pitch or a volume + on, 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. No row starts out playing a sample. + """ + + 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.panel._rows_taking_offsets = frozenset() + self.panel.marked_voice = None + self.writes: List[Write] = [] + self.offsets: List[Offset] = [] + self.panel.on_set_row = self._record + + def _record( + self, + row: int, + channel: Optional[ChannelName], + voice_id: Optional[str], + pitch: Optional[RowPitch], + volume: Optional[int], + ) -> None: + self.writes.append((row, channel, voice_id)) + if pitch is not None or volume is not None: + self.offsets.append((row, channel, pitch, volume)) + + def mark(self, index: int) -> None: + """Marks the voice at ``index`` in the voices list, as a click on its row does.""" + self.panel.marked_voice = lambda: MARKS[index] + + def type_pitch(self, pitch: RowPitch, channel: Optional[ChannelName]) -> None: + self.panel._handle_edit_action( + EditAction( + row=0, + channel=channel, + sample_index=None, + pitch=pitch, + volume=None, + ) + ) + + def type_voice(self, index: int, channel: Optional[ChannelName]) -> None: + self.panel._handle_edit_action( + EditAction( + row=0, + channel=channel, + sample_index=index, + pitch=None, + volume=None, + ) + ) + + def type_volume(self, volume: int, channel: Optional[ChannelName]) -> None: + self.panel._handle_edit_action( + EditAction( + row=0, + channel=channel, + sample_index=None, + pitch=None, + volume=volume, + ) + ) + + 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 shown_pitch(self, channel: Optional[ChannelName]) -> str: + """The pitch the cell cache holds, which is what the cell shows once the commit settles.""" + return self.panel._editable_cells.values.get((0, channel, SubColumn.TRANSPOSE), STORED_PITCH) + + def shown_volume(self, channel: Optional[ChannelName]) -> str: + """The volume the cell cache holds, which is what the cell shows once the commit settles.""" + return self.panel._editable_cells.values.get((0, channel, SubColumn.VOLUME), STORED_VOLUME) + + 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, + pitch=None, + volume=None, + note_off=True, + ) + ) + + assert panel.kind(ChannelName.TRIANGLE) is None + + +class TestTypingAnOffsetInTheSampleColumn: + """The sample column takes a pitch or a volume on a row where a sample plays.""" + + def test_a_volume_typed_where_no_sample_plays_leaves_the_cell_showing_what_it_held( + self, + panel: Panel, + ) -> None: + """The project would take nothing, so a cell taking the value optimistically would keep it.""" + panel.type_volume(TYPED_VOLUME, None) + + assert panel.writes == [] + assert panel.shown_volume(None) == STORED_VOLUME + + def test_a_volume_typed_where_a_sample_plays_is_written(self, panel: Panel) -> None: + panel.panel._rows_taking_offsets = frozenset({0}) + + panel.type_volume(TYPED_VOLUME, None) + + assert panel.offsets == [(0, None, None, TYPED_VOLUME)] + assert panel.shown_volume(None) == display_volume(TYPED_VOLUME) + + def test_a_volume_typed_in_a_channel_column_is_written_where_no_sample_plays( + self, + panel: Panel, + ) -> None: + panel.type_volume(TYPED_VOLUME, ChannelName.PULSE1) + + assert panel.offsets == [(0, ChannelName.PULSE1, None, TYPED_VOLUME)] + assert panel.shown_volume(ChannelName.PULSE1) == display_volume(TYPED_VOLUME) + + +class TestTypingAPitchWithAMarkedVoice: + """A pitch typed while the voices list marks a voice places that voice too, where the column takes it.""" + + def test_a_marked_sample_lands_with_the_pitch_in_a_channel_column(self, panel: Panel) -> None: + panel.mark(SAMPLE_INDEX) + + panel.type_pitch(TYPED_NOTE, ChannelName.PULSE1) + + assert panel.writes == [(0, ChannelName.PULSE1, "lead-id")] + assert panel.offsets == [(0, ChannelName.PULSE1, TYPED_NOTE, None)] + assert panel.shown(ChannelName.PULSE1) == display_id(SAMPLE_INDEX) + assert panel.kind(ChannelName.PULSE1) is VoiceKind.SAMPLE + assert panel.shown_pitch(ChannelName.PULSE1) == display_pitch(TYPED_NOTE) + + def test_a_marked_instrument_lands_with_the_pitch_in_a_channel_column(self, panel: Panel) -> None: + panel.mark(INSTRUMENT_INDEX) + + panel.type_pitch(TYPED_NOTE, ChannelName.NOISE) + + assert panel.writes == [(0, ChannelName.NOISE, "pad-id")] + assert panel.kind(ChannelName.NOISE) is VoiceKind.INSTRUMENT + + def test_a_marked_sample_fills_the_sample_column_where_no_sample_plays(self, panel: Panel) -> None: + """The sample spreads over its channels first, so the pitch has channels to land on.""" + panel.mark(SAMPLE_INDEX) + + panel.type_pitch(TYPED_NOTE, None) + + assert panel.writes == [(0, None, "lead-id")] + assert panel.offsets == [(0, None, TYPED_NOTE, None)] + assert panel.shown(None) == display_id(SAMPLE_INDEX) + + def test_a_marked_instrument_writes_the_pitch_alone_in_the_sample_column(self, panel: Panel) -> None: + panel.panel._rows_taking_offsets = frozenset({0}) + panel.mark(INSTRUMENT_INDEX) + + panel.type_pitch(TYPED_NOTE, None) + + assert panel.writes == [(0, None, None)] + assert panel.offsets == [(0, None, TYPED_NOTE, None)] + assert panel.shown(None) == STORED_LABEL + + def test_a_marked_instrument_lands_nowhere_in_the_sample_column_without_a_sample(self, panel: Panel) -> None: + panel.mark(INSTRUMENT_INDEX) + + panel.type_pitch(TYPED_NOTE, None) + + assert panel.writes == [] + assert panel.shown_pitch(None) == STORED_PITCH + + def test_without_a_mark_the_pitch_goes_alone(self, panel: Panel) -> None: + panel.type_pitch(TYPED_NOTE, ChannelName.PULSE1) + + assert panel.writes == [(0, ChannelName.PULSE1, None)] + assert panel.shown(ChannelName.PULSE1) == STORED_LABEL + + def test_a_volume_leaves_the_mark_out(self, panel: Panel) -> None: + panel.mark(SAMPLE_INDEX) + + panel.type_volume(TYPED_VOLUME, ChannelName.PULSE1) + + assert panel.writes == [(0, ChannelName.PULSE1, None)] + + def test_a_typed_number_names_its_own_voice_over_the_mark(self, panel: Panel) -> None: + panel.mark(SAMPLE_INDEX) + + panel.type_voice(INSTRUMENT_INDEX, ChannelName.PULSE1) + + assert panel.writes == [(0, ChannelName.PULSE1, "pad-id")] 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/tracker/conftest.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/conftest.py new file mode 100644 index 000000000..bfe4c8b8e --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/conftest.py @@ -0,0 +1,53 @@ +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.tags.sequencer import TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.sequencer.columns import TRACKER_TABLE_COLUMNS +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 the tracker's rows resolve as they are built.""" + 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() + + +@pytest.fixture +def tracker_table(dpg_context: None) -> str: + """The tracker's table, its columns laid out and its header row raised, with no row below it.""" + with dpg.window(): + with dpg.table(tag=TAG_SEQUENCER_TRACKER_TABLE, header_row=False, row_background=True): + for _ in range(TRACKER_TABLE_COLUMNS): + dpg.add_table_column() + + dpg.add_table_row() + + return TAG_SEQUENCER_TRACKER_TABLE diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_band.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_band.py new file mode 100644 index 000000000..8ee2f21d5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_band.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass +from typing import Final + +import pytest + +from sampletones_application.ui.panels.sequencer.columns import HEADER_TABLE_ROW, HEADER_TABLE_ROWS +from sampletones_application.ui.panels.sequencer.tracker.band import TrackerBand, TrackerRows +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ROW_HEIGHT: Final[float] = 29.0 +REACH: Final[int] = 3 +FRAME_ROWS: Final[int] = 8 + + +class TestWhereTheTableRowsStand: + """The header heads the table, the rows before the frame follow, then the frame's own, then those after.""" + + def test_the_frame_starts_below_the_header_and_the_rows_before_it(self) -> None: + rows = TrackerRows(reach=REACH, frame_rows=FRAME_ROWS) + + assert rows.table_row(0) == HEADER_TABLE_ROWS + REACH + assert rows.body_row(0) == REACH + + def test_no_row_lands_on_the_header(self) -> None: + rows = TrackerRows(reach=REACH, frame_rows=FRAME_ROWS) + mapped = ( + {rows.lead_table_row(slot) for slot in range(REACH)} + | {rows.table_row(row) for row in range(FRAME_ROWS)} + | {rows.trail_table_row(slot) for slot in range(REACH)} + ) + + assert HEADER_TABLE_ROW not in mapped + + def test_every_row_takes_a_table_row_of_its_own_in_order(self) -> None: + rows = TrackerRows(reach=REACH, frame_rows=FRAME_ROWS) + mapped = ( + [rows.lead_table_row(slot) for slot in range(REACH)] + + [rows.table_row(row) for row in range(FRAME_ROWS)] + + [rows.trail_table_row(slot) for slot in range(REACH)] + ) + + assert mapped == list(range(HEADER_TABLE_ROWS, HEADER_TABLE_ROWS + rows.body_rows)) + + def test_without_a_reach_a_pattern_row_sits_one_past_the_header(self) -> None: + rows = TrackerRows(reach=0, frame_rows=FRAME_ROWS) + + assert [rows.table_row(row) for row in range(FRAME_ROWS)] == list(range(1, FRAME_ROWS + 1)) + + +class TestTheBandsReach(BaseTestSuite): + """The band reaches as many rows either side of a frame as stand above its center row.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + rows_tall: float + expected: int + + test_cases = ( + TestCase(label="an odd number of rows, centered exactly", rows_tall=7.0, expected=3), + TestCase(label="an even number of rows", rows_tall=8.0, expected=4), + TestCase(label="a part of a row past an odd fit", rows_tall=7.4, expected=4), + TestCase(label="a single row", rows_tall=1.0, expected=0), + TestCase(label="less than one row", rows_tall=0.5, expected=0), + TestCase(label="nothing measured", rows_tall=0.0, expected=0), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_reach(self, test_case: TestCase) -> None: + band = TrackerBand(height=test_case.rows_tall * ROW_HEIGHT, row_height=ROW_HEIGHT) + + assert band.reach == test_case.expected + + +class TestCenteringARow: + """The scroll that centers a row puts the middle of that row on the middle of the band.""" + + @pytest.mark.parametrize("rows_tall", [7.0, 8.0, 7.4, 12.6]) + @pytest.mark.parametrize("frame_row", [0, 1, FRAME_ROWS - 1]) + def test_the_row_stands_at_the_center(self, rows_tall: float, frame_row: int) -> None: + band = TrackerBand(height=rows_tall * ROW_HEIGHT, row_height=ROW_HEIGHT) + rows = TrackerRows(reach=band.reach, frame_rows=FRAME_ROWS) + + scroll = band.centering(rows.body_row(frame_row)) + middle_on_screen = rows.body_row(frame_row) * ROW_HEIGHT - scroll + ROW_HEIGHT / 2 + + assert middle_on_screen == pytest.approx(band.height / 2) + + def test_the_room_either_side_lets_every_frame_row_reach_the_center(self) -> None: + band = TrackerBand(height=7.4 * ROW_HEIGHT, row_height=ROW_HEIGHT) + rows = TrackerRows(reach=band.reach, frame_rows=FRAME_ROWS) + scroll_max = rows.body_rows * ROW_HEIGHT - band.height + + for frame_row in range(FRAME_ROWS): + assert 0.0 <= band.centering(rows.body_row(frame_row)) <= scroll_max + + def test_a_row_above_the_band_scrolls_no_further_than_the_top(self) -> None: + band = TrackerBand(height=7.0 * ROW_HEIGHT, row_height=ROW_HEIGHT) + + assert band.centering(0) == 0.0 diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_context.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_context.py new file mode 100644 index 000000000..7e1a10166 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_context.py @@ -0,0 +1,225 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.config import LayoutConfig +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_application.tags.sequencer import TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.panels.sequencer.tracker.band import TrackerRows +from sampletones_application.ui.panels.sequencer.tracker.context import BLANK_LABEL, ContextRows +from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes +from sampletones_application.view_model.sequencer.settings import SequencerSettingsViewModel +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.tracker import SequencerTrackerViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.utils.display import display_id, display_volume + +FRAME_ROWS: Final[int] = 4 +REACH: Final[int] = 3 +SONG_REACH: Final[int] = 2 +MARKED_VOLUME: Final[int] = 5 +BAR: Final[int] = 2 +BEAT: Final[int] = 8 +PULSE1_VOLUME_SLOT: Final[int] = len(SubColumn) + list(SubColumn).index(SubColumn.VOLUME) + + +@dataclass(frozen=True, kw_only=True) +class Song: + """Three frames of :data:`FRAME_ROWS` rows, a volume on the first frame's last row.""" + + logic: SequencerTrackerLogic + + def around(self, frame_index: int) -> SequencerTrackerViewModel: + """The grid of one frame with :data:`SONG_REACH` rows of the song either side of it.""" + self.logic.select_frame(frame_index) + return self.logic.build_grid() + + +@pytest.fixture +def song() -> Song: + controller = ProjectController(ProjectManager()) + controller.set_rows_per_pattern(FRAME_ROWS) + controller.append_frame() + controller.append_frame() + logic = SequencerTrackerLogic(controller) + logic.set_cell_subcolumn(FRAME_ROWS - 1, ChannelName.PULSE1, volume=MARKED_VOLUME) + logic.set_reach(SONG_REACH) + return Song(logic=logic) + + +@pytest.fixture +def context_rows(layout_config: LayoutConfig, tracker_table: str) -> ContextRows: + layout = layout_config.tabs.sequencer + themes = TrackerThemes(layout) + themes.create() + return ContextRows( + layout=layout, + themes=themes, + subcolumn_widths={subcolumn: layout.tracker.subcolumn_widths.voice for subcolumn in SubColumn}, + ) + + +def _settings() -> SequencerSettingsViewModel: + """A meter whose bar opens every :data:`BAR` rows and whose beat never opens inside one.""" + return SequencerSettingsViewModel( + nes_frequency=60, + tempo=150, + speed=6, + rows_per_pattern=FRAME_ROWS, + first_highlight=BEAT, + second_highlight=BAR, + ) + + +def _table_rows() -> List[int]: + """The rows the table holds below its header.""" + return list(dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1))[1:] + + +def _numbers() -> List[str]: + """What the row-number cell of each row below the header reads.""" + numbers: List[str] = [] + for table_row in _table_rows(): + number_cell = dpg.get_item_children(table_row, 1)[1] + numbers.append(dpg.get_item_configuration(dpg.get_item_children(number_cell, 1)[0])["label"]) + + return numbers + + +def _slots(table_row: int) -> List[int]: + """Every slot selectable a row lays out, the Sample column's first.""" + slots: List[int] = [] + for cell in dpg.get_item_children(table_row, 1): + for child in dpg.get_item_children(cell, 1): + if dpg.get_item_info(child)["type"] == "mvAppItemType::mvGroup": + slots.extend(dpg.get_item_children(child, 1)) + + return slots + + +def _volume_label(table_row: int) -> str: + """What the Pulse 1 volume slot of a row reads, the Sample column's three slots standing before it.""" + return str(dpg.get_item_configuration(_slots(table_row)[PULSE1_VOLUME_SLOT])["label"]) + + +class TestTheRowsBeforeTheFrame: + """The rows above the frame show the song's rows leading into it, blank above where the song begins.""" + + def test_the_songs_rows_stand_nearest_the_frame_last(self, context_rows: ContextRows, song: Song) -> None: + context_rows.build_lead(REACH, song.around(1).lead) + + assert _numbers() == [BLANK_LABEL, display_id(FRAME_ROWS - 2), display_id(FRAME_ROWS - 1)] + + def test_a_row_reads_what_its_own_frame_holds(self, context_rows: ContextRows, song: Song) -> None: + context_rows.build_lead(REACH, song.around(1).lead) + + assert _volume_label(_table_rows()[-1]) == display_volume(MARKED_VOLUME) + + def test_a_reach_shorter_than_what_the_song_holds_takes_the_nearest( + self, + context_rows: ContextRows, + song: Song, + ) -> None: + context_rows.build_lead(1, song.around(1).lead) + + assert _numbers() == [display_id(FRAME_ROWS - 1)] + + +class TestTheRowsAfterTheFrame: + """The rows below the frame show the song's rows following it, blank below where the song ends.""" + + def test_the_songs_rows_stand_nearest_the_frame_first(self, context_rows: ContextRows, song: Song) -> None: + context_rows.build_trail(REACH, song.around(1).trail) + + assert _numbers() == [display_id(0), display_id(1), BLANK_LABEL] + + def test_past_the_last_frame_every_row_is_blank(self, context_rows: ContextRows, song: Song) -> None: + context_rows.build_trail(REACH, song.around(2).trail) + + assert _numbers() == [BLANK_LABEL] * REACH + + +class TestARowOfAnotherFrameTakesNoGesture: + """A row beside the frame is read and never edited: its cells answer no click and carry no handler.""" + + def test_no_cell_carries_a_callback_or_a_handler(self, context_rows: ContextRows, song: Song) -> None: + context_rows.build_lead(REACH, song.around(1).lead) + + for table_row in _table_rows(): + for slot in _slots(table_row): + assert dpg.get_item_configuration(slot)["callback"] is None + assert dpg.get_item_info(slot)["handlers"] is None + + +class TestARefill: + """Showing another frame rewrites the rows standing beside it in place.""" + + def test_the_rows_take_the_neighbors_of_the_frame_now_shown( + self, + context_rows: ContextRows, + song: Song, + ) -> None: + context_rows.build_lead(REACH, song.around(1).lead) + context_rows.build_trail(REACH, song.around(1).trail) + + shown = song.around(0) + context_rows.fill(shown.lead, shown.trail) + + assert _numbers() == [BLANK_LABEL] * REACH + [display_id(0), display_id(1), BLANK_LABEL] + + +class TestTheShadesBesideTheFrame: + """A row beside the frame takes its group's shade by its own index, and the mark while it sounds.""" + + @staticmethod + def _shaded(rows: TrackerRows) -> Dict[int, bool]: + return { + slot: bool(dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, rows.lead_table_row(slot))) + for slot in range(rows.reach) + } + + def test_a_row_opening_a_bar_is_shaded_and_a_blank_row_is_not( + self, + context_rows: ContextRows, + song: Song, + ) -> None: + rows = TrackerRows(reach=REACH, frame_rows=0) + context_rows.build_lead(REACH, song.around(1).lead) + + context_rows.paint(rows, _settings(), playing=None) + + assert self._shaded(rows) == {0: False, 1: True, 2: False} + + def test_a_dimmed_shade_is_fainter_than_the_frames_own( + self, + context_rows: ContextRows, + layout_config: LayoutConfig, + song: Song, + ) -> None: + """A group's wash is translucent already, so a row beside the frame takes a fraction of its strength.""" + bar_row = song.around(1).lead[0] + bar = layout_config.tabs.sequencer.colors.rows.bar.rgba + + shade = context_rows._background(bar_row, _settings(), playing=None) + + assert shade is not None + assert shade.rgba[:3] == bar[:3] + assert 0 < shade.rgba[3] < bar[3] + + @pytest.mark.parametrize("playing", [(0, FRAME_ROWS - 1), None]) + def test_the_sounding_row_carries_the_mark( + self, + context_rows: ContextRows, + song: Song, + playing: Optional[Tuple[int, int]], + ) -> None: + rows = TrackerRows(reach=REACH, frame_rows=0) + context_rows.build_lead(REACH, song.around(1).lead) + + context_rows.paint(rows, _settings(), playing=playing) + + assert self._shaded(rows)[2] is (playing is not None) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py new file mode 100644 index 000000000..f87fccdf9 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_header_menu.py @@ -0,0 +1,406 @@ +import contextlib +from typing import Any, Dict, FrozenSet, Iterator, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.paths import LANG_EN +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 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.""" + +STALE_WIDGET_ID: Sender = 8999 +"""A header id a rebuild has already replaced, which the live map no longer names.""" + +LABEL_MUTE = "Mute" +LABEL_UNMUTE = "Unmute" +LABEL_SOLO = "Solo" +LABEL_UNSOLO = "Unsolo" +LABEL_MUTE_ALL = "Mute all channels" +LABEL_UNMUTE_ALL = "Unmute all channels" +ALL_CHANNEL_LABELS = [LABEL_MUTE_ALL, LABEL_UNMUTE_ALL] + +COLUMN_LABELS: Dict[Optional[ChannelName], str] = { + None: "Sample", + ChannelName.PULSE1: "Pulse 1", + ChannelName.PULSE2: "Pulse 2", + ChannelName.TRIANGLE: "Triangle", + ChannelName.NOISE: "Noise", +} + +HEADER_WIDGETS: Dict[Optional[ChannelName], Sender] = { + column: 200 + index for index, column in enumerate(COLUMN_LABELS) +} + +FULL_MIX: FrozenSet[ChannelName] = frozenset() +EVERY_CHANNEL: FrozenSet[ChannelName] = frozenset(ChannelName.items()) + + +def _others(channel: ChannelName) -> FrozenSet[ChannelName]: + """The mute set a solo of ``channel`` leaves behind.""" + return EVERY_CHANNEL - {channel} + + +class _MenuRecorder: + """Captures the items a header menu builds, standing in for the DearPyGui popup.""" + + def __init__(self) -> None: + self.titles: List[str] = [] + self.items: List[Tuple[str, bool]] = [] + self.callbacks: Dict[str, VoidCallback] = {} + + def add_menu_item(self, **kwargs: Any) -> int: + label = kwargs["label"] + self.items.append((label, kwargs.get("enabled", True))) + self.callbacks[label] = kwargs["callback"] + return 0 + + def add_text(self, message: str, **kwargs: Any) -> int: + self.titles.append(message) + return 0 + + def add_separator(self, **kwargs: Any) -> int: + return 0 + + @property + def labels(self) -> List[str]: + return [label for label, _ in self.items] + + def is_enabled(self, label: str) -> bool: + return dict(self.items)[label] + + def click(self, label: str) -> None: + """Fires a recorded item the way DearPyGui fires a zero-argument callback.""" + self.callbacks[label]() + + +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 + column, so those are wired directly and the rest of the panel is left out. The switch behind + 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(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(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(menu_module, "context_menu", _popup) + return instance + + +def _right_click(panel: GUISequencerTrackerPanel, column: Optional[ChannelName]) -> None: + panel._on_header_right_clicked( + SENDER_WIDGET_ID, + (dpg.mvMouseButton_Right, HEADER_WIDGETS[column]), + ) + + +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, ChannelName.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) + + _right_click(panel, None) + + assert recorder.titles == [COLUMN_LABELS[None]] + assert recorder.labels == ALL_CHANNEL_LABELS + + @pytest.mark.parametrize( + "mouse_button", + [dpg.mvMouseButton_Left, dpg.mvMouseButton_Middle], + ids=["left", "middle"], + ) + def test_only_the_right_button_opens_the_menu(self, recorder: _MenuRecorder, mouse_button: int) -> None: + panel = _panel(FULL_MIX) + + panel._on_header_right_clicked( + SENDER_WIDGET_ID, + (mouse_button, HEADER_WIDGETS[ChannelName.NOISE]), + ) + + assert not recorder.titles + + def test_a_header_a_rebuild_replaced_opens_nothing(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + + panel._on_header_right_clicked(SENDER_WIDGET_ID, (dpg.mvMouseButton_Right, STALE_WIDGET_ID)) + + assert not recorder.titles + + def test_every_channel_header_reaches_its_own_menu(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + + for channel in ChannelName.items(): + _right_click(panel, channel) + + assert recorder.titles == [COLUMN_LABELS[channel] for channel in ChannelName.items()] + + +class TestChannelItemLabels: + @pytest.mark.parametrize( + "muted, expected", + [ + (FULL_MIX, LABEL_MUTE), + (frozenset({ChannelName.PULSE1}), LABEL_UNMUTE), + ], + ids=["audible", "silenced"], + ) + def test_the_mute_item_names_the_change_it_makes( + self, + recorder: _MenuRecorder, + muted: FrozenSet[ChannelName], + expected: str, + ) -> None: + panel = _panel(muted) + + _right_click(panel, ChannelName.PULSE1) + + assert expected in recorder.labels + + @pytest.mark.parametrize( + "muted, expected", + [ + (FULL_MIX, LABEL_SOLO), + (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[ChannelName], + expected: str, + ) -> None: + panel = _panel(muted) + + _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({ChannelName.PULSE1})) + + _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(ChannelName.PULSE1)) + + _right_click(panel, ChannelName.PULSE2) + + assert LABEL_UNMUTE in recorder.labels + assert LABEL_SOLO in recorder.labels + + def test_a_channel_menu_carries_both_blocks(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + + _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({ChannelName.NOISE})) + + _right_click(panel, None) + + assert LABEL_MUTE not in recorder.labels + assert LABEL_UNMUTE not in recorder.labels + assert LABEL_SOLO not in recorder.labels + + +class TestAllChannelItems: + @pytest.mark.parametrize( + "muted", + [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[ChannelName], + ) -> None: + panel = _panel(muted) + + _right_click(panel, None) + + assert recorder.is_enabled(LABEL_MUTE_ALL) + + def test_muting_everything_is_withheld_in_full_silence(self, recorder: _MenuRecorder) -> None: + panel = _panel(EVERY_CHANNEL) + + _right_click(panel, None) + + assert not recorder.is_enabled(LABEL_MUTE_ALL) + + @pytest.mark.parametrize( + "muted", + [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[ChannelName], + ) -> None: + panel = _panel(muted) + + _right_click(panel, None) + + assert recorder.is_enabled(LABEL_UNMUTE_ALL) + + def test_restoring_everything_is_withheld_in_the_full_mix(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + + _right_click(panel, None) + + assert not recorder.is_enabled(LABEL_UNMUTE_ALL) + + def test_both_menus_end_with_the_whole_mix_items(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + + _right_click(panel, ChannelName.PULSE1) + channel_menu = recorder.labels[-2:] + _right_click(panel, None) + + assert channel_menu == ALL_CHANNEL_LABELS + assert recorder.labels[-2:] == ALL_CHANNEL_LABELS + + +class TestHeaderMenuActions: + def test_the_mute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + toggled: List[ChannelName] = [] + panel.on_channel_mute_toggled = toggled.append + + _right_click(panel, ChannelName.TRIANGLE) + recorder.click(LABEL_MUTE) + + assert toggled == [ChannelName.TRIANGLE] + + def test_the_unmute_item_switches_the_clicked_channel(self, recorder: _MenuRecorder) -> None: + panel = _panel(frozenset({ChannelName.TRIANGLE})) + toggled: List[ChannelName] = [] + panel.on_channel_mute_toggled = toggled.append + + _right_click(panel, ChannelName.TRIANGLE) + recorder.click(LABEL_UNMUTE) + + assert toggled == [ChannelName.TRIANGLE] + + def test_the_solo_item_solos_the_clicked_channel(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + soloed: List[ChannelName] = [] + panel.on_channel_soloed = soloed.append + + _right_click(panel, ChannelName.NOISE) + recorder.click(LABEL_SOLO) + + 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(ChannelName.NOISE)) + soloed: List[ChannelName] = [] + panel.on_channel_soloed = soloed.append + + _right_click(panel, ChannelName.NOISE) + recorder.click(LABEL_UNSOLO) + + assert soloed == [ChannelName.NOISE] + + def test_muting_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None: + 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") + + _right_click(panel, None) + recorder.click(LABEL_MUTE_ALL) + + assert muted == [None] + + def test_restoring_everything_reaches_its_own_hook(self, recorder: _MenuRecorder) -> None: + 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") + + _right_click(panel, None) + recorder.click(LABEL_UNMUTE_ALL) + + assert unmuted == [None] + + def test_a_channel_menu_reaches_the_whole_mix_too(self, recorder: _MenuRecorder) -> None: + panel = _panel(FULL_MIX) + muted: List[None] = [] + panel.on_channels_muted = lambda: muted.append(None) + + _right_click(panel, ChannelName.PULSE2) + recorder.click(LABEL_MUTE_ALL) + + assert muted == [None] + + +class TestHeaderTooltips: + @pytest.fixture + def panel(self) -> GUISequencerTrackerPanel: + instance = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + instance._load_header_tooltips(LanguageManager(LANG_EN)) + return instance + + def test_the_channel_tooltip_names_the_solo_modifier(self, panel: GUISequencerTrackerPanel) -> None: + assert Modifier.CTRL.value in panel._tooltip_header_channel + + def test_the_channel_tooltip_leaves_no_placeholder_behind(self, panel: GUISequencerTrackerPanel) -> None: + assert "{" not in panel._tooltip_header_channel + + def test_both_headers_explain_their_click(self, panel: GUISequencerTrackerPanel) -> None: + assert panel._tooltip_header_channel + assert panel._tooltip_header_sample + assert panel._tooltip_header_channel != panel._tooltip_header_sample 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_status.py b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_status.py new file mode 100644 index 000000000..eb9919852 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_status.py @@ -0,0 +1,294 @@ +from dataclasses import dataclass +from typing import Dict, Final, FrozenSet, Optional, Tuple + +import pytest + +from sampletones_application.categories.elements.sequencer import SequencerTrackerElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.ui.panels.sequencer.tracker.status import CellStatusText +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_application.view_model.sequencer.tracker import ( + SequencerCellViewModel, + SequencerRowViewModel, +) +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +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.utils.display import display_id, display_pitch, display_volume +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.language import FakeLanguageManager + +KICK: Final[VoiceEntryViewModel] = VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE) +LEAD: Final[VoiceEntryViewModel] = VoiceEntryViewModel(voice_id="lead-id", name="Lead", kind=VoiceKind.INSTRUMENT) +VOICES: Final[Tuple[VoiceEntryViewModel, ...]] = (KICK, LEAD) +GONE: Final[str] = "gone-id" +NO_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() +BOTH: Final[FrozenSet[ChannelName]] = frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE}) +MIDDLE_C: Final[int] = 60 +PERIOD: Final[int] = 4 +LEVEL: Final[int] = 13 +TEXTS: Final[Dict[object, str]] = { + (Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, SequencerTrackerElements.STATUS_VOICE): "Voice", + (Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, SequencerTrackerElements.STATUS_SAMPLE): "Sample", + (Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, SequencerTrackerElements.STATUS_PITCH): "Pitch", + (Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, SequencerTrackerElements.STATUS_VOLUME): "Volume", + (Page.SEQUENCER, Panel.TRACKER, TextType.LABEL, SequencerTrackerElements.STATUS_NOTE_OFF): "Note off", + ( + Page.SEQUENCER, + Panel.TRACKER, + TextType.LABEL, + SequencerTrackerElements.STATUS_DIFFERENT_VOICES, + ): "Different voices", + ( + Page.SEQUENCER, + Panel.TRACKER, + TextType.LABEL, + SequencerTrackerElements.STATUS_DIFFERENT_PITCHES, + ): "Different pitches", + ( + Page.SEQUENCER, + Panel.TRACKER, + TextType.LABEL, + SequencerTrackerElements.STATUS_DIFFERENT_VOLUMES, + ): "Different volumes", + "sequencer.tracker.template.status_voice": "Voice {voice}", + "sequencer.tracker.template.status_sample": "Sample {voice}", + "sequencer.tracker.template.status_note": "Note {note}", + "sequencer.tracker.template.status_period": "Period {period}", + "sequencer.tracker.template.status_transpose": "Transpose {step}", + "sequencer.tracker.template.status_volume": "Volume {volume}", +} + + +def _cell( + command: Optional[NoteCommand] = None, + pitch: Optional[RowPitch] = None, + level: Optional[int] = None, + kind: Optional[VoiceKind] = None, +) -> SequencerCellViewModel: + return SequencerCellViewModel( + voice=display_id(None), + transpose=display_pitch(pitch), + volume=display_volume(level), + kind=kind, + pitch=pitch, + level=level, + command=command, + ) + + +def _row( + cells: Dict[ChannelName, SequencerCellViewModel], + sample_channels: FrozenSet[ChannelName] = NO_CHANNELS, + carried_channels: FrozenSet[ChannelName] = NO_CHANNELS, +) -> SequencerRowViewModel: + """A row holding ``cells`` on the channels named and empty cells on the rest.""" + return SequencerRowViewModel( + index=0, + cells={channel: cells.get(channel, _cell()) for channel in ChannelName.items()}, + sample_channels=sample_channels, + carried_channels=carried_channels, + ) + + +def _kick() -> SequencerCellViewModel: + return _cell(command=NoteOn(voice_id=KICK.voice_id), kind=VoiceKind.SAMPLE) + + +def _lead() -> SequencerCellViewModel: + return _cell(command=NoteOn(voice_id=LEAD.voice_id), kind=VoiceKind.INSTRUMENT) + + +@pytest.fixture(name="status") +def status_fixture() -> CellStatusText: + return CellStatusText(FakeLanguageManager(texts=TEXTS)) + + +class TestWhatTheStatusSaysOfASlot(BaseTestSuite): + """A slot is named by what it holds in the terms the grid prints it in, and an empty one by its name.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: SequencerRowViewModel + channel: Optional[ChannelName] + subcolumn: SubColumn + expected: str + + test_cases: Tuple["TestWhatTheStatusSaysOfASlot.TestCase", ...] = ( + TestCase( + label="a voice by its number and name", + row=_row({ChannelName.PULSE1: _lead()}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.VOICE, + expected="Voice 01: Lead", + ), + TestCase( + label="a cut", + row=_row({ChannelName.PULSE1: _cell(command=NoteOff())}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.VOICE, + expected="Note off", + ), + TestCase( + label="an empty voice slot", + row=_row({}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.VOICE, + expected="Voice", + ), + TestCase( + label="a voice the pool lacks", + row=_row({ChannelName.PULSE1: _cell(command=NoteOn(voice_id=GONE))}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.VOICE, + expected="Voice", + ), + TestCase( + label="a note by its name", + row=_row({ChannelName.PULSE1: _cell(pitch=Note(value=MIDDLE_C))}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.TRANSPOSE, + expected=f"Note {display_pitch(Note(value=MIDDLE_C))}", + ), + TestCase( + label="a period by its number", + row=_row({ChannelName.NOISE: _cell(pitch=Note(value=PERIOD))}), + channel=ChannelName.NOISE, + subcolumn=SubColumn.TRANSPOSE, + expected=f"Period {PERIOD}", + ), + TestCase( + label="a step up in decimal", + row=_row({ChannelName.PULSE1: _cell(pitch=Step(value=3))}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.TRANSPOSE, + expected="Transpose +3", + ), + TestCase( + label="a step down in decimal", + row=_row({ChannelName.PULSE1: _cell(pitch=Step(value=-12))}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.TRANSPOSE, + expected="Transpose -12", + ), + TestCase( + label="an empty pitch slot", + row=_row({}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.TRANSPOSE, + expected="Pitch", + ), + TestCase( + label="a volume by its level", + row=_row({ChannelName.PULSE1: _cell(level=LEVEL)}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.VOLUME, + expected=f"Volume {LEVEL}", + ), + TestCase( + label="an empty volume slot", + row=_row({}), + channel=ChannelName.PULSE1, + subcolumn=SubColumn.VOLUME, + expected="Volume", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_sentence(self, status: CellStatusText, test_case: TestCase) -> None: + assert status.describe(test_case.row, test_case.channel, test_case.subcolumn, VOICES) == test_case.expected + + +class TestWhatTheStatusSaysOfTheSampleColumn(BaseTestSuite): + """The sample column's slot speaks for the channels its row spans, and says when they differ.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: SequencerRowViewModel + subcolumn: SubColumn + expected: str + + test_cases: Tuple["TestWhatTheStatusSaysOfTheSampleColumn.TestCase", ...] = ( + TestCase( + label="a sample over its channels", + row=_row({ChannelName.PULSE1: _kick(), ChannelName.TRIANGLE: _kick()}, sample_channels=BOTH), + subcolumn=SubColumn.VOICE, + expected="Sample 00: Kick", + ), + TestCase( + label="a sample missing from one of its channels", + row=_row({ChannelName.PULSE1: _kick(), ChannelName.TRIANGLE: _lead()}, sample_channels=BOTH), + subcolumn=SubColumn.VOICE, + expected="Different voices", + ), + TestCase( + label="an instrument alone reads as an empty sample slot", + row=_row({ChannelName.PULSE1: _lead()}), + subcolumn=SubColumn.VOICE, + expected="Sample", + ), + TestCase( + label="a row cut everywhere", + row=_row({channel: _cell(command=NoteOff()) for channel in ChannelName.items()}), + subcolumn=SubColumn.VOICE, + expected="Note off", + ), + TestCase( + label="pitches that differ", + row=_row( + { + ChannelName.PULSE1: _cell(pitch=Note(value=MIDDLE_C)), + ChannelName.TRIANGLE: _cell(pitch=Step(value=3)), + }, + sample_channels=BOTH, + ), + subcolumn=SubColumn.TRANSPOSE, + expected="Different pitches", + ), + TestCase( + label="pitches that agree", + row=_row( + {ChannelName.PULSE1: _cell(pitch=Step(value=3)), ChannelName.TRIANGLE: _cell(pitch=Step(value=3))}, + carried_channels=BOTH, + ), + subcolumn=SubColumn.TRANSPOSE, + expected="Transpose +3", + ), + TestCase( + label="a pitch slot spanning no channel", + row=_row({ChannelName.PULSE1: _cell(pitch=Step(value=3))}), + subcolumn=SubColumn.TRANSPOSE, + expected="Pitch", + ), + TestCase( + label="volumes that differ", + row=_row( + {ChannelName.PULSE1: _cell(level=LEVEL), ChannelName.TRIANGLE: _cell(level=LEVEL - 1)}, + sample_channels=BOTH, + ), + subcolumn=SubColumn.VOLUME, + expected="Different volumes", + ), + TestCase( + label="volumes that agree", + row=_row( + {ChannelName.PULSE1: _cell(level=LEVEL), ChannelName.TRIANGLE: _cell(level=LEVEL)}, sample_channels=BOTH + ), + subcolumn=SubColumn.VOLUME, + expected=f"Volume {LEVEL}", + ), + TestCase( + label="a volume slot spanning no channel", + row=_row({}), + subcolumn=SubColumn.VOLUME, + expected="Volume", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_sentence(self, status: CellStatusText, test_case: TestCase) -> None: + assert status.describe(test_case.row, None, test_case.subcolumn, VOICES) == test_case.expected 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..3cfb3ff8c --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/tracker/test_themes.py @@ -0,0 +1,109 @@ +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]]: + themes, colors = TestWhichThemesAreBuilt._themes(monkeypatch) + return themes._subcolumn, themes._muted_subcolumn, colors + + @staticmethod + def _themes(monkeypatch: pytest.MonkeyPatch) -> Tuple[TrackerThemes, List[BaseColor]]: + """Builds the cell themes, noting the color each one was built in by the number it answers.""" + 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) + monkeypatch.setattr(themes_module, "create_label_selectable_theme", _record) + themes = TrackerThemes(LAYOUT) + themes._create_subcolumn_themes() + return themes, 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) + + def test_a_row_of_another_frame_reads_in_the_dimmed_color(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A cell beside the frame wears its slot's color at the silenced channel's alpha, as a label.""" + themes, colors = self._themes(monkeypatch) + + assert set(themes._context_subcolumn) == set(themes._subcolumn) + for theme_key, theme in themes._context_subcolumn.items(): + assert colors[theme - 1].rgba == colors[themes._muted_subcolumn[theme_key] - 1].rgba + + +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..3fb284eec --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_hover.py @@ -0,0 +1,138 @@ +from typing import Final, Iterator, Optional, Tuple + +import dearpygui.dearpygui as dpg +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 +from tests.suite.status import RecordedStatusBar + +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) + + +@pytest.fixture +def context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +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) == "" + + +@pytest.mark.usefixtures("context") +class TestAHoverReportedAfterTheRowWent: + """A hover is answered a frame after it happened, and the list rebuilds every row on each update.""" + + def test_a_standing_cell_names_its_voice(self) -> None: + status_bar = RecordedStatusBar() + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = status_bar + with dpg.window(): + cell = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + + panel._on_row_hovered(0, cell) + + assert len(status_bar.messages) == 1 + assert status_bar.messages[0].startswith("Kick") + + def test_a_cell_a_rebuild_removed_leaves_the_status_bar_as_it_was(self) -> None: + status_bar = RecordedStatusBar() + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = status_bar + with dpg.window(): + cell = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + dpg.delete_item(cell) + + panel._on_row_hovered(0, cell) + + assert status_bar.messages == [] + with dpg.window(): + standing = dpg.add_text("Kick", user_data=(0, SAMPLE_ID)) + panel._on_row_hovered(0, standing) + assert len(status_bar.messages) == 1 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_mark.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_mark.py new file mode 100644 index 000000000..7e31c9ccb --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_mark.py @@ -0,0 +1,127 @@ +from types import SimpleNamespace +from typing import Callable, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.ui.panels.sequencer.voices import panel as voices_module +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind + +ENTRIES: Tuple[VoiceEntryViewModel, ...] = ( + VoiceEntryViewModel(voice_id="kick-id", name="Kick", kind=VoiceKind.SAMPLE), + VoiceEntryViewModel(voice_id="bass-id", name="Bass", kind=VoiceKind.SAMPLE), +) +MARKED_ID = "bass-id" +MARKED_ROW = 1 +CLICKED_ID = "kick-id" +CLICKED_ROW = 0 +ANY_ITEM = 7 + + +@pytest.fixture(name="panel") +def panel_fixture(monkeypatch: pytest.MonkeyPatch) -> GUISequencerVoicesPanel: + """A panel marking a voice and holding the keyboard, with its table calls stubbed out. + + The key scope reads the tab, the card and the router, which here all leave the panel free to + answer, so what the cases read is the panel's own claim. + """ + panel = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) + panel._entries = ENTRIES + panel._selected_voice_id = MARKED_ID + panel._selected_row = MARKED_ROW + panel._editing_voice_id = None + panel._focused = True + panel._mark_clear_pending = False + panel._tab_active = lambda: True + panel._router = SimpleNamespace(is_field_focused=False) + panel.on_voice_selected = None + monkeypatch.setattr(panel, "card_open", lambda: True) + monkeypatch.setattr(panel, "_highlight_selected_row", lambda position: None) + monkeypatch.setattr(voices_module.dpg, "unhighlight_table_row", lambda table, row: None) + monkeypatch.setattr(voices_module.dpg, "set_value", lambda item, value: None) + return panel + + +@pytest.fixture(name="deferred") +def deferred_fixture(monkeypatch: pytest.MonkeyPatch) -> List[Callable[[], None]]: + """The callbacks the panel asks to run a frame later, held for a case to run.""" + deferred: List[Callable[[], None]] = [] + monkeypatch.setattr(voices_module.FrameCallbackManager, "set_frame_callback", deferred.append) + return deferred + + +class TestTheMarkOutlivesTheKeyboard: + """A marked voice holds the keys until a cell elsewhere takes them, and stays marked while they are away.""" + + def test_a_marked_voice_holds_the_keys(self, panel: GUISequencerVoicesPanel) -> None: + assert panel._keys_active() is True + + def test_blur_yields_the_keys_and_keeps_the_mark(self, panel: GUISequencerVoicesPanel) -> None: + panel.blur() + + assert panel._keys_active() is False + assert panel.selection is not None + assert panel.selection.voice_id == MARKED_ID + + def test_a_row_click_takes_the_keys_back(self, panel: GUISequencerVoicesPanel) -> None: + panel.blur() + + panel._on_voice_selected(ANY_ITEM, False, (CLICKED_ROW, CLICKED_ID)) + + assert panel._keys_active() is True + assert panel.selection is not None + assert panel.selection.voice_id == CLICKED_ID + + def test_deselect_drops_the_mark_and_the_keys(self, panel: GUISequencerVoicesPanel) -> None: + panel.deselect() + + assert panel.selection is None + assert panel._keys_active() is False + + +class TestAPressBelowTheRows: + """A left press on the empty foot of the list clears the mark a frame later, and a row landed on keeps it.""" + + def test_the_press_clears_the_mark_a_frame_later( + self, + panel: GUISequencerVoicesPanel, + deferred: List[Callable[[], None]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(panel, "_pointer_within_list", lambda: True) + + panel._on_list_left_clicked(ANY_ITEM, 0) + + assert panel.selection is not None + (clear,) = deferred + clear() + assert panel.selection is None + + def test_a_row_answering_the_press_keeps_the_mark( + self, + panel: GUISequencerVoicesPanel, + deferred: List[Callable[[], None]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(panel, "_pointer_within_list", lambda: True) + + panel._on_list_left_clicked(ANY_ITEM, 0) + panel._on_voice_clicked(ANY_ITEM, (dpg.mvMouseButton_Left, ANY_ITEM)) + (clear,) = deferred + clear() + + assert panel.selection is not None + + def test_a_press_outside_the_list_changes_nothing( + self, + panel: GUISequencerVoicesPanel, + deferred: List[Callable[[], None]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(panel, "_pointer_within_list", lambda: False) + + panel._on_list_left_clicked(ANY_ITEM, 0) + + assert deferred == [] + assert panel.selection is not None 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..8089d34e8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -0,0 +1,813 @@ +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.tags.sequencer import ( + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, +) +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.elements.panel import GUIPanel +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, + accepts_voices: bool = True, +) -> 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) + GUIPanel.__init__(panel, tag=TAG_SEQUENCER_VOICES_PANEL) + 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._focused = selected_row is not None + panel._editing_voice_id = editing + panel._list_menu_pending = False + panel._accepts_voices = accepts_voices + 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: + """An instrument 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 TestThePoolItemsFollowTheProject: + """The ways a voice comes in answer while a project stands open to take one, as the card's button does.""" + + def test_the_items_answer_while_a_project_is_open( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, accepts_voices=True).menu.add_pool_items() + + assert [item.enabled for item in recorder.items] == [True, True, True] + + def test_the_items_are_grayed_out_with_no_project( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, accepts_voices=False).menu.add_pool_items() + + assert [item.enabled for item in recorder.items] == [False, False, False] + + @pytest.mark.parametrize("enabled", [True, False]) + def test_the_button_its_tooltip_and_the_menus_follow_one_answer( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + enabled: bool, + ) -> None: + """The button's own tooltip shows while it answers, and the one naming what brings it back while it + stands greyed out.""" + configured: List[Tuple[str, bool]] = [] + tooltips: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + panel_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), + ) + monkeypatch.setattr( + panel_module, + "set_tooltip_visible", + lambda tag, visible: tooltips.append((tag, visible)), + ) + fixture = _panel(monkeypatch, accepts_voices=not enabled) + + fixture.panel.set_enabled(enabled) + fixture.menu.add_pool_items() + + assert configured == [(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, enabled)] + assert tooltips == [ + (TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, enabled), + (TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, not enabled), + ] + assert [item.enabled for item in recorder.items] == [enabled] * len(recorder.items) + + +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_on_a_menu_open_over_the_list_is_the_menus( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + fixture = _panel(monkeypatch) + monkeypatch.setattr(panel_module, "dpg_pointer_within_window", lambda _window, _anchor: True) + monkeypatch.setattr(panel_module, "context_menu_under_pointer", lambda: True) + + assert fixture.panel._pointer_within_list() is False + + 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/voices/test_selection.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py new file mode 100644 index 000000000..803e414d3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_selection.py @@ -0,0 +1,56 @@ +from typing import Optional, Tuple + +from sampletones_application.ui.panels.sequencer.voices.panel import GUISequencerVoicesPanel +from sampletones_application.view_model.sequencer.voices import VoiceEntryViewModel, VoiceKind + +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_voice_id: Optional[str], + selected_row: Optional[int], + 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 = GUISequencerVoicesPanel.__new__(GUISequencerVoicesPanel) + panel._entries = entries + panel._selected_voice_id = selected_voice_id + panel._selected_row = selected_row + return panel + + +class TestSelectionAccessor: + def test_reports_the_highlighted_sample(self) -> None: + panel = _panel("bass-id", 1) + + selection = panel.selection + + assert selection is not None + assert selection.voice_id == "bass-id" + assert selection.position == 1 + assert selection.name == "Bass" + assert selection.label == "01: Bass" + + def test_absent_without_a_selection(self) -> None: + assert _panel(None, None).selection is None + + def test_absent_once_the_selected_sample_leaves_the_pool(self) -> None: + panel = _panel("removed-id", 0) + + assert panel.selection is None + + def test_follows_a_renamed_sample(self) -> None: + panel = _panel("kick-id", 0) + panel._entries = (VoiceEntryViewModel(voice_id="kick-id", name="Thump", kind=VoiceKind.SAMPLE),) + + selection = panel.selection + + assert selection is not None + assert selection.label == "00: Thump" 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/callbacks/conftest.py b/tests/unit/sampletones_application/utils/callbacks/conftest.py new file mode 100644 index 000000000..2fc42345f --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import delay_clock, live_queue + +__all__ = ["delay_clock", "live_queue"] diff --git a/tests/unit/sampletones_application/utils/callbacks/test_delay.py b/tests/unit/sampletones_application/utils/callbacks/test_delay.py new file mode 100644 index 000000000..68b2488ba --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/test_delay.py @@ -0,0 +1,105 @@ +import threading +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_application.utils.callbacks.delay import call_after +from sampletones_application.utils.callbacks.queue import CallbackQueue +from tests.suite.application import ManualClock, draw_frame +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +WAIT_SECONDS: Final[float] = 1.0 +SHORT_OF_THE_WAIT: Final[float] = 0.999 +FRAMES_AFTER: Final[int] = 5 + + +class Calls: + """The threads a callback ran on, one entry per run.""" + + def __init__(self) -> None: + self.threads: List[threading.Thread] = [] + + def __call__(self) -> None: + self.threads.append(threading.current_thread()) + + +@pytest.mark.usefixtures("live_queue") +class TestTheWaitRunsOnTheRenderThread(BaseTestSuite): + """A wait handed to ``call_after`` ends in one call, made by the thread draining the queue.""" + + def test_nothing_runs_while_the_wait_stands(self, delay_clock: ManualClock) -> None: + calls = Calls() + + call_after(WAIT_SECONDS, calls) + delay_clock.advance(SHORT_OF_THE_WAIT) + draw_frame() + + assert calls.threads == [] + + def test_the_first_frame_after_the_wait_runs_it_once(self, delay_clock: ManualClock) -> None: + calls = Calls() + + call_after(WAIT_SECONDS, calls) + delay_clock.advance(WAIT_SECONDS) + for _ in range(FRAMES_AFTER): + draw_frame() + + assert calls.threads == [threading.current_thread()] + + def test_the_wait_starts_no_thread(self, delay_clock: ManualClock) -> None: + standing = set(threading.enumerate()) + + call_after(WAIT_SECONDS, Calls()) + + assert set(threading.enumerate()) == standing + + def test_a_stopped_queue_drops_the_wait(self, delay_clock: ManualClock) -> None: + """The shutdown stops the queue before the context goes, so a wait under way never lands.""" + calls = Calls() + + call_after(WAIT_SECONDS, calls) + CallbackQueue.stop() + delay_clock.advance(WAIT_SECONDS) + CallbackQueue.start() + for _ in range(FRAMES_AFTER): + draw_frame() + + assert calls.threads == [] + + +@pytest.mark.usefixtures("live_queue") +class TestTheWaitLastsTheSameSpanAtAnyFrameRate(BaseTestSuite): + """The frame rate is the reader's to set, so the wait is read against the clock.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + frames_during_the_wait: int + + test_cases = ( + TestCase(label="one_frame", frames_during_the_wait=1), + TestCase(label="a_frame_rate_limited_display", frames_during_the_wait=60), + TestCase(label="an_unlimited_frame_rate", frames_during_the_wait=2000), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_runs_once_the_clock_reaches_the_end( + self, + test_case: TestCase, + delay_clock: ManualClock, + ) -> None: + calls = Calls() + step = SHORT_OF_THE_WAIT / test_case.frames_during_the_wait + + call_after(WAIT_SECONDS, calls) + for _ in range(test_case.frames_during_the_wait): + delay_clock.advance(step) + draw_frame() + + assert calls.threads == [] + + delay_clock.advance(WAIT_SECONDS) + draw_frame() + + assert len(calls.threads) == 1 diff --git a/tests/unit/sampletones_application/utils/callbacks/test_failures.py b/tests/unit/sampletones_application/utils/callbacks/test_failures.py new file mode 100644 index 000000000..3176245a4 --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/test_failures.py @@ -0,0 +1,183 @@ +import threading +from typing import Final, Iterator, List, Tuple +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.utils.callbacks.failures import FailurePresenter, UnhandledFailures + +FAILURES_LOGGER: Final[str] = "sampletones_application.utils.callbacks.failures.logger" +ORIGIN: Final[str] = "Error in the place it escaped" +THREAD_TIMEOUT: Final[float] = 5.0 + + +class Posts: + """What the channel hands to the render thread, held until the case drains it.""" + + def __init__(self) -> None: + self.held: List[Tuple[FailurePresenter, Exception]] = [] + self._lock = threading.Lock() + + def post(self, presenter: FailurePresenter, exception: Exception) -> None: + with self._lock: + self.held.append((presenter, exception)) + + def drain(self) -> None: + """Runs what was posted, the way the render loop's drain does.""" + with self._lock: + held, self.held = self.held, [] + + for presenter, exception in held: + presenter(exception) + + +@pytest.fixture +def posts() -> Posts: + return Posts() + + +@pytest.fixture +def presented() -> List[Exception]: + return [] + + +@pytest.fixture +def attached(posts: Posts, presented: List[Exception]) -> None: + UnhandledFailures.attach(presented.append, post=posts.post) + + +class HookInPlace: + """The thread hook a case puts in place before attaching, noting what each escape reached it with.""" + + def __init__(self) -> None: + self.reached: List[type] = [] + + def __call__(self, arguments: threading.ExceptHookArgs) -> None: + self.reached.append(arguments.exc_type) + + +@pytest.fixture +def hook_in_place(monkeypatch: pytest.MonkeyPatch) -> Iterator[HookInPlace]: + """A hook of the case's own in place of the thread hook, detached from before the hook goes back.""" + hook = HookInPlace() + monkeypatch.setattr(threading, "excepthook", hook) + yield hook + UnhandledFailures.detach() + + +@pytest.fixture +def logger() -> Iterator[MagicMock]: + with patch(FAILURES_LOGGER) as logger: + yield logger + + +def escape_on_a_thread(exception: BaseException) -> None: + """Runs a thread that lets ``exception`` escape, and waits for it to end.""" + + def target() -> None: + raise exception + + thread = threading.Thread(target=target) + thread.start() + thread.join(THREAD_TIMEOUT) + + +class TestReporting: + """A failure is logged where it escaped, and the reader is shown it from the render thread.""" + + @pytest.mark.usefixtures("attached") + def test_a_report_is_logged_and_shown_once_drained( + self, + posts: Posts, + presented: List[Exception], + logger: MagicMock, + ) -> None: + failure = RuntimeError("it went wrong") + + UnhandledFailures.report(failure, ORIGIN) + assert presented == [] + posts.drain() + + logger.error_with_traceback.assert_called_once_with(failure, ORIGIN) + assert presented == [failure] + + def test_a_report_with_nothing_attached_is_logged_alone(self, posts: Posts, logger: MagicMock) -> None: + failure = RuntimeError("it went wrong") + + UnhandledFailures.report(failure, ORIGIN) + + logger.error_with_traceback.assert_called_once_with(failure, ORIGIN) + assert posts.held == [] + + @pytest.mark.usefixtures("attached") + def test_a_report_after_the_detachment_is_logged_alone(self, posts: Posts, logger: MagicMock) -> None: + UnhandledFailures.detach() + + UnhandledFailures.report(RuntimeError("it went wrong"), ORIGIN) + + logger.error_with_traceback.assert_called_once() + assert posts.held == [] + + @pytest.mark.usefixtures("attached") + def test_a_report_drained_after_the_detachment_shows_nothing( + self, + posts: Posts, + presented: List[Exception], + ) -> None: + UnhandledFailures.report(RuntimeError("it went wrong"), ORIGIN) + UnhandledFailures.detach() + + posts.drain() + + assert presented == [] + + def test_a_presenter_that_fails_is_logged_and_posts_nothing_more(self, posts: Posts, logger: MagicMock) -> None: + breakage = RuntimeError("the report broke") + + def failing(_: Exception) -> None: + raise breakage + + UnhandledFailures.attach(failing, post=posts.post) + UnhandledFailures.report(RuntimeError("it went wrong"), ORIGIN) + + posts.drain() + + assert logger.error_with_traceback.call_args_list[-1].args[0] is breakage + assert posts.held == [] + + +class TestTheThreadHook: + """A thread letting an ``Exception`` escape reports it, and anything else reaches the hook in place.""" + + @pytest.mark.usefixtures("attached") + def test_an_escaping_exception_is_reported(self, posts: Posts, presented: List[Exception]) -> None: + failure = RuntimeError("the worker went wrong") + + escape_on_a_thread(failure) + posts.drain() + + assert presented == [failure] + + def test_a_system_exit_reaches_the_hook_in_place(self, hook_in_place: HookInPlace, posts: Posts) -> None: + UnhandledFailures.attach(lambda _: None, post=posts.post) + + escape_on_a_thread(SystemExit(0)) + + assert hook_in_place.reached == [SystemExit] + assert posts.held == [] + + def test_the_detachment_puts_back_the_hook_in_place(self, hook_in_place: HookInPlace, posts: Posts) -> None: + UnhandledFailures.attach(lambda _: None, post=posts.post) + assert threading.excepthook is not hook_in_place + + UnhandledFailures.detach() + + assert threading.excepthook is hook_in_place + + def test_attaching_again_keeps_the_hook_found_first(self, hook_in_place: HookInPlace, posts: Posts) -> None: + UnhandledFailures.attach(lambda _: None, post=posts.post) + UnhandledFailures.attach(lambda _: None, post=posts.post) + + UnhandledFailures.detach() + + assert threading.excepthook is hook_in_place diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py new file mode 100644 index 000000000..c528a61d5 --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -0,0 +1,795 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List, Optional, Sequence, Tuple + +import pytest + +from sampletones_application.utils.callbacks.gates import ( + FirstRequestFlight, + Gate, + LatestRequestFlight, + SingleFlight, + asking, + gated, + pass_gates, + waiting, + waiting_for_the_screen, +) +from sampletones_shared.types.callback import VoidCallback +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ARRIVED: Final[str] = "arrived" +DECLINED: Final[str] = "declined" + + +class Guard: + """A gate over one unfinished thing, recording when it is reached and holding its question.""" + + def __init__(self, name: str, reached: List[str], *, unfinished: bool) -> None: + self.name = name + self.unfinished = unfinished + self._reached = reached + self._proceed: Optional[VoidCallback] = None + self._decline: Optional[VoidCallback] = None + + def __call__(self, proceed: VoidCallback, decline: VoidCallback) -> None: + self._reached.append(self.name) + if not self.unfinished: + proceed() + return + + self._proceed = proceed + self._decline = decline + + @property + def is_asking(self) -> bool: + return self._proceed is not None + + def answer(self) -> None: + """The reader going on past the question this gate asked.""" + assert self._proceed is not None + proceed = self._proceed + self._proceed, self._decline = None, None + proceed() + + def cancel(self) -> None: + """The reader turning the request away at the question this gate asked.""" + assert self._decline is not None + decline = self._decline + self._proceed, self._decline = None, None + decline() + + +class HeldWait: + """A wait a case lets go of by hand, the way the open reconstruction's edits hold a gesture until they land.""" + + def __init__(self) -> None: + self._held: List[VoidCallback] = [] + + def __call__(self, proceed: VoidCallback) -> None: + self._held.append(proceed) + + def release(self) -> None: + held, self._held = self._held, [] + for proceed in held: + proceed() + + +@pytest.fixture(name="reached") +def reached_fixture() -> List[str]: + return [] + + +class TestPassingGates: + def test_no_gate_arrives_at_once(self, reached: List[str]) -> None: + pass_gates((), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) + + assert reached == [ARRIVED] + + def test_open_gates_are_passed_in_order(self, reached: List[str]) -> None: + gates = [Guard(name, reached, unfinished=False) for name in ("first", "second", "third")] + + pass_gates(gates, lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) + + assert reached == ["first", "second", "third", ARRIVED] + + def test_a_question_holds_the_gates_after_it(self, reached: List[str]) -> None: + asking = Guard("first", reached, unfinished=True) + + pass_gates( + (asking, Guard("second", reached, unfinished=False)), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + assert reached == ["first"] + + def test_an_answer_goes_on_to_the_next_gate(self, reached: List[str]) -> None: + asking = Guard("first", reached, unfinished=True) + pass_gates( + (asking, Guard("second", reached, unfinished=False)), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + asking.answer() + + assert reached == ["first", "second", ARRIVED] + + def test_a_declined_question_runs_the_decline_alone(self, reached: List[str]) -> None: + """The gates after a declined question and the arrival stay unreached, and the decline says so.""" + first = Guard("first", reached, unfinished=True) + second = Guard("second", reached, unfinished=True) + third = Guard("third", reached, unfinished=False) + pass_gates((first, second, third), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) + + first.answer() + second.cancel() + + assert reached == ["first", "second", DECLINED] + + def test_a_gate_reads_what_it_guards_when_it_is_reached(self, reached: List[str]) -> None: + """A state an earlier question saw settled meanwhile asks nothing once its gate is reached.""" + asking = Guard("first", reached, unfinished=True) + later = Guard("second", reached, unfinished=True) + pass_gates((asking, later), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) + + later.unfinished = False + asking.answer() + + assert reached == ["first", "second", ARRIVED] + assert not later.is_asking + + +class TestAWaitAsAGate: + def test_a_waiting_gate_holds_the_request_until_the_wait_ends(self, reached: List[str]) -> None: + wait = HeldWait() + + pass_gates((waiting(wait),), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) + assert reached == [] + + wait.release() + + assert reached == [ARRIVED] + + def test_a_gated_gesture_runs_with_its_arguments_once_the_wait_ends(self) -> None: + wait = HeldWait() + opened: List[Path] = [] + gesture = gated(wait, opened.append) + + gesture(Path("song.stp")) + assert opened == [] + + wait.release() + + assert opened == [Path("song.stp")] + + +class TestAQuestionAskedOnTheScreen: + """A question waits for the screen, and reads what it asks about once the screen is free for it. + + The thing asked about is a guard's own state: ``unfinished`` decides whether there is anything to ask. + """ + + @pytest.fixture(name="screen") + def screen_fixture(self) -> HeldWait: + return HeldWait() + + @pytest.fixture(name="question") + def question_fixture(self, reached: List[str]) -> Guard: + return Guard("question", reached, unfinished=True) + + @staticmethod + def ask(question: Guard, screen: HeldWait, reached: List[str]) -> None: + pass_gates( + (asking(lambda: question.unfinished, question, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + def test_nothing_unfinished_goes_on_while_the_screen_is_taken( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + question.unfinished = False + + self.ask(question, screen, reached) + + assert reached == [ARRIVED] + + def test_a_question_waits_for_the_screen(self, question: Guard, screen: HeldWait, reached: List[str]) -> None: + self.ask(question, screen, reached) + + assert reached == [] + assert not question.is_asking + + def test_a_question_asks_once_the_screen_is_free( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + self.ask(question, screen, reached) + + screen.release() + + assert reached == ["question"] + assert question.is_asking + + def test_a_thing_settled_while_the_screen_was_taken_asks_nothing( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + """A project another conversation closed meanwhile lets the request through with no question.""" + self.ask(question, screen, reached) + + question.unfinished = False + screen.release() + + assert reached == [ARRIVED] + assert not question.is_asking + + def test_the_answer_reaches_whoever_asked(self, question: Guard, screen: HeldWait, reached: List[str]) -> None: + self.ask(question, screen, reached) + screen.release() + + question.cancel() + + assert reached == ["question", DECLINED] + + def test_a_question_that_fails_on_the_free_screen_turns_the_request_away( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + """The failure goes on up from the turn, and whoever waits on the request hears it ended.""" + + def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: + raise RuntimeError("the question could not be asked") + + pass_gates( + (asking(lambda: True, broken, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + with pytest.raises(RuntimeError): + screen.release() + + assert reached == [DECLINED] + + def test_a_reading_that_fails_on_the_free_screen_turns_the_request_away( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + readings = [True] + + def unsettled() -> bool: + if not readings: + raise RuntimeError("the state could not be read") + + return readings.pop() + + pass_gates( + (asking(unsettled, question, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + with pytest.raises(RuntimeError): + screen.release() + + assert reached == [DECLINED] + assert not question.is_asking + + def test_a_failure_after_the_answer_reaches_whoever_asked_once( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + """A question that went on before it failed has handed the request over, so nothing declines it again.""" + + def going_on_then_failing(proceed: VoidCallback, _decline: VoidCallback) -> None: + proceed() + raise RuntimeError("the question failed after it went on") + + pass_gates( + (asking(lambda: True, going_on_then_failing, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + with pytest.raises(RuntimeError): + screen.release() + + assert reached == [ARRIVED] + + +class TestAChainWaitingForTheScreen: + """A chain of questions waits for the screen while any guard in it has something to ask, and every guard + reads its state once the screen is free.""" + + @pytest.fixture(name="screen") + def screen_fixture(self) -> HeldWait: + return HeldWait() + + def test_nothing_to_ask_goes_on_while_the_screen_is_taken(self, screen: HeldWait, reached: List[str]) -> None: + pass_gates( + (waiting_for_the_screen(lambda: False, screen), Guard("clean", reached, unfinished=False)), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + assert reached == ["clean", ARRIVED] + + def test_something_to_ask_holds_every_guard_until_the_screen_is_free( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + pass_gates( + ( + waiting_for_the_screen(lambda: True, screen), + Guard("clean", reached, unfinished=False), + Guard("unfinished", reached, unfinished=True), + ), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + assert reached == [] + + def test_a_guard_clean_at_the_request_reads_its_state_on_the_free_screen( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + """A dialog standing as the request was made left the first guard's thing unfinished, so it asks first.""" + clean = Guard("clean", reached, unfinished=False) + unfinished = Guard("unfinished", reached, unfinished=True) + pass_gates( + (waiting_for_the_screen(lambda: clean.unfinished or unfinished.unfinished, screen), clean, unfinished), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + clean.unfinished = True + screen.release() + + assert reached == ["clean"] + assert clean.is_asking + assert not unfinished.is_asking + + +class RaisingOnce: + """A gate that fails the first time it is reached and lets every later request through.""" + + def __init__(self, reached: List[str]) -> None: + self._reached = reached + self._raised = False + + def __call__(self, proceed: VoidCallback, _decline: VoidCallback) -> None: + self._reached.append("broken") + if not self._raised: + self._raised = True + raise RuntimeError("the question could not be asked") + + proceed() + + +class Answers: + """A gate that asks each time it is reached and keeps every pair of answers it was handed, stale ones included.""" + + def __init__(self) -> None: + self.handed: List[Tuple[VoidCallback, VoidCallback]] = [] + + def __call__(self, proceed: VoidCallback, decline: VoidCallback) -> None: + self.handed.append((proceed, decline)) + + +def latest_request(gates: Sequence[Gate], arrive: VoidCallback) -> SingleFlight[[]]: + return LatestRequestFlight(gates, arrive) + + +def first_request(gates: Sequence[Gate], arrive: VoidCallback) -> SingleFlight[[]]: + return FirstRequestFlight(lambda: gates, arrive) + + +class TestEitherFlight(BaseTestSuite): + """A gesture holds one conversation at a time, whichever request its end lets through. + + The flight ends when the gates let the gesture through or turn it away, so a gesture asked for + after either asks again. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + build: Callable[[Sequence[Gate], VoidCallback], SingleFlight[[]]] + + test_cases = ( + TestCase(label="latest_request", build=latest_request), + TestCase(label="first_request", build=first_request), + ) + + @pytest.fixture(name="guard") + def guard_fixture(self, reached: List[str]) -> Guard: + return Guard("question", reached, unfinished=True) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_two_requests_ask_once(self, test_case: TestCase, guard: Guard, reached: List[str]) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) + + flight() + flight() + + assert reached == ["question"] + assert flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_answer_arrives_once(self, test_case: TestCase, guard: Guard, reached: List[str]) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) + flight() + flight() + + guard.answer() + + assert reached == ["question", ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_request_after_an_arrival_asks_again( + self, + test_case: TestCase, + guard: Guard, + reached: List[str], + ) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) + flight() + guard.answer() + + flight() + + assert reached == ["question", ARRIVED, "question"] + assert flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_request_after_a_decline_asks_again( + self, + test_case: TestCase, + guard: Guard, + reached: List[str], + ) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) + flight() + guard.cancel() + assert not flight.in_flight + + flight() + + assert reached == ["question", "question"] + assert guard.is_asking + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_two_requests_while_a_wait_holds_the_gesture_ask_once( + self, + test_case: TestCase, + guard: Guard, + reached: List[str], + ) -> None: + wait = HeldWait() + flight = test_case.build((waiting(wait), guard), lambda: reached.append(ARRIVED)) + + flight() + flight() + wait.release() + + assert reached == ["question"] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_gesture_with_nothing_to_ask_arrives_and_lands(self, test_case: TestCase, reached: List[str]) -> None: + flight = test_case.build((Guard("clear", reached, unfinished=False),), lambda: reached.append(ARRIVED)) + + flight() + flight() + + assert reached == ["clear", ARRIVED, "clear", ARRIVED] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_gate_that_raises_ends_the_flight(self, test_case: TestCase, reached: List[str]) -> None: + def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: + reached.append("broken") + raise RuntimeError("the question could not be asked") + + flight = test_case.build((broken,), lambda: reached.append(ARRIVED)) + + with pytest.raises(RuntimeError): + flight() + + assert not flight.in_flight + with pytest.raises(RuntimeError): + flight() + assert reached == ["broken", "broken"] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_raise_once_the_wait_lets_go_ends_the_flight(self, test_case: TestCase, reached: List[str]) -> None: + """The edits landing run the rest of the conversation, and a failure there leaves the gesture to ask again.""" + wait = HeldWait() + flight = test_case.build((waiting(wait), RaisingOnce(reached)), lambda: reached.append(ARRIVED)) + flight() + + with pytest.raises(RuntimeError): + wait.release() + + assert not flight.in_flight + flight() + assert flight.in_flight + wait.release() + assert reached == ["broken", "broken", ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_raise_once_the_question_is_answered_ends_the_flight( + self, + test_case: TestCase, + guard: Guard, + reached: List[str], + ) -> None: + """An answer runs the rest of the conversation, and a failure there leaves the gesture to ask again.""" + flight = test_case.build((guard, RaisingOnce(reached)), lambda: reached.append(ARRIVED)) + flight() + + with pytest.raises(RuntimeError): + guard.answer() + + assert not flight.in_flight + flight() + assert guard.is_asking + guard.answer() + assert reached == ["question", "broken", "question", "broken", ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_raise_once_the_screen_is_free_ends_the_flight(self, test_case: TestCase, reached: List[str]) -> None: + """A question waiting for the screen fails as it is asked there, and the gesture asks again when asked for.""" + screen = HeldWait() + flight = test_case.build( + (asking(lambda: True, RaisingOnce(reached), screen),), + lambda: reached.append(ARRIVED), + ) + flight() + + with pytest.raises(RuntimeError): + screen.release() + + assert not flight.in_flight + flight() + assert flight.in_flight + screen.release() + assert reached == ["broken", "broken", ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_stale_answer_going_on_leaves_the_newer_flight_standing( + self, + test_case: TestCase, + reached: List[str], + ) -> None: + """An answer handed to a conversation that has ended reaches nothing the newer one holds.""" + answers = Answers() + flight = test_case.build((answers,), lambda: reached.append(ARRIVED)) + flight() + stale_proceed, stale_decline = answers.handed[0] + stale_decline() + flight() + + stale_proceed() + + assert flight.in_flight + assert reached == [] + newer_proceed, _ = answers.handed[1] + newer_proceed() + assert reached == [ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_stale_answer_turning_away_leaves_the_newer_flight_standing( + self, + test_case: TestCase, + reached: List[str], + ) -> None: + answers = Answers() + flight = test_case.build((answers,), lambda: reached.append(ARRIVED)) + flight() + stale_proceed, stale_decline = answers.handed[0] + stale_proceed() + flight() + + stale_decline() + + assert flight.in_flight + newer_proceed, _ = answers.handed[1] + newer_proceed() + assert reached == [ARRIVED, ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_an_arrival_that_asks_again_and_raises_keeps_the_newer_flight( + self, + test_case: TestCase, + reached: List[str], + ) -> None: + """A gesture whose arrival asks for it again and then fails leaves the question it asked standing.""" + guard = Guard("question", reached, unfinished=False) + + def arrive() -> None: + reached.append(ARRIVED) + guard.unfinished = True + flight() + raise RuntimeError("the arrival failed after asking again") + + flight = test_case.build((guard,), arrive) + + with pytest.raises(RuntimeError): + flight() + + assert flight.in_flight + assert guard.is_asking + assert reached == ["question", ARRIVED, "question"] + + +class TestLatestRequestFlight: + """A conversation that is the same whatever the gesture carries lets the latest request through. + + The question is asked once for the whole flight, a decline drops every request made during it, and + a request made after the flight ended asks again with what it carries. + """ + + @pytest.fixture(name="guard") + def guard_fixture(self, reached: List[str]) -> Guard: + return Guard("question", reached, unfinished=True) + + @pytest.fixture(name="opened") + def opened_fixture(self) -> List[Path]: + return [] + + @pytest.fixture(name="flight") + def flight_fixture(self, guard: Guard, opened: List[Path]) -> LatestRequestFlight[[Path]]: + return LatestRequestFlight((guard,), opened.append) + + def test_the_answer_goes_on_with_the_latest_request( + self, + flight: LatestRequestFlight[[Path]], + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + flight(Path("first.stn")) + flight(Path("second.stn")) + flight(Path("third.stn")) + + guard.answer() + + assert reached == ["question"] + assert opened == [Path("third.stn")] + + def test_a_request_made_while_a_wait_holds_the_gesture_arrives( + self, + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + """Two files opened while the edits are on their way ask once, and the answer opens the second.""" + wait = HeldWait() + flight: LatestRequestFlight[[Path]] = LatestRequestFlight((waiting(wait), guard), opened.append) + flight(Path("drums.stn")) + flight(Path("bass.stn")) + wait.release() + + guard.answer() + + assert reached == ["question"] + assert opened == [Path("bass.stn")] + + def test_a_decline_drops_every_request_of_the_flight( + self, + flight: LatestRequestFlight[[Path]], + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + flight(Path("first.stn")) + flight(Path("second.stn")) + guard.cancel() + + flight(Path("third.stn")) + guard.answer() + + assert reached == ["question", "question"] + assert opened == [Path("third.stn")] + + def test_a_request_after_the_arrival_starts_a_new_conversation( + self, + flight: LatestRequestFlight[[Path]], + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + flight(Path("first.stn")) + guard.answer() + + flight(Path("second.stn")) + assert opened == [Path("first.stn")] + guard.answer() + + assert reached == ["question", "question"] + assert opened == [Path("first.stn"), Path("second.stn")] + + def test_a_gate_that_raises_drops_its_request(self, reached: List[str], opened: List[Path]) -> None: + flight: LatestRequestFlight[[Path]] = LatestRequestFlight((RaisingOnce(reached),), opened.append) + with pytest.raises(RuntimeError): + flight(Path("first.stn")) + + flight(Path("second.stn")) + + assert reached == ["broken", "broken"] + assert opened == [Path("second.stn")] + + +class TestFirstRequestFlight: + """A conversation built from the request keeps the first request, so the answer holds for what it asked about. + + A request made while the question stands is absorbed, and one made after the answer asks about + what it carries. + """ + + def test_the_answer_goes_on_with_the_first_request(self, reached: List[str]) -> None: + guard = Guard("question", reached, unfinished=True) + opened: List[Path] = [] + flight: FirstRequestFlight[[Path]] = FirstRequestFlight(lambda _path: (guard,), opened.append) + + flight(Path("first.stn")) + flight(Path("second.stn")) + guard.answer() + + assert reached == ["question"] + assert opened == [Path("first.stn")] + + def test_the_question_is_built_from_the_gesture_that_asked(self, reached: List[str]) -> None: + """A question speaking of what the gesture asks for is built from that gesture's arguments.""" + guards: List[Guard] = [] + + def conversation(path: Path) -> List[Gate]: + guard = Guard(path.name, reached, unfinished=True) + guards.append(guard) + return [guard] + + opened: List[Path] = [] + flight: FirstRequestFlight[[Path]] = FirstRequestFlight(conversation, opened.append) + + flight(Path("first.stn")) + flight(Path("second.stn")) + guards[0].answer() + flight(Path("third.stn")) + + assert reached == ["first.stn", "third.stn"] + assert opened == [Path("first.stn")] + assert flight.in_flight + + def test_a_conversation_that_raises_ends_the_flight(self, reached: List[str]) -> None: + def conversation() -> List[Gate]: + raise RuntimeError("the question could not be built") + + flight: FirstRequestFlight[[]] = FirstRequestFlight(conversation, lambda: reached.append(ARRIVED)) + + with pytest.raises(RuntimeError): + flight() + + assert not flight.in_flight + assert not reached diff --git a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py index 50c4711aa..98f5b6f77 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py @@ -1,31 +1,15 @@ from typing import List -from unittest.mock import patch import pytest +from sampletones_application.utils.callbacks.failures import UnhandledFailures from sampletones_application.utils.callbacks.priority import CallbackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -_real_queue_add = CallbackQueue.add - GENEROUS_BUDGET = 1.0 DRAIN_ONE_BUDGET = 0.0 - -@pytest.fixture(autouse=True) -def fresh_queue(): - """Give each test a clean, live CallbackQueue. - - CallbackQueue is a process-global singleton, so a prior test may leave tasks - pending or the queue stopped. stop() clears the heap and marks it stopped, - then start() marks it live again; teardown stops it. The add() patch pins the - real heap-based add() over any call-through stub other suites install. - """ - with patch.object(CallbackQueue, "add", _real_queue_add): - CallbackQueue.stop() - CallbackQueue.start() - yield - CallbackQueue.stop() +pytestmark = pytest.mark.usefixtures("live_queue") class TestCallbackQueueDelivery: @@ -68,6 +52,39 @@ def test_stop_discards_pending_callbacks(self) -> None: assert fired == [] + def test_work_posted_to_a_stopped_queue_is_let_go(self) -> None: + fired: List[bool] = [] + + CallbackQueue.stop() + CallbackQueue.add(lambda: fired.append(True)) + CallbackQueue.start() + CallbackQueue.notify_frame() + CallbackQueue.process(GENEROUS_BUDGET) + + assert fired == [] + + def test_clear_lets_go_of_what_waited(self) -> None: + fired: List[str] = [] + + CallbackQueue.add(lambda: fired.append("before")) + CallbackQueue.clear() + CallbackQueue.add(lambda: fired.append("after")) + CallbackQueue.notify_frame() + CallbackQueue.process(GENEROUS_BUDGET) + + assert fired == ["after"] + + def test_clear_makes_a_stopped_queue_live_again(self) -> None: + fired: List[bool] = [] + + CallbackQueue.stop() + CallbackQueue.clear() + CallbackQueue.add(lambda: fired.append(True)) + CallbackQueue.notify_frame() + CallbackQueue.process(GENEROUS_BUDGET) + + assert fired == [True] + def test_exception_in_callback_is_isolated(self) -> None: good_fired: List[bool] = [] @@ -81,6 +98,20 @@ def bad_callback() -> None: assert good_fired == [True] + def test_a_failing_callback_is_reported_through_the_failure_channel(self) -> None: + reported: List[Exception] = [] + failure = RuntimeError("intentional error") + + def bad_callback() -> None: + raise failure + + UnhandledFailures.attach(reported.append, post=lambda present, exception: present(exception)) + CallbackQueue.add(bad_callback) + CallbackQueue.notify_frame() + CallbackQueue.process(GENEROUS_BUDGET) + + assert reported == [failure] + def test_base_exception_propagates_out_of_process(self) -> None: """run() catches Exception but not BaseException, so a SystemExit raised in a callback propagates out of process() into the render loop rather than @@ -127,6 +158,14 @@ def test_zero_delay_callback_is_due_at_current_frame(self) -> None: assert fired == [True] + def test_current_frame_counts_each_frame_drawn(self) -> None: + start = CallbackQueue.current_frame() + + CallbackQueue.notify_frame() + CallbackQueue.notify_frame() + + assert CallbackQueue.current_frame() == start + 2 + class TestCallbackQueueBudget: def test_zero_budget_runs_one_due_callback_per_call(self) -> None: diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py index c0c4e81f7..c106e1d64 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py @@ -65,6 +65,24 @@ def select_directory(self, *, title: str, initial_directory: Optional[Path]) -> return self._result +class TestTheFolderADialogOpensIn: + """A dialog opens where the reader last worked, or as close to it as the disk still allows.""" + + def test_a_folder_standing_is_opened_in(self, tmp_path: Path) -> None: + backend = FakeBackend(None) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + open_file_dialog(title="Open", initial_directory=tmp_path) + + assert backend.calls[0][2] == tmp_path + + def test_a_folder_gone_opens_the_nearest_one_above_it(self, tmp_path: Path) -> None: + backend = FakeBackend(None) + with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): + select_directory_dialog(title="Choose", initial_directory=tmp_path / "gone" / "deeper") + + assert backend.calls[0][2] == tmp_path + + class TestSaveFileDialog: def test_appends_missing_extension(self) -> None: backend = FakeBackend(Path("/home/user/song")) @@ -153,26 +171,26 @@ def test_without_extension_no_filter_and_no_append(self) -> None: class TestOpenFileDialog: - def test_builds_filter_and_converts_directory(self) -> None: + def test_builds_filter_and_converts_directory(self, tmp_path: Path) -> None: backend = FakeBackend(Path("/audio/clip.wav")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): result = open_file_dialog( title="Open", - initial_directory="/audio", + initial_directory=str(tmp_path), filters=(FileFilter.for_extensions("Audio files", [".wav", ".mp3"]),), ) assert result == Path("/audio/clip.wav") _, _, initial_directory, filters = backend.calls[0] - assert initial_directory == Path("/audio") + assert initial_directory == tmp_path assert filters == (FileFilter(name="Audio files", patterns=("*.wav", "*.mp3")),) class TestSelectDirectoryDialog: - def test_passes_through(self) -> None: + def test_passes_through(self, tmp_path: Path) -> None: backend = FakeBackend(Path("/audio/library")) with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend): - result = select_directory_dialog(title="Choose", initial_directory="/audio") + result = select_directory_dialog(title="Choose", initial_directory=str(tmp_path)) assert result == Path("/audio/library") - assert backend.calls[0] == ("directory", "Choose", Path("/audio"), ()) + assert backend.calls[0] == ("directory", "Choose", tmp_path, ()) 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/clipboard/__init__.py b/tests/unit/sampletones_application/utils/gui/clipboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/__init__.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py new file mode 100644 index 000000000..9e9548910 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py @@ -0,0 +1,189 @@ +import threading +import time +from typing import Callable, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.clipboard.backends.x11.clipboard import ( + CLIPBOARD_ANSWER_SECONDS, + X11TextClipboard, +) +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from tests.suite.application import draw_frame +from tests.suite.base import BaseTestSuite + +TEXT: Final[str] = "SampleToNES/1 order rows=1 positions=0..0\n03" +WAIT_LIMIT_SECONDS: Final[float] = 5.0 +FRAME_PAUSE_SECONDS: Final[float] = 0.001 + + +class HeldReader: + """An owner that hands its text over once a case lets it, counting the transfers asked of it.""" + + def __init__(self, text: str) -> None: + self.text = text + self.release = threading.Event() + self.threads: List[threading.Thread] = [] + self.deadlines: List[float] = [] + + def read_text(self, seconds: float) -> str: + self.threads.append(threading.current_thread()) + self.deadlines.append(seconds) + self.release.wait(WAIT_LIMIT_SECONDS) + return self.text + + +class FailingReader: + """A reader whose transfer breaks, the way an unforeseen failure on the worker would.""" + + def __init__(self) -> None: + self.transfers: int = 0 + + def read_text(self, seconds: float) -> Optional[str]: + self.transfers += 1 + raise RuntimeError("the transfer broke") + + +class SilentReader: + """An owner that answers nothing in the span it is given, the way a stopped program does.""" + + def read_text(self, seconds: float) -> Optional[str]: + return None + + +class Answers: + """The texts a read was answered with, each beside the thread that answered it.""" + + def __init__(self) -> None: + self.received: List[Tuple[Optional[str], threading.Thread]] = [] + + def __call__(self, text: Optional[str]) -> None: + self.received.append((text, threading.current_thread())) + + @property + def texts(self) -> List[Optional[str]]: + return [text for text, _ in self.received] + + +def _draw_until(answered: Callable[[], bool]) -> None: + """Draws frames until ``answered`` holds, the way the render loop drains between frames.""" + deadline = time.monotonic() + WAIT_LIMIT_SECONDS + while not answered() and time.monotonic() < deadline: + draw_frame() + time.sleep(FRAME_PAUSE_SECONDS) + + +@pytest.mark.usefixtures("live_queue") +class TestAReadAnswersOnTheRenderThread(BaseTestSuite): + """The owner is read on a worker, and its text reaches the thread that drains the queue.""" + + def test_the_text_reaches_the_thread_draining_the_queue(self) -> None: + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + reader.release.set() + _draw_until(lambda: bool(answers.received)) + + assert answers.received == [(TEXT, threading.current_thread())] + assert reader.threads[0] is not threading.current_thread() + + def test_the_owner_has_the_answer_span_to_hand_its_text_over(self) -> None: + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + reader.release.set() + _draw_until(lambda: bool(answers.received)) + + assert reader.deadlines == [CLIPBOARD_ANSWER_SECONDS] + + def test_an_owner_answering_nothing_reaches_the_reads_as_no_answer(self) -> None: + """The text the clipboard holds stays unknown, which stands apart from an empty clipboard.""" + answers = Answers() + + X11TextClipboard(SilentReader()).read(answers) + _draw_until(lambda: bool(answers.received)) + + assert answers.texts == [None] + + def test_the_frames_go_on_while_the_owner_is_silent(self) -> None: + """This application's own window hands its text over as a frame polls its events.""" + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + for _ in range(3): + draw_frame() + + assert answers.received == [] + + reader.release.set() + _draw_until(lambda: bool(answers.received)) + + assert answers.texts == [TEXT] + + +@pytest.mark.usefixtures("live_queue") +class TestReadsShareATransfer(BaseTestSuite): + """A burst of reads — a menu opening and a paste after it — asks the owner once.""" + + def test_reads_asked_while_one_is_out_share_its_answer(self) -> None: + reader = HeldReader(TEXT) + clipboard = X11TextClipboard(reader) + first, second = Answers(), Answers() + + clipboard.read(first) + clipboard.read(second) + reader.release.set() + _draw_until(lambda: bool(first.received and second.received)) + + assert (first.texts, second.texts) == ([TEXT], [TEXT]) + assert len(reader.threads) == 1 + + def test_a_read_after_an_answer_asks_the_owner_again(self) -> None: + """The clipboard changes between gestures, so each answered read leaves the next to ask.""" + reader = HeldReader(TEXT) + reader.release.set() + clipboard = X11TextClipboard(reader) + first, second = Answers(), Answers() + + clipboard.read(first) + _draw_until(lambda: bool(first.received)) + reader.text = "the text copied since" + clipboard.read(second) + _draw_until(lambda: bool(second.received)) + + assert second.texts == ["the text copied since"] + assert len(reader.threads) == 2 + + @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") + def test_a_broken_transfer_answers_nothing_and_the_next_read_asks_again(self) -> None: + reader = FailingReader() + clipboard = X11TextClipboard(reader) + first, second = Answers(), Answers() + + clipboard.read(first) + _draw_until(lambda: bool(first.received)) + clipboard.read(second) + _draw_until(lambda: bool(second.received)) + + assert (first.texts, second.texts) == ([None], [None]) + assert reader.transfers == 2 + + +@pytest.mark.usefixtures("live_queue") +class TestShutdown(BaseTestSuite): + def test_an_answer_on_its_way_at_shutdown_is_dropped(self) -> None: + """The shutdown stops the queue before the context goes, so the answer reaches no widget.""" + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + CallbackQueue.stop() + reader.release.set() + SingleThreadExecutor.join_all(timeout=WAIT_LIMIT_SECONDS) + draw_frame() + + assert answers.received == [] diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py new file mode 100644 index 000000000..12ab51595 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py @@ -0,0 +1,207 @@ +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, Dict, Final, List, Mapping, Optional, Tuple + +import pytest + +from sampletones_application.utils.gui.clipboard.backends.x11.connection import ( + PropertyNotify, + PropertyValue, + SelectionEvent, + SelectionNotify, +) +from sampletones_application.utils.gui.clipboard.backends.x11.transfer import ( + INCREMENTAL, + NO_PROPERTY, + NO_WINDOW, + SelectionTransfer, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +UTF8: Final[str] = "UTF8_STRING" +LATIN1: Final[str] = "STRING" +OWNER_WINDOW: Final[int] = 0x400001 +REQUESTOR_WINDOW: Final[int] = 0x600001 +DEADLINE: Final[float] = 0.0 +BLOCK_TEXT: Final[str] = "SampleToNES/1 order rows=1 positions=0..1\n00 03" +WIDE_TEXT: Final[str] = "Zażółć gęślą jaźń" + + +@dataclass(frozen=True) +class Answer: + """How the owner answers a request for one text type: in one write, in pieces, or with silence. + + No pieces at all is a type the owner declines. + """ + + pieces: Tuple[bytes, ...] = () + incremental: bool = False + silent_after: Optional[int] = None + + +class ScriptedOwner: + """The X server and the application holding the clipboard, answering from a script. + + It answers the way an ICCCM owner does: a property written and announced before the + notification naming it, and a text sent in pieces written a piece at a time, each once the + requestor has taken the one before. Events it has no more of mean the deadline passed. + """ + + def __init__(self, answers: Mapping[str, Answer], *, owner: int = OWNER_WINDOW) -> None: + self.answers = answers + self.owner = owner + self.asked: List[str] = [] + self._atoms: Dict[str, int] = {} + self._events: Deque[SelectionEvent] = deque() + self._properties: Dict[int, PropertyValue] = {} + self._pieces: Deque[bytes] = deque() + self._pieces_sent: int = 0 + self._answer: Answer = Answer() + + def atom(self, name: str) -> int: + return self._atoms.setdefault(name, len(self._atoms) + 1) + + def selection_owner(self, selection: int) -> int: + return self.owner + + def create_requestor(self) -> int: + return REQUESTOR_WINDOW + + def convert_selection( + self, + *, + requestor: int, + selection: int, + target: int, + landing: int, + ) -> None: + name = self._name(target) + self.asked.append(name) + self._answer = self.answers.get(name, Answer()) + if self._answer.silent_after == 0: + return + + if not self._answer.pieces: + self._events.append(SelectionNotify(requestor=requestor, landing=NO_PROPERTY)) + return + + if self._answer.incremental: + self._pieces = deque(self._answer.pieces + (b"",)) + self._write(landing, PropertyValue(value_type=self.atom(INCREMENTAL), data=b"\x00\x00\x10\x00")) + else: + self._write(landing, PropertyValue(value_type=target, data=self._answer.pieces[0])) + + self._events.append(SelectionNotify(requestor=requestor, landing=landing)) + + def take_property(self, window: int, atom: int) -> PropertyValue: + value = self._properties.pop(atom, PropertyValue(value_type=0, data=b"")) + self._events.append(PropertyNotify(window=window, atom=atom, new_value=False)) + if self._pieces and self._answer.silent_after != self._pieces_sent: + self._pieces_sent += 1 + self._write(atom, PropertyValue(value_type=self.atom(UTF8), data=self._pieces.popleft())) + + return value + + def next_event(self, deadline: float) -> Optional[SelectionEvent]: + return self._events.popleft() if self._events else None + + def _write(self, atom: int, value: PropertyValue) -> None: + self._properties[atom] = value + self._events.append(PropertyNotify(window=REQUESTOR_WINDOW, atom=atom, new_value=True)) + + def _name(self, atom: int) -> str: + return next(name for name, known in self._atoms.items() if known == atom) + + +def _split(data: bytes, *, size: int) -> Tuple[bytes, ...]: + return tuple(data[start : start + size] for start in range(0, len(data), size)) + + +class TestTheTextATransferReads(BaseTestSuite): + """The owner answers in whichever way it answers, and the transfer reads the text it carried.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + answers: Mapping[str, Answer] = field(default_factory=dict) + text: str + + test_cases = ( + TestCase( + label="text_written_as_utf8", + answers={UTF8: Answer(pieces=(WIDE_TEXT.encode("utf-8"),))}, + text=WIDE_TEXT, + ), + TestCase( + label="latin1_from_an_owner_declining_utf8", + answers={LATIN1: Answer(pieces=("café".encode("latin-1"),))}, + text="café", + ), + TestCase( + label="a_long_text_in_pieces_split_inside_a_character", + answers={UTF8: Answer(pieces=_split(WIDE_TEXT.encode("utf-8"), size=3), incremental=True)}, + text=WIDE_TEXT, + ), + TestCase( + label="an_owner_declining_every_text_type", + answers={}, + text="", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_transfer_reads_the_text_the_owner_carried(self, test_case: TestCase) -> None: + owner = ScriptedOwner(test_case.answers) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == test_case.text + + +class TestAnOwnerThatFallsSilent(BaseTestSuite): + """Silence leaves the clipboard's text unknown, which the transfer ends the conversation over.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + answers: Mapping[str, Answer] + + test_cases = ( + TestCase( + label="an_owner_staying_silent", + answers={UTF8: Answer(pieces=(BLOCK_TEXT.encode(),), silent_after=0)}, + ), + TestCase( + label="an_owner_falling_silent_between_pieces", + answers={UTF8: Answer(pieces=_split(BLOCK_TEXT.encode(), size=8), incremental=True, silent_after=2)}, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_transfer_ends_the_conversation_over_the_silence(self, test_case: TestCase) -> None: + owner = ScriptedOwner(test_case.answers) + + with pytest.raises(TimeoutError): + SelectionTransfer(owner, deadline=DEADLINE).text() + + +class TestWhatATransferAsksFor(BaseTestSuite): + """A transfer asks for what it needs and no more, so a quiet clipboard costs nothing.""" + + def test_a_clipboard_with_no_owner_reads_as_empty_unasked(self) -> None: + owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),))}, owner=NO_WINDOW) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == "" + assert owner.asked == [] + + def test_utf8_is_asked_for_first(self) -> None: + owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),)), LATIN1: Answer(pieces=(b"other",))}) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == BLOCK_TEXT + assert owner.asked == [UTF8] + + def test_a_silent_owner_is_asked_once(self) -> None: + """The deadline covers the whole conversation, so silence ends it.""" + owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),), silent_after=0)}) + + with pytest.raises(TimeoutError): + SelectionTransfer(owner, deadline=DEADLINE).text() + + assert owner.asked == [UTF8] diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py new file mode 100644 index 000000000..48ea13771 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py @@ -0,0 +1,112 @@ +from typing import Final, Iterator, List +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.themes.items import ThemeItems +from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.gui.clipboard import copy_button as copy_button_module +from sampletones_application.utils.gui.clipboard.copy_button import ( + COPIED_LABEL_SECONDS, + copy_to_clipboard, +) +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from tests.suite.application import ManualClock, draw_frame +from tests.suite.base import BaseTestSuite + +WINDOW: Final[str] = "probe.window" +BUTTON: Final[str] = "probe.window.button.copy" +THEME: Final[str] = "probe.theme" +LABEL: Final[str] = "Copy" +COPIED: Final[str] = "Copied" +TEXT: Final[str] = "the text a reader copies" +FAILURES_LOGGER: Final[str] = "sampletones_application.utils.callbacks.failures.logger" + + +@pytest.fixture +def written(monkeypatch: pytest.MonkeyPatch) -> List[str]: + """What reaches the desktop's clipboard, held here so a case leaves the reader's own alone.""" + texts: List[str] = [] + monkeypatch.setattr(copy_button_module.dpg, "set_clipboard_text", texts.append) + return texts + + +@pytest.fixture +def copy_button() -> Iterator[GUIButton]: + """A Copy button standing in a window, built the way the traceback and the instruments build theirs.""" + layout_config = load_layout_config( + LAYOUT_DIRECTORY, + BEHAVIOR_DIRECTORY, + PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default), + ) + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + try: + with dpg.window(tag=WINDOW): + button = GUIButton( + tag=BUTTON, + label=LABEL, + parent=WINDOW, + theme=Theme(tag=THEME, items=ThemeItems()), + ) + + yield button + finally: + GUIButton.delete(BUTTON) + dpg.destroy_context() + + +@pytest.mark.usefixtures("live_queue", "written") +class TestCopyingWithTheButton(BaseTestSuite): + """A press puts the text on the clipboard and says so on the button for a moment.""" + + def test_the_text_reaches_the_clipboard(self, copy_button: GUIButton, written: List[str]) -> None: + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + + assert written == [TEXT] + + def test_the_button_says_copied_while_the_moment_stands( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + draw_frame() + + assert copy_button.get_item_label() == COPIED + + def test_the_button_reads_its_own_label_once_the_moment_passes( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + delay_clock.advance(COPIED_LABEL_SECONDS) + draw_frame() + + assert copy_button.get_item_label() == LABEL + + def test_a_button_closed_within_the_moment_is_passed_over( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + """The error dialog carrying a Copy button can be closed before its label comes back.""" + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + dpg.delete_item(BUTTON) + delay_clock.advance(COPIED_LABEL_SECONDS) + with patch(FAILURES_LOGGER) as failures_logger: + draw_frame() + + failures_logger.error_with_traceback.assert_not_called() diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py b/tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py new file mode 100644 index 000000000..3be504920 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Final, Optional, Type +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.utils.gui.clipboard.backends.dearpygui import DearPyGuiTextClipboard +from sampletones_application.utils.gui.clipboard.backends.x11.clipboard import X11TextClipboard +from sampletones_application.utils.gui.clipboard.protocol import TextClipboard +from sampletones_application.utils.gui.clipboard.selection import ( + DISPLAY_VARIABLE, + select_text_clipboard, +) +from sampletones_shared.utils.system.system import System +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +MODULE: Final[str] = "sampletones_application.utils.gui.clipboard.selection" +DISPLAY: Final[str] = ":7" + + +class TestTheClipboardFitsTheEnvironment(BaseTestSuite): + """Linux reads over a connection of its own while it can reach the display; elsewhere DearPyGui serves.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + system: System + display: Optional[str] + xcb_loads: bool + expected: Type[TextClipboard] + + test_cases = ( + TestCase( + label="linux_on_a_display", + system=System.LINUX, + display=DISPLAY, + xcb_loads=True, + expected=X11TextClipboard, + ), + TestCase( + label="linux_naming_no_display", + system=System.LINUX, + display=None, + xcb_loads=True, + expected=DearPyGuiTextClipboard, + ), + TestCase( + label="linux_lacking_libxcb", + system=System.LINUX, + display=DISPLAY, + xcb_loads=False, + expected=DearPyGuiTextClipboard, + ), + TestCase( + label="windows", + system=System.WINDOWS, + display=None, + xcb_loads=False, + expected=DearPyGuiTextClipboard, + ), + TestCase( + label="macos", + system=System.MACOS, + display=DISPLAY, + xcb_loads=False, + expected=DearPyGuiTextClipboard, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_selected_clipboard_fits(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None: + if test_case.display is None: + monkeypatch.delenv(DISPLAY_VARIABLE, raising=False) + else: + monkeypatch.setenv(DISPLAY_VARIABLE, test_case.display) + + with ( + patch(f"{MODULE}.System.current", return_value=test_case.system), + patch(f"{MODULE}.load_xcb", return_value=MagicMock() if test_case.xcb_loads else None), + ): + clipboard = select_text_clipboard() + + assert isinstance(clipboard, test_case.expected) diff --git a/tests/unit/sampletones_application/utils/gui/conftest.py b/tests/unit/sampletones_application/utils/gui/conftest.py new file mode 100644 index 000000000..2fc42345f --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import delay_clock, live_queue + +__all__ = ["delay_clock", "live_queue"] diff --git a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py index a936841cb..67a68f371 100644 --- a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py +++ b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py @@ -128,6 +128,18 @@ def test_dispose_releases_the_keyboard_once(self) -> None: assert router.is_modal_open + def test_a_navigator_disposed_beneath_a_newer_claim_releases_its_own(self) -> None: + """A dialog closing after it raised another leaves the keyboard with the one it raised.""" + router = KeyRouter() + navigator = _navigator(on_escape=MagicMock(), router=router) + router.push_modal(navigator) + raised = MagicMock() + router.push_modal(raised) + + navigator.dispose() + + assert router._modal_stack == [raised] + def test_focus_initial_delegates_while_the_window_is_present(self) -> None: navigator = _navigator(on_escape=MagicMock(), router=KeyRouter()) navigator._ring = MagicMock() diff --git a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py index 11738fc7a..136544633 100644 --- a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py +++ b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_ring.py @@ -118,6 +118,34 @@ def test_leaves_field_stops_without_an_outline(self) -> None: registry.get.assert_not_called() + def test_a_button_styled_beyond_the_default_keeps_its_own_look_while_focused(self) -> None: + """A themed button, such as a danger one, names its own focused variant, so the accent + border a stop takes on carries that button's colors forward rather than the plain + default look every other stop shares.""" + stops = [ + FocusStop.field("title"), + FocusStop.button( + "cancel", + MagicMock(), + base_theme_tag="global.theme.danger_button", + focused_theme_tag="global.theme.danger_button_focused", + ), + ] + ring = FocusRing(stops, initial_index=0) + + themes: Dict[str, MagicMock] = defaultdict(MagicMock) + registry = MagicMock() + registry.get.side_effect = lambda tag: themes[tag] + + with patch(f"{MODULE}.dpg", _dpg()), patch(f"{MODULE}.ThemeRegistry", registry): + ring.focus_initial() + ring.cycle(1) + ring.cycle(1) + + themes["global.theme.danger_button_focused"].bind_to_item.assert_any_call("cancel.button") + themes["global.theme.danger_button"].bind_to_item.assert_any_call("cancel.button") + themes[TAG_GLOBAL_THEME_FOCUSED_BUTTON].bind_to_item.assert_not_called() + class TestActivateFocused: def test_activates_the_focused_button(self) -> None: diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/conftest.py b/tests/unit/sampletones_application/utils/gui/dialogs/conftest.py new file mode 100644 index 000000000..7e6992a1f --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/conftest.py @@ -0,0 +1,41 @@ +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 +from tests.suite.frames import held_frames + +__all__ = ["held_frames"] + + +@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/test_renderer.py b/tests/unit/sampletones_application/utils/gui/dialogs/test_renderer.py new file mode 100644 index 000000000..694b1360f --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/test_renderer.py @@ -0,0 +1,125 @@ +from typing import Final, Iterator, List +from unittest.mock import patch + +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_GROUP +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.dialogs.renderer import FAILURE_REPORT_TAG +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.modal_queue import ModalQueue +from tests.suite.frames import Frames, held_frames +from tests.suite.questions import StandingWindow, standing_window +from tests.suite.shortcuts import shipped_source + +__all__ = ["held_frames", "standing_window"] + +TURN: Final[str] = "turn" +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +REPORT_LINE: Final[str] = "the last action did not finish" +FIRST_FAILURE: Final[str] = "the first failure" +SECOND_FAILURE: Final[str] = "the second failure" +VIEWPORT_WIDTH: Final[int] = 1280 +VIEWPORT_HEIGHT: Final[int] = 800 + + +class TestTheWaitForTheScreen: + """A guard waits for the screen through the renderer, as a turn in the modal line.""" + + def test_a_turn_runs_at_once_on_a_free_screen(self) -> None: + turns: List[str] = [] + + DialogsRenderer.when_free(lambda: turns.append(TURN)) + + assert turns == [TURN] + + def test_a_turn_runs_once_the_standing_window_leaves(self, standing_window: StandingWindow) -> None: + turns: List[str] = [] + + DialogsRenderer.when_free(lambda: turns.append(TURN)) + assert not turns + standing_window.leave() + + assert turns == [TURN] + + +@pytest.fixture +def renderer(dpg_context: None, layout_config: LayoutConfig) -> DialogsRenderer: + return DialogsRenderer( + layout=layout_config.general, + language_manager=LANGUAGE_MANAGER, + status_bar=GUIStatusBar(display_time=layout_config.behavior.ui.status_bar_display_time), + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def reported_texts() -> List[str]: + """The lines naming the failure the standing report shows.""" + return [str(dpg.get_value(item)) for item in dpg.get_item_children(compose_tag(FAILURE_REPORT_TAG, SUF_GROUP), 1)] + + +def dismiss_the_report() -> None: + """Presses OK on the standing report.""" + dpg.get_item_callback(compose_tag(FAILURE_REPORT_TAG, SUF_BUTTON_OK, SUF_BUTTON))() + + +class TestTheFailureReport: + """A failure nothing recovered from stands on the screen once, until the reader dismisses it.""" + + @pytest.fixture(autouse=True) + def viewport(self) -> Iterator[None]: + """The viewport a report centers itself in, which a suite never opens.""" + with ( + patch.object(dpg, "get_viewport_client_width", return_value=VIEWPORT_WIDTH), + patch.object(dpg, "get_viewport_client_height", return_value=VIEWPORT_HEIGHT), + ): + yield + + @pytest.mark.usefixtures("held_frames") + def test_a_report_stands_under_its_own_tag(self, renderer: DialogsRenderer) -> None: + renderer.show_failure_report(RuntimeError(FIRST_FAILURE), REPORT_LINE) + + assert ModalQueue.snapshot().shown == FAILURE_REPORT_TAG + assert FIRST_FAILURE in reported_texts() + + @pytest.mark.usefixtures("held_frames") + def test_a_second_failure_leaves_the_first_report_standing(self, renderer: DialogsRenderer) -> None: + renderer.show_failure_report(RuntimeError(FIRST_FAILURE), REPORT_LINE) + + renderer.show_failure_report(RuntimeError(SECOND_FAILURE), REPORT_LINE) + + assert FIRST_FAILURE in reported_texts() + assert ModalQueue.snapshot().waiting == () + + def test_a_failure_after_the_dismissal_is_reported_again( + self, + renderer: DialogsRenderer, + held_frames: Frames, + ) -> None: + renderer.show_failure_report(RuntimeError(FIRST_FAILURE), REPORT_LINE) + dismiss_the_report() + + renderer.show_failure_report(RuntimeError(SECOND_FAILURE), REPORT_LINE) + held_frames.render() + + assert SECOND_FAILURE in reported_texts() + + def test_a_report_waits_behind_a_standing_window( + self, + renderer: DialogsRenderer, + standing_window: StandingWindow, + ) -> None: + renderer.show_failure_report(RuntimeError(FIRST_FAILURE), REPORT_LINE) + renderer.show_failure_report(RuntimeError(SECOND_FAILURE), REPORT_LINE) + assert ModalQueue.snapshot().waiting == (FAILURE_REPORT_TAG,) + + standing_window.leave() + + assert FIRST_FAILURE in reported_texts() 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/test_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py new file mode 100644 index 000000000..6976801a8 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_confirmation.py @@ -0,0 +1,316 @@ +from pathlib import Path +from typing import Final, List, Optional +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_EXIT_CONFIRMATION, + TAG_GLOBAL_DIALOG_PATH_MESSAGE, +) +from sampletones_application.utils.gui.dialogs import get_dialog_tag +from sampletones_application.utils.gui.dialogs.windows.confirmation import ( + GUIConfirmationWindow, +) +from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames +from tests.suite.shortcuts import shipped_scheme, shipped_source + +WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) +FOLLOWING_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION) +CONFIRMED: Final[str] = "confirmed" +CANCELED: Final[str] = "canceled" +OPTED_OUT: Final[str] = "opted_out" +FOLLOWING_CANCELED: Final[str] = "following_canceled" + + +@pytest.fixture(name="router") +def router_fixture() -> KeyRouter: + return KeyRouter() + + +def build_window(tag: str, layout_config: LayoutConfig, router: KeyRouter) -> GUIConfirmationWindow: + return GUIConfirmationWindow( + tag=tag, + geometry=layout_config.general.dialogs.confirmation, + wrap=layout_config.general.dialogs.default.width - 10, + path_color=layout_config.general.colors.paths.default, + path_hover_color=layout_config.general.colors.paths.hover, + path_message="path", + status_bar=MagicMock(), + key_router=router, + shortcut_source=shipped_source(), + ) + + +@pytest.fixture(name="window") +def window_fixture( + dpg_context: None, + layout_config: LayoutConfig, + router: KeyRouter, +) -> GUIConfirmationWindow: + return build_window(WINDOW_TAG, layout_config, router) + + +def render( + window: GUIConfirmationWindow, + *, + answers: List[str], + path: Optional[Path] = None, + opt_out_label: Optional[str] = None, + on_confirm: Optional[VoidCallback] = None, +) -> None: + """Builds the prompt for the given question, the way ``show`` does without a live frame.""" + window.prepare( + "Save it?", + "Title", + on_confirm if on_confirm is not None else lambda: answers.append(CONFIRMED), + ok_label="Yes", + cancel_label="No", + path=path, + opt_out_label=opt_out_label, + on_opt_out=lambda: answers.append(OPTED_OUT), + on_cancel=lambda: answers.append(CANCELED), + ) + window.create_window() + + +def button_callback(window_tag: str, suffix: str) -> VoidCallback: + """What a click on one of the prompt's buttons runs, read while the button stands.""" + callback = dpg.get_item_callback(compose_tag(compose_tag(window_tag, suffix), SUF_BUTTON)) + assert callback is not None + return callback + + +def press(window_tag: str, suffix: str) -> None: + button_callback(window_tag, suffix)() + + +def escape(router: KeyRouter) -> None: + """The press the shipped scheme cancels a dialog with, routed the way the keyboard sends it.""" + combination = shipped_scheme().shortcut(ShortcutId.DIALOG_CANCEL).combination + assert combination is not None + router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + +class TestAnAnswerRunsOnceThePromptHasLeft: + """DearPyGui carries one modal at a time, so whatever an answer raises waits for the prompt to go.""" + + def test_ok_leaves_the_screen_before_it_answers( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(WINDOW_TAG, SUF_BUTTON_OK) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + + def test_ok_confirms_a_frame_later( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + + assert answers == [CONFIRMED] + + def test_cancel_leaves_the_screen_before_it_answers( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(WINDOW_TAG, SUF_BUTTON_CANCEL) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + + def test_cancel_answers_the_negative_choice_a_frame_later( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + press(WINDOW_TAG, SUF_BUTTON_CANCEL) + held_frames.render() + + assert answers == [CANCELED] + + def test_escape_answers_as_cancel( + self, + window: GUIConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + escape(router) + held_frames.render() + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [CANCELED] + + def test_the_title_bar_close_answers_as_cancel( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers) + + dpg.get_item_configuration(WINDOW_TAG)["on_close"]() + held_frames.render() + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [CANCELED] + + def test_a_ticked_opt_out_rides_the_confirmation( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + """The box is read as the reader leaves, since the answer runs once it has been deleted.""" + answers: List[str] = [] + render(window, answers=answers, opt_out_label="Do not ask again") + dpg.set_value(compose_tag(WINDOW_TAG, SUF_CHECKBOX), True) + + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + + assert not dpg.does_item_exist(compose_tag(WINDOW_TAG, SUF_CHECKBOX)) + assert answers == [OPTED_OUT, CONFIRMED] + + def test_a_clear_opt_out_confirms_alone( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, answers=answers, opt_out_label="Do not ask again") + + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + + assert answers == [CONFIRMED] + + def test_the_keyboard_is_free_when_the_answer_runs( + self, + window: GUIConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + """A prompt the answer raises claims the keyboard on its own, over whatever stood beneath.""" + claims: List[bool] = [] + render(window, answers=[], on_confirm=lambda: claims.append(router.is_modal_open)) + + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + + assert claims == [False] + + def test_a_second_press_answers_nothing( + self, + window: GUIConfirmationWindow, + held_frames: Frames, + ) -> None: + """A click DearPyGui delivers after the first one reaches a prompt that has already left.""" + answers: List[str] = [] + render(window, answers=answers) + confirm = button_callback(WINDOW_TAG, SUF_BUTTON_OK) + cancel = button_callback(WINDOW_TAG, SUF_BUTTON_CANCEL) + + confirm() + confirm() + cancel() + held_frames.render() + + assert answers == [CONFIRMED] + assert held_frames.pending == 0 + + def test_the_path_is_shown_when_given( + self, + window: GUIConfirmationWindow, + tmp_path: Path, + ) -> None: + render(window, answers=[], path=tmp_path / "song.stn") + + assert dpg.does_item_exist(compose_tag(WINDOW_TAG, "path")) + + +class TestAPromptRaisedFromAnAnswer: + """An answer asking a question of its own — "Load it now?" leading to "Save your changes?" — + raises a second prompt, which stands alone and answers the keyboard.""" + + @pytest.fixture(name="answers") + def answers_fixture( + self, + window: GUIConfirmationWindow, + layout_config: LayoutConfig, + router: KeyRouter, + held_frames: Frames, + ) -> List[str]: + answers: List[str] = [] + following = build_window(FOLLOWING_TAG, layout_config, router) + + def ask_again() -> None: + answers.append(f"first_standing={dpg.does_item_exist(WINDOW_TAG)}") + following.prepare( + "Save your changes?", + "Title", + lambda: answers.append(CONFIRMED), + ok_label="Save", + cancel_label="Cancel", + path=None, + opt_out_label=None, + on_opt_out=None, + on_cancel=lambda: answers.append(FOLLOWING_CANCELED), + ) + following.create_window() + + render(window, answers=answers, on_confirm=ask_again) + press(WINDOW_TAG, SUF_BUTTON_OK) + held_frames.render() + return answers + + def test_the_first_prompt_is_gone_when_the_second_is_built(self, answers: List[str]) -> None: + assert answers == ["first_standing=False"] + + def test_the_second_prompt_stands(self, answers: List[str]) -> None: + assert dpg.does_item_exist(FOLLOWING_TAG) + + def test_the_second_prompt_holds_the_keyboard(self, answers: List[str], router: KeyRouter) -> None: + assert router.is_modal_open + + def test_escape_reaches_the_second_prompt( + self, + answers: List[str], + router: KeyRouter, + held_frames: Frames, + ) -> None: + escape(router) + held_frames.render() + + assert answers[1:] == [FOLLOWING_CANCELED] + assert not dpg.does_item_exist(FOLLOWING_TAG) diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py new file mode 100644 index 000000000..aa87f3b44 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_error.py @@ -0,0 +1,88 @@ +from typing import Final, List + +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 import error as error_module +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, + geometry=layout_config.general.dialogs.error, + traceback_height=layout_config.general.dialogs.traceback_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_the_traceback_toggle_centers_the_window_on_its_new_height( + self, + window: GUIErrorDialogWindow, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Unfolding a traceback grows the report past the screen unless it is placed again.""" + centered: List[str] = [] + monkeypatch.setattr(error_module, "center_when_settled", centered.append) + render(window) + + press(compose_tag(WINDOW_TAG, SUF_BUTTON_SHOW_TRACEBACK)) + + assert centered == [WINDOW_TAG] + + 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..d776c5a2e --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -0,0 +1,335 @@ +from typing import Callable, Final, Iterator, List +from unittest.mock import patch + +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.outcome import SaveOutcome +from sampletones_application.utils.gui.dialogs.windows.save_confirmation import ( + GUISaveConfirmationWindow, +) +from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames +from tests.suite.shortcuts import shipped_scheme, shipped_source + +WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) +MESSAGE: Final[str] = "Save first?" +SAVED: Final[str] = "saved" +CONFIRMED: Final[str] = "confirmed" +CANCELED: Final[str] = "canceled" + + +@pytest.fixture(name="router") +def router_fixture() -> KeyRouter: + return KeyRouter() + + +@pytest.fixture(name="window") +def window_fixture( + dpg_context: None, + layout_config: LayoutConfig, + router: KeyRouter, +) -> GUISaveConfirmationWindow: + return GUISaveConfirmationWindow( + tag=WINDOW_TAG, + geometry=layout_config.general.dialogs.confirmation, + wrap=layout_config.general.dialogs.default.width - 10, + save_label="Save", + cancel_label="Cancel", + key_router=router, + shortcut_source=shipped_source(), + ) + + +def render( + window: GUISaveConfirmationWindow, + *, + save_outcome: SaveOutcome, + answers: List[str], +) -> None: + """Builds the prompt for the given save, the way ``show`` does without a live frame.""" + + def save() -> SaveOutcome: + answers.append(SAVED) + return save_outcome + + build(window, save=save, answers=answers) + + +def build( + window: GUISaveConfirmationWindow, + *, + save: Callable[[], SaveOutcome], + answers: List[str], +) -> None: + """Builds the prompt over ``save``, recording each way forward and back in ``answers``.""" + window.prepare( + MESSAGE, + "Title", + save, + lambda: answers.append(CONFIRMED), + lambda: answers.append(CANCELED), + ok_label="Proceed", + ) + window.create_window() + + +def button_callback(suffix: str) -> VoidCallback: + callback = dpg.get_item_callback(compose_tag(compose_tag(WINDOW_TAG, suffix), SUF_BUTTON)) + assert callback is not None + return callback + + +def press(suffix: str) -> None: + button_callback(suffix)() + + +def escape(router: KeyRouter) -> None: + """The press the shipped scheme cancels a dialog with, routed the way the keyboard sends it.""" + combination = shipped_scheme().shortcut(ShortcutId.DIALOG_CANCEL).combination + assert combination is not None + router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + +@pytest.fixture(name="placed") +def placed_fixture(layout_config: LayoutConfig) -> Iterator[None]: + """Stands in for the viewport a prompt raised again is centered against, which a suite draws none of.""" + window = layout_config.general.window + with ( + patch.object(dpg, "get_viewport_client_width", return_value=window.width), + patch.object(dpg, "get_viewport_client_height", return_value=window.height), + ): + yield + + +class TestSavingFromThePrompt: + """Save writes the document once the prompt has left, so whatever the save opens stands alone.""" + + def test_save_leaves_the_screen_before_it_writes( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_SAVE) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + + def test_a_written_document_goes_on( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_SAVE) + held_frames.render() + + assert answers == [SAVED, CONFIRMED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_a_save_called_off_brings_the_prompt_back( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + placed: None, + ) -> None: + """A reader who closes the file dialog without a name is asked the same question again.""" + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=answers) + + press(SUF_BUTTON_SAVE) + held_frames.render() + + assert answers == [SAVED] + assert dpg.does_item_exist(WINDOW_TAG) + assert dpg.get_item_label(WINDOW_TAG) == "Title" + + def test_the_prompt_back_answers_as_it_did( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + placed: None, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=answers) + press(SUF_BUTTON_SAVE) + held_frames.render() + + press(SUF_BUTTON_OK) + held_frames.render() + + assert answers == [SAVED, CONFIRMED] + + def test_the_prompt_back_holds_the_keyboard_once( + self, + window: GUISaveConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + placed: None, + ) -> None: + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=[]) + press(SUF_BUTTON_SAVE) + held_frames.render() + + assert len(router._modal_stack) == 1 + + def test_a_failed_save_leaves_the_error_alone_on_screen_and_answers_cancel( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + """The request the prompt guarded goes no further, and its caller hears that it ended.""" + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.FAILED, answers=answers) + + press(SUF_BUTTON_SAVE) + held_frames.render() + + assert answers == [SAVED, CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + assert held_frames.pending == 0 + + def test_a_save_that_raises_answers_cancel_and_lets_the_error_through( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + """A save failing with an error it names no outcome for still ends the request the prompt guarded.""" + answers: List[str] = [] + + def save() -> SaveOutcome: + answers.append(SAVED) + raise RuntimeError("the save broke") + + build(window, save=save, answers=answers) + press(SUF_BUTTON_SAVE) + + with pytest.raises(RuntimeError): + held_frames.render() + + assert answers == [SAVED, CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + + +class TestTheOtherAnswers: + def test_the_middle_button_goes_on_once_the_prompt_has_left( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_OK) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + held_frames.render() + assert answers == [CONFIRMED] + + def test_cancel_leaves_before_it_answers( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_CANCEL) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + + def test_cancel_answers_cancel_a_frame_later( + self, + window: GUISaveConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_CANCEL) + held_frames.render() + + assert answers == [CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + assert not router.is_modal_open + + def test_escape_answers_as_cancel( + self, + window: GUISaveConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + escape(router) + held_frames.render() + + assert answers == [CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_the_title_bar_close_answers_as_cancel( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + dpg.get_item_configuration(WINDOW_TAG)["on_close"]() + held_frames.render() + + assert answers == [CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_a_save_called_off_and_then_canceled_answers_cancel( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + placed: None, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=answers) + press(SUF_BUTTON_SAVE) + held_frames.render() + + press(SUF_BUTTON_CANCEL) + held_frames.render() + + assert answers == [SAVED, CANCELED] + + def test_a_second_press_answers_nothing( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + save = button_callback(SUF_BUTTON_SAVE) + discard = button_callback(SUF_BUTTON_OK) + + save() + discard() + held_frames.render() + + assert answers == [SAVED, CONFIRMED] diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py index 6d5b1f04e..254293df6 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Final import dearpygui.dearpygui as dpg import pytest @@ -6,17 +7,26 @@ from sampletones_application.utils.gui.keyboard.focus.consumption import ( field_consumes_key, ) +from sampletones_application.utils.gui.keyboard.focus.items import field_kind from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind from sampletones_application.utils.gui.keyboard.modifiers import ( ALT, CTRL, + CTRL_ALT, CTRL_SHIFT, NO_MODIFIERS, SHIFT, + SUPER, + SUPER_SHIFT, + Modifier, ModifierSet, ) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.unit.sampletones_application.utils.gui.keyboard.focus.item_tree import INPUT_INT, SLIDER_INT + +ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.ALT, Modifier.SHIFT}) +SUPER_ALT: Final[ModifierSet] = frozenset({Modifier.SUPER, Modifier.ALT}) class TestFieldConsumesKey(BaseTestSuite): @@ -109,18 +119,189 @@ class TestCase(BaseRegularTestCase): expected=False, ), TestCase( - label="text field yields Alt+F4", + label="text field yields Alt on a function key, so Alt+F4 exits", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_F4, modifiers=ALT, expected=False, ), + TestCase( + label="text field yields Alt on a caret key, Alt+Up", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Up, + modifiers=ALT, + expected=False, + ), + TestCase( + label="text field yields Alt on an editing key, Alt+Home", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Home, + modifiers=ALT, + expected=False, + ), + TestCase( + label="text field types AltGr on a digit, which Linux reports as Alt+2", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_2, + modifiers=ALT, + expected=True, + ), + TestCase( + label="text field types AltGr on a letter, which Linux reports as Alt+E", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_E, + modifiers=ALT, + expected=True, + ), + TestCase( + label="text field types a shifted AltGr letter, Alt+Shift+S", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=ALT_SHIFT, + expected=True, + ), + TestCase( + label="text field types AltGr on punctuation, Alt+Period", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Period, + modifiers=ALT, + expected=True, + ), + TestCase( + label="text field types AltGr on a letter, which Windows reports as Ctrl+Alt+S", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=CTRL_ALT, + expected=True, + ), + TestCase( + label="text field yields Ctrl+Alt on a caret key", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Left, + modifiers=CTRL_ALT, + expected=False, + ), + TestCase( + label="text field yields Super+S, so Cmd+S saves from a field", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=SUPER, + expected=False, + ), + TestCase( + label="text field yields Super+Space", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Spacebar, + modifiers=SUPER, + expected=False, + ), + TestCase( + label="text field yields Super+Alt+S, so Cmd+Option+S saves from a field", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=SUPER_ALT, + expected=False, + ), + TestCase( + label="text field yields Super+Shift+A", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_A, + modifiers=SUPER_SHIFT, + expected=False, + ), + TestCase( + label="text field copies on Super+C", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_C, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="text field pastes on Super+V", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_V, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="text field undoes on Super+Z", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Z, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="text field redoes on Super+Shift+Z", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Z, + modifiers=SUPER_SHIFT, + expected=True, + ), TestCase( label="text field yields F11", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_F11, expected=False, ), + TestCase( + label="number field types a digit", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_2, + expected=True, + ), + TestCase( + label="number field types a minus sign", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_Minus, + expected=True, + ), + TestCase( + label="number field commits on Enter", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_Return, + expected=True, + ), + TestCase( + label="number field selects all on Ctrl+A", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_A, + modifiers=CTRL, + expected=True, + ), + TestCase( + label="number field pastes on Super+V", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_V, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="number field yields Ctrl+S", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_S, + modifiers=CTRL, + expected=False, + ), + TestCase( + label="number field yields Ctrl+Alt+S, which types nothing in a number", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_S, + modifiers=CTRL_ALT, + expected=False, + ), + TestCase( + label="number field yields Alt+2, which types nothing in a number", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_2, + modifiers=ALT, + expected=False, + ), + TestCase( + label="number field yields F11", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_F11, + expected=False, + ), TestCase( label="open combo yields a plain space", kind=FieldKind.CHOICE, @@ -140,6 +321,13 @@ class TestCase(BaseRegularTestCase): modifiers=CTRL, expected=False, ), + TestCase( + label="open combo yields Alt+2", + kind=FieldKind.CHOICE, + key=dpg.mvKey_2, + modifiers=ALT, + expected=False, + ), ) @pytest.mark.parametrize( @@ -169,3 +357,26 @@ def test_a_ctrl_chord_letter_reaches_the_shortcut_when_shift_joins_it(self) -> N def test_redo_stays_with_the_field_as_a_ctrl_shift_chord(self) -> None: assert field_consumes_key(FieldKind.TEXT_ENTRY, dpg.mvKey_Z, CTRL_SHIFT) is True + + +class TestANumberFieldYieldsAltChords(BaseTestSuite): + """A number field types no character with AltGr or Option, so an Alt chord pressed in one reaches + its shortcut.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + item_type: str + modifiers: ModifierSet + + test_cases = ( + TestCase(label="Ctrl+Alt+S from an integer input", item_type=INPUT_INT, modifiers=CTRL_ALT), + TestCase(label="Alt+S from a slider", item_type=SLIDER_INT, modifiers=ALT), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_chord_reaches_its_shortcut(self, test_case: TestCase) -> None: + assert field_consumes_key(field_kind(test_case.item_type), dpg.mvKey_S, test_case.modifiers) is False diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py index dfe4ba574..78b1e457f 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py @@ -4,6 +4,7 @@ from sampletones_application.utils.gui.keyboard.focus.items import ( CHOICE_ITEM_TYPES, + NUMBER_ENTRY_ITEM_TYPES, TEXT_ENTRY_ITEM_TYPES, field_kind, reports_child_focus, @@ -40,14 +41,14 @@ class TestCase(BaseRegularTestCase): expected=FieldKind.TEXT_ENTRY, ), TestCase( - label="integer input types characters", + label="integer input types a number", item_type=INPUT_INT, - expected=FieldKind.TEXT_ENTRY, + expected=FieldKind.NUMBER_ENTRY, ), TestCase( - label="slider types characters", + label="slider types a number", item_type=SLIDER_INT, - expected=FieldKind.TEXT_ENTRY, + expected=FieldKind.NUMBER_ENTRY, ), TestCase( label="combo navigates options", @@ -84,11 +85,16 @@ class TestTaxonomyCoverage: def test_every_text_entry_type_maps_to_text_entry(self) -> None: assert {field_kind(item_type) for item_type in TEXT_ENTRY_ITEM_TYPES} == {FieldKind.TEXT_ENTRY} + def test_every_number_entry_type_maps_to_number_entry(self) -> None: + assert {field_kind(item_type) for item_type in NUMBER_ENTRY_ITEM_TYPES} == {FieldKind.NUMBER_ENTRY} + def test_every_choice_type_maps_to_choice(self) -> None: assert {field_kind(item_type) for item_type in CHOICE_ITEM_TYPES} == {FieldKind.CHOICE} - def test_the_two_field_taxonomies_stay_disjoint(self) -> None: - assert TEXT_ENTRY_ITEM_TYPES.isdisjoint(CHOICE_ITEM_TYPES) + def test_the_field_taxonomies_stay_disjoint(self) -> None: + taxonomies = (TEXT_ENTRY_ITEM_TYPES, NUMBER_ENTRY_ITEM_TYPES, CHOICE_ITEM_TYPES) + + assert sum(len(taxonomy) for taxonomy in taxonomies) == len(frozenset().union(*taxonomies)) class TestReportsChildFocus(BaseTestSuite): diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py index 4aecd3b3b..2c9aa293e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py @@ -46,7 +46,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="actively edited integer input", items={FOCUSED: editing(INPUT_INT)}, - expected=FieldKind.TEXT_ENTRY, + expected=FieldKind.NUMBER_ENTRY, ), TestCase( label="open combo", 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..35ff15293 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,18 @@ 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 + + +class Dialog: + """A dialog holding the keyboard beside the capture, recording the keys it is sent.""" + + def __init__(self) -> None: + self.keys: List[int] = [] + + def handle_key(self, event: KeyEvent) -> None: + self.keys.append(event.key) @pytest.fixture(name="harness") @@ -77,13 +87,31 @@ def test_starting_twice_claims_the_keyboard_once(self, harness: Harness) -> None assert not harness.router.is_modal_open - def test_stopping_twice_releases_the_claim_once(self, harness: Harness) -> None: + def test_stopping_twice_releases_the_claim_once(self) -> None: """A second release would drop the claim of the dialog the capture sits above.""" - harness.router.push_modal(harness.capture) + harness = Harness() + dialog = Dialog() + harness.router.push_modal(dialog) + harness.capture.start() + harness.capture.stop() harness.capture.stop() + harness.press(dpg.mvKey_F5) - assert harness.router.is_modal_open + assert dialog.keys == [dpg.mvKey_F5] + + def test_stopping_gives_back_its_own_claim_alone(self) -> None: + """A dialog raised over a listening capture keeps the keyboard when the capture stops.""" + harness = Harness() + harness.capture.start() + dialog = Dialog() + harness.router.push_modal(dialog) + + harness.capture.stop() + harness.press(dpg.mvKey_F5) + + assert dialog.keys == [dpg.mvKey_F5] + assert harness.captured == [] class TestCapturedPress(BaseTestSuite): @@ -234,16 +262,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_combination.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py index f9fe80ae8..e64210f4b 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py @@ -1,9 +1,15 @@ from dataclasses import dataclass +from typing import Final, Tuple import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KEY_LIST_JOINER, + KeyCombination, + display_combinations, + parse_combinations, +) from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.keys import ( KEY_MODIFIER_ALT, @@ -23,6 +29,12 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +CTRL_COMMA: Final[KeyCombination] = KeyCombination(dpg.mvKey_Comma, CTRL) +CTRL_Y: Final[KeyCombination] = KeyCombination(dpg.mvKey_Y, CTRL) +CTRL_SHIFT_Z: Final[KeyCombination] = KeyCombination(dpg.mvKey_Z, CTRL_SHIFT) +COMMA: Final[KeyCombination] = KeyCombination(dpg.mvKey_Comma) +CTRL_PLUS: Final[KeyCombination] = KeyCombination(KEY_PLUS, CTRL) + WRITTEN_COMBINATIONS = ( "Ctrl+Shift+Z", "Ctrl+D", @@ -207,7 +219,7 @@ class TestCase(BaseRegularTestCase): expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT), ), TestCase( - label="any capitalisation", + label="any capitalization", text="ctrl+shift+z", expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT), ), @@ -257,3 +269,52 @@ def test_a_spelling_reads_back_as_the_one_the_combination_displays_under( ) -> None: """A reader writes a combination however they know it and reads back one canonical form.""" assert KeyCombination.parse(written).display() == expected + + +class TestKeyLists(BaseTestSuite): + """A written list names several combinations apart by commas, and a comma written where a key goes + is the comma key.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + text: str + expected: Tuple[KeyCombination, ...] + + test_cases = ( + TestCase(label="nothing", text="", expected=()), + TestCase(label="one combination", text="Ctrl+Y", expected=(CTRL_Y,)), + TestCase(label="two combinations", text="Ctrl+Y, Ctrl+Shift+Z", expected=(CTRL_Y, CTRL_SHIFT_Z)), + TestCase(label="two combinations written tight", text="Ctrl+Y,Ctrl+Shift+Z", expected=(CTRL_Y, CTRL_SHIFT_Z)), + TestCase(label="a blank between two commas", text="Ctrl+Y,,Ctrl+Shift+Z", expected=(CTRL_Y, CTRL_SHIFT_Z)), + TestCase(label="the comma as a modified key", text="Ctrl+,", expected=(CTRL_COMMA,)), + TestCase(label="the comma written out", text="Ctrl+Comma", expected=(CTRL_COMMA,)), + TestCase( + label="the comma as a modified key ahead of another", text="Ctrl+,, Ctrl+Y", expected=(CTRL_COMMA, CTRL_Y) + ), + TestCase( + label="the comma as a modified key after another", text="Ctrl+Y, Ctrl+,", expected=(CTRL_Y, CTRL_COMMA) + ), + TestCase(label="the plus key ahead of another", text="Ctrl++, Ctrl+Y", expected=(CTRL_PLUS, CTRL_Y)), + TestCase(label="the comma alone", text=",", expected=(COMMA,)), + TestCase(label="the comma alone after another", text="Ctrl+Y, ,", expected=(CTRL_Y, COMMA)), + TestCase(label="a combination named twice", text="Ctrl+Y, Ctrl+Y", expected=(CTRL_Y,)), + TestCase(label="a combination spelled two ways", text="Ctrl+Y, ctrl+y", expected=(CTRL_Y,)), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_parse_combinations(self, test_case: TestCase) -> None: + assert parse_combinations(test_case.text) == test_case.expected + + def test_a_displayed_list_reads_back_as_itself(self) -> None: + combinations = (CTRL_COMMA, COMMA, CTRL_Y) + + assert parse_combinations(display_combinations(combinations)) == combinations + + def test_a_displayed_list_joins_its_combinations_by_the_joiner(self) -> None: + assert display_combinations((CTRL_Y, CTRL_SHIFT_Z)) == KEY_LIST_JOINER.join( + (CTRL_Y.display(), CTRL_SHIFT_Z.display()) + ) diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py index 3c2d9db5d..86e5b3cdc 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py @@ -1,7 +1,20 @@ +from dataclasses import dataclass from unittest.mock import patch +import pytest + from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.utils.gui.keyboard.modifiers import CTRL_SHIFT +from sampletones_application.utils.gui.keyboard.modifiers import ( + ALT, + CTRL, + CTRL_SHIFT, + NO_MODIFIERS, + SHIFT, + SUPER, + ModifierSet, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase MODULE = "sampletones_application.utils.gui.keyboard.event" @@ -13,3 +26,29 @@ def test_capture_carries_the_key_and_the_modifiers_held_with_it() -> None: event = KeyEvent.capture(KEY) assert event == KeyEvent(key=KEY, modifiers=CTRL_SHIFT) + + +class TestIsPlain(BaseTestSuite): + """A press types its key alone or under Shift, and Ctrl, Alt or Super make it a command.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + modifiers: ModifierSet + expected: bool + + test_cases = ( + TestCase(label="no modifier", modifiers=NO_MODIFIERS, expected=True), + TestCase(label="shift, as a capital is typed", modifiers=SHIFT, expected=True), + TestCase(label="ctrl", modifiers=CTRL, expected=False), + TestCase(label="alt", modifiers=ALT, expected=False), + TestCase(label="super, the command key on macOS", modifiers=SUPER, expected=False), + TestCase(label="ctrl with shift", modifiers=CTRL_SHIFT, expected=False), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_is_plain(self, test_case: TestCase) -> None: + assert KeyEvent(key=KEY, modifiers=test_case.modifiers).is_plain is test_case.expected 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/keyboard/test_piano.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_piano.py new file mode 100644 index 000000000..1d82bb974 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_piano.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass +from typing import Optional + +import pytest + +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.utils.gui.keyboard.piano import semitone_of +from sampletones_shared.constants.music import OCTAVE_SEMITONES +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +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 TestSemitoneOf(BaseTestSuite): + """A plain note key names its note, and the same key in a combination names none.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + press: str + expected: Optional[int] + + test_cases = ( + TestCase(label="the C of the bottom row", press="Z", expected=0), + TestCase(label="a black key above it", press="S", expected=1), + TestCase(label="the C of the top row", press="Q", expected=OCTAVE_SEMITONES), + TestCase(label="a white key of the top row", press="W", expected=OCTAVE_SEMITONES + 2), + TestCase(label="a digit on the top row", press="2", expected=None), + TestCase(label="a note key under shift", press="Shift+Z", expected=0), + TestCase(label="undo", press="Ctrl+Z", expected=None), + TestCase(label="save", press="Ctrl+S", expected=None), + TestCase(label="a note key under alt", press="Alt+Z", expected=None), + TestCase(label="undo on macOS", press="Super+Z", expected=None), + TestCase(label="redo", press="Ctrl+Shift+Z", expected=None), + TestCase(label="a key naming no note", press="K", expected=None), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_semitone_of(self, test_case: TestCase) -> None: + assert semitone_of(_press(test_case.press)) == test_case.expected diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py index 74e0ab336..3d9cddc0e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py @@ -104,20 +104,82 @@ def test_push_marks_modal_open(self) -> None: def test_nested_modals_stay_open_until_last_pop(self) -> None: router = KeyRouter() - router.push_modal(_RecordingModal()) - router.push_modal(_RecordingModal()) + lower = _RecordingModal() + upper = _RecordingModal() + router.push_modal(lower) + router.push_modal(upper) - router.pop_modal() + router.pop_modal(upper) assert router.is_modal_open def test_pop_without_a_modal_stays_closed(self) -> None: router = KeyRouter() - router.pop_modal() + router.pop_modal(_RecordingModal()) + + assert not router.is_modal_open + + def test_a_claim_released_beneath_the_top_leaves_the_top_answering(self) -> None: + """A dialog closing while a later one stands gives up its own claim and none other.""" + router = KeyRouter() + lower = _RecordingModal() + upper = _RecordingModal() + router.push_modal(lower) + router.push_modal(upper) + + router.pop_modal(lower) + router.route(_event()) + + assert upper.keys == [_event().key] + assert lower.keys == [] + + def test_a_claim_released_beneath_the_top_is_gone(self) -> None: + router = KeyRouter() + lower = _RecordingModal() + upper = _RecordingModal() + router.push_modal(lower) + router.push_modal(upper) + + router.pop_modal(lower) + router.pop_modal(upper) assert not router.is_modal_open + def test_releasing_a_handler_holding_no_claim_changes_nothing(self) -> None: + router = KeyRouter() + standing = _RecordingModal() + router.push_modal(standing) + + router.pop_modal(_RecordingModal()) + router.route(_event()) + + assert standing.keys == [_event().key] + + def test_a_handler_claiming_twice_gives_up_one_claim_at_a_time(self) -> None: + router = KeyRouter() + handler = _RecordingModal() + router.push_modal(handler) + router.push_modal(handler) + + router.pop_modal(handler) + + assert router.is_modal_open + + def test_a_handler_claiming_twice_gives_up_its_latest_claim(self) -> None: + """The claim released is the one standing highest, so what it stood above answers again.""" + router = KeyRouter() + handler = _RecordingModal() + other = _RecordingModal() + router.push_modal(handler) + router.push_modal(other) + router.push_modal(handler) + + router.pop_modal(handler) + router.route(_event()) + + assert other.keys == [_event().key] + def test_an_open_modal_claims_the_key_and_suppresses_lower_scopes(self) -> None: router = KeyRouter() log: List[str] = [] @@ -155,8 +217,9 @@ def test_a_closed_modal_returns_the_keyboard_to_lower_scopes(self) -> None: priority=PRIORITY_SHORTCUT, active=lambda: True, ) - router.push_modal(_RecordingModal()) - router.pop_modal() + modal = _RecordingModal() + router.push_modal(modal) + router.pop_modal(modal) claimed = router.route(_event()) diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_scope.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_scope.py new file mode 100644 index 000000000..1b749336b --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_scope.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_application.utils.gui.keyboard import KeyRouter, focus, panel_scope_active +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +@pytest.fixture(autouse=True) +def no_focused_field(monkeypatch: pytest.MonkeyPatch) -> None: + """No text field is being edited, so a case names what it is about and nothing beside it.""" + monkeypatch.setattr(focus, "is_field_focused", lambda: False) + + +class TestWhatAPanelScopeAnswersTo(BaseTestSuite): + """A panel owns the next key while every condition its scope names holds at once.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + """One reading of the four conditions, and whether the panel owns the press.""" + + tab_active: bool + card_open: bool + holds: bool + expected: bool + + test_cases = ( + TestCase(label="everything stands", tab_active=True, card_open=True, holds=True, expected=True), + TestCase(label="another tab is in front", tab_active=False, card_open=True, holds=True, expected=False), + TestCase(label="the card is put away", tab_active=True, card_open=False, holds=True, expected=False), + TestCase(label="the panel holds nothing", tab_active=True, card_open=True, holds=False, expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_scope_answers_where_every_condition_holds(self, test_case: TestCase) -> None: + active = panel_scope_active( + tab_active=lambda: test_case.tab_active, + router=KeyRouter(), + holds=test_case.holds, + card_open=lambda: test_case.card_open, + ) + + assert active is test_case.expected + + def test_a_field_holding_the_keyboard_stands_the_panel_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A reader typing keeps the plain keys, whatever the panel behind the field holds.""" + monkeypatch.setattr(focus, "is_field_focused", lambda: True) + + active = panel_scope_active( + tab_active=lambda: True, + router=KeyRouter(), + holds=True, + card_open=lambda: True, + ) + + assert active is False 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..ce22b32f2 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -1,10 +1,14 @@ from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Optional, Tuple import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KEY_LIST_JOINER, + KeyCombination, + display_combinations, +) from sampletones_application.utils.gui.keyboard.keys import ( KEY_DISPLAY_NAMES, KEY_MODIFIER_ALT, @@ -18,11 +22,21 @@ from tests.suite.case import BaseRegularTestCase FREE_COMBINATION = "Ctrl+Alt+B" +SECOND_FREE_COMBINATION = "Ctrl+Alt+N" TABLE_COMBINATION = "Del" UNNAMED_KEY = -1 +def keys(*written: str) -> Tuple[KeyCombination, ...]: + return tuple(KeyCombination.parse(combination) for combination in written) + + +def unbound_action(draft: ShortcutDraft) -> ShortcutId: + """An action the scheme the draft opened on ships with no keys.""" + return next(shortcut_id for shortcut_id in ShortcutId if not draft.keys(shortcut_id)) + + @pytest.fixture def draft(shipped: ShortcutScheme) -> ShortcutDraft: """A draft of the shipped scheme, opened on a session that stores no preference of its own.""" @@ -31,7 +45,7 @@ def draft(shipped: ShortcutScheme) -> ShortcutDraft: class TestOpen: def test_a_session_storing_nothing_opens_on_the_keys_the_scheme_ships(self, draft: ShortcutDraft) -> None: - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Z") def test_a_draft_opens_on_what_the_session_holds(self, shipped: ShortcutScheme) -> None: """A dialog asks whether the reader changed anything, which counts from the moment it opened.""" @@ -40,7 +54,7 @@ def test_a_draft_opens_on_what_the_session_holds(self, shipped: ShortcutScheme) def test_a_stored_override_opens_as_the_keys_its_action_answers(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}) - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Alt+U") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Alt+U") def test_a_stored_override_reads_back_as_the_preference_it_came_from(self, shipped: ShortcutScheme) -> None: overrides: Dict[str, Optional[str]] = {"Undo": "Ctrl+Alt+U"} @@ -50,13 +64,46 @@ def test_a_stored_override_reads_back_as_the_preference_it_came_from(self, shipp def test_a_stored_override_stating_no_combination_opens_unbound(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": None}) - assert draft.combination(ShortcutId.UNDO) is None + assert draft.keys(ShortcutId.UNDO) == () + + def test_a_stored_override_listing_the_main_key_alone_opens_as_an_edit(self, shipped: ShortcutScheme) -> None: + """An override lists every key of its action, so one naming the main key alone leaves out the rest.""" + main = shipped.shortcut(ShortcutId.ORDER_INSERT_FRAME).display() + draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": main}) + + assert draft.overrides() == {"OrderInsertFrame": main} + + def test_a_stored_override_listing_every_shipped_key_opens_as_no_edit(self, shipped: ShortcutScheme) -> None: + every = display_combinations(shipped.shortcut(ShortcutId.ORDER_INSERT_FRAME).combinations()) + draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": every}) + + assert draft.overrides() == {} + + def test_a_stored_single_key_opens_as_an_action_with_that_key_alone(self, shipped: ShortcutScheme) -> None: + """A preference stored as one key per action reads the same way, as a list of one key.""" + draft = ShortcutDraft.open(shipped, {"Redo": FREE_COMBINATION}) - def test_a_stored_override_dropping_the_aliases_alone_opens_as_an_edit(self, shipped: ShortcutScheme) -> None: - """An override states the whole of what reaches an action, so the aliases go with it.""" - draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": "Plus"}) + assert draft.keys(ShortcutId.REDO) == keys(FREE_COMBINATION) - assert draft.overrides() == {"OrderInsertFrame": "Plus"} + def test_a_stored_list_naming_a_key_twice_opens_and_stores_it_once(self, shipped: ShortcutScheme) -> None: + draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}{KEY_LIST_JOINER}{FREE_COMBINATION}"}) + + assert (draft.keys(ShortcutId.REDO), draft.overrides()) == ( + keys(FREE_COMBINATION), + {"Redo": FREE_COMBINATION}, + ) + + def test_a_stored_list_opens_as_every_key_it_names(self, shipped: ShortcutScheme) -> None: + draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}, {SECOND_FREE_COMBINATION}"}) + + assert draft.keys(ShortcutId.REDO) == keys(FREE_COMBINATION, SECOND_FREE_COMBINATION) + + def test_a_stored_list_naming_no_key_in_one_place_stays_behind_whole(self, shipped: ShortcutScheme) -> None: + """An override that is unreadable in part costs its action alone, which keeps every shipped key.""" + draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}, Ctrl+Nonsense", "Undo": "Ctrl+Alt+U"}) + + assert draft.overrides() == {"Undo": "Ctrl+Alt+U"} + assert draft.keys(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO).combinations() def test_a_stored_override_this_build_carries_no_action_for_stays_behind(self, shipped: ShortcutScheme) -> None: """A preference outlives the build that stored it, so a stale entry costs only itself.""" @@ -70,22 +117,58 @@ def test_a_stored_override_its_category_already_answers_stays_behind(self, shipp assert draft.overrides() == {} -class TestCombination: - def test_an_untouched_action_reads_the_keys_the_scheme_gives_it(self, draft: ShortcutDraft) -> None: - assert draft.combination(ShortcutId.SAVE_PROJECT) == KeyCombination.parse("Ctrl+S") +class TestKeys: + def test_an_untouched_action_reads_every_key_the_scheme_gives_it( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + assert draft.keys(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO).combinations() def test_an_assigned_action_reads_the_keys_the_reader_gave_it(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, SECOND_FREE_COMBINATION)) + + assert edited.keys(ShortcutId.UNDO) == keys(FREE_COMBINATION, SECOND_FREE_COMBINATION) - assert edited.combination(ShortcutId.UNDO) == KeyCombination.parse(FREE_COMBINATION) + def test_a_key_given_twice_counts_once(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, FREE_COMBINATION)) + + assert edited.keys(ShortcutId.UNDO) == keys(FREE_COMBINATION) def test_a_cleared_action_reads_as_unbound(self, draft: ShortcutDraft) -> None: - edited = draft.clear(ShortcutId.UNDO) + edited = draft.clear(ShortcutId.REDO) - assert edited.combination(ShortcutId.UNDO) is None + assert edited.keys(ShortcutId.REDO) == () def test_an_action_the_scheme_leaves_unbound_reads_as_unbound(self, draft: ShortcutDraft) -> None: - assert draft.combination(ShortcutId.ABOUT_DIALOG) is None + assert draft.keys(ShortcutId.ABOUT_DIALOG) == () + + +class TestKeysLedBy: + """A pressed key becomes the action's main key, and the keys it had follow it.""" + + def test_a_new_key_leads_the_keys_the_action_had(self, draft: ShortcutDraft, shipped: ShortcutScheme) -> None: + led = draft.keys_led_by(ShortcutId.REDO, KeyCombination.parse(FREE_COMBINATION)) + + assert led == (KeyCombination.parse(FREE_COMBINATION), *shipped.shortcut(ShortcutId.REDO).combinations()) + + def test_an_own_alias_moves_to_the_front_and_nothing_drops( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + redo = shipped.shortcut(ShortcutId.REDO) + + led = draft.keys_led_by(ShortcutId.REDO, redo.aliases[0]) + + assert led[0] == redo.aliases[0] + assert set(led) == set(redo.combinations()) + assert len(led) == len(redo.combinations()) + + def test_an_own_alias_asks_no_one(self, draft: ShortcutDraft, shipped: ShortcutScheme) -> None: + redo = shipped.shortcut(ShortcutId.REDO) + + assert draft.holders(ShortcutId.REDO, draft.keys_led_by(ShortcutId.REDO, redo.aliases[0])) == {} class TestClaimant: @@ -95,7 +178,7 @@ def test_the_action_holding_a_combination_answers_for_it(self, draft: ShortcutDr assert claimant is ShortcutId.SAVE_PROJECT def test_an_alias_is_held_as_firmly_as_the_combination_it_extends(self, draft: ShortcutDraft) -> None: - """An assignment takes every key that reaches the holder, aliases included.""" + """An alias is a key its holder answers, so giving it away asks the holder as the main key does.""" claimant = draft.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) assert claimant is ShortcutId.ORDER_INSERT_FRAME @@ -111,18 +194,29 @@ def test_an_action_holds_its_own_keys_against_no_one(self, draft: ShortcutDraft) assert draft.claimant(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Z")) is None def test_the_keys_an_edit_left_behind_are_free(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert edited.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+Z")) is None - def test_the_aliases_an_edit_left_behind_are_free(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")) + def test_the_aliases_a_written_list_leaves_out_are_free(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, keys("Ctrl+Alt+I")) assert edited.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) is None + def test_the_aliases_a_pressed_key_leads_stay_held(self, draft: ShortcutDraft) -> None: + """A pressed key joins the keys an action had, so its aliases still answer it.""" + led = draft.keys_led_by(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")) + edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, led) + + assert edited.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) is ( + ShortcutId.ORDER_INSERT_FRAME + ) + class TestAssign(BaseTestSuite): - """An assignment takes the combination from whichever action of the category holds it.""" + """An assignment takes the combination from whichever action of the category holds it, and the holder + keeps every other key it had. + """ @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): @@ -145,9 +239,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, ), ) @@ -156,14 +250,18 @@ class TestCase(BaseRegularTestCase): test_cases, ids=lambda test_case: test_case.label, ) - def test_the_action_that_held_the_combination_is_left_unbound( + def test_the_action_that_held_the_combination_keeps_its_other_keys( self, test_case: TestCase, draft: ShortcutDraft, + shipped: ShortcutScheme, ) -> None: - edited = draft.assign(test_case.shortcut_id, KeyCombination.parse(test_case.written)) + taken = KeyCombination.parse(test_case.written) + edited = draft.assign(test_case.shortcut_id, (taken,)) - assert edited.combination(test_case.holder) is None + assert edited.keys(test_case.holder) == tuple( + key for key in shipped.shortcut(test_case.holder).combinations() if key != taken + ) @pytest.mark.parametrize( "test_case", @@ -176,24 +274,65 @@ def test_the_scheme_the_assignment_produces_reaches_the_action_it_named( draft: ShortcutDraft, ) -> None: combination = KeyCombination.parse(test_case.written) - scheme = draft.assign(test_case.shortcut_id, combination).scheme() + scheme = draft.assign(test_case.shortcut_id, (combination,)).scheme() assert scheme.claimant(test_case.shortcut_id.category, combination) is test_case.shortcut_id def test_an_assignment_leaves_the_draft_holding_keys_to_store(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert edited.is_dirty is True - def test_the_actions_an_assignment_leaves_alone_keep_their_keys(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + def test_the_actions_an_assignment_leaves_alone_keep_their_keys( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) - assert edited.combination(ShortcutId.REDO) == KeyCombination.parse("Ctrl+Y") + assert edited.keys(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO).combinations() def test_an_action_given_the_keys_it_already_answers_keeps_them(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Z")) + edited = draft.assign(ShortcutId.UNDO, keys("Ctrl+Z")) - assert edited.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert edited.keys(ShortcutId.UNDO) == keys("Ctrl+Z") + + def test_an_action_given_the_keys_it_already_answers_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, draft.keys(ShortcutId.UNDO)) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + + def test_an_unbound_action_given_no_keys_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.assign(unbound_action(draft), ()) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + + def test_an_action_given_back_its_shipped_keys_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)).assign( + ShortcutId.UNDO, + draft.keys(ShortcutId.UNDO), + ) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + + def test_a_list_taking_keys_from_two_actions_takes_each_from_its_holder( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + taken = ( + shipped.shortcut(ShortcutId.SAVE_PROJECT).combinations()[0], + shipped.shortcut(ShortcutId.REDO).aliases[0], + ) + + edited = draft.assign(ShortcutId.ABOUT_DIALOG, taken) + + assert edited.holders(ShortcutId.ABOUT_DIALOG, taken) == {} + assert draft.holders(ShortcutId.ABOUT_DIALOG, taken) == { + taken[0]: ShortcutId.SAVE_PROJECT, + taken[1]: ShortcutId.REDO, + } + assert edited.keys(ShortcutId.REDO) == (shipped.shortcut(ShortcutId.REDO).combinations()[0],) class TestUnwritableCombination(BaseTestSuite): @@ -234,7 +373,7 @@ class TestCase(BaseRegularTestCase): ) def test_assigning_it_is_refused(self, test_case: TestCase, draft: ShortcutDraft) -> None: with pytest.raises(KeyError): - draft.assign(ShortcutId.UNDO, test_case.combination) + draft.assign(ShortcutId.UNDO, (test_case.combination,)) @pytest.mark.parametrize( "test_case", @@ -243,9 +382,9 @@ def test_assigning_it_is_refused(self, test_case: TestCase, draft: ShortcutDraft ) def test_the_action_keeps_the_keys_it_had(self, test_case: TestCase, draft: ShortcutDraft) -> None: with pytest.raises(KeyError): - draft.assign(ShortcutId.UNDO, test_case.combination) + draft.assign(ShortcutId.UNDO, (test_case.combination,)) - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Z") assert draft.is_dirty is False @pytest.mark.parametrize( @@ -259,11 +398,25 @@ def test_it_reaches_no_action_of_the_scope(self, test_case: TestCase, draft: Sho class TestClear: + def test_clearing_an_unbound_action_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.clear(unbound_action(draft)) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + def test_a_cleared_action_stores_as_unbound(self, draft: ShortcutDraft) -> None: edited = draft.clear(ShortcutId.UNDO) assert edited.overrides() == {"Undo": None} + def test_every_key_a_cleared_action_held_is_free_for_another( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + edited = draft.clear(ShortcutId.REDO) + + assert edited.holders(ShortcutId.ABOUT_DIALOG, shipped.shortcut(ShortcutId.REDO).combinations()) == {} + def test_the_keys_a_cleared_action_held_are_free_for_another(self, draft: ShortcutDraft) -> None: edited = draft.clear(ShortcutId.UNDO) @@ -279,7 +432,7 @@ class TestReset: def test_a_reset_draft_reads_the_keys_the_scheme_ships(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset() - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Z") def test_a_reset_draft_stores_no_override(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset() @@ -297,25 +450,38 @@ def test_a_reset_of_a_draft_on_the_shipped_keys_leaves_it_as_it_was(self, draft: class TestScheme: def test_the_scheme_answers_the_keys_the_reader_gave(self, draft: ShortcutDraft) -> None: - scheme = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)).scheme() + scheme = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, SECOND_FREE_COMBINATION)).scheme() - assert scheme.shortcut(ShortcutId.UNDO).display() == FREE_COMBINATION + assert scheme.shortcut(ShortcutId.UNDO).combinations() == keys(FREE_COMBINATION, SECOND_FREE_COMBINATION) - def test_an_untouched_action_keeps_the_aliases_the_scheme_ships(self, draft: ShortcutDraft) -> None: - scheme = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)).scheme() + def test_an_untouched_action_keeps_the_aliases_the_scheme_ships( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + scheme = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)).scheme() + + assert scheme.shortcut(ShortcutId.REDO).aliases == shipped.shortcut(ShortcutId.REDO).aliases - assert scheme.shortcut(ShortcutId.REDO).aliases == (KeyCombination.parse("Ctrl+Shift+Z"),) + def test_a_pressed_key_leaves_the_action_answering_its_aliases_too( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + """A pressed key becomes the main key the menus print, and every key the action had still answers.""" + pressed = KeyCombination.parse("Ctrl+Alt+I") + led = draft.keys_led_by(ShortcutId.ORDER_INSERT_FRAME, pressed) - def test_a_touched_action_answers_the_combination_it_was_given_alone(self, draft: ShortcutDraft) -> None: - scheme = draft.assign(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")).scheme() + shortcut = draft.assign(ShortcutId.ORDER_INSERT_FRAME, led).scheme().shortcut(ShortcutId.ORDER_INSERT_FRAME) - assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == () + assert shortcut.combination == pressed + assert shortcut.aliases == shipped.shortcut(ShortcutId.ORDER_INSERT_FRAME).combinations() def test_two_actions_trade_the_combinations_they_held(self, draft: ShortcutDraft) -> None: """Every edit is read at once, so a swap arrives without either action holding both keys.""" - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Y")).assign( + edited = draft.assign(ShortcutId.UNDO, keys("Ctrl+Y")).assign( ShortcutId.REDO, - KeyCombination.parse("Ctrl+Z"), + keys("Ctrl+Z"), ) scheme = edited.scheme() @@ -327,7 +493,9 @@ def test_a_draft_on_the_shipped_keys_produces_the_scheme_it_opened_on(self, draf def test_every_key_the_table_names_produces_a_scheme_that_resolves(self, draft: ShortcutDraft) -> None: """What a reader may assign is what the application then runs on, key for key.""" - assigned = {key: draft.assign(ShortcutId.UNDO, KeyCombination(key, CTRL)).scheme() for key in KEY_DISPLAY_NAMES} + assigned = { + key: draft.assign(ShortcutId.UNDO, (KeyCombination(key, CTRL),)).scheme() for key in KEY_DISPLAY_NAMES + } assert all( scheme.shortcut(ShortcutId.UNDO).combination == KeyCombination(key, CTRL) @@ -337,31 +505,60 @@ def test_every_key_the_table_names_produces_a_scheme_that_resolves(self, draft: class TestOverrides: def test_an_edit_stores_under_the_name_a_keybinding_file_writes(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert edited.overrides() == {"Undo": FREE_COMBINATION} def test_an_edit_stores_the_combination_as_it_reads(self, draft: ShortcutDraft) -> None: """A stored preference is written the way the dialog shows it, whatever the reader typed.""" - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("shift+ctrl+alt+u")) + edited = draft.assign(ShortcutId.UNDO, keys("shift+ctrl+alt+u")) assert edited.overrides() == {"Undo": "Ctrl+Alt+Shift+U"} def test_a_displaced_action_stores_as_unbound(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S")) + edited = draft.assign(ShortcutId.ABOUT_DIALOG, keys("Ctrl+S")) assert edited.overrides() == {"AboutDialog": "Ctrl+S", "SaveProject": None} + def test_an_edit_stores_every_key_joined_by_commas(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, SECOND_FREE_COMBINATION)) + + assert edited.overrides() == {"Undo": f"{FREE_COMBINATION}, {SECOND_FREE_COMBINATION}"} + + def test_a_list_reopens_on_every_key_it_stored(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, "Comma", SECOND_FREE_COMBINATION)) + + reopened = ShortcutDraft.open(draft.base, edited.overrides()) + + assert reopened.keys(ShortcutId.UNDO) == keys(FREE_COMBINATION, "Comma", SECOND_FREE_COMBINATION) + def test_the_actions_the_reader_left_alone_store_nothing(self, draft: ShortcutDraft) -> None: """A preference states the actions the reader touched, so the rest follow the scheme.""" - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert set(edited.overrides()) == {"Undo"} def test_a_stored_draft_reopens_on_the_keys_it_stored(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S")) + edited = draft.assign(ShortcutId.ABOUT_DIALOG, keys("Ctrl+S")) reopened = ShortcutDraft.open(draft.base, edited.overrides()) assert reopened.edits == edited.edits assert reopened.is_dirty is False + + +class TestEveryKey: + def test_taking_an_alias_leaves_its_holder_answering_its_main_key( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + redo = shipped.shortcut(ShortcutId.REDO) + edited = draft.assign(ShortcutId.ABOUT_DIALOG, (redo.aliases[0],)) + + assert edited.keys(ShortcutId.REDO) == (redo.combinations()[0],) + + def test_a_stored_list_reads_back_as_the_preference_it_came_from(self, shipped: ShortcutScheme) -> None: + overrides: Dict[str, Optional[str]] = {"Redo": "Ctrl+Alt+R, Ctrl+Alt+Shift+R"} + + assert ShortcutDraft.open(shipped, overrides).overrides() == overrides diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py index 71eb277cb..417cdc248 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -95,6 +95,29 @@ def test_an_action_the_scheme_leaves_unassigned_answers_no_press(self, source: S callback.assert_not_called() +class TestShortcutLookup: + """A reader outside the dispatch asks the manager which keys an action answers to.""" + + def test_the_binding_read_is_the_one_the_scheme_gives(self, source: ShortcutSource) -> None: + manager = _manager(source, ShortcutId.SAVE_PROJECT, Mock()) + + shortcut = manager.shortcut(ShortcutId.SAVE_PROJECT) + + assert shortcut == source.shortcut(ShortcutId.SAVE_PROJECT) + + def test_the_binding_read_follows_a_rebind( + self, + source: ShortcutSource, + rebound: RebindScheme, + ) -> None: + manager = _manager(source, ShortcutId.SAVE_PROJECT, Mock()) + + source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")})) + manager.rebind() + + assert manager.shortcut(ShortcutId.SAVE_PROJECT).matches(_event(dpg.mvKey_K, modifiers=CTRL_ALT)) + + class TestRebind: """A registration names the action, so activating another scheme changes the keys that fire it.""" 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..3e0f3c80b 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -1,4 +1,6 @@ +import logging from pathlib import Path +from typing import List, Mapping, Optional import dearpygui.dearpygui as dpg import pytest @@ -6,7 +8,7 @@ from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.paths import KEYBINDINGS_DIRECTORY -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId @@ -31,6 +33,12 @@ """ +def _reported(caplog: pytest.LogCaptureFixture, overrides: Mapping[str, Optional[str]]) -> List[str]: + """The overrides a warning names, in the order the overrides list them.""" + warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING] + return [name for name in overrides if any(repr(name) in warning for warning in warnings)] + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -109,10 +117,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 +135,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 +168,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") @@ -171,34 +179,42 @@ def test_each_category_answers_a_shared_combination_with_its_own_action(self, sh class TestWithBinding: def test_an_action_answers_the_combination_it_is_given(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Alt+U")) + scheme = shipped.with_binding(ShortcutId.UNDO, (KeyCombination.parse("Ctrl+Alt+U"),)) assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U" - def test_an_action_answers_that_combination_alone(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")) + def test_an_action_given_one_key_answers_that_key_alone(self, shipped: ShortcutScheme) -> None: + scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, (KeyCombination.parse("Ctrl+Alt+I"),)) assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == () + def test_an_action_given_several_keys_answers_each_and_displays_the_first(self, shipped: ShortcutScheme) -> None: + given = (KeyCombination.parse("Ctrl+Alt+I"), KeyCombination.parse("Ctrl+Alt+Shift+I")) + + scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, given) + + assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).combinations() == given + assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).display() == given[0].display() + def test_an_action_given_no_combination_is_left_unbound(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.UNDO, None) + scheme = shipped.with_binding(ShortcutId.UNDO, ()) assert scheme.shortcut(ShortcutId.UNDO).combinations() == () def test_a_combination_the_category_already_answers_raises(self, shipped: ShortcutScheme) -> None: """An editor is told which action holds the keys, so the reader decides who keeps them.""" with pytest.raises(SystemError): - shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S")) + shipped.with_binding(ShortcutId.ABOUT_DIALOG, (KeyCombination.parse("Ctrl+S"),)) def test_a_combination_another_category_holds_stands(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse(TABLE_COMBINATION)) + scheme = shipped.with_binding(ShortcutId.ABOUT_DIALOG, (KeyCombination.parse(TABLE_COMBINATION),)) combination = KeyCombination.parse(TABLE_COMBINATION) assert scheme.claimant(ShortcutCategory.APPLICATION, combination) is ShortcutId.ABOUT_DIALOG def test_a_combination_naming_no_key_raises(self, shipped: ShortcutScheme) -> None: with pytest.raises(KeyError): - shipped.with_binding(ShortcutId.UNDO, KeyCombination(UNNAMED_KEY)) + shipped.with_binding(ShortcutId.UNDO, (KeyCombination(UNNAMED_KEY),)) class TestWithBindings: @@ -206,8 +222,8 @@ def test_two_actions_trade_the_combinations_they_held(self, shipped: ShortcutSch """A whole set is read at once, so a swap arrives without either action holding both keys.""" scheme = shipped.with_bindings( { - ShortcutId.UNDO: KeyCombination.parse("Ctrl+Y"), - ShortcutId.REDO: KeyCombination.parse("Ctrl+Z"), + ShortcutId.UNDO: (KeyCombination.parse("Ctrl+Y"),), + ShortcutId.REDO: (KeyCombination.parse("Ctrl+Z"),), }, ) @@ -215,7 +231,7 @@ def test_two_actions_trade_the_combinations_they_held(self, shipped: ShortcutSch assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Z")) is ShortcutId.REDO def test_the_actions_no_binding_names_keep_the_scheme_s_keys(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_bindings({ShortcutId.UNDO: KeyCombination.parse("Ctrl+Alt+U")}) + scheme = shipped.with_bindings({ShortcutId.UNDO: (KeyCombination.parse("Ctrl+Alt+U"),)}) assert scheme.shortcut(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO) @@ -233,12 +249,28 @@ def test_the_keys_the_override_replaces_stop_reaching_the_action(self, shipped: assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+Z")) is None def test_an_override_states_the_whole_of_what_reaches_the_action(self, shipped: ShortcutScheme) -> None: - """A reader names one combination, so the keypad alias the scheme shipped goes with it.""" + """An override lists every key of its action, so one naming a single key leaves the shipped keypad + alias out. + """ scheme = shipped.with_overrides({"OrderInsertFrame": "Ctrl+Alt+I"}) assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == () assert scheme.action(ShortcutCategory.ORDER, _press("Num+")) is None + def test_an_override_listing_several_keys_reaches_the_action_from_each(self, shipped: ShortcutScheme) -> None: + scheme = shipped.with_overrides({"OrderInsertFrame": "Ctrl+Alt+I, Num+"}) + + assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).display() == "Ctrl+Alt+I" + assert scheme.action(ShortcutCategory.ORDER, _press("Ctrl+Alt+I")) is ShortcutId.ORDER_INSERT_FRAME + assert scheme.action(ShortcutCategory.ORDER, _press("Num+")) is ShortcutId.ORDER_INSERT_FRAME + + def test_an_override_naming_no_key_in_one_place_is_left_out_whole(self, shipped: ShortcutScheme) -> None: + """An override that is unreadable in part costs only itself, and its action keeps the shipped keys.""" + scheme = shipped.with_overrides({"Redo": "Ctrl+Alt+R, Ctrl+Gibberish", "Undo": "Ctrl+Alt+U"}) + + assert scheme.shortcut(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO) + assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U" + def test_a_rebound_action_keeps_the_transparency_its_role_carries(self, shipped: ShortcutScheme) -> None: """Switching tabs outranks text entry whichever keys it answers to.""" scheme = shipped.with_overrides({"NextTab": "Ctrl+Alt+N"}) @@ -272,7 +304,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}) @@ -289,6 +321,56 @@ def test_overrides_passing_a_combination_between_two_actions_both_stand( assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+S")) is ShortcutId.ABOUT_DIALOG assert scheme.shortcut(ShortcutId.SAVE_PROJECT).combinations() == () + def test_a_pair_moving_a_key_stands_beside_a_stale_entry_and_the_stale_entry_alone_is_reported( + self, + shipped: ShortcutScheme, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An editor writes the action taking a key ahead of the action giving it up, and a later build + can give a stored key to an action of its own. The pair still moves the key, and the stale + entry costs only itself.""" + taken = display_combinations(shipped.shortcut(ShortcutId.SAVE_PROJECT).combinations()) + held = display_combinations(shipped.shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_1).combinations()) + overrides = { + ShortcutId.ABOUT_DIALOG.value: taken, + ShortcutId.SAVE_PROJECT.value: None, + ShortcutId.KEYBOARD_SETTINGS.value: held, + } + caplog.set_level(logging.WARNING) + + scheme = shipped.with_overrides(overrides) + + assert ( + display_combinations(scheme.shortcut(ShortcutId.ABOUT_DIALOG).combinations()), + scheme.shortcut(ShortcutId.SAVE_PROJECT).combinations(), + scheme.shortcut(ShortcutId.KEYBOARD_SETTINGS) == shipped.shortcut(ShortcutId.KEYBOARD_SETTINGS), + ) == (taken, (), True) + assert _reported(caplog, overrides) == [ShortcutId.KEYBOARD_SETTINGS.value] + + def test_two_actions_trading_their_keys_stand_beside_a_stale_entry( + self, + shipped: ShortcutScheme, + ) -> None: + undo_keys = display_combinations(shipped.shortcut(ShortcutId.UNDO).combinations()) + redo_keys = display_combinations(shipped.shortcut(ShortcutId.REDO).combinations()) + held = display_combinations(shipped.shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_1).combinations()) + + scheme = shipped.with_overrides( + { + ShortcutId.UNDO.value: redo_keys, + ShortcutId.REDO.value: undo_keys, + ShortcutId.KEYBOARD_SETTINGS.value: held, + } + ) + + assert ( + scheme.shortcut(ShortcutId.UNDO).combinations(), + scheme.shortcut(ShortcutId.REDO).combinations(), + ) == ( + shipped.shortcut(ShortcutId.REDO).combinations(), + shipped.shortcut(ShortcutId.UNDO).combinations(), + ) + def test_a_scheme_without_overrides_is_the_one_it_started_as(self, shipped: ShortcutScheme) -> None: assert shipped.with_overrides({}) is shipped 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/shortcuts/test_written.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py index 96e715835..f8406cdf5 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py @@ -38,22 +38,30 @@ def test_an_entry_naming_no_key_raises(self) -> None: class TestRebound: def test_a_rebound_entry_answers_the_combination_the_reader_named(self) -> None: - entry = WrittenShortcut(combination="Ctrl+Y").rebound("Ctrl+Alt+R") + entry = WrittenShortcut(combination="Ctrl+Y").rebound(("Ctrl+Alt+R",)) assert entry.combination == "Ctrl+Alt+R" - def test_a_rebound_entry_answers_that_combination_alone(self) -> None: - entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound("Ctrl+Alt+R") + def test_a_rebound_entry_answers_the_keys_named_and_those_alone(self) -> None: + """A reader states every key of the action, so one key leaves the earlier aliases out.""" + entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound(("Ctrl+Alt+R",)) assert entry.aliases == () + def test_a_rebound_entry_displays_the_first_key_and_answers_the_rest(self) -> None: + entry = WrittenShortcut(combination="Ctrl+Y").rebound(("Ctrl+Alt+R", "Ctrl+Shift+Z")) + + assert entry.combination == "Ctrl+Alt+R" + assert entry.aliases == ("Ctrl+Shift+Z",) + def test_an_entry_rebound_to_no_combination_is_left_unbound(self) -> None: """A reader takes an action's keys away by giving it none.""" - entry = WrittenShortcut(combination="Ctrl+Y").rebound(None) + entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound(()) assert entry.combination is None + assert entry.aliases == () def test_a_rebound_entry_keeps_the_transparency_the_action_carries(self) -> None: - entry = WrittenShortcut(combination="Ctrl+PgDn", field_transparent=True).rebound("Ctrl+Alt+N") + entry = WrittenShortcut(combination="Ctrl+PgDn", field_transparent=True).rebound(("Ctrl+Alt+N",)) assert entry.field_transparent is True diff --git a/tests/unit/sampletones_application/utils/gui/test_align.py b/tests/unit/sampletones_application/utils/gui/test_align.py new file mode 100644 index 000000000..aad4eb3d5 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_align.py @@ -0,0 +1,112 @@ +from typing import Final, List, Sequence, Tuple + +import pytest + +from sampletones_application.utils.gui import align as align_module +from sampletones_application.utils.gui.align import center_when_settled +from sampletones_application.utils.placement import centered_position +from sampletones_shared.types.callback import VoidCallback + +FORM_SIZES: Final[Tuple[Tuple[int, int], ...]] = ((420, 45), (420, 217), (420, 217)) +SETTLED_SIZE: Final[Tuple[int, int]] = (420, 217) +VIEWPORT: Final[Tuple[int, int]] = (1600, 1000) +WIDENING_STEP: Final[int] = 63 +WINDOW_TAG: Final[str] = "settings.window" + + +class Window: + """A window drawn at a stated size each frame, with the frames the pass asks for run by hand. + + A frame runs the callbacks armed for it and the readings taken during it answer with that + frame's size, which is what lets a case state a window's whole way to its final size. + """ + + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + sizes: Sequence[Tuple[int, int]], + *, + exists: bool = True, + ) -> None: + self.sizes = list(sizes) + self.frame = 0 + self.positions: List[List[int]] = [] + self._armed: List[VoidCallback] = [] + monkeypatch.setattr(align_module.FrameCallbackManager, "set_frame_callback", self._arm) + monkeypatch.setattr(align_module.dpg, "does_item_exist", lambda tag: exists) + monkeypatch.setattr(align_module.dpg, "get_item_rect_size", lambda tag: self.size) + monkeypatch.setattr(align_module.dpg, "set_item_pos", lambda tag, position: self.positions.append(position)) + monkeypatch.setattr(align_module.dpg, "get_viewport_client_width", lambda: VIEWPORT[0]) + monkeypatch.setattr(align_module.dpg, "get_viewport_client_height", lambda: VIEWPORT[1]) + + @property + def size(self) -> Tuple[int, int]: + return self.sizes[min(self.frame, len(self.sizes) - 1)] + + @property + def waiting(self) -> bool: + """Whether the pass has asked for another frame.""" + return bool(self._armed) + + def _arm(self, callback: VoidCallback, frame_count: int = 1) -> None: + self._armed.append(callback) + + def draw(self, frames: int) -> None: + """Draws frames while the pass still asks for one.""" + for _ in range(frames): + if not self._armed: + return + + armed, self._armed = self._armed, [] + for callback in armed: + callback() + + self.frame += 1 + + +def _centered(size: Tuple[int, int]) -> List[int]: + return list(centered_position((VIEWPORT[0] // 2, VIEWPORT[1] // 2), *size)) + + +class TestCenteringAWindowAsItTakesItsSize: + """A dialog stands centered from the frame it is first drawn in until it settles there.""" + + def test_a_form_is_centered_against_every_size_it_is_drawn_at(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A form measures its fields on the second frame, and stands centered on both.""" + window = Window(monkeypatch, FORM_SIZES) + + center_when_settled(WINDOW_TAG) + window.draw(len(FORM_SIZES)) + + assert window.positions == [_centered(FORM_SIZES[0]), _centered(SETTLED_SIZE), _centered(SETTLED_SIZE)] + + def test_two_readings_that_agree_end_the_pass(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A dialog can be dragged, so the pass lets go once the size it reads stops changing.""" + window = Window(monkeypatch, FORM_SIZES) + + center_when_settled(WINDOW_TAG) + window.draw(len(FORM_SIZES) + 5) + + assert not window.waiting + assert len(window.positions) == len(FORM_SIZES) + + def test_a_window_still_widening_is_read_again(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The keybindings list widens over a dozen frames, and is centered on each of them.""" + widening = [(620 + WIDENING_STEP * step, 647) for step in range(13)] + [(620 + WIDENING_STEP * 12, 647)] + window = Window(monkeypatch, widening) + + center_when_settled(WINDOW_TAG) + window.draw(len(widening)) + + assert window.positions == [_centered(size) for size in widening] + assert not window.waiting + + def test_a_window_no_longer_drawn_ends_the_pass(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A dialog closed before it settled leaves nothing to center.""" + window = Window(monkeypatch, FORM_SIZES, exists=False) + + center_when_settled(WINDOW_TAG) + window.draw(len(FORM_SIZES)) + + assert window.positions == [] + assert not window.waiting diff --git a/tests/unit/sampletones_application/utils/gui/test_callbacks.py b/tests/unit/sampletones_application/utils/gui/test_callbacks.py new file mode 100644 index 000000000..f45d345a4 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_callbacks.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from typing import Any, Iterator, List, Sequence, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.callbacks import hold_callbacks, run_held_callbacks +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +SENDER = "the.widget" +APP_DATA = 7 +USER_DATA = "carried" +JOB = (SENDER, APP_DATA, USER_DATA) + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def held(*jobs: Tuple[Any, ...]) -> Any: + """Stands in for what DearPyGui hands back, which is a list of jobs or nothing at all.""" + return patch.object(dpg, "get_callback_queue", return_value=list(jobs) or None) + + +class TestHoldingACallback(BaseTestSuite): + """A gesture is gathered rather than answered where it lands, so the frame runs it.""" + + def test_it_asks_dearpygui_to_gather_them(self, dpg_context: None) -> None: + hold_callbacks() + + assert dpg.get_app_configuration()["manual_callback_management"] + + +class TestRunningWhatWasHeld(BaseTestSuite): + """Each gathered gesture runs on the thread that drew the items it reaches.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + declared: int + expected: Sequence[Any] + + test_cases = ( + TestCase(label="a_callback_taking_nothing", declared=0, expected=()), + TestCase(label="a_callback_taking_the_sender", declared=1, expected=(SENDER,)), + TestCase(label="a_callback_taking_the_payload", declared=2, expected=(SENDER, APP_DATA)), + TestCase(label="a_callback_taking_the_user_data", declared=3, expected=(SENDER, APP_DATA, USER_DATA)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_callback_is_handed_the_arguments_it_declares(self, test_case: TestCase) -> None: + received: List[Sequence[Any]] = [] + callback = self._taking(test_case.declared, received) + with held((callback, *JOB)): + run_held_callbacks() + + assert received == [tuple(test_case.expected)] + + def test_a_callback_taking_whatever_comes_is_handed_all_of_it(self) -> None: + received: List[Sequence[Any]] = [] + + def callback(*arguments: Any) -> None: + received.append(arguments) + + with held((callback, *JOB)): + run_held_callbacks() + + assert received == [JOB] + + def test_a_gesture_without_a_callback_is_passed_over(self) -> None: + with held((None, *JOB)): + run_held_callbacks() + + def test_an_empty_queue_leaves_the_frame_alone(self) -> None: + with held(): + run_held_callbacks() + + def test_a_failing_gesture_leaves_the_rest_to_run(self) -> None: + """A gesture that raises is reported rather than taking the frame's other gestures down.""" + received: List[Sequence[Any]] = [] + + def failing() -> None: + raise RuntimeError("the gesture went wrong") + + with held((failing, *JOB), (self._taking(0, received), *JOB)): + run_held_callbacks() + + assert received == [()] + + def test_the_gestures_run_in_the_order_they_were_made(self) -> None: + order: List[str] = [] + with held( + (lambda: order.append("first"), *JOB), + (lambda: order.append("second"), *JOB), + ): + run_held_callbacks() + + assert order == ["first", "second"] + + @staticmethod + def _taking( + declared: int, + received: List[Sequence[Any]], + ) -> Any: + """A callback declaring ``declared`` of DearPyGui's arguments, recording what it was handed.""" + recorders = ( + lambda: received.append(()), + lambda sender: received.append((sender,)), + lambda sender, app_data: received.append((sender, app_data)), + lambda sender, app_data, user_data: received.append((sender, app_data, user_data)), + ) + return recorders[declared] diff --git a/tests/unit/sampletones_application/utils/gui/test_dpg.py b/tests/unit/sampletones_application/utils/gui/test_dpg.py new file mode 100644 index 000000000..7368ef153 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_dpg.py @@ -0,0 +1,69 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.dpg import dpg_get_item_parent, dpg_get_item_user_data + +ROOT_TAG = "test_root" +CHILD_TAG = "test_child" +ROW_DATA = (3, "voice-id") + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +class TestAnAbsentItem: + """A queued callback can remove an item underneath a lookup, which resolves to nothing.""" + + def test_a_standing_item_names_the_one_holding_it(self, dpg_context: None) -> None: + with dpg.window(tag=ROOT_TAG): + dpg.add_text("held", tag=CHILD_TAG) + + assert dpg_get_item_parent(CHILD_TAG) is not None + + def test_an_item_that_has_gone_names_nothing(self, dpg_context: None) -> None: + assert dpg_get_item_parent("never_built") is None + + def test_the_library_raises_the_base_class_for_an_absent_item(self, dpg_context: None) -> None: + """What the helper's broad catch answers: there is nothing narrower to name. + + The day DearPyGui raises a type of its own is the day this test fails and the catch + narrows to it. + """ + with pytest.raises(Exception) as raised: + dpg.get_item_parent("never_built") + + assert type(raised.value) is Exception # pylint: disable=unidiomatic-typecheck + + +class TestTheUserDataOfAnItemAGestureNamed: + """A held callback reads what a gesture landed on a frame late, so a row a rebuild took away reads as nothing.""" + + def test_a_standing_item_answers_with_its_user_data(self, dpg_context: None) -> None: + with dpg.window(tag=ROOT_TAG): + dpg.add_selectable(label="row", tag=CHILD_TAG, user_data=ROW_DATA) + + assert dpg_get_item_user_data(CHILD_TAG) == ROW_DATA + + def test_an_item_a_rebuild_took_away_answers_with_nothing(self, dpg_context: None) -> None: + with dpg.window(tag=ROOT_TAG): + row = dpg.add_selectable(label="row", user_data=ROW_DATA) + dpg.delete_item(row) + + assert dpg_get_item_user_data(row) is None + + def test_the_library_raises_for_an_item_a_rebuild_took_away(self, dpg_context: None) -> None: + """What the guard answers: the bare read of a deleted item raises, and the callback logs it.""" + with dpg.window(tag=ROOT_TAG): + row = dpg.add_selectable(label="row", user_data=ROW_DATA) + dpg.delete_item(row) + + with pytest.raises(Exception, match="Item not found"): + dpg.get_item_user_data(row) diff --git a/tests/unit/sampletones_application/utils/gui/test_frame.py b/tests/unit/sampletones_application/utils/gui/test_frame.py new file mode 100644 index 000000000..35d15a221 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_frame.py @@ -0,0 +1,53 @@ +from typing import Final, Iterator, List +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.callbacks.failures import UnhandledFailures +from sampletones_application.utils.gui.frame import FrameCallbackManager + +DRAWN_FRAME: Final[int] = 1 + + +class FrameCount: + """The frame DearPyGui reports as drawn, moved by the case alone.""" + + def __init__(self) -> None: + self.drawn = 0 + + def __call__(self) -> int: + return self.drawn + + +@pytest.fixture +def frame_count() -> Iterator[FrameCount]: + """DearPyGui's frame count and frame callbacks stood in for, since a suite renders no frame.""" + count = FrameCount() + with ( + patch.object(dpg, "get_frame_count", count), + patch.object(dpg, "set_frame_callback"), + ): + yield count + + +class TestAFrameCallbackThatRaises: + """A frame callback that raises is reported, and the others due in that frame still run.""" + + def test_the_others_due_still_run(self, frame_count: FrameCount) -> None: + reported: List[Exception] = [] + ran: List[str] = [] + failure = RuntimeError("the frame callback went wrong") + + def failing() -> None: + raise failure + + UnhandledFailures.attach(reported.append, post=lambda present, exception: present(exception)) + FrameCallbackManager.set_frame_callback(failing) + FrameCallbackManager.set_frame_callback(lambda: ran.append("after")) + frame_count.drawn = DRAWN_FRAME + + FrameCallbackManager.process() + + assert ran == ["after"] + assert reported == [failure] diff --git a/tests/unit/sampletones_application/utils/gui/test_hover.py b/tests/unit/sampletones_application/utils/gui/test_hover.py new file mode 100644 index 000000000..7948d9c00 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_hover.py @@ -0,0 +1,101 @@ +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.hover import HOVER_RECHECK_FRAMES, HoverWatch +from tests.suite.frames import Frames + +RESTING_FRAMES: Final[int] = 12 + + +class Pointer: + """Where the pointer stands, and the looks a watch painted as it read that.""" + + def __init__(self) -> None: + self.over = True + self.failing = False + self.painted: List[bool] = [] + + def paint(self) -> bool: + if self.failing: + raise RuntimeError("the item painted is gone") + + self.painted.append(self.over) + return self.over + + +@pytest.fixture +def frames(monkeypatch: pytest.MonkeyPatch) -> Frames: + held = Frames() + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", held.hold) + return held + + +class TestTheHoverAWatchHolds: + """A hover handler reports every frame the pointer rests on an item, and the watch reads it again + until the pointer has left.""" + + def test_a_report_paints_the_hover_at_once(self, frames: Frames) -> None: + pointer = Pointer() + + HoverWatch(pointer.paint).report() + + assert pointer.painted == [True] + + def test_a_pointer_resting_keeps_one_reading_waiting(self, frames: Frames) -> None: + pointer = Pointer() + watch = HoverWatch(pointer.paint) + + for _ in range(RESTING_FRAMES): + watch.report() + frames.render() + + assert frames.pending == 1 + + def test_the_reading_waits_for_the_hover_to_end(self, frames: Frames) -> None: + pointer = Pointer() + HoverWatch(pointer.paint).report() + pointer.painted.clear() + + frames.render(HOVER_RECHECK_FRAMES - 1) + assert pointer.painted == [] + + frames.render() + assert pointer.painted == [True] + + def test_a_pointer_leaving_paints_the_idle_look_and_ends_the_watch(self, frames: Frames) -> None: + pointer = Pointer() + HoverWatch(pointer.paint).report() + pointer.over = False + + frames.render(HOVER_RECHECK_FRAMES) + + assert (pointer.painted, frames.pending) == ([True, False], 0) + + def test_a_pointer_back_starts_the_watch_again(self, frames: Frames) -> None: + pointer = Pointer() + watch = HoverWatch(pointer.paint) + watch.report() + pointer.over = False + frames.render(HOVER_RECHECK_FRAMES) + pointer.over = True + + watch.report() + + assert (pointer.painted, frames.pending) == ([True, False, True], 1) + + def test_a_paint_that_failed_leaves_the_next_report_free_to_watch(self, frames: Frames) -> None: + """An item taken down under the watch fails its paint, and the pointer resting elsewhere is + still watched.""" + pointer = Pointer() + watch = HoverWatch(pointer.paint) + watch.report() + pointer.failing = True + with pytest.raises(RuntimeError): + frames.render(HOVER_RECHECK_FRAMES) + pointer.failing = False + + watch.report() + + assert frames.pending == 1 diff --git a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py new file mode 100644 index 000000000..ee628bddf --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -0,0 +1,505 @@ +from typing import Final, List + +import pytest + +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames, held_frames + +__all__ = ["held_frames"] + +FIRST: Final[str] = "first" +SECOND: Final[str] = "second" +THIRD: Final[str] = "third" +DIALOG: Final[str] = "dialog" +PROMPT: Final[str] = "prompt" +TURN: Final[str] = "turn" + + +class Screen: + """The windows the line built, in the order it built them, and what each one stands for.""" + + def __init__(self) -> None: + self.built: List[str] = [] + self.revealed: List[str] = [] + + def builder(self, tag: str) -> VoidCallback: + return lambda: self.built.append(tag) + + def open(self, tag: str) -> None: + ModalQueue.open(tag, self.builder(tag)) + + def revealer(self, tag: str) -> VoidCallback: + return lambda: self.revealed.append(tag) + + def take_a_turn(self) -> None: + """A gesture waiting for the screen, which records the moment it reads what it asks about.""" + ModalQueue.when_free(lambda: self.built.append(TURN)) + + +@pytest.fixture +def screen() -> Screen: + return Screen() + + +class TestTheFreeScreen: + """A modal asked for while nothing holds the screen opens at once.""" + + def test_the_first_modal_opens_at_once(self, screen: Screen) -> None: + screen.open(FIRST) + + assert screen.built == [FIRST] + + def test_a_modal_left_frees_the_screen_a_frame_later(self, screen: Screen, held_frames: Frames) -> None: + """The frame the modal left in still draws it, so a modal built there would open hidden.""" + screen.open(FIRST) + ModalQueue.leave(FIRST) + + screen.open(SECOND) + assert screen.built == [FIRST] + + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + +class TestTheLine: + """A modal asked for while another conversation holds the screen waits its turn.""" + + def test_a_modal_asked_for_while_another_stands_waits(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + + screen.open(SECOND) + held_frames.render() + + assert screen.built == [FIRST] + + def test_the_waiting_modal_opens_a_frame_after_the_standing_one_leaves( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + assert screen.built == [FIRST] + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + def test_modals_waiting_open_in_the_order_they_were_asked_for( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + ModalQueue.leave(FIRST) + held_frames.render() + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, THIRD] + + def test_asking_again_for_a_waiting_modal_keeps_one_place_with_the_newer_request( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + newer: List[str] = [] + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + ModalQueue.open(SECOND, lambda: newer.append(SECOND)) + ModalQueue.leave(FIRST) + held_frames.render() + + assert screen.built == [FIRST, THIRD] + assert not newer + + def test_a_modal_taken_away_while_it_waits_never_opens(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(SECOND) + ModalQueue.leave(FIRST) + held_frames.render() + + assert screen.built == [FIRST] + + +class TestAConversation: + """A modal and what it hands the screen to keep the screen until the last of them leaves.""" + + def test_what_an_answer_raises_opens_ahead_of_the_line(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + assert screen.built == [FIRST, PROMPT] + + def test_the_line_moves_once_the_answer_raised_nothing(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + ModalQueue.hand_off(lambda: None) + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + def test_a_dialog_standing_aside_keeps_the_screen_for_its_prompt( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(DIALOG) + screen.open(SECOND) + + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + assert screen.built == [DIALOG, PROMPT] + + def test_the_dialog_comes_back_ahead_of_the_line(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + screen.open(SECOND) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + ModalQueue.leave(PROMPT) + ModalQueue.hand_off(lambda: ModalQueue.come_back(DIALOG, screen.revealer(DIALOG))) + held_frames.render() + + assert screen.revealed == [DIALOG] + assert screen.built == [DIALOG, PROMPT] + + def test_the_line_moves_once_the_dialog_itself_leaves(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + screen.open(SECOND) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + ModalQueue.leave(PROMPT) + ModalQueue.hand_off(lambda: ModalQueue.leave(DIALOG)) + held_frames.render(2) + + assert screen.built == [DIALOG, PROMPT, SECOND] + + def test_a_second_modal_one_answer_raises_waits_for_the_first( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """DearPyGui shows one of them, so the answer's second modal opens once its first one leaves.""" + screen.open(FIRST) + screen.open(THIRD) + ModalQueue.leave(FIRST) + + def answer() -> None: + screen.open(PROMPT) + screen.open(SECOND) + + ModalQueue.hand_off(answer) + held_frames.render() + ModalQueue.leave(PROMPT) + held_frames.render() + + assert screen.built == [FIRST, PROMPT, SECOND] + + def test_a_modal_raised_where_the_answer_closed_one_opens_a_frame_later( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """The frame the answer closed a modal in still draws it, and the answer's modal keeps its turn.""" + screen.open(DIALOG) + screen.open(THIRD) + + def answer() -> None: + ModalQueue.leave(DIALOG) + screen.open(SECOND) + + ModalQueue.hand_off(answer) + held_frames.render() + assert screen.built == [DIALOG] + + held_frames.render() + + assert screen.built == [DIALOG, SECOND] + + def test_closing_a_dialog_standing_aside_frees_the_screen_at_once( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """A dialog standing aside is drawn nowhere, so the screen it leaves is free in the same frame.""" + screen.open(DIALOG) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + ModalQueue.leave(PROMPT) + held_frames.render() + + def answer() -> None: + ModalQueue.leave(DIALOG) + screen.open(SECOND) + + ModalQueue.hand_off(answer) + held_frames.render() + + assert screen.built == [DIALOG, PROMPT, SECOND] + + +class TestATurnInTheLine: + """A gesture waiting for the screen goes on once the screen is free for it, in its place in the line.""" + + def test_a_turn_runs_at_once_on_a_free_screen(self, screen: Screen) -> None: + screen.take_a_turn() + + assert screen.built == [TURN] + + def test_a_turn_waits_behind_a_standing_conversation(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + + screen.take_a_turn() + held_frames.render() + + assert screen.built == [FIRST] + + def test_a_turn_runs_a_frame_after_the_conversation_ends(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.take_a_turn() + + ModalQueue.leave(FIRST) + assert screen.built == [FIRST] + held_frames.render() + + assert screen.built == [FIRST, TURN] + + def test_a_turn_joins_the_conversation_a_hand_off_carries(self, screen: Screen, held_frames: Frames) -> None: + """An answer that asks for the screen goes on at once, ahead of the modals waiting in line.""" + screen.open(FIRST) + screen.open(SECOND) + ModalQueue.leave(FIRST) + + ModalQueue.hand_off(lambda: ModalQueue.when_free(lambda: screen.open(PROMPT))) + held_frames.render() + + assert screen.built == [FIRST, PROMPT] + + def test_the_line_keeps_its_order_across_turns_and_windows(self, screen: Screen, held_frames: Frames) -> None: + """A turn that opens nothing lets the line go on to the next window in the same frame.""" + screen.open(FIRST) + screen.open(SECOND) + screen.take_a_turn() + screen.open(THIRD) + + ModalQueue.leave(FIRST) + held_frames.render() + assert screen.built == [FIRST, SECOND] + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, TURN, THIRD] + + def test_a_turn_that_opens_a_modal_holds_the_line(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + ModalQueue.when_free(lambda: screen.open(PROMPT)) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + held_frames.render() + assert screen.built == [FIRST, PROMPT] + ModalQueue.leave(PROMPT) + held_frames.render() + + assert screen.built == [FIRST, PROMPT, SECOND] + + def test_a_turn_that_raises_leaves_the_line_moving(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + ModalQueue.when_free(self.broken) + screen.open(SECOND) + ModalQueue.leave(FIRST) + + with pytest.raises(RuntimeError): + held_frames.render() + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + def test_a_modal_asked_for_after_a_raising_turn_waits_behind_the_line( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """The line goes on a frame after the failure, in the order it was asked.""" + screen.open(FIRST) + ModalQueue.when_free(self.broken) + screen.open(SECOND) + ModalQueue.leave(FIRST) + with pytest.raises(RuntimeError): + held_frames.render() + + screen.open(THIRD) + assert screen.built == [FIRST] + held_frames.render() + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, THIRD] + + def test_a_turn_asked_for_after_a_raising_turn_waits_behind_the_line( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + ModalQueue.when_free(self.broken) + screen.open(SECOND) + ModalQueue.leave(FIRST) + with pytest.raises(RuntimeError): + held_frames.render() + + screen.take_a_turn() + assert screen.built == [FIRST] + held_frames.render() + assert screen.built == [FIRST, SECOND] + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, TURN] + + @staticmethod + def broken() -> None: + raise RuntimeError("the gesture failed") + + +class TestAHandOffThatRaises: + """A hand-off that fails leaves the conversation and the line to go on in a coming frame.""" + + @staticmethod + def broken() -> None: + raise RuntimeError("the answer failed") + + def test_the_hand_offs_after_it_run_in_a_coming_frame(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + ModalQueue.leave(FIRST) + ModalQueue.hand_off(self.broken) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + + with pytest.raises(RuntimeError): + held_frames.render() + assert screen.built == [FIRST] + held_frames.render() + + assert screen.built == [FIRST, PROMPT] + + def test_the_line_moves_on_behind_it(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + ModalQueue.leave(FIRST) + ModalQueue.hand_off(self.broken) + + with pytest.raises(RuntimeError): + held_frames.render() + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + +class TestTheSnapshot: + """A reader outside the line sees which window stands, which wait, and whether the line moves.""" + + def test_a_free_screen_reads_settled(self) -> None: + snapshot = ModalQueue.snapshot() + + assert snapshot.shown is None + assert snapshot.is_settled + + def test_the_standing_window_and_the_line_read_in_order(self, screen: Screen) -> None: + screen.open(FIRST) + screen.open(SECOND) + screen.open(THIRD) + + snapshot = ModalQueue.snapshot() + + assert snapshot.shown == FIRST + assert snapshot.waiting == (SECOND, THIRD) + assert not snapshot.is_settled + + @pytest.mark.usefixtures("held_frames") + def test_a_window_that_left_reads_as_a_turn_due(self, screen: Screen) -> None: + screen.open(FIRST) + + ModalQueue.leave(FIRST) + + snapshot = ModalQueue.snapshot() + assert snapshot.shown is None + assert snapshot.turning + assert not snapshot.is_settled + + def test_the_line_settles_a_frame_after_the_last_window_left( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + ModalQueue.leave(FIRST) + + held_frames.render() + + assert ModalQueue.snapshot().is_settled + + def test_a_turn_waiting_reads_apart_from_the_windows(self, screen: Screen) -> None: + screen.open(FIRST) + screen.take_a_turn() + screen.open(SECOND) + + snapshot = ModalQueue.snapshot() + + assert snapshot.waiting == (SECOND,) + assert snapshot.turns == 1 + assert not snapshot.is_settled + + def test_a_dialog_standing_aside_reads_aside(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + + snapshot = ModalQueue.snapshot() + + assert snapshot.shown == PROMPT + assert snapshot.aside == (DIALOG,) + assert not snapshot.is_settled + + def test_a_window_standing_aside_or_waiting_is_held(self, screen: Screen, held_frames: Frames) -> None: + screen.open(DIALOG) + ModalQueue.step_aside(DIALOG) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + held_frames.render() + screen.open(FIRST) + + snapshot = ModalQueue.snapshot() + + assert [snapshot.holds(tag) for tag in (PROMPT, DIALOG, FIRST)] == [True, True, True] + + @pytest.mark.usefixtures("held_frames") + def test_a_window_that_left_is_held_no_more(self, screen: Screen) -> None: + screen.open(FIRST) + + ModalQueue.leave(FIRST) + + assert not ModalQueue.snapshot().holds(FIRST) diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py index d011a4305..761eaefab 100644 --- a/tests/unit/sampletones_application/utils/gui/test_palette.py +++ b/tests/unit/sampletones_application/utils/gui/test_palette.py @@ -7,7 +7,7 @@ dpg_add_palette_theme_color, dpg_set_palette_color, ) -from sampletones_application.utils.gui.palette.palette import PaletteBindings +from sampletones_application.utils.gui.palette.palette import PRUNE_FLOOR, PaletteBindings from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_application.utils.palette.colors.literal import LiteralColor @@ -21,6 +21,7 @@ STUDIO_ACCENT: ColorRGBA = (169, 127, 227, 255) LIGHT_ACCENT: ColorRGBA = (107, 63, 176, 255) LITERAL: ColorRGBA = (240, 146, 86, 255) +TRANSIENT_ITEMS: int = 4000 @pytest.fixture @@ -48,7 +49,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 +63,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 +89,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 +103,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): @@ -128,9 +129,22 @@ def test_a_deleted_item_is_dropped( assert not list(PaletteBindings.bindings()) + def test_items_bound_and_deleted_over_a_session_leave_a_bounded_registry( + self, + context: None, + accent: BaseColor, + ) -> None: + """Every right-click builds a menu's worth of colored text and takes it down again.""" + for _ in range(TRANSIENT_ITEMS): + item = _add_text() + dpg_set_palette_color(item, accent) + dpg.delete_item(item) + + assert len(list(PaletteBindings.bindings())) < 2 * PRUNE_FLOOR + 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 +160,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/gui/test_press.py b/tests/unit/sampletones_application/utils/gui/test_press.py new file mode 100644 index 000000000..808fad782 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_press.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Final + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.press import LeftPress + +HELD: Final[str] = "held" + + +@dataclass +class Button: + """Whether the left mouse button reads down.""" + + down: bool = True + + +@pytest.fixture +def button(monkeypatch: pytest.MonkeyPatch) -> Button: + button = Button() + monkeypatch.setattr(dpg, "is_mouse_button_down", lambda _button: button.down) + return button + + +class TestALeftPress: + """A press stands from its beginning until its release, or until it is read with the button up.""" + + def test_a_press_keeps_what_it_began_with_while_the_button_is_down(self, button: Button) -> None: + press: LeftPress[str] = LeftPress() + press.begin(HELD) + + assert press.held() == HELD + + def test_a_reported_release_ends_the_press(self, button: Button) -> None: + press: LeftPress[str] = LeftPress() + press.begin(HELD) + + press.end() + + assert press.held() is None + + def test_a_release_never_reported_ends_the_press_once_the_button_reads_up(self, button: Button) -> None: + """A later press keeps the button down again, and finds nothing held.""" + press: LeftPress[str] = LeftPress() + press.begin(HELD) + + button.down = False + press.settle() + button.down = True + + assert press.held() is None + + def test_reading_with_the_button_up_ends_the_press(self, button: Button) -> None: + press: LeftPress[str] = LeftPress() + press.begin(HELD) + button.down = False + + assert press.held() is None diff --git a/tests/unit/sampletones_application/utils/gui/test_render_thread.py b/tests/unit/sampletones_application/utils/gui/test_render_thread.py new file mode 100644 index 000000000..b8a45cf21 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_render_thread.py @@ -0,0 +1,212 @@ +import threading +from dataclasses import dataclass, field +from typing import Callable, Final, Iterator, List +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.render_thread import ( + answered_while_drawing, + claim_render_thread, + is_render_thread, + on_render_thread, + release_render_thread, + reset_render_thread, +) + +ANSWER: Final[str] = "the reader answered" +WAITING_TIMEOUT: Final[float] = 5.0 + + +@pytest.fixture +def unclaimed() -> None: + """A context no run has claimed, over a queue live enough to drain what reaches it.""" + reset_render_thread() + CallbackQueue.start() + + +def from_another_thread(work: Callable[[], None]) -> None: + """Runs ``work`` on a thread of its own and waits for it, the way a worker reaches the interface.""" + worker = threading.Thread(target=work) + worker.start() + worker.join() + + +@dataclass +class Frames: + """The frames a waiting draws, counted, and the moment it draws the first of them.""" + + drawn: List[int] = field(default_factory=list) + first: threading.Event = field(default_factory=threading.Event) + + +@pytest.fixture(name="frames") +def frames_fixture(unclaimed: None) -> Iterator[Frames]: + """A loop drawing on the test's thread, each frame counted where DearPyGui would draw it. + + The thread claims the frames the way a running interface does. A suite has no context to draw + in, so DearPyGui's two calls are stood in for, and each frame drawn is a count. + """ + frames = Frames() + + def frame() -> None: + frames.drawn.append(len(frames.drawn)) + frames.first.set() + + claim_render_thread() + with ( + patch.object(dpg, "is_dearpygui_running", return_value=True), + patch.object(dpg, "render_dearpygui_frame", side_effect=frame), + ): + yield frames + + +class TestWhereWorkRuns: + """Work that creates or deletes widgets belongs on the thread holding DearPyGui's context.""" + + def test_an_unclaimed_context_runs_where_it_stands(self, unclaimed: None) -> None: + ran: List[str] = [] + + on_render_thread(ran.append, "built") + + assert ran == ["built"] + + def test_the_thread_a_run_claimed_runs_where_it_stands(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + try: + on_render_thread(ran.append, "drawn") + finally: + release_render_thread() + + assert ran == ["drawn"] + + def test_another_thread_joins_the_queue_the_loop_drains(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + worker = threading.Thread(target=on_render_thread, args=(ran.append, "gestured")) + try: + worker.start() + worker.join() + + assert ran == [] + CallbackQueue.process(1.0) + finally: + release_render_thread() + + assert ran == ["gestured"] + + +class TestAfterTheLoopStopped: + """Once a run's loop stops, the thread that drew keeps the context until the context is destroyed. + + The teardown runs on that thread, so its work runs where it stands. A worker's late work joins the + queue, which the teardown stops, so it never reaches a context that is going or gone. + """ + + def test_the_thread_that_drew_still_holds_the_context(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + release_render_thread() + + on_render_thread(ran.append, "taken down") + + assert is_render_thread() is True + assert ran == ["taken down"] + + def test_another_thread_holds_no_context(self, unclaimed: None) -> None: + answers: List[bool] = [] + claim_render_thread() + release_render_thread() + + from_another_thread(lambda: answers.append(is_render_thread())) + + assert answers == [False] + + def test_a_late_worker_s_work_is_let_go_with_the_stopped_queue(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + release_render_thread() + CallbackQueue.stop() + + from_another_thread(lambda: on_render_thread(ran.append, "late")) + CallbackQueue.process(1.0) + + assert ran == [] + + def test_a_run_taken_down_before_it_drew_leaves_the_context_to_its_teardown(self, unclaimed: None) -> None: + answers: List[bool] = [] + + release_render_thread() + from_another_thread(lambda: answers.append(is_render_thread())) + + assert is_render_thread() is True + assert answers == [False] + + def test_a_fresh_context_is_built_by_whichever_thread_asks(self, unclaimed: None) -> None: + answers: List[bool] = [] + claim_render_thread() + release_render_thread() + + reset_render_thread() + from_another_thread(lambda: answers.append(is_render_thread())) + + assert answers == [True] + + +class TestAGestureThatWaits: + """A gesture on the thread drawing the frames holds them up, so its waiting draws them meanwhile. + + The cases that wait for a drawn frame before answering take the loop whichever way the threads + are scheduled. + """ + + def test_it_reports_what_the_waiting_answered(self, frames: Frames) -> None: + assert answered_while_drawing(lambda: ANSWER) == ANSWER + + def test_it_draws_while_the_waiting_stands(self, frames: Frames) -> None: + def answer() -> str: + frames.first.wait(WAITING_TIMEOUT) + return ANSWER + + assert answered_while_drawing(answer) == ANSWER + assert frames.drawn + + def test_a_failure_reaches_the_gesture_that_waited(self, frames: Frames) -> None: + def failing() -> str: + frames.first.wait(WAITING_TIMEOUT) + raise RuntimeError("the dialog went wrong") + + with pytest.raises(RuntimeError): + answered_while_drawing(failing) + + assert frames.drawn + + +class TestAWaitingWithNoFramesToDraw: + """Work asked where no loop draws its frames runs where it stands, on the thread that asked. + + The work answers the thread it ran on. These cases leave DearPyGui as it is, since a suite holds + no context for a frame. + """ + + def test_work_from_another_thread_runs_on_it(self, frames: Frames) -> None: + on_the_asking_thread: List[bool] = [] + + from_another_thread( + lambda: on_the_asking_thread.append(answered_while_drawing(threading.get_ident) == threading.get_ident()) + ) + + assert on_the_asking_thread == [True] + assert not frames.drawn + + def test_work_before_the_loop_draws_runs_where_it_stands(self, unclaimed: None) -> None: + assert answered_while_drawing(threading.get_ident) == threading.get_ident() + + def test_work_after_the_loop_stopped_runs_where_it_stands(self, unclaimed: None) -> None: + claim_render_thread() + release_render_thread() + + assert answered_while_drawing(threading.get_ident) == threading.get_ident() diff --git a/tests/unit/sampletones_application/utils/palette/test_catalog.py b/tests/unit/sampletones_application/utils/palette/test_catalog.py index 4e58edbc0..14214da5e 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 sample and an instrument 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_coalescing.py b/tests/unit/sampletones_application/utils/parallelization/test_coalescing.py index f760999a9..a9dd3299e 100644 --- a/tests/unit/sampletones_application/utils/parallelization/test_coalescing.py +++ b/tests/unit/sampletones_application/utils/parallelization/test_coalescing.py @@ -3,6 +3,9 @@ from typing import Callable, List from unittest.mock import patch +import pytest + +from sampletones_application.utils.callbacks.failures import UnhandledFailures from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor @@ -15,6 +18,14 @@ def _wait_until(predicate: Callable[[], bool], timeout: float = 1.0) -> bool: return predicate() +@pytest.fixture +def reported() -> List[Exception]: + """The failures the channel shows, presented on the thread that reports them.""" + failures: List[Exception] = [] + UnhandledFailures.attach(failures.append, post=lambda present, exception: present(exception)) + return failures + + class TestLatestWinsExecutor: def test_runs_a_submitted_task(self) -> None: executor = LatestWinsExecutor() @@ -99,3 +110,42 @@ def test_launch_failure_reports_false_and_stays_idle(self) -> None: assert accepted is False assert executor.is_running is False + + +class TestAFailingTask: + """A task that fails is reported, and the worker goes on serving what is submitted.""" + + def test_a_failing_task_is_reported_and_a_later_one_runs(self, reported: List[Exception]) -> None: + executor = LatestWinsExecutor() + failure = RuntimeError("the task went wrong") + done = threading.Event() + + def failing() -> None: + raise failure + + executor.submit(failing) + assert _wait_until(lambda: executor.is_running is False) + executor.submit(done.set) + + assert done.wait(1.0) + assert reported == [failure] + assert _wait_until(lambda: executor.is_running is False) + + def test_a_task_submitted_while_one_fails_runs_after_it(self, reported: List[Exception]) -> None: + executor = LatestWinsExecutor() + started = threading.Event() + release = threading.Event() + done = threading.Event() + + def failing() -> None: + started.set() + release.wait(1.0) + raise RuntimeError("the task went wrong") + + executor.submit(failing) + assert started.wait(1.0) + executor.submit(done.set) + release.set() + + assert done.wait(1.0) + assert len(reported) == 1 diff --git a/tests/unit/sampletones_application/utils/parallelization/test_thread.py b/tests/unit/sampletones_application/utils/parallelization/test_thread.py index 018becfea..0c66f6e55 100644 --- a/tests/unit/sampletones_application/utils/parallelization/test_thread.py +++ b/tests/unit/sampletones_application/utils/parallelization/test_thread.py @@ -3,14 +3,37 @@ from typing import Dict, List from unittest.mock import patch +import pytest + +from sampletones_application.utils.callbacks.failures import UnhandledFailures from sampletones_application.utils.parallelization.thread import ( - BackgroundWorkCancelled, + BackgroundWorkCanceled, SingleThreadExecutor, concurrent, + run_background_task, ) JOIN_TIMEOUT: float = 5.0 DEADLINE_TIMEOUT: float = 0.05 +FAILURES_LOGGER: str = "sampletones_application.utils.callbacks.failures.logger" +ORIGIN: str = "Error in the task under test" + + +class HeldTask: + """A task that says it has started, then holds its worker until it is released.""" + + def __init__(self) -> None: + self.started = threading.Event() + self.release = threading.Event() + + def __call__(self) -> None: + self.started.set() + self.release.wait(JOIN_TIMEOUT) + + +@pytest.fixture +def held_task() -> HeldTask: + return HeldTask() class TestJoinAll: @@ -49,13 +72,10 @@ def task() -> None: worker_holder["thread"].join(JOIN_TIMEOUT) assert not worker_alive_after_join - def test_returns_at_the_deadline_while_a_worker_still_runs(self) -> None: - started = threading.Event() - release = threading.Event() - + def test_returns_at_the_deadline_while_a_worker_still_runs(self, held_task: HeldTask) -> None: executor = SingleThreadExecutor() - assert executor.execute(lambda: (started.set(), release.wait()) and None, wait=False) - assert started.wait(JOIN_TIMEOUT) + assert executor.execute(held_task, wait=False) + assert held_task.started.wait(JOIN_TIMEOUT) try: SingleThreadExecutor.join_all(timeout=DEADLINE_TIMEOUT) @@ -64,7 +84,7 @@ def test_returns_at_the_deadline_while_a_worker_still_runs(self) -> None: assert worker is not None assert worker.is_alive() finally: - release.set() + held_task.release.set() SingleThreadExecutor.join_all(timeout=JOIN_TIMEOUT) @@ -100,31 +120,70 @@ 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: + with patch(FAILURES_LOGGER) as logger: Worker().work() SingleThreadExecutor.join_all(timeout=JOIN_TIMEOUT) logger.error_with_traceback.assert_not_called() + def test_a_failing_task_is_reported_through_the_failure_channel(self) -> None: + reported: List[Exception] = [] + failure = RuntimeError("the task went wrong") -class TestExecute: - def test_skips_a_new_task_while_busy_without_wait(self) -> None: - started = threading.Event() - release = threading.Event() + class Worker: + @concurrent(wait=True) + def work(self) -> None: + raise failure + UnhandledFailures.attach(reported.append, post=lambda present, exception: present(exception)) + Worker().work() + SingleThreadExecutor.join_all(timeout=JOIN_TIMEOUT) + + assert reported == [failure] + + +class TestRunBackgroundTask: + """A task's failure ends the task alone: it is reported, and a cancellation unwinds quietly.""" + + def test_a_failing_task_is_reported_under_its_origin(self) -> None: + reported: List[Exception] = [] + failure = RuntimeError("the task went wrong") + + def failing() -> None: + raise failure + + UnhandledFailures.attach(reported.append, post=lambda present, exception: present(exception)) + with patch(FAILURES_LOGGER) as logger: + run_background_task(failing, ORIGIN) + + logger.error_with_traceback.assert_called_once_with(failure, ORIGIN) + assert reported == [failure] + + def test_a_canceled_task_ends_quietly(self) -> None: + def canceled() -> None: + raise BackgroundWorkCanceled + + with patch(FAILURES_LOGGER) as logger: + run_background_task(canceled, ORIGIN) + + logger.error_with_traceback.assert_not_called() + + +class TestExecute: + def test_skips_a_new_task_while_busy_without_wait(self, held_task: HeldTask) -> None: executor = SingleThreadExecutor() - assert executor.execute(lambda: (started.set(), release.wait()) and None, wait=False) - assert started.wait(JOIN_TIMEOUT) + assert executor.execute(held_task, wait=False) + assert held_task.started.wait(JOIN_TIMEOUT) try: assert not executor.execute(lambda: None, wait=False) finally: - release.set() + held_task.release.set() SingleThreadExecutor.join_all(timeout=JOIN_TIMEOUT) 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..07ba39399 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,50 @@ 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.constants.output import OutputKind +from sampletones_application.constants.sources import SourceKind 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( + kind=SourceKind.RECORDING, + held=(), + partial_channels=frozenset(), + key=str(path), + name=path.stem, + path=path, + channels=channels, + offered_channels=frozenset(ChannelName.items()), + available=True, + level=level, + position=position, + stem_id=None, + level_size=level_size, + level_count=level_count, + ) def _view_model( @@ -14,16 +52,25 @@ def _view_model( phase: ConversionPhase, other_operation_active: bool = False, progress: float = 0.0, + input_path: Optional[Path] = Path("/audio/sample.wav"), + mixes: bool = False, + stem_sources: Tuple[StemRowViewModel, ...] = (_row("sample"),), + 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, + output=OutputKind.MIXED if mixes else OutputKind.PER_RECORDING, + stem_sources=stem_sources, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + max_sources=max_sources, + selected_key=None, ) @@ -71,9 +118,9 @@ class TestPrimaryAction: (ConversionPhase.IDLE, ConverterAction.CONVERT), (ConversionPhase.WAITING, ConverterAction.CANCEL), (ConversionPhase.RUNNING, ConverterAction.CANCEL), - (ConversionPhase.CANCELLING, ConverterAction.CANCEL), + (ConversionPhase.CANCELING, ConverterAction.CANCEL), (ConversionPhase.COMPLETED, ConverterAction.CONVERT), - (ConversionPhase.CANCELLED, ConverterAction.CONVERT), + (ConversionPhase.CANCELED, ConverterAction.CONVERT), (ConversionPhase.FAILED, ConverterAction.CONVERT), ], ) @@ -90,7 +137,7 @@ class TestPrimaryActionEnabled: [ (ConversionPhase.WAITING, True), (ConversionPhase.RUNNING, True), - (ConversionPhase.CANCELLING, False), + (ConversionPhase.CANCELING, False), ], ) def test_cancel_enablement(self, phase: ConversionPhase, enabled: bool) -> None: @@ -98,10 +145,94 @@ 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, + mixes=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, mixes=True, stem_sources=()) + + assert view_model.has_input is False + assert view_model.convert_button_enabled is False + + def test_a_gathered_recording_carries_a_run_writing_one_apiece(self) -> None: + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=False, stem_sources=(_row("bass"),)) + + assert view_model.has_input is True + + def test_a_run_writing_one_apiece_takes_more_than_a_mix_could_hold(self) -> None: + rows = tuple(_row(str(index)) for index in range(MAX_STEM_SOURCES)) + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=False, stem_sources=rows) + + assert view_model.can_add_source 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, mixes=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, mixes=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, + mixes=True, + stem_sources=(_row("bass", channels=frozenset()),), + ) + + assert view_model.has_input is False + 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/main/test_source.py b/tests/unit/sampletones_application/view_model/main/test_source.py new file mode 100644 index 000000000..92dc9395b --- /dev/null +++ b/tests/unit/sampletones_application/view_model/main/test_source.py @@ -0,0 +1,43 @@ +from typing import Final + +from sampletones_application.view_model.main.source import ( + CHANNEL_CAP_STEPS, + ChannelSettingsViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.algorithm import ALL_STEMS_CHANNEL_CAP, MIN_STEMS_CHANNEL_CAP +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite + +LOUD_DRIVE: Final[float] = 2.0 + + +def _channel( + channel_name: ChannelName, + use: Agreement, + bend: Agreement = Agreement.NONE, +) -> ChannelSettingsViewModel: + return ChannelSettingsViewModel(channel=channel_name, use=use, bend=bend, drive=LOUD_DRIVE) + + +class TestWhenABendIsOffered(BaseTestSuite): + """A bend belongs to a channel a recording occupies whose hardware loads a divider.""" + + def test_a_tone_channel_a_recording_occupies_offers_one(self) -> None: + assert _channel(ChannelName.TRIANGLE, Agreement.ALL).bend_offered is True + + def test_a_tone_channel_some_of_them_occupy_still_offers_one(self) -> None: + assert _channel(ChannelName.PULSE1, Agreement.SOME).bend_offered is True + + def test_a_channel_none_of_them_occupy_offers_none(self) -> None: + assert _channel(ChannelName.PULSE1, Agreement.NONE).bend_offered is False + + def test_the_noise_channel_offers_none(self) -> None: + noise = _channel(ChannelName.NOISE, Agreement.ALL) + + assert (noise.bendable, noise.bend_offered) == (False, False) + + +class TestTheStepsTheCountOffers(BaseTestSuite): + def test_the_steps_run_from_one_channel_to_them_all(self) -> None: + assert CHANNEL_CAP_STEPS == tuple(range(MIN_STEMS_CHANNEL_CAP, ALL_STEMS_CHANNEL_CAP + 1)) 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..1422c7883 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,35 @@ from dataclasses import dataclass +from pathlib import Path +from typing import Optional 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.rate import RateLock +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( + selected_key=None, + rows=(), + channels_in_play=(), + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=False, +) @dataclass(frozen=True) @@ -28,6 +51,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 +102,111 @@ 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, + rate_lock=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 TestReconstructionViewModelNesFrequency: + """The rate is the tab's to change while nothing locks it, and a lock always comes with its hint.""" + + @staticmethod + def _view_model(*, loaded: bool, rate_lock: Optional[RateLock]) -> ReconstructionViewModel: + file_state = ReconstructionPathState.AVAILABLE if loaded else ReconstructionPathState.EMPTY + return ReconstructionViewModel( + reconstruction_loaded=loaded, + playing_channels=frozenset(), + selected_channels=frozenset(), + reconstruction_file=ReconstructionPathViewModel(state=file_state, paths=()), + original_audio=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()), + nes_frequency=None, + rate_lock=rate_lock, + ) + + def test_a_document_nothing_locks_takes_a_new_rate(self) -> None: + view_model = self._view_model(loaded=True, rate_lock=None) + + assert view_model.nes_frequency_editable + assert not view_model.show_nes_frequency_hint + + @pytest.mark.parametrize("rate_lock", list(RateLock), ids=lambda rate_lock: rate_lock.value) + def test_a_locked_document_keeps_its_rate_and_explains_it(self, rate_lock: RateLock) -> None: + """A project sample and a document with no file are both locked, each for its own reason.""" + view_model = self._view_model(loaded=True, rate_lock=rate_lock) + + assert not view_model.nes_frequency_editable + assert view_model.show_nes_frequency_hint + assert view_model.rate_lock is rate_lock + + def test_an_empty_tab_takes_and_explains_nothing(self) -> None: + view_model = self._view_model(loaded=False, rate_lock=None) + + assert not view_model.nes_frequency_editable + assert not view_model.show_nes_frequency_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, + ) + + assert stems.show_setup_line + assert stems.hierarchy_mode is HierarchyMode.STRICT + + 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_samples.py b/tests/unit/sampletones_application/view_model/sequencer/test_samples.py deleted file mode 100644 index 3cf52ff50..000000000 --- a/tests/unit/sampletones_application/view_model/sequencer/test_samples.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest - -from sampletones_application.view_model.sequencer.samples import SampleSelection - - -class TestSampleSelectionLabel: - @pytest.mark.parametrize( - ("position", "name", "expected"), - [ - (0, "Kick", "00: Kick"), - (26, "Bass", "1A: Bass"), - (255, "Lead", "FF: Lead"), - ], - ) - def test_label_pairs_the_hex_position_with_the_name( - self, - position: int, - name: str, - expected: str, - ) -> None: - selection = SampleSelection(sample_id="id", position=position, name=name) - - assert selection.label == expected 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..6297ac17b 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,26 @@ 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, + pitch=None, + level=None, + command=None, ) @@ -41,18 +47,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 +67,331 @@ 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] + carried_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(), + carried_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, + label="transpose_and_volume_read_empty_where_no_sample_plays", + cells={channel: _cell(volume=display_volume(8)) for channel in ChannelName.items()}, + sample_channels=frozenset(), + carried_channels=frozenset(), + expected_sample=_EMPTY_VOICE, + expected_transpose=_EMPTY_TRANSPOSE, + expected_volume=_EMPTY_VOLUME, + ), + AggregateCase( + label="transpose_and_volume_span_the_channels_a_sample_still_plays_on", + cells=_row_cells( + pulse1=_cell(volume=display_volume(8)), + triangle=_cell(volume=display_volume(8)), + noise=_cell(volume=display_volume(3)), + ), + sample_channels=frozenset(), + carried_channels=frozenset( + { + ChannelName.PULSE1, + ChannelName.TRIANGLE, + } + ), + expected_sample=_EMPTY_VOICE, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=display_volume(8), ), AggregateCase( - label="single_relevant_channel_present", + label="a_placed_sample_spans_its_own_channels_over_the_ones_still_playing", + cells=_row_cells( + pulse1=_OCCUPIED, + pulse2=_cell(volume=display_volume(3)), + ), + sample_channels=frozenset({ChannelName.PULSE1}), + carried_channels=frozenset({ChannelName.PULSE2}), + expected_sample=display_id(0), + expected_transpose=display_transpose(5), + expected_volume=display_volume(8), + ), + AggregateCase( + 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}), + carried_channels=frozenset(), + 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), + carried_channels=frozenset(), + 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, + carried_channels=frozenset(), + 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), + carried_channels=frozenset(), + 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(), + carried_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, + } + ), + carried_channels=frozenset(), + 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(), + carried_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(), + carried_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(), + carried_channels=frozenset( + { + ChannelName.PULSE1, + ChannelName.PULSE2, + } + ), + 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, + label="zero_transpose_shared_by_every_channel_still_playing_reads_as_zero", + cells=_row_cells( + pulse1=_cell(transpose=display_transpose(0)), + pulse2=_cell(transpose=display_transpose(0)), + ), + sample_channels=frozenset(), + carried_channels=frozenset( + { + ChannelName.PULSE1, + ChannelName.PULSE2, + } + ), + 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, + carried_channels=case.carried_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, + carried_channels=frozenset(), + ) + + 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_kind is None + + +class TestWhetherTheSampleColumnTakesOffsets(BaseTestSuite): + """A pitch or a volume typed in the sample column lands where a sample plays at the row.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + sample_channels: FrozenSet[ChannelName] + carried_channels: FrozenSet[ChannelName] + expected: bool + + test_cases = ( + TestCase( + label="a row placing a sample takes them", + sample_channels=frozenset({ChannelName.PULSE1}), + carried_channels=frozenset({ChannelName.PULSE1}), + expected=True, + ), + TestCase( + label="a row below a sample still playing takes them", + sample_channels=frozenset(), + carried_channels=frozenset({ChannelName.TRIANGLE}), + expected=True, + ), + TestCase( + label="a row where no sample plays turns them away", + sample_channels=frozenset(), + carried_channels=frozenset(), + expected=False, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_row_takes_offsets_where_a_sample_plays(self, test_case: TestCase) -> None: + row = SequencerRowViewModel( + index=0, + cells=_row_cells(), + sample_channels=test_case.sample_channels, + carried_channels=test_case.carried_channels, ) - assert row.sample_instrument == case.expected_instrument - assert row.sample_transpose == case.expected_transpose - assert row.sample_volume == case.expected_volume + assert row.takes_offsets is test_case.expected diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_voices.py b/tests/unit/sampletones_application/view_model/sequencer/test_voices.py new file mode 100644 index 000000000..14a0f2694 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/sequencer/test_voices.py @@ -0,0 +1,23 @@ +import pytest + +from sampletones_application.view_model.sequencer.voices import VoiceKind, VoiceSelection + + +class TestVoiceSelectionLabel: + @pytest.mark.parametrize( + ("position", "name", "expected"), + [ + (0, "Kick", "00: Kick"), + (26, "Bass", "1A: Bass"), + (255, "Lead", "FF: Lead"), + ], + ) + def test_label_pairs_the_hex_position_with_the_name( + self, + position: int, + name: str, + expected: str, + ) -> None: + 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/nsf/__init__.py b/tests/unit/sampletones_application/view_model/shared/nsf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/test_choices.py b/tests/unit/sampletones_application/view_model/shared/nsf/test_choices.py new file mode 100644 index 000000000..eb0e3f72f --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/nsf/test_choices.py @@ -0,0 +1,186 @@ +from dataclasses import dataclass +from typing import Callable, Final + +import pytest + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.project import Project +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation, fit_field +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.nsf import ( + PROGRAM_ARTIST, + PROGRAM_TITLE, + SAMPLE_CHANNELS, + project_offer, + sample_offer, + standing_choices, +) + +OVERLONG_TEXT: Final[str] = "Ünïcödé " * STRING_TEXT_SIZE +LATER_FRAME: Final[int] = 3 + + +class TestTheTextFitsItsField(BaseTestSuite): + """A typed text is held as the part of it the header field carries, so the dialog shows what + the file will list.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + edit: Callable[[NSFExportChoices, str], NSFExportChoices] + read: Callable[[NSFInformation], str] + + test_cases = ( + TestCase( + label="title", + edit=NSFExportChoices.with_title, + read=lambda information: information.title, + ), + TestCase( + label="artist", + edit=NSFExportChoices.with_artist, + read=lambda information: information.artist, + ), + TestCase( + label="copyright", + edit=NSFExportChoices.with_copyright, + read=lambda information: information.copyright, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_an_overlong_text_is_cut_by_the_fields_rule(self, test_case: TestCase) -> None: + edited = test_case.edit(standing_choices(project_offer()), OVERLONG_TEXT) + + assert test_case.read(edited.information) == fit_field(OVERLONG_TEXT) + + def test_editing_one_field_keeps_the_others(self) -> None: + choices = standing_choices(project_offer()) + + edited = choices.with_copyright(PROGRAM_ARTIST) + + assert edited.information.title == PROGRAM_TITLE + assert edited.information.artist == PROGRAM_ARTIST + assert edited.information.copyright == PROGRAM_ARTIST + + +class TestAChannelIsTickedAmongTheSounded: + def test_unticking_a_channel_rests_it(self) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_channel(ChannelName.NOISE, False, offer) + + assert ChannelName.NOISE not in edited.channels + + def test_ticking_a_channel_sounds_it_again(self) -> None: + offer = project_offer() + choices = standing_choices(offer).with_channel(ChannelName.NOISE, False, offer) + + edited = choices.with_channel(ChannelName.NOISE, True, offer) + + assert edited.channels == frozenset(offer.channels) + + def test_a_channel_the_source_leaves_silent_stays_unticked(self) -> None: + offer = sample_offer() + + edited = standing_choices(offer).with_channel(ChannelName.NOISE, True, offer) + + assert edited.channels == frozenset(SAMPLE_CHANNELS) + + def test_choices_with_no_channel_write_no_program(self) -> None: + offer = sample_offer() + choices = standing_choices(offer) + for channel in SAMPLE_CHANNELS: + choices = choices.with_channel(channel, False, offer) + + assert not choices.writable + + +class TestTheLoopFrameStaysInTheOrder(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + frame: int + + test_cases = ( + TestCase(label="before_the_first", frame=FIRST_FRAME - 1, expected=FIRST_FRAME), + TestCase(label="within", frame=LATER_FRAME, expected=LATER_FRAME), + TestCase(label="past_the_last", frame=project_offer().frame_count, expected=project_offer().last_frame), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_frame_is_brought_within_the_order(self, test_case: TestCase) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_loop_frame(test_case.frame, offer) + + assert edited.loop_frame == test_case.expected + + +class TestTheSourceBoundsTheChoices: + def test_a_song_laid_out_in_frames_repeats_from_one(self) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_repeat(NSFRepeat.FROM_FRAME, offer) + + assert edited.repeat == NSFRepeat.FROM_FRAME + + def test_a_reconstruction_keeps_its_repeat_off_the_frames(self) -> None: + offer = sample_offer() + + edited = standing_choices(offer).with_repeat(NSFRepeat.FROM_FRAME, offer) + + assert edited.repeat == NSFRepeat.FROM_START + + def test_a_project_compresses_with_its_instruments(self) -> None: + offer = project_offer() + + edited = standing_choices(offer).with_scheme(CompressionScheme.INSTRUMENTS, offer) + + assert edited.scheme == CompressionScheme.INSTRUMENTS + + def test_a_reconstruction_keeps_to_the_schemes_that_write_it_differently(self) -> None: + offer = sample_offer() + choices = standing_choices(offer) + + edited = choices.with_scheme(CompressionScheme.INSTRUMENTS, offer) + + assert edited.scheme == choices.scheme + + +class TestTheDialogOpensOnTheStatedProgram: + def test_a_project_opens_on_the_program_it_is_written_as(self) -> None: + program = NSFProgram.for_project(Project.create(title=PROGRAM_TITLE, author=PROGRAM_ARTIST)) + + choices = NSFExportChoices.initial(program, project_offer()) + + assert choices.information == program.information + assert choices.channels == program.channels + assert choices.repeat == NSFRepeat.FROM_START + assert choices.scheme == program.scheme + + def test_the_channels_narrow_to_the_ones_the_source_sounds(self) -> None: + program = NSFProgram.for_project(Project.create()) + + choices = NSFExportChoices.initial(program, sample_offer()) + + assert choices.channels == frozenset(SAMPLE_CHANNELS) + + def test_a_program_stopping_at_its_end_opens_playing_once(self) -> None: + program = NSFProgram.for_project(Project.create()).model_copy(update={"loop_tick": None}) + + choices = NSFExportChoices.initial(program, sample_offer()) + + assert choices.repeat == NSFRepeat.ONCE + + def test_a_program_repeating_from_later_in_the_song_is_refused(self) -> None: + program = NSFProgram.for_project(Project.create()).model_copy(update={"loop_tick": SONG_START + 1}) + + with pytest.raises(ValueError): + NSFExportChoices.initial(program, project_offer()) diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/test_offer.py b/tests/unit/sampletones_application/view_model/shared/nsf/test_offer.py new file mode 100644 index 000000000..8e13e8afa --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/nsf/test_offer.py @@ -0,0 +1,17 @@ +from sampletones_application.view_model.shared.nsf.offer import FIRST_FRAME +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from tests.suite.nsf import ORDER_FRAMES, project_offer, sample_offer + + +class TestTheRepeatsFollowTheFrames: + def test_a_song_laid_out_in_frames_repeats_from_any_of_them(self) -> None: + assert NSFRepeat.FROM_FRAME in project_offer().repeats + + def test_a_song_without_frames_repeats_from_its_start_or_plays_once(self) -> None: + assert sample_offer().repeats == (NSFRepeat.ONCE, NSFRepeat.FROM_START) + + def test_the_last_frame_is_the_orders_last(self) -> None: + assert project_offer().last_frame == ORDER_FRAMES - 1 + + def test_a_song_without_frames_returns_to_the_first(self) -> None: + assert sample_offer().last_frame == FIRST_FRAME diff --git a/tests/unit/sampletones_application/view_model/shared/nsf/test_view.py b/tests/unit/sampletones_application/view_model/shared/nsf/test_view.py new file mode 100644 index 000000000..679272c7f --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/nsf/test_view.py @@ -0,0 +1,69 @@ +from pathlib import Path +from typing import Final + +from sampletones_application.view_model.shared.nsf.choices import NSFExportChoices +from sampletones_application.view_model.shared.nsf.offer import NSFExportOffer +from sampletones_application.view_model.shared.nsf.repeat import NSFRepeat +from sampletones_application.view_model.shared.nsf.view import NSFExportViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_player.nsf.information import field_size +from sampletones_player.specification.nsf import STRING_TEXT_SIZE +from sampletones_shared.utils.time import format_span +from tests.suite.nsf import SAMPLE_CHANNELS, project_offer, sample_offer, standing_choices + +DESTINATION: Final[Path] = Path("song.nsf") +SONG_TICKS: Final[int] = 10800 +NES_FREQUENCY: Final[int] = 60 +LENGTH_TEMPLATE: Final[str] = "{length}|{ticks}|{rate}" +SIZE_TEMPLATE: Final[str] = "{used}|{room}" + + +def export_view(offer: NSFExportOffer, choices: NSFExportChoices) -> NSFExportViewModel: + return NSFExportViewModel( + offer=offer, + choices=choices, + destination=DESTINATION, + ticks=SONG_TICKS, + nes_frequency=NES_FREQUENCY, + ) + + +class TestTheSetupShowsTheChoices: + def test_the_frame_is_asked_for_once_the_repeat_returns_to_one(self) -> None: + offer = project_offer() + choices = standing_choices(offer) + + assert not export_view(offer, choices).loop_frame_visible + assert export_view(offer, choices.with_repeat(NSFRepeat.FROM_FRAME, offer)).loop_frame_visible + + def test_the_program_is_written_while_a_channel_sounds(self) -> None: + offer = sample_offer() + choices = standing_choices(offer) + silent = choices + for channel in SAMPLE_CHANNELS: + silent = silent.with_channel(channel, False, offer) + + assert export_view(offer, choices).export_enabled + assert not export_view(offer, silent).export_enabled + + def test_a_channel_takes_a_tick_where_the_source_sounds_it(self) -> None: + offer = sample_offer() + view = export_view(offer, standing_choices(offer)) + + assert view.channel_offered(ChannelName.TRIANGLE) + assert not view.channel_offered(ChannelName.NOISE) + + def test_the_length_states_the_span_the_ticks_and_the_rate(self) -> None: + offer = project_offer() + view = export_view(offer, standing_choices(offer)) + + span = format_span(SONG_TICKS / NES_FREQUENCY) + assert view.length_label(LENGTH_TEMPLATE) == f"{span}|{SONG_TICKS}|{NES_FREQUENCY}" + + def test_a_field_states_the_bytes_its_text_takes_against_the_room(self) -> None: + offer = project_offer() + view = export_view(offer, standing_choices(offer)) + + label = view.text_size_label(view.choices.information.title, SIZE_TEMPLATE) + + assert label == f"{field_size(view.choices.information.title)}|{STRING_TEXT_SIZE}" diff --git a/tests/unit/sampletones_application/view_model/shared/test_agreement.py b/tests/unit/sampletones_application/view_model/shared/test_agreement.py new file mode 100644 index 000000000..b2ad5e20c --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_agreement.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.view_model.shared.agreement import Agreement +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestTheReadingAGroupGives(BaseTestSuite): + """A row standing for several recordings reads as what they agree on.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Agreement + holdings: Tuple[bool, ...] + + test_cases = ( + TestCase(label="standing_for_nothing", holdings=(), expected=Agreement.NONE), + TestCase(label="one_holding_it", holdings=(True,), expected=Agreement.ALL), + TestCase(label="one_leaving_it", holdings=(False,), expected=Agreement.NONE), + TestCase(label="every_one_holding_it", holdings=(True, True, True), expected=Agreement.ALL), + TestCase(label="none_holding_it", holdings=(False, False), expected=Agreement.NONE), + TestCase(label="some_holding_it", holdings=(True, False, True), expected=Agreement.SOME), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_group_reads_as_what_it_agrees_on(self, test_case: TestCase) -> None: + assert Agreement.over(test_case.holdings) == test_case.expected + + +class TestWhatOneGestureSettlesTo(BaseTestSuite): + """One gesture always moves a group, whichever reading it stood at.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: bool + agreement: Agreement + + test_cases = ( + TestCase(label="from_none", agreement=Agreement.NONE, expected=True), + TestCase(label="from_some", agreement=Agreement.SOME, expected=True), + TestCase(label="from_all", agreement=Agreement.ALL, expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_gesture_settles_the_whole_group(self, test_case: TestCase) -> None: + assert test_case.agreement.settles_to is test_case.expected diff --git a/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py b/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py index f412e6b9d..6ec55fd65 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py +++ b/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py @@ -11,7 +11,7 @@ MasterGainReadout, ) from sampletones_core.audio import AudioDevice, CurrentDevice -from sampletones_core.constants.audio import BUFFER_SIZES, BufferSize +from sampletones_core.constants.audio import BUFFER_SIZES, BufferSize, SampleRate from sampletones_shared.constants.audio import UNITY_GAIN DEVICE_LABEL_FORMAT = "{index}: {name}" @@ -32,7 +32,7 @@ def _device(index: int, name: str) -> AudioDevice: def _view_model( devices: Dict[int, AudioDevice], - current_device: CurrentDevice, + current_device: Optional[CurrentDevice], buffer_size: BufferSize, master_gain: float = UNITY_GAIN, ) -> AudioSettingsViewModel: @@ -47,9 +47,12 @@ def _view_model( class MappingCase: label: str devices: Dict[int, AudioDevice] - current_device: CurrentDevice + current_device: Optional[CurrentDevice] buffer_size: BufferSize + current_device_index: Optional[int] + current_sample_rate: Optional[SampleRate] current_device_label: str + current_sample_rate_label: str MAPPING_CASES = [ @@ -58,21 +61,40 @@ class MappingCase: devices={0: _device(0, "Speakers")}, current_device=CurrentDevice(device_index=0, name="Speakers", sample_rate=44100, host_api=0), buffer_size=1024, + current_device_index=0, + current_sample_rate=44100, current_device_label="0: Speakers", + current_sample_rate_label="44100 Hz", ), MappingCase( label="multiple_devices", devices={0: _device(0, "Speakers"), 3: _device(3, "Headphones")}, current_device=CurrentDevice(device_index=3, name="Headphones", sample_rate=48000, host_api=0), buffer_size=256, + current_device_index=3, + current_sample_rate=48000, current_device_label="3: Headphones", + current_sample_rate_label="48000 Hz", ), MappingCase( label="no_devices", devices={}, - current_device=CurrentDevice.default(), + current_device=None, buffer_size=512, + current_device_index=None, + current_sample_rate=None, current_device_label="", + current_sample_rate_label="", + ), + MappingCase( + label="devices_with_none_in_force", + devices={0: _device(0, "Speakers")}, + current_device=None, + buffer_size=512, + current_device_index=None, + current_sample_rate=None, + current_device_label="", + current_sample_rate_label="", ), ] @@ -80,6 +102,9 @@ class MappingCase: class TestFromDeviceManager: """The projection carries display labels alongside the typed values a selection commits, so the window renders and resolves selections without formatting or parsing of its own. + + A manager with no device in force, as on a machine offering no output device, projects no + current device and no rate, and their labels read empty. """ @pytest.mark.parametrize("case", MAPPING_CASES, ids=lambda case: case.label) @@ -87,10 +112,16 @@ def test_projects_the_manager_state(self, case: MappingCase) -> None: view_model = _view_model(case.devices, case.current_device, case.buffer_size) assert view_model.devices == tuple(AudioDeviceItem.from_device(device) for device in case.devices.values()) - assert view_model.current_device_index == case.current_device.device_index - assert view_model.current_sample_rate == case.current_device.sample_rate + assert view_model.current_device_index == case.current_device_index + assert view_model.current_sample_rate == case.current_sample_rate assert view_model.buffer_size == case.buffer_size + @pytest.mark.parametrize("case", MAPPING_CASES, ids=lambda case: case.label) + def test_current_sample_rate_label_reads_the_rate_in_force(self, case: MappingCase) -> None: + view_model = _view_model(case.devices, case.current_device, case.buffer_size) + + assert view_model.current_sample_rate_label(SAMPLE_RATE_FORMAT) == case.current_sample_rate_label + def test_carries_the_master_gain(self) -> None: view_model = _view_model( {0: _device(0, "Speakers")}, diff --git a/tests/unit/sampletones_application/view_model/shared/test_display_settings.py b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py index 1eb195f73..1c910c511 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_display_settings.py +++ b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py @@ -143,6 +143,7 @@ def settings( ), vsync=True, frame_rate=frame_rate, + show_frame_rate=True, ) @@ -185,6 +186,12 @@ def test_the_settings_a_change_was_asked_of_stay_as_they_were(self) -> None: assert snapshot.vsync is True + def test_taking_the_frame_rate_reading_off_leaves_the_pacing_standing(self) -> None: + changed = settings().with_show_frame_rate(False) + + assert changed.show_frame_rate is False + assert (changed.vsync, changed.frame_rate) == (True, 60) + class TestDisplaySettingsViewModel: def test_the_offer_holds_only_what_the_monitor_leaves_room_for(self) -> None: @@ -200,6 +207,9 @@ def test_a_window_at_a_size_of_its_own_selects_the_nearest_offered_one( def test_a_stored_rate_the_build_stopped_offering_selects_the_closest(self) -> None: assert view_model(settings(frame_rate=100)).settings.frame_rate == 90 + def test_the_frame_rate_reading_switch_passes_through(self) -> None: + assert view_model(settings().with_show_frame_rate(False)).settings.show_frame_rate is False + def test_a_windowed_window_offers_its_size_and_frame(self) -> None: assert view_model(settings()).window_controls_enabled 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..d239e86d0 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_export.py @@ -0,0 +1,98 @@ +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_traveling_stage_shows_its_bar(self) -> None: + assert view_model(traveling=True).progress_visible is True + + def test_a_traveling_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_stage_with_words_to_state_shows_its_figure(self) -> None: + assert view_model(traveling=False, figure=SIZE).figure_visible is True + + def test_a_stage_with_nothing_to_state_leaves_the_indicator_alone(self) -> None: + assert view_model(traveling=False, figure="").figure_visible is False + + 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.CANCELING).cancel_enabled is False + + def test_an_export_being_stopped_still_holds_the_screen(self) -> None: + assert view_model(phase=ExportPhase.CANCELING).is_active is True diff --git a/tests/unit/sampletones_application/view_model/shared/test_ownership.py b/tests/unit/sampletones_application/view_model/shared/test_ownership.py new file mode 100644 index 000000000..5bf8b2888 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_ownership.py @@ -0,0 +1,61 @@ +from typing import Final, Tuple + +import pytest + +from sampletones_application.view_model.shared.ownership import ( + OwnershipLaneViewModel, + OwnershipRunViewModel, +) +from sampletones_core.constants.enums import ChannelName + +FIRST_STEM: Final[int] = 0 +SECOND_STEM: Final[int] = 1 + + +def _run(start_frame: int, end_frame: int, stem_id: int) -> OwnershipRunViewModel: + return OwnershipRunViewModel( + start_frame=start_frame, + end_frame=end_frame, + stem_id=stem_id, + heard=True, + ) + + +def _lane(*runs: OwnershipRunViewModel) -> OwnershipLaneViewModel: + return OwnershipLaneViewModel(channel_name=ChannelName.PULSE1, runs=runs) + + +class TestTheStretchesUnderAReading: + """A lane runs the length of its channel, and answers for the frames a reading draws. + + A channel's readings trim to their own lengths, so a lane handed whole to each of them would + paint a stretch over frames that reading never drew. + """ + + def test_a_reading_as_long_as_the_channel_takes_every_stretch(self) -> None: + lane = _lane(_run(0, 4, FIRST_STEM), _run(4, 10, SECOND_STEM)) + + assert lane.up_to(10) == lane.runs + + def test_a_stretch_crossing_the_reading_ends_where_the_reading_does(self) -> None: + lane = _lane(_run(0, 4, FIRST_STEM), _run(4, 10, SECOND_STEM)) + + assert lane.up_to(6) == (_run(0, 4, FIRST_STEM), _run(4, 6, SECOND_STEM)) + + def test_a_stretch_beyond_the_reading_stands_out_of_it(self) -> None: + lane = _lane(_run(0, 4, FIRST_STEM), _run(4, 10, SECOND_STEM)) + + assert lane.up_to(4) == (_run(0, 4, FIRST_STEM),) + + def test_a_reading_of_one_frame_keeps_the_stretch_over_it(self) -> None: + lane = _lane(_run(0, 4, FIRST_STEM), _run(4, 10, SECOND_STEM)) + + assert lane.up_to(1) == (_run(0, 1, FIRST_STEM),) + + def test_a_reading_writing_nothing_carries_no_stretch(self) -> None: + lane = _lane(_run(0, 4, FIRST_STEM), _run(4, 10, SECOND_STEM)) + + assert lane.up_to(0) == () + + def test_a_lane_with_no_stretches_answers_with_none(self) -> None: + assert _lane().up_to(10) == () 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..448cdcbe3 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 @@ -143,7 +143,7 @@ def test_rendering_shows_the_progress_alone(self) -> None: assert view.cancel_enabled def test_a_render_already_stopping_takes_no_further_stop(self) -> None: - view = view_model(wave_settings(), phase=RenderPhase.CANCELLING) + view = view_model(wave_settings(), phase=RenderPhase.CANCELING) assert view.is_active assert not view.cancel_enabled @@ -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_stems.py b/tests/unit/sampletones_application/view_model/shared/test_stems.py new file mode 100644 index 000000000..a7909b6de --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_stems.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import FrozenSet + +from sampletones_application.constants.sources import SourceKind +from sampletones_application.view_model.shared.stems import StemRowViewModel, StemsListViewModel +from sampletones_core.constants.enums import ChannelName + +OFFERED: FrozenSet[ChannelName] = frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + +def _row(name: str, *, heard: FrozenSet[ChannelName], offered: FrozenSet[ChannelName] = OFFERED) -> StemRowViewModel: + return StemRowViewModel( + key=name, + kind=SourceKind.RECORDING, + name=name, + path=Path(f"/audio/{name}.wav"), + held=(), + channels=heard, + partial_channels=frozenset(), + offered_channels=offered, + available=True, + level=0, + position=0, + stem_id=0, + level_size=1, + level_count=1, + ) + + +def _view(*rows: StemRowViewModel) -> StemsListViewModel: + return StemsListViewModel.empty().model_copy(update={"rows": rows}) + + +class TestARowHeardAlone: + def test_the_only_row_holding_every_channel_it_offers_stands_soloed(self) -> None: + view = _view(_row("kick", heard=OFFERED), _row("snare", heard=frozenset())) + + assert view.soloed("kick") + assert not view.soloed("snare") + + def test_a_row_heard_on_part_of_what_it_offers_is_not_soloed(self) -> None: + view = _view(_row("kick", heard=frozenset({ChannelName.PULSE1})), _row("snare", heard=frozenset())) + + assert not view.soloed("kick") + + def test_a_row_heard_beside_another_is_not_soloed(self) -> None: + view = _view(_row("kick", heard=OFFERED), _row("snare", heard=frozenset({ChannelName.NOISE}))) + + assert not view.soloed("kick") + + def test_a_list_holding_a_single_row_has_none_to_silence(self) -> None: + view = _view(_row("kick", heard=OFFERED)) + + assert not view.soloed("kick") + + def test_a_row_offering_no_channel_is_not_soloed(self) -> None: + view = _view( + _row("kick", heard=frozenset(), offered=frozenset()), + _row("snare", heard=frozenset()), + ) + + assert not view.soloed("kick") + + def test_a_key_the_list_does_not_hold_is_not_soloed(self) -> None: + assert not _view(_row("kick", heard=OFFERED), _row("snare", heard=frozenset())).soloed("hat") 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..93c1d1914 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,25 +4,26 @@ 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]), approximations=approximations, coefficient=1.0, frame_length=2, + sample_rate=44100, ) @@ -31,32 +32,64 @@ 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, + sample_rate=44100, + ) + + 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, + sample_rate=44100, + ) + + 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_geometry.py b/tests/unit/sampletones_assets/mark/test_geometry.py deleted file mode 100644 index 78279edb4..000000000 --- a/tests/unit/sampletones_assets/mark/test_geometry.py +++ /dev/null @@ -1,69 +0,0 @@ -import itertools -from typing import Final - -import pytest - -from sampletones_assets.mark.geometry import Rectangle, sine_points, square_rectangles -from sampletones_assets.mark.specification import Mark - -SAMPLES: Final[int] = 5 - - -def _overlap(first: Rectangle, second: Rectangle) -> float: - width = min(first.right, second.right) - max(first.left, second.left) - height = min(first.bottom, second.bottom) - max(first.top, second.top) - return min(width, height) - - -class TestSinePoints: - def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None: - sine = Mark.load().waves.sine - points = sine_points(sine, SAMPLES) - - assert points[0] == sine.start - assert points[-1].x == pytest.approx(sine.end.x) - assert points[-1].y == pytest.approx(sine.end.y) - - def test_every_segment_contributes_its_samples(self) -> None: - sine = Mark.load().waves.sine - assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1 - - def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None: - """The wave swings between the extremes its control points reach, keeping it inside the frame.""" - sine = Mark.load().waves.sine - controls = [sine.start] + [ - point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end) - ] - lowest = min(point.y for point in controls) - highest = max(point.y for point in controls) - - for point in sine_points(sine, SAMPLES): - assert lowest <= point.y <= highest - - -class TestSquareRectangles: - def test_one_rectangle_covers_each_segment(self) -> None: - square = Mark.load().waves.square - assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1 - - def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None: - square = Mark.load().waves.square - for rectangle in square_rectangles(square, width=4.0): - assert rectangle.left < rectangle.right - assert rectangle.top < rectangle.bottom - - def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None: - width = 4.0 - square = Mark.load().waves.square - for (start, end), rectangle in zip( - itertools.pairwise(square.points), - square_rectangles(square, width=width), - ): - across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left - assert across == pytest.approx(width) - - def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None: - """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke.""" - square = Mark.load().waves.square - for first, second in itertools.pairwise(square_rectangles(square, width=4.0)): - assert _overlap(first, second) > 0.0 diff --git a/tests/unit/sampletones_assets/mark/test_specification.py b/tests/unit/sampletones_assets/mark/test_specification.py deleted file mode 100644 index a3f51d363..000000000 --- a/tests/unit/sampletones_assets/mark/test_specification.py +++ /dev/null @@ -1,176 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Dict, Final - -import pytest -from pydantic import ValidationError - -from sampletones_assets.mark.specification import Mark -from tests.suite.case import BaseRegularTestCase - -VALID_FRAME: Final[Dict[str, Any]] = { - "grid": 64, - "corner_radius": 14, - "rim": {"inset": 1, "width": 2, "opacity": 0.14}, -} - -VALID_COLORS: Final[Dict[str, Any]] = { - "background": {"top": "#3a3650", "bottom": "#211d30"}, - "sine": "#64c8ff", - "square": "#ffc864", - "rim": "#cdb6ff", -} - -VALID_SINE: Final[Dict[str, Any]] = { - "start": {"x": 8, "y": 32}, - "curves": [ - { - "control_start": {"x": 11, "y": 16}, - "control_end": {"x": 15, "y": 16}, - "end": {"x": 18, "y": 32}, - }, - ], -} - -VALID_SQUARE: Final[Dict[str, Any]] = { - "points": [ - {"x": 18, "y": 32}, - {"x": 18, "y": 20}, - {"x": 28, "y": 20}, - ], -} - -VALID_WAVES: Final[Dict[str, Any]] = { - "width": 4, - "sine": VALID_SINE, - "square": VALID_SQUARE, -} - -VALID_RENDER: Final[Dict[str, Any]] = { - "supersample": 16, - "curve_samples": 96, - "raster_size": 256, - "windows_sizes": [256, 128, 64], -} - -VALID_FIELDS: Final[Dict[str, Any]] = { - "frame": VALID_FRAME, - "colors": VALID_COLORS, - "waves": VALID_WAVES, - "render": VALID_RENDER, -} - - -class TestMark: - @dataclass(frozen=True, kw_only=True) - class InvalidFieldCase(BaseRegularTestCase): - field: str - value: Any - - test_cases = ( - InvalidFieldCase( - field="frame", - value={**VALID_FRAME, "grid": 0}, - label="empty_grid", - ), - InvalidFieldCase( - field="frame", - value={**VALID_FRAME, "corner_radius": 33}, - label="corner_radius_over_half_the_grid", - ), - InvalidFieldCase( - field="frame", - value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}}, - label="rim_inset_outside_the_corner_radius", - ), - InvalidFieldCase( - field="frame", - value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}}, - label="rim_opacity_over_full", - ), - InvalidFieldCase( - field="colors", - value={**VALID_COLORS, "sine": "64c8ff"}, - label="color_without_a_hash", - ), - InvalidFieldCase( - field="colors", - value={**VALID_COLORS, "sine": "#64c8"}, - label="color_of_four_hex_digits", - ), - InvalidFieldCase( - field="waves", - value={**VALID_WAVES, "width": 0}, - label="wave_without_width", - ), - InvalidFieldCase( - field="waves", - value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}}, - label="smooth_half_without_curves", - ), - InvalidFieldCase( - field="waves", - value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}}, - label="stepped_half_without_a_segment", - ), - InvalidFieldCase( - field="waves", - value={ - **VALID_WAVES, - "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]}, - }, - label="stepped_segment_turning_on_both_axes", - ), - InvalidFieldCase( - field="waves", - value={ - **VALID_WAVES, - "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]}, - }, - label="halves_meeting_apart", - ), - InvalidFieldCase( - field="render", - value={**VALID_RENDER, "supersample": 0}, - label="drawing_below_the_design_grid", - ), - InvalidFieldCase( - field="render", - value={**VALID_RENDER, "windows_sizes": []}, - label="windows_icon_without_a_frame", - ), - InvalidFieldCase( - field="render", - value={**VALID_RENDER, "windows_sizes": [64, 128, 256]}, - label="windows_sizes_in_ascending_order", - ), - InvalidFieldCase( - field="render", - value={**VALID_RENDER, "windows_sizes": [256, 256, 128]}, - label="repeated_windows_size", - ), - ) - - def test_the_packaged_definition_loads(self) -> None: - mark = Mark.load() - assert isinstance(mark, Mark) - - def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None: - """The mark draws one wave, so the stepped half carries on from where the smooth half arrives.""" - mark = Mark.load() - assert mark.waves.square.points[0] == mark.waves.sine.end - - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None: - fields = {**VALID_FIELDS, case.field: case.value} - with pytest.raises(ValidationError): - Mark.model_validate(fields) - - @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) - def test_a_missing_field_is_rejected(self, field: str) -> None: - fields = {key: value for key, value in VALID_FIELDS.items() if key != field} - with pytest.raises(ValidationError): - Mark.model_validate(fields) - - def test_an_unknown_field_is_rejected(self) -> None: - with pytest.raises(ValidationError): - Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}}) diff --git a/tests/unit/sampletones_assets/mark/test_suite.py b/tests/unit/sampletones_assets/mark/test_suite.py deleted file mode 100644 index b0a8f3e3c..000000000 --- a/tests/unit/sampletones_assets/mark/test_suite.py +++ /dev/null @@ -1,55 +0,0 @@ -from pathlib import Path - -import pytest -from PIL import Image - -from sampletones_assets.mark.specification import Mark -from sampletones_assets.mark.suite import write_icon_suite -from sampletones_shared.paths.resources import ( - ICON_UNIX_FILENAME, - ICON_VECTOR_FILENAME, - ICON_WIN_FILENAME, -) - -RGBA_MODE = "RGBA" -ICO_SIZES_KEY = "sizes" - - -@pytest.fixture(name="mark", scope="module") -def mark_fixture() -> Mark: - return Mark.load() - - -class TestWriteIconSuite: - def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None: - paths = write_icon_suite(tmp_path, mark) - assert [path.name for path in paths] == [ - ICON_VECTOR_FILENAME, - ICON_UNIX_FILENAME, - ICON_WIN_FILENAME, - ] - assert all(path.is_file() for path in paths) - - def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None: - directory = tmp_path / "icons" - write_icon_suite(directory, mark) - assert directory.is_dir() - - def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None: - write_icon_suite(tmp_path, mark) - with Image.open(tmp_path / ICON_UNIX_FILENAME) as image: - assert image.size == (mark.render.raster_size, mark.render.raster_size) - assert image.mode == RGBA_MODE - - def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None: - write_icon_suite(tmp_path, mark) - with Image.open(tmp_path / ICON_WIN_FILENAME) as image: - carried = {width for width, _ in image.info[ICO_SIZES_KEY]} - - assert carried == set(mark.render.windows_sizes) - - def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None: - """One definition produces one suite, so a rebuild leaves the shipped files as they were.""" - first = write_icon_suite(tmp_path / "first", mark) - second = write_icon_suite(tmp_path / "second", mark) - assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second] 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..2182fc642 --- /dev/null +++ b/tests/unit/sampletones_core/audio/test_io.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import Final, Tuple + +import numpy as np + +from sampletones_core.audio.io import UNIT_SCALE, load_audio, mix_scale, read_stems, scale_stems, write_wave +from sampletones_core.audio.processing import quantize +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 +LOUD_LEVEL: Final[float] = 0.6 +QUIET_LEVEL: Final[float] = 0.2 +HALF_SCALE: Final[float] = 0.5 + + +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 _read(*paths: Path) -> Tuple[np.ndarray, ...]: + return read_stems(paths, target_sample_rate=SAMPLE_RATE) + + +def _scaled(recordings: Tuple[np.ndarray, ...], scale: float, *, quantized: bool = False) -> Tuple[np.ndarray, ...]: + return scale_stems(recordings, scale=scale, quantize=quantized, quantization_levels=QUANTIZATION_LEVELS) + + +class TestReadStems: + """A set of recordings read onto one sample rate and one length, at the levels captured.""" + + def test_the_recordings_keep_the_captured_levels(self, tmp_path: Path) -> None: + louder, quieter = _read(_write(tmp_path / "loud.wav", LOUD_LEVEL), _write(tmp_path / "quiet.wav", QUIET_LEVEL)) + + np.testing.assert_allclose(np.max(np.abs(louder)), LOUD_LEVEL, rtol=1e-6) + np.testing.assert_allclose(np.max(np.abs(quieter)), QUIET_LEVEL, rtol=1e-6) + + def test_a_shorter_recording_runs_on_in_silence(self, tmp_path: Path) -> None: + longer, shorter = _read( + _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_no_paths_reach_no_recordings(self) -> None: + assert _read() == () + + +class TestMixScale: + """The one factor a set is divided by, drawn from the peak of its mix.""" + + def test_the_scale_is_the_peak_of_the_mix(self, tmp_path: Path) -> None: + recordings = _read(_write(tmp_path / "loud.wav", LOUD_LEVEL), _write(tmp_path / "quiet.wav", QUIET_LEVEL)) + + np.testing.assert_allclose(mix_scale(recordings, normalize=True), LOUD_LEVEL + QUIET_LEVEL, rtol=1e-6) + + def test_silence_takes_unit_scale(self, tmp_path: Path) -> None: + """A set holding no sound has no peak to scale by, so it stays as it went in.""" + recordings = _read(_write(tmp_path / "silent.wav", 0.0)) + + assert mix_scale(recordings, normalize=True) == UNIT_SCALE + + def test_a_set_left_unnormalized_takes_unit_scale(self, tmp_path: Path) -> None: + recordings = _read(_write(tmp_path / "loud.wav", LOUD_LEVEL)) + + assert mix_scale(recordings, normalize=False) == UNIT_SCALE + + def test_no_recordings_take_unit_scale(self) -> None: + assert mix_scale((), normalize=True) == UNIT_SCALE + + +class TestScaleStems: + """Every recording of a set divided by one factor, then quantized.""" + + 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) + recordings = _read(path) + + (loaded,) = _scaled(recordings, mix_scale(recordings, normalize=True)) + + np.testing.assert_allclose( + loaded, + load_audio(path, target_sample_rate=SAMPLE_RATE, normalize=True, quantize=False), + ) + + def test_the_recordings_keep_their_balance(self, tmp_path: Path) -> None: + louder, quieter = _scaled( + _read(_write(tmp_path / "loud.wav", LOUD_LEVEL), _write(tmp_path / "quiet.wav", QUIET_LEVEL)), + HALF_SCALE, + ) + + np.testing.assert_allclose(louder, (LOUD_LEVEL / QUIET_LEVEL) * quieter, rtol=1e-6) + np.testing.assert_allclose(np.max(np.abs(louder)), LOUD_LEVEL / HALF_SCALE, rtol=1e-6) + + def test_the_set_scaled_by_its_own_mix_reaches_the_full_range(self, tmp_path: Path) -> None: + recordings = _read(_write(tmp_path / "first.wav", LOUD_LEVEL), _write(tmp_path / "second.wav", QUIET_LEVEL)) + + scaled = _scaled(recordings, mix_scale(recordings, normalize=True)) + + np.testing.assert_allclose(np.max(np.abs(sum(scaled))), 1.0, rtol=1e-6) + + def test_quantization_follows_scaling(self, tmp_path: Path) -> None: + recordings = _read(_write(tmp_path / "loud.wav", LOUD_LEVEL)) + + (quantized,) = _scaled(recordings, HALF_SCALE, quantized=True) + + np.testing.assert_array_equal( + quantized, + quantize((recordings[0] / HALF_SCALE).astype(np.float32), levels=QUANTIZATION_LEVELS), + ) diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index 27902d376..074bcae15 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -1,16 +1,48 @@ import threading -from typing import Callable, Final, List +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, Union from unittest.mock import MagicMock, patch import numpy as np import pytest +from sampletones_core.audio.device import AudioDevice, CurrentDevice from sampletones_core.audio.manager import AudioDeviceManager -from sampletones_shared.exceptions import PlaybackError +from sampletones_core.audio.stream import write_to_stream +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO, SampleRate +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase _LOW = 0 _HIGH = 1 _RELEASE_TIMEOUT: Final[float] = 5.0 +_OPEN_DELAY: Final[float] = 0.2 +_SHORT_JOIN: Final[float] = 0.05 +_STOP_JOIN_TARGET: Final[str] = "sampletones_core.audio.manager.STOP_JOIN_SECONDS" +_LET_GO_JOIN_TARGET: Final[str] = "sampletones_core.audio.manager.LET_GO_JOIN_SECONDS" +_BACKEND: Final[str] = "sampletones_core.audio.manager.pyaudio.PyAudio" +_SPEAKERS_INDEX: Final[int] = 0 +_SPEAKERS_NAME: Final[str] = "Speakers" +_SPEAKERS_HOST_API: Final[int] = 2 +_CHOSEN_RATE: Final[SampleRate] = 48000 +_SPEAKERS: Final[Dict[str, Union[int, str]]] = { + "index": _SPEAKERS_INDEX, + "name": _SPEAKERS_NAME, + "maxOutputChannels": 2, + "defaultSampleRate": DEFAULT_SAMPLE_RATE, + "hostApi": _SPEAKERS_HOST_API, +} +_SPEAKERS_DEVICE: Final[AudioDevice] = AudioDevice( + index=_SPEAKERS_INDEX, + name=_SPEAKERS_NAME, + default_sample_rate=DEFAULT_SAMPLE_RATE, + supported_sample_rates=[DEFAULT_SAMPLE_RATE], + host_api=_SPEAKERS_HOST_API, +) +_AUDIO_FILE: Final[str] = "recording.wav" +_LOAD_AUDIO: Final[str] = "sampletones_core.audio.manager.load_audio" def _manager() -> AudioDeviceManager: @@ -21,11 +53,15 @@ def _manager() -> AudioDeviceManager: """ manager = object.__new__(AudioDeviceManager) manager._pyaudio = MagicMock() - manager._device_index = 0 + manager._devices = {_SPEAKERS_INDEX: _SPEAKERS_DEVICE} + manager._device_index = _SPEAKERS_INDEX + manager._sample_rate = DEFAULT_SAMPLE_RATE manager._lock = threading.Lock() manager._resume_event = threading.Event() manager._playing = False manager._active_priority = 0 + manager._generation = 0 + manager._let_go_workers = [] manager._stream_owners = {} manager.on_acquire_output = None manager.external_output_priority = None @@ -134,6 +170,370 @@ def test_higher_priority_takes_over(self) -> None: thread.assert_called_once() +class TestPlaybackStart(BaseTestSuite): + """Playback begins at the sample asked for, placed before the thread writing it starts.""" + + AUDIO_LENGTH: Final[int] = 8 + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + start: int + expected: int + + test_cases = ( + TestCase(start=5, expected=5, label="within_the_audio"), + TestCase(start=-3, expected=0, label="before_the_audio_clamps_to_its_beginning"), + TestCase(start=AUDIO_LENGTH + 4, expected=AUDIO_LENGTH, label="past_the_audio_clamps_to_its_end"), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_playback_begins_at_the_start_asked_for(self, test_case: TestCase) -> None: + manager = _manager() + manager.stop = MagicMock() + positions_at_thread_start: List[int] = [] + + def thread(**_kwargs: object) -> MagicMock: + started = MagicMock() + started.start.side_effect = lambda: positions_at_thread_start.append(manager._position) + return started + + with patch("sampletones_core.audio.manager.threading.Thread", side_effect=thread): + manager.play(np.zeros(self.AUDIO_LENGTH, dtype=np.float32), start=test_case.start) + + assert positions_at_thread_start == [test_case.expected] + + +class TestAStreamTheDeviceRefuses: + """A stream the device refuses to open leaves the playback idle before the refusal is reported.""" + + def test_the_playback_reads_stopped_when_the_refusal_is_reported(self) -> None: + manager = _manager() + manager._pyaudio.open.side_effect = OSError("the device is busy") + manager._position_callback = MagicMock() + playing_when_reported: List[bool] = [] + manager.on_playback_error = lambda _error: playing_when_reported.append(manager.is_playing()) + manager._playing = True + manager._audio_data = np.zeros(TestPlaybackStart.AUDIO_LENGTH, dtype=np.float32) + + manager._playback_worker(output=manager.require_output(), update=True, generation=manager._generation) + + assert playing_when_reported == [False] + manager._position_callback.assert_called_once_with(0) + + def test_a_handed_out_stream_refused_raises_a_playback_error(self) -> None: + manager = _manager() + manager.stop = MagicMock() + manager._pyaudio.open.side_effect = OSError("the device is busy") + + with pytest.raises(PlaybackError, match="the device is busy"): + manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) + + assert manager._stream_owners == {} + + def test_both_kinds_of_playback_name_the_refusal_alike(self) -> None: + """A refused stream reads the same whether the manager plays the audio or hands the stream out.""" + manager = _manager() + manager.stop = MagicMock() + manager._pyaudio.open.side_effect = OSError("the device is busy") + manager._position_callback = None + reported: List[Exception] = [] + manager.on_playback_error = reported.append + manager._playing = True + manager._audio_data = np.zeros(TestPlaybackStart.AUDIO_LENGTH, dtype=np.float32) + manager._playback_worker(output=manager.require_output(), update=False, generation=manager._generation) + + with pytest.raises(PlaybackError) as raised: + manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) + + assert [str(error) for error in reported] == [str(raised.value)] + + +class TestAPlaybackANewerOneReplaced: + """A worker whose stream opens only after a newer play leaves the newer playback as it stands. + + Stopping waits a while for the worker, and a device slow to open can keep it longer, so the older + worker can come back after the newer play has begun. Its refusal is the older playback's alone, and a + stream it opened late closes without a sound. + """ + + FIRST_LENGTH: Final[int] = 8 + SECOND_LENGTH: Final[int] = 16 + + @pytest.fixture(name="replaced") + def replaced_fixture(self) -> Tuple[AudioDeviceManager, Dict[str, Any]]: + """A manager playing a second buffer, and the arguments the worker of the first one was started with.""" + manager = _manager() + manager._buffer_size = self.FIRST_LENGTH + manager._position_callback = MagicMock() + manager._playback_thread = None + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(self.FIRST_LENGTH, dtype=np.float32)) + manager.play(np.ones(self.SECOND_LENGTH, dtype=np.float32)) + + manager._position_callback.reset_mock() + return manager, thread.call_args_list[0].kwargs["kwargs"] + + @staticmethod + def assert_the_newer_playback_stands(manager: AudioDeviceManager) -> None: + assert manager.is_playing() + assert manager._audio_data is not None + assert len(manager._audio_data) == TestAPlaybackANewerOneReplaced.SECOND_LENGTH + manager._position_callback.assert_not_called() + + def test_an_older_worker_refused_late_leaves_the_newer_playback_alone( + self, + replaced: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + manager, first_worker = replaced + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + + manager._playback_worker(**first_worker) + + self.assert_the_newer_playback_stands(manager) + assert reported == [] + + def test_an_older_worker_opened_late_closes_its_stream_unplayed( + self, + replaced: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + manager, first_worker = replaced + stream = MagicMock() + manager._pyaudio.open.return_value = stream + + manager._playback_worker(**first_worker) + + stream.write.assert_not_called() + stream.close.assert_called_once_with() + self.assert_the_newer_playback_stands(manager) + + def test_the_newer_worker_refused_reports_it( + self, + replaced: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + """The refusal of the playback in force still reaches the reader.""" + manager, _ = replaced + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.ones(self.SECOND_LENGTH, dtype=np.float32)) + + manager._playback_worker(**thread.call_args.kwargs["kwargs"]) + + assert len(reported) == 1 + assert not manager.is_playing() + + +class TestADeviceFailingMidPlayback: + """A write or a stop the device fails leaves the playback idle, and then reports the failure once. + + The failure reaches the reader the way a refused stream does, and only while the playback that met it + is the one in force. + """ + + LENGTH: Final[int] = 8 + + @pytest.fixture(name="playing") + def playing_fixture(self) -> Tuple[AudioDeviceManager, Dict[str, Any], MagicMock]: + """A manager playing a buffer, the arguments its worker was started with, and the stream it opens.""" + manager = _manager() + manager._buffer_size = self.LENGTH + manager._position_callback = None + manager._playback_thread = None + stream = MagicMock() + manager._pyaudio.open.return_value = stream + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(self.LENGTH, dtype=np.float32)) + + return manager, thread.call_args.kwargs["kwargs"], stream + + @staticmethod + def record_reports(manager: AudioDeviceManager) -> List[Tuple[PlaybackError, bool]]: + """Notes each failure reported with whether the playback read playing then.""" + reports: List[Tuple[PlaybackError, bool]] = [] + manager.on_playback_error = lambda error: reports.append((error, manager.is_playing())) + return reports + + def test_a_failed_write_is_reported_once_with_the_playback_stopped( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + manager, worker, stream = playing + stream.write.side_effect = OSError("the device went away") + reports = self.record_reports(manager) + + manager._playback_worker(**worker) + + assert [is_playing for _, is_playing in reports] == [False] + assert "the device went away" in str(reports[0][0]) + stream.close.assert_called_once_with() + + def test_a_failed_stop_still_closes_the_stream_and_leaves_the_playback_idle( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + manager, worker, stream = playing + stream.stop_stream.side_effect = OSError("the device went away") + reports = self.record_reports(manager) + + manager._playback_worker(**worker) + + assert [is_playing for _, is_playing in reports] == [False] + stream.close.assert_called_once_with() + assert not manager.is_playing() + + def test_a_write_failing_after_a_newer_play_reports_nothing( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + """The newer playback stands, and the failure belongs to the playback it replaced.""" + manager, worker, stream = playing + + def replaced_then_failing(_data: bytes) -> None: + with patch("sampletones_core.audio.manager.threading.Thread"): + manager.play(np.ones(self.LENGTH, dtype=np.float32)) + raise OSError("the device went away") + + stream.write.side_effect = replaced_then_failing + reports = self.record_reports(manager) + + manager._playback_worker(**worker) + + assert reports == [] + assert manager.is_playing() + + def test_a_failed_write_reads_as_the_failed_write_of_a_handed_out_stream( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + """Both kinds of playback name the failure alike, so the reader reads the same words.""" + manager, worker, stream = playing + stream.write.side_effect = OSError("the device went away") + reports = self.record_reports(manager) + manager._playback_worker(**worker) + + with pytest.raises(PlaybackError) as raised: + write_to_stream(stream, np.zeros(self.LENGTH, dtype=np.float32).tobytes()) + + assert [str(error) for error, _ in reports] == [str(raised.value)] + + +class TestAPlaybackAStopLetGo: + """A worker a stop let go of plays for nobody, so what its device does later reaches nothing. + + Stopping waits a while for the worker, and a device slow to open can keep it longer. A song taking the + device stops the manager's playback first, so the worker can meet a refusal while the song plays. + """ + + LENGTH: Final[int] = 8 + + @pytest.fixture(name="let_go") + def let_go_fixture(self) -> Tuple[AudioDeviceManager, Dict[str, Any]]: + """A stopped manager, and the arguments the worker of its stopped playback was started with.""" + manager = _manager() + manager._buffer_size = self.LENGTH + manager._position_callback = MagicMock() + manager._playback_thread = None + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(self.LENGTH, dtype=np.float32)) + manager.stop() + + manager._position_callback.reset_mock() + return manager, thread.call_args.kwargs["kwargs"] + + def test_a_refusal_it_meets_later_reports_nothing_and_resets_nothing( + self, + let_go: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + manager, worker = let_go + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + + manager._playback_worker(**worker) + + assert reported == [] + manager._position_callback.assert_not_called() + + def test_a_stream_it_opens_later_closes_unplayed(self, let_go: Tuple[AudioDeviceManager, Dict[str, Any]]) -> None: + manager, worker = let_go + stream = MagicMock() + manager._pyaudio.open.return_value = stream + + manager._playback_worker(**worker) + + stream.write.assert_not_called() + stream.close.assert_called_once_with() + manager._position_callback.assert_not_called() + + def test_a_playback_started_after_the_stop_stands(self, let_go: Tuple[AudioDeviceManager, Dict[str, Any]]) -> None: + """The worker let go of leaves the playback asked for after it as it is.""" + manager, worker = let_go + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + with patch("sampletones_core.audio.manager.threading.Thread"): + manager.play(np.ones(self.LENGTH, dtype=np.float32)) + + manager._playback_worker(**worker) + + assert manager.is_playing() + assert reported == [] + + +class TestSeekingAPlayback(BaseTestSuite): + """A seek moves the playback of the owner asking for it, clamped to the audio, under one lock.""" + + AUDIO_LENGTH: Final[int] = 8 + + @staticmethod + def _playing(owner: object, length: int) -> AudioDeviceManager: + manager = _manager() + manager._audio_data = np.zeros(length, dtype=np.float32) + manager._playing = True + manager._output_owner = owner + return manager + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + position: int + expected: int + + test_cases = ( + TestCase(position=5, expected=5, label="within_the_audio"), + TestCase(position=-3, expected=0, label="before_the_audio_clamps_to_its_beginning"), + TestCase(position=AUDIO_LENGTH + 4, expected=AUDIO_LENGTH, label="past_the_audio_clamps_to_its_end"), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_owner_moves_its_playback(self, test_case: TestCase) -> None: + owner = object() + manager = self._playing(owner, self.AUDIO_LENGTH) + + moved = manager.set_position(test_case.position, owner=owner) + + assert (moved, manager.position_of(owner)) == (True, test_case.expected) + + def test_another_owner_leaves_the_playback_where_it_stands(self) -> None: + owner = object() + manager = self._playing(owner, self.AUDIO_LENGTH) + manager._position = 3 + + moved = manager.set_position(5, owner=object()) + + assert (moved, manager.position_of(owner)) == (False, 3) + + class TestOwnership: """Ownership tells a source's own playback apart from a preview or another source's output.""" @@ -168,6 +568,36 @@ def test_preview_owned_by_nobody_is_not_owned_by_a_source(self) -> None: assert manager.is_owned_by(object()) is False +class TestPositionOfAnOwner: + """A source reads where its own playback stands, and the start of the audio for anyone else's.""" + + def test_the_owner_reads_the_position_its_playback_reached(self) -> None: + manager = _manager() + owner = object() + manager._output_owner = owner + manager._playing = True + manager._position = 640 + + assert manager.position_of(owner) == 640 + + def test_another_source_reads_the_start_of_the_audio(self) -> None: + manager = _manager() + manager._output_owner = object() + manager._playing = True + manager._position = 640 + + assert manager.position_of(object()) == START_OF_AUDIO + + def test_an_owner_whose_playback_ended_reads_the_start_of_the_audio(self) -> None: + manager = _manager() + owner = object() + manager._output_owner = owner + manager._playing = False + manager._position = 640 + + assert manager.position_of(owner) == START_OF_AUDIO + + class TestBackendTeardown: """The backend is torn down only once every handed-out stream has come back.""" @@ -231,3 +661,223 @@ def test_a_release_may_hand_its_stream_back_from_the_writing_thread(self) -> Non assert owner.handed_back.is_set() assert manager._pyaudio is None + + +class TestTeardownAfterAStopLetAWorkerGo: + """Teardown waits for a worker a stop let go of, so the backend goes once nothing reaches into it. + + A device slow to open keeps the worker past the stop's wait, and terminating the backend under an + open in progress pulls it from under the worker. + """ + + LENGTH: Final[int] = 8 + + @pytest.fixture(name="slow_open") + def slow_open_fixture(self) -> Iterator[Tuple[AudioDeviceManager, threading.Event, List[str]]]: + """A manager whose playback a stop let go of while its stream opens, the open's gate, and what happened.""" + events: List[str] = [] + opening = threading.Event() + manager = _manager() + manager._buffer_size = self.LENGTH + manager._position_callback = None + manager._playback_thread = None + manager.on_playback_error = None + + def open_slowly(**_options: Any) -> MagicMock: + opening.wait(timeout=_RELEASE_TIMEOUT) + events.append("opened") + return MagicMock() + + manager._pyaudio.open.side_effect = open_slowly + manager._pyaudio.terminate.side_effect = lambda: events.append("terminate") + manager.play(np.zeros(self.LENGTH, dtype=np.float32)) + with patch(_STOP_JOIN_TARGET, _SHORT_JOIN): + manager.stop() + + yield manager, opening, events + opening.set() + + def test_terminate_waits_for_the_worker_to_finish_its_open( + self, + slow_open: Tuple[AudioDeviceManager, threading.Event, List[str]], + ) -> None: + manager, opening, events = slow_open + releaser = threading.Timer(_OPEN_DELAY, opening.set) + releaser.start() + try: + manager.terminate() + finally: + releaser.cancel() + opening.set() + + assert events == ["opened", "terminate"] + assert manager._pyaudio is None + + def test_terminate_keeps_the_backend_while_the_worker_outlives_the_wait( + self, + slow_open: Tuple[AudioDeviceManager, threading.Event, List[str]], + ) -> None: + manager, _, events = slow_open + instance = manager._pyaudio + + with patch(_LET_GO_JOIN_TARGET, _SHORT_JOIN): + manager.terminate() + + assert events == [] + assert manager._pyaudio is instance + + def test_reinitialize_refuses_while_the_worker_outlives_the_wait( + self, + slow_open: Tuple[AudioDeviceManager, threading.Event, List[str]], + ) -> None: + manager, _, events = slow_open + instance = manager._pyaudio + + with patch(_LET_GO_JOIN_TARGET, _SHORT_JOIN), pytest.raises(PlaybackError): + manager.reinitialize() + + assert events == [] + assert manager._pyaudio is instance + + +@pytest.fixture(name="backend") +def backend_fixture() -> Iterator[MagicMock]: + """The audio backend of a machine offering one pair of speakers as its default output.""" + with patch(_BACKEND) as backend_class: + backend = backend_class.return_value + backend.get_device_count.return_value = 1 + backend.get_device_info_by_index.return_value = _SPEAKERS + backend.get_default_output_device_info.return_value = _SPEAKERS + yield backend + + +@pytest.fixture(name="silent_backend") +def silent_backend_fixture() -> Iterator[MagicMock]: + """The audio backend of a machine offering no output device at all.""" + with patch(_BACKEND) as backend_class: + backend = backend_class.return_value + backend.get_device_count.return_value = 0 + backend.get_default_output_device_info.side_effect = OSError + yield backend + + +class TestCurrentDevice: + """The device in force reads as ``None`` wherever nothing is selected. + + A machine offering no output device starts with nothing selected, and a refresh that takes the + selected device off the list leaves nothing selected too. Each answers ``None``, where a machine + with a device answers the device and the rate it plays at. + """ + + def test_the_default_device_is_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + + assert manager.get_current_device() == CurrentDevice( + device_index=_SPEAKERS_INDEX, + name=_SPEAKERS_NAME, + sample_rate=DEFAULT_SAMPLE_RATE, + host_api=_SPEAKERS_HOST_API, + ) + + def test_a_machine_offering_no_device_has_none_in_force(self, silent_backend: MagicMock) -> None: + manager = AudioDeviceManager() + + assert manager.list_devices() == {} + assert manager.get_current_device() is None + + def test_a_device_taken_off_the_list_leaves_none_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + assert manager.get_current_device() is not None + + backend.get_device_count.return_value = 0 + manager.refresh_devices() + + assert manager.get_current_device() is None + + def test_configuring_answers_the_device_now_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + + configured = manager.configure_device(_SPEAKERS_INDEX, _CHOSEN_RATE) + + assert configured.sample_rate == _CHOSEN_RATE + assert manager.get_current_device() == configured + + +class TestPlayingWithNoOutput: + """Every way to start a sound asks the manager for the output first, and with none in force the + manager refuses at once, before a thread or a stream starts. + + Each refusal is the typed ``NoOutputDeviceError``, which every recovery boundary catches as a + ``PlaybackError``. A machine offering speakers answers the device and rate the stream opens on. + """ + + @pytest.fixture(name="silent_manager") + def silent_manager_fixture(self, silent_backend: MagicMock) -> AudioDeviceManager: + """A manager on a machine offering no output device.""" + return AudioDeviceManager() + + def test_a_device_in_force_answers_what_the_stream_opens_on(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + + assert manager.require_output() == manager.get_current_device() + + def test_asking_for_the_output_is_refused(self, silent_manager: AudioDeviceManager) -> None: + with pytest.raises(NoOutputDeviceError): + silent_manager.require_output() + + def test_a_refusal_is_a_playback_error(self) -> None: + assert issubclass(NoOutputDeviceError, PlaybackError) + + def test_playing_audio_is_refused_before_a_thread_starts(self, silent_manager: AudioDeviceManager) -> None: + with ( + patch("sampletones_core.audio.manager.threading.Thread") as thread, + pytest.raises(NoOutputDeviceError), + ): + silent_manager.play(np.zeros(4, dtype=np.float32), priority=_HIGH) + + thread.assert_not_called() + assert silent_manager.is_playing() is False + + def test_playing_a_file_is_refused_before_the_file_is_read(self, silent_manager: AudioDeviceManager) -> None: + with ( + patch(_LOAD_AUDIO) as load_audio, + pytest.raises(NoOutputDeviceError), + ): + silent_manager.play_file(Path(_AUDIO_FILE)) + + load_audio.assert_not_called() + + def test_a_stream_is_refused_before_it_opens( + self, + silent_manager: AudioDeviceManager, + silent_backend: MagicMock, + ) -> None: + with pytest.raises(NoOutputDeviceError): + silent_manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) + + silent_backend.open.assert_not_called() + assert silent_manager._stream_owners == {} + + def test_the_rate_reads_as_the_same_refusal(self, silent_manager: AudioDeviceManager) -> None: + with pytest.raises(NoOutputDeviceError): + _ = silent_manager.sample_rate + + def test_a_device_taken_off_the_list_refuses_a_playback(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + backend.get_device_count.return_value = 0 + manager.refresh_devices() + + with ( + patch("sampletones_core.audio.manager.threading.Thread") as thread, + pytest.raises(NoOutputDeviceError), + ): + manager.play(np.zeros(4, dtype=np.float32)) + + thread.assert_not_called() + + def test_a_playback_opens_its_stream_on_the_device_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(4, dtype=np.float32)) + + assert thread.call_args.kwargs["kwargs"]["output"] == manager.get_current_device() 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/calibration/config/test_corpus.py b/tests/unit/sampletones_core/calibration/config/test_corpus.py deleted file mode 100644 index b09e60af6..000000000 --- a/tests/unit/sampletones_core/calibration/config/test_corpus.py +++ /dev/null @@ -1,124 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Dict, Final - -import pytest -from pydantic import ValidationError - -from sampletones_core.calibration.config.corpus import CorpusConfig -from tests.suite.case import BaseRegularTestCase - -VALID_TRANSIENT: Final[Dict[str, Any]] = { - "snare_decay_seconds": 0.15, - "kick_decay_seconds": 0.25, - "kick_sweep_frequencies": (150.0, 50.0), - "attack_seconds": 0.005, - "attack_tone_decay_seconds": 0.3, -} - -VALID_FIELDS: Final[Dict[str, Any]] = { - "seed": 1, - "item_seconds": 1.5, - "amplitude": 0.5, - "reference_frequency": 440.0, - "tone": {"frequencies": (55.0, 440.0)}, - "timbre": {"duty_cycles": (0.125, 0.5), "frequency": 220.0}, - "noise": {"white_level": 0.5}, - "mix": {"noise_levels": (0.05, 0.15)}, - "transient": VALID_TRANSIENT, -} - - -class TestCorpusConfig: - @dataclass(frozen=True, kw_only=True) - class InvalidFieldCase(BaseRegularTestCase): - field: str - value: Any - - test_cases = ( - InvalidFieldCase( - field="seed", - value=-1, - label="negative_seed", - ), - InvalidFieldCase( - field="item_seconds", - value=0.0, - label="zero_item_seconds", - ), - InvalidFieldCase( - field="amplitude", - value=0.0, - label="zero_amplitude", - ), - InvalidFieldCase( - field="amplitude", - value=1.5, - label="amplitude_above_full_scale", - ), - InvalidFieldCase( - field="reference_frequency", - value=0.0, - label="zero_reference_frequency", - ), - InvalidFieldCase( - field="tone", - value={"frequencies": ()}, - label="empty_tone_frequencies", - ), - InvalidFieldCase( - field="tone", - value={"frequencies": (440.0, 0.0)}, - label="nonpositive_tone_frequency", - ), - InvalidFieldCase( - field="timbre", - value={"duty_cycles": (), "frequency": 220.0}, - label="empty_duty_cycles", - ), - InvalidFieldCase( - field="timbre", - value={"duty_cycles": (1.0,), "frequency": 220.0}, - label="duty_cycle_at_full_width", - ), - InvalidFieldCase( - field="timbre", - value={"duty_cycles": (0.5,), "frequency": 0.0}, - label="zero_timbre_frequency", - ), - InvalidFieldCase( - field="noise", - value={"white_level": 0.0}, - label="zero_white_noise_level", - ), - InvalidFieldCase( - field="mix", - value={"noise_levels": ()}, - label="empty_mix_noise_levels", - ), - InvalidFieldCase( - field="transient", - value={**VALID_TRANSIENT, "snare_decay_seconds": 0.0}, - label="zero_snare_decay", - ), - InvalidFieldCase( - field="transient", - value={**VALID_TRANSIENT, "attack_seconds": 0.0}, - label="zero_attack", - ), - ) - - def test_packaged_configuration_loads(self) -> None: - config = CorpusConfig.load() - assert isinstance(config, CorpusConfig) - - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_out_of_bounds_field_is_rejected(self, case: InvalidFieldCase) -> None: - fields = {**VALID_FIELDS, case.field: case.value} - with pytest.raises(ValidationError): - CorpusConfig.model_validate(fields) - - @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) - def test_missing_field_is_rejected(self, field: str) -> None: - fields = {key: value for key, value in VALID_FIELDS.items() if key != field} - with pytest.raises(ValidationError): - CorpusConfig.model_validate(fields) diff --git a/tests/unit/sampletones_core/calibration/corpus/conftest.py b/tests/unit/sampletones_core/calibration/corpus/conftest.py deleted file mode 100644 index db4080c0d..000000000 --- a/tests/unit/sampletones_core/calibration/corpus/conftest.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Final, List - -import pytest - -from sampletones_core.calibration.config.corpus import CorpusConfig -from sampletones_core.calibration.corpus.item import CorpusItem -from sampletones_core.calibration.corpus.synthesis import build_corpus - -SAMPLE_RATE: Final[int] = 22050 - - -@pytest.fixture(scope="session") -def sample_rate() -> int: - return SAMPLE_RATE - - -@pytest.fixture(scope="session") -def corpus_config() -> CorpusConfig: - return CorpusConfig.load() - - -@pytest.fixture(scope="session") -def corpus_items(corpus_config: CorpusConfig, sample_rate: int) -> List[CorpusItem]: - return build_corpus(sample_rate, config=corpus_config) diff --git a/tests/unit/sampletones_core/calibration/corpus/test_writer.py b/tests/unit/sampletones_core/calibration/corpus/test_writer.py deleted file mode 100644 index 0520366e3..000000000 --- a/tests/unit/sampletones_core/calibration/corpus/test_writer.py +++ /dev/null @@ -1,19 +0,0 @@ -from pathlib import Path -from typing import List - -from sampletones_core.calibration.corpus.item import CorpusItem -from sampletones_core.calibration.corpus.writer import write_corpus - - -class TestWriteCorpus: - def test_writes_one_wav_per_item( - self, - corpus_items: List[CorpusItem], - tmp_path: Path, - sample_rate: int, - ) -> None: - paths = write_corpus(corpus_items, tmp_path / "corpus", sample_rate) - assert set(paths) == {item.name for item in corpus_items} - for path in paths.values(): - assert path.exists() - assert path.suffix == ".wav" diff --git a/tests/unit/sampletones_core/calibration/test_runner.py b/tests/unit/sampletones_core/calibration/test_runner.py deleted file mode 100644 index 2eb447e31..000000000 --- a/tests/unit/sampletones_core/calibration/test_runner.py +++ /dev/null @@ -1,34 +0,0 @@ -import warnings -from typing import Final, List - -import pytest - -from sampletones_core.calibration.runner import build_variants -from sampletones_core.configs import Config -from sampletones_core.constants.enums import SpectrumMethod - -METHODS: Final[List[SpectrumMethod]] = [SpectrumMethod.FFT, SpectrumMethod.CQT] -EXPONENTS: Final[List[float]] = [1.0] - - -class TestBuildVariants: - def test_sweeps_every_combination(self) -> None: - variants = build_variants(Config(), METHODS, [1.0, 0.5], [0.25]) - assert [variant.label for variant in variants] == [ - "fft-pe1-tw0.25", - "fft-pe0.5-tw0.25", - "cqt-pe1-tw0.25", - "cqt-pe0.5-tw0.25", - ] - - @pytest.mark.parametrize("method", METHODS, ids=lambda method: method.value) - def test_variant_holds_the_spectrum_method_member(self, method: SpectrumMethod) -> None: - (variant,) = build_variants(Config(), [method], EXPONENTS, []) - assert variant.config.library.spectrum_method is method - - def test_variant_configuration_serializes_cleanly(self) -> None: - variants = build_variants(Config(), METHODS, EXPONENTS, []) - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - for variant in variants: - variant.config.model_dump() 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..a0fba9553 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/project/test_v1_1.py @@ -0,0 +1,123 @@ +from typing import Any, Dict + +import pytest + +from sampletones_core.compatibility.fields import ( + KIND, + KIND_SAMPLE, + KIND_STEP, + NAME, + PITCH, + SAMPLES, + TRANSPOSE, + VALUE, + 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 _song_of_row(row: Dict[str, Any]) -> Dict[str, Any]: + return {"song": {"channels": {"pulse1": {"generator": "pulse1", "patterns": {"0": {"rows": [row]}}}}}} + + +def _first_row(data: Dict[str, Any]) -> Dict[str, Any]: + row: Dict[str, Any] = data["song"]["channels"]["pulse1"]["patterns"]["0"]["rows"][0] + return row + + +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 + + +class TestARowsTransposeBecomesAStep: + """A 1.0 row wrote its pitch as a bare transpose, which 1.1 reads as a step from the voice's reference.""" + + @pytest.mark.parametrize("transpose", [3, 0, -12]) + def test_a_transpose_becomes_a_step_of_the_same_value(self, transpose: int) -> None: + upgraded = update(_song_of_row({TRANSPOSE: transpose, "volume": 8})) + + row = _first_row(upgraded) + assert row[PITCH] == {KIND: KIND_STEP, VALUE: transpose} + assert TRANSPOSE not in row + assert row["volume"] == 8 + + def test_an_empty_transpose_leaves_the_row_without_a_pitch(self) -> None: + row = _first_row(update(_song_of_row({TRANSPOSE: None}))) + + assert PITCH not in row + assert TRANSPOSE not in row + + def test_a_row_without_the_key_stays_as_it_stands(self) -> None: + assert _first_row(update(_song_of_row({"volume": 8}))) == {"volume": 8} + + def test_a_row_carrying_a_command_and_a_transpose_has_both_reshaped(self) -> None: + row = _first_row( + update(_song_of_row({"command": {"sample_id": "a", "generator_name": "pulse1"}, TRANSPOSE: 5})) + ) + + assert row == {"command": {VOICE_ID: "a"}, PITCH: {KIND: KIND_STEP, VALUE: 5}} + + def test_the_input_row_is_left_untouched(self) -> None: + data = _song_of_row({TRANSPOSE: 5}) + + update(data) + + assert _first_row(data) == {TRANSPOSE: 5} 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..a1487e223 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -0,0 +1,249 @@ +from typing import Any, Dict, Final + +from sampletones_core.compatibility.fields import ( + APPROXIMATION, + APPROXIMATIONS_DATA, + ASSIGNMENTS, + AUDIO_FILEPATH, + BENDS, + CHANNEL_CAP, + CHANNEL_NAME, + CHANNELS, + CONFIG, + DRIVES, + ENTRIES, + GENERATION, + GENERATOR_NAME, + GENERATORS, + ID, + INSTRUCTION, + INSTRUCTIONS, + INSTRUCTIONS_DATA, + METADATA, + NAME, + ON, + PATH, + RECONSTRUCTION_DATA_VERSION, + SCALE, + SETTINGS, + SOURCES, + STEM_ID, + STEM_IDS, + STEMS_DATA, + UNION_DATA, +) +from sampletones_core.compatibility.reconstruction.v2_2 import SINGLE_STEM_ID, TARGET_DATA_VERSION, update +from sampletones_core.constants.algorithm import ( + ALL_STEMS_CHANNEL_CAP, + MAX_DRIVE, + MIN_DRIVE, + RESTING_STEM_ID, + UNIT_DRIVE, +) + +STORED_DRIVE: Final[float] = 2.5 +STORED_REFERENCE: Final[int] = 43 +INITIAL_PITCH: Final[str] = "initial_pitch" +RECORDING: Final[str] = "/audio/kick.wav" +RECORDING_NAME: Final[str] = "kick" + + +def _frame(sounds: bool) -> Dict[str, Any]: + """One frame as a stream stores it, which is the instruction tagged and nested.""" + return {"instruction_class": "PulseInstruction", INSTRUCTION: {"_type": 1, UNION_DATA: {ON: sounds}}} + + +def _stream(channel: str, *sounds: bool) -> Dict[str, Any]: + return {GENERATOR_NAME: channel, INSTRUCTIONS: [_frame(value) for value in sounds]} + + +def _entry(upgraded: Dict[str, Any]) -> Dict[str, Any]: + """The one recording the upgraded record answers to.""" + entry: Dict[str, Any] = upgraded[STEMS_DATA][CONFIG][ENTRIES][0] + return entry + + +def _drives(upgraded: Dict[str, Any]) -> Dict[str, float]: + drives: Dict[str, float] = _entry(upgraded)[SETTINGS][DRIVES] + return drives + + +def _owners(upgraded: Dict[str, Any]) -> Any: + return upgraded[STEMS_DATA][ASSIGNMENTS] + + +class TestTheAudioAFileNoLongerCarries: + """A reconstruction renders its channels from what it stores, so the stored waveform goes.""" + + def test_the_mixed_audio_is_let_go_of(self) -> None: + data = {APPROXIMATION: [0.0, 1.0]} + + assert APPROXIMATION not in update(data) + + def test_the_audio_each_channel_rendered_is_let_go_of(self) -> None: + data = {APPROXIMATIONS_DATA: [{GENERATOR_NAME: "pulse1", APPROXIMATION: [0.0]}]} + + assert APPROXIMATIONS_DATA not in update(data) + + +class TestWhatNamesAStream: + """A stream was keyed by the generator that played it, and is keyed by its channel now.""" + + def test_a_stream_reads_as_its_channel(self) -> None: + upgraded = update({INSTRUCTIONS_DATA: [_stream("pulse1", True)]}) + + assert upgraded[INSTRUCTIONS_DATA][0][CHANNEL_NAME] == "pulse1" + assert GENERATOR_NAME not in upgraded[INSTRUCTIONS_DATA][0] + + +class TestTheVersionTheEmbeddedConfigStates: + """A reconstruction carries the configuration it was built under, metadata and all.""" + + def test_the_embedded_metadata_states_the_version_reached(self) -> None: + data = {CONFIG: {METADATA: {RECONSTRUCTION_DATA_VERSION: "2.1"}}} + + upgraded = update(data) + + assert upgraded[CONFIG][METADATA][RECONSTRUCTION_DATA_VERSION] == TARGET_DATA_VERSION + + def test_a_config_stating_no_metadata_gains_none(self) -> None: + data = {CONFIG: {GENERATION: {GENERATORS: ["pulse1"]}}} + + assert METADATA not in update(data)[CONFIG] + + +class TestTheRecordingTheRecordAnswersTo: + """The channels a run handed out and the level it drove them at move onto the recording.""" + + def test_the_channels_the_run_handed_out_reach_the_recording(self) -> None: + data = {CONFIG: {GENERATION: {GENERATORS: ["pulse1", "noise"]}}} + + assert _entry(update(data)) == { + ID: SINGLE_STEM_ID, + SETTINGS: { + CHANNELS: ["pulse1", "noise"], + BENDS: [], + DRIVES: {"pulse1": UNIT_DRIVE, "noise": UNIT_DRIVE}, + CHANNEL_CAP: ALL_STEMS_CHANNEL_CAP, + }, + } + + def test_the_stored_drive_reaches_every_channel(self) -> None: + data = {CONFIG: {GENERATION: {GENERATORS: ["pulse1", "noise"], "drive": STORED_DRIVE}}} + + assert _drives(update(data)) == {"pulse1": STORED_DRIVE, "noise": STORED_DRIVE} + + def test_a_drive_stored_under_its_older_name_is_read(self) -> None: + data = {CONFIG: {GENERATION: {GENERATORS: ["pulse1"], "mixer": STORED_DRIVE}}} + + assert _drives(update(data)) == {"pulse1": STORED_DRIVE} + + def test_a_run_stating_no_drive_played_at_unit(self) -> None: + data = {CONFIG: {GENERATION: {GENERATORS: ["pulse1"]}}} + + assert _drives(update(data)) == {"pulse1": UNIT_DRIVE} + + def test_a_drive_outside_the_bounds_is_held_within_them(self) -> None: + quiet = {CONFIG: {GENERATION: {GENERATORS: ["pulse1"], "drive": MIN_DRIVE / 2}}} + loud = {CONFIG: {GENERATION: {GENERATORS: ["pulse1"], "drive": MAX_DRIVE * 2}}} + + assert _drives(update(quiet)) == {"pulse1": MIN_DRIVE} + assert _drives(update(loud)) == {"pulse1": MAX_DRIVE} + + +class TestTheScaleTheRecordingWasReadAt: + """A file written before the scale was recorded leaves it unmeasured, and its one recording reads at its peak.""" + + def test_an_upgraded_record_states_no_scale(self) -> None: + assert update({AUDIO_FILEPATH: RECORDING})[STEMS_DATA][SCALE] is None + + +class TestWhereTheRecordingCameFrom: + """The file a conversion read moves onto the record, which is where a document keeps it.""" + + def test_the_stated_file_becomes_the_recordings_source(self) -> None: + upgraded = update({AUDIO_FILEPATH: RECORDING}) + + assert upgraded[STEMS_DATA][SOURCES] == [{STEM_ID: SINGLE_STEM_ID, NAME: RECORDING_NAME, PATH: RECORDING}] + + def test_a_file_naming_no_recording_records_no_source(self) -> None: + """A document detached from its origin names none, which a stored project carries.""" + assert update({})[STEMS_DATA][SOURCES] == [] + + +class TestWhoHoldsEachFrame: + """Rest and silence name the same frames, so the owners are read from the stream itself.""" + + def test_a_sounding_frame_answers_to_the_recording(self) -> None: + upgraded = update({INSTRUCTIONS_DATA: [_stream("pulse1", True, True)]}) + + assert _owners(upgraded) == [{CHANNEL_NAME: "pulse1", STEM_IDS: [SINGLE_STEM_ID, SINGLE_STEM_ID]}] + + def test_a_silent_frame_answers_to_rest(self) -> None: + upgraded = update({INSTRUCTIONS_DATA: [_stream("pulse1", True, False, True)]}) + + assert _owners(upgraded) == [ + {CHANNEL_NAME: "pulse1", STEM_IDS: [SINGLE_STEM_ID, RESTING_STEM_ID, SINGLE_STEM_ID]} + ] + + def test_a_channel_holding_no_frame_names_no_owner(self) -> None: + data = {INSTRUCTIONS_DATA: [_stream("pulse1", True), _stream("noise")]} + + assert _owners(update(data)) == [{CHANNEL_NAME: "pulse1", STEM_IDS: [SINGLE_STEM_ID]}] + + def test_a_frame_stating_nothing_reads_as_silent(self) -> None: + """A payload this build cannot read a flag from describes no sound, so nothing holds it.""" + data = {INSTRUCTIONS_DATA: [{GENERATOR_NAME: "pulse1", INSTRUCTIONS: [_frame(True), "frame"]}]} + + assert _owners(update(data)) == [{CHANNEL_NAME: "pulse1", STEM_IDS: [SINGLE_STEM_ID, RESTING_STEM_ID]}] + + +class TestAChannelRestingThroughEveryFrame: + """A channel whose every frame rests stands by, so its stream states none of them.""" + + @staticmethod + def _upgraded() -> Dict[str, Any]: + resting = {**_stream("noise", False, False), INITIAL_PITCH: STORED_REFERENCE} + return update({INSTRUCTIONS_DATA: [_stream("pulse1", True), resting]}) + + def test_its_stream_holds_no_frame(self) -> None: + assert self._upgraded()[INSTRUCTIONS_DATA][1][INSTRUCTIONS] == [] + + def test_it_keeps_the_reference_it_stored(self) -> None: + assert self._upgraded()[INSTRUCTIONS_DATA][1][INITIAL_PITCH] == STORED_REFERENCE + + def test_it_names_no_owner(self) -> None: + assert _owners(self._upgraded()) == [{CHANNEL_NAME: "pulse1", STEM_IDS: [SINGLE_STEM_ID]}] + + def test_a_channel_that_sounds_keeps_its_frames(self) -> None: + assert len(self._upgraded()[INSTRUCTIONS_DATA][0][INSTRUCTIONS]) == 1 + + def test_a_file_sounding_nothing_keeps_its_recording(self) -> None: + """No frame tells the one recording from another, so the record keeps it.""" + upgraded = update({INSTRUCTIONS_DATA: [_stream("pulse1", False)], AUDIO_FILEPATH: RECORDING}) + + assert [entry[ID] for entry in upgraded[STEMS_DATA][CONFIG][ENTRIES]] == [SINGLE_STEM_ID] + assert upgraded[STEMS_DATA][SOURCES] == [{STEM_ID: SINGLE_STEM_ID, NAME: RECORDING_NAME, PATH: RECORDING}] + assert _owners(upgraded) == [] + + +class TestWhatTheStepLeavesAlone: + """The payload handed in stands as it was, and what the step knows nothing of travels.""" + + def test_the_payload_handed_in_is_left_untouched(self) -> None: + data = { + APPROXIMATIONS_DATA: [{GENERATOR_NAME: "pulse1"}], + INSTRUCTIONS_DATA: [_stream("pulse1", True)], + } + + update(data) + + assert APPROXIMATIONS_DATA in data + assert data[INSTRUCTIONS_DATA][0][GENERATOR_NAME] == "pulse1" + + def test_a_payload_naming_no_known_section_still_gains_a_record(self) -> None: + upgraded = update({ID: "abc"}) + + assert upgraded[ID] == "abc" + assert _entry(upgraded)[SETTINGS][CHANNELS] == [] + assert _owners(upgraded) == [] 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..25919b972 --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/test_binary.py @@ -0,0 +1,55 @@ +import msgpack + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import upgrade_binary +from sampletones_shared.application import 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_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_names_streams_by_channel_and_stamps(self) -> None: + binary = msgpack.packb( + { + "metadata": {"reconstruction_data_version": "2.1"}, + "approximation": [1.0, 2.0], + "approximations_data": [{"generator_name": "pulse1", "approximation": [1.0, 2.0]}], + "instructions_data": [{"generator_name": "pulse1", "instructions": []}], + "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 "approximation" not in data + assert "approximations_data" not in data + assert data["instructions_data"][0]["channel_name"] == "pulse1" + assert data["stems_data"]["config"]["entries"][0]["settings"]["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..11b03d53c --- /dev/null +++ b/tests/unit/sampletones_core/compatibility/test_upgrade.py @@ -0,0 +1,161 @@ +from dataclasses import dataclass +from typing import Any, Final, Optional + +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.update import VersionUpdate +from sampletones_core.compatibility.upgrade import read_version, upgrade +from sampletones_shared.deployment.version import Version +from sampletones_shared.types.data import SerializedData +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +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.RECONSTRUCTION, Version.model_validate(base), Version.model_validate(target), apply) + + +def _reconstruction_data(version: str) -> SerializedData: + return {"metadata": {"reconstruction_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.RECONSTRUCTION, "1.0", _reconstruction_data("1.0"), updates, "1.2") + + assert upgraded["markers"] == [FIRST_MARKER, SECOND_MARKER] + assert upgraded["metadata"]["reconstruction_data_version"] == "1.2" + + def test_current_version_returns_the_input_unchanged(self) -> None: + updates = (_marking_update("1.0", "1.1", FIRST_MARKER),) + data = _reconstruction_data("1.1") + + assert upgrade(ObjectKind.RECONSTRUCTION, "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 = _reconstruction_data("1.0") + + assert upgrade(ObjectKind.RECONSTRUCTION, "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 = _reconstruction_data("1.2") + + assert upgrade(ObjectKind.RECONSTRUCTION, "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 = _reconstruction_data("0.9") + + assert upgrade(ObjectKind.RECONSTRUCTION, "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.RECONSTRUCTION, "1.1", _reconstruction_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.RECONSTRUCTION, "1.0", _reconstruction_data("1.0"), updates, "1.2") + + +class TestReadVersion(BaseTestSuite): + """A format's version is read where the format keeps it, and anything else there states none.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + kind: ObjectKind + payload: Any + expected: Optional[str] + + test_cases = ( + TestCase( + label="a project states it at the root", + kind=ObjectKind.PROJECT, + payload={"format_version": "1.0"}, + expected="1.0", + ), + TestCase( + label="a reconstruction states it in its metadata", + kind=ObjectKind.RECONSTRUCTION, + payload={"metadata": {"reconstruction_data_version": "2.1"}}, + expected="2.1", + ), + TestCase( + label="a library states it in its metadata", + kind=ObjectKind.LIBRARY, + payload={"metadata": {"library_data_version": "2.0"}}, + expected="2.0", + ), + TestCase( + label="a project version in metadata is another format's place", + kind=ObjectKind.PROJECT, + payload={"metadata": {"format_version": "1.0"}}, + expected=None, + ), + TestCase( + label="a reconstruction version at the root is another format's place", + kind=ObjectKind.RECONSTRUCTION, + payload={"reconstruction_data_version": "2.1"}, + expected=None, + ), + TestCase( + label="a version written as a number", + kind=ObjectKind.PROJECT, + payload={"format_version": 1.0}, + expected=None, + ), + TestCase( + label="metadata that is not a mapping", + kind=ObjectKind.RECONSTRUCTION, + payload={"metadata": ["reconstruction_data_version", "2.1"]}, + expected=None, + ), + TestCase( + label="a payload that is not a mapping", + kind=ObjectKind.PROJECT, + payload=["format_version", "1.0"], + expected=None, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_stated_version_is_read_from_its_place(self, test_case: TestCase) -> None: + assert read_version(test_case.kind, test_case.payload) == test_case.expected diff --git a/tests/unit/sampletones_core/configs/test_defaults.py b/tests/unit/sampletones_core/configs/test_defaults.py new file mode 100644 index 000000000..c1004a564 --- /dev/null +++ b/tests/unit/sampletones_core/configs/test_defaults.py @@ -0,0 +1,55 @@ +from typing import Any, Dict, Mapping, Tuple + +import pytest +from pydantic import BaseModel + +from sampletones_core.configs.defaults import GENERATION_DEFAULTS_PATH, generation_default +from sampletones_core.configs.generation import GenerationConfig +from sampletones_shared.utils.serialization import load_yaml + + +def _shipped() -> Mapping[str, Any]: + values = load_yaml(GENERATION_DEFAULTS_PATH) + assert isinstance(values, dict) + return values + + +def _settings(model: BaseModel, *path: str) -> Dict[Tuple[str, ...], Any]: + """Every value the model holds, keyed by the path of names reaching it.""" + held: Dict[Tuple[str, ...], Any] = {} + for name, value in model: + if isinstance(value, BaseModel): + held.update(_settings(value, *path, name)) + else: + held[(*path, name)] = value + + return held + + +def _stated(values: Mapping[str, Any], *path: str) -> Dict[Tuple[str, ...], Any]: + """Every value the file states, keyed by the path of names reaching it.""" + stated: Dict[Tuple[str, ...], Any] = {} + for name, value in values.items(): + if isinstance(value, Mapping): + stated.update(_stated(value, *path, name)) + else: + stated[(*path, name)] = value + + return stated + + +class TestTheFileStatesTheMatchingDefaults: + """The packaged `generation.yaml` is where a matching default is written, and the only place.""" + + def test_every_setting_starts_at_the_value_the_file_states(self) -> None: + for path, value in _settings(GenerationConfig()).items(): + assert value == generation_default(*path) + + def test_every_value_the_file_states_names_a_setting(self) -> None: + assert set(_stated(_shipped())) == set(_settings(GenerationConfig())) + + +class TestReadingOneDefault: + def test_a_path_the_file_states_nothing_at_is_refused(self) -> None: + with pytest.raises(KeyError): + generation_default("metric", "loudness") 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_document.py b/tests/unit/sampletones_core/data/test_document.py new file mode 100644 index 000000000..334fc6b60 --- /dev/null +++ b/tests/unit/sampletones_core/data/test_document.py @@ -0,0 +1,84 @@ +from pathlib import Path +from typing import Final + +import msgpack +import pytest + +from sampletones_core.data.document import ( + DOCUMENT_MAGIC, + compress_document, + decompress_document, + open_document, +) + +RECORDS: Final[int] = 512 +FIELD: Final[str] = "leading" + + +def _payload() -> bytes: + """A payload of the shape a stored document holds: one map per record, keyed by name.""" + records = [{"on": index % 2 == 0, "pitch": 33, "volume": index % 16} for index in range(RECORDS)] + return bytes(msgpack.packb({FIELD: "2.2", "records": records}, use_bin_type=True)) + + +class TestWhatAStoredDocumentHolds: + def test_a_payload_comes_back_as_it_was_written(self) -> None: + payload = _payload() + + assert decompress_document(compress_document(payload)) == payload + + def test_a_payload_written_before_the_framing_reads_as_it_stands(self) -> None: + payload = _payload() + + assert decompress_document(payload) == payload + + def test_the_stored_bytes_carry_the_framing_magic(self) -> None: + assert compress_document(_payload()).startswith(DOCUMENT_MAGIC) + + def test_the_repeated_field_names_deflate_away(self) -> None: + payload = _payload() + + assert len(compress_document(payload)) < len(payload) + + def test_storing_one_payload_twice_writes_the_same_bytes(self) -> None: + """The framing states its timestamp, so a document's bytes follow from its payload alone.""" + payload = _payload() + + assert compress_document(payload) == compress_document(payload) + + def test_an_empty_payload_survives_the_round_trip(self) -> None: + assert decompress_document(compress_document(b"")) == b"" + + +class TestReadingADocumentAsAStream: + def test_a_stored_document_streams_as_its_payload(self, tmp_path: Path) -> None: + payload = _payload() + path = tmp_path / "document.bin" + path.write_bytes(compress_document(payload)) + + with open_document(path) as stream: + assert stream.read() == payload + + def test_a_document_written_before_the_framing_streams_as_it_stands(self, tmp_path: Path) -> None: + payload = _payload() + path = tmp_path / "document.bin" + path.write_bytes(payload) + + with open_document(path) as stream: + assert stream.read() == payload + + def test_the_stream_reaches_the_front_without_the_rest(self, tmp_path: Path) -> None: + payload = _payload() + path = tmp_path / "document.bin" + path.write_bytes(compress_document(payload)) + + with open_document(path) as stream: + unpacker = msgpack.Unpacker(stream, raw=False) + unpacker.read_map_header() + + assert unpacker.unpack() == FIELD + + def test_a_missing_document_is_reported(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + with open_document(tmp_path / "absent.bin"): + pass diff --git a/tests/unit/sampletones_core/data/test_metadata.py b/tests/unit/sampletones_core/data/test_metadata.py new file mode 100644 index 000000000..825047ac3 --- /dev/null +++ b/tests/unit/sampletones_core/data/test_metadata.py @@ -0,0 +1,57 @@ +from typing import Final + +import pytest + +from sampletones_core.data import Metadata, MetadataContract +from sampletones_shared.exceptions import IncompatibleProjectVersionError, InvalidMetadataError + +EXPECTED_VERSION: Final[str] = "1.1" +SPELLED_IN_FULL: Final[str] = "1.1.0" +OLDER_VERSION: Final[str] = "1.0" +NEWER_VERSION: Final[str] = "2.0" +FOREIGN_APPLICATION: Final[str] = "Another tracker" + + +@pytest.fixture(name="contract") +def contract_fixture() -> MetadataContract: + """A contract accepting one version and refusing any other with a project's error.""" + return MetadataContract( + label="Project data", + expected_version=EXPECTED_VERSION, + error=IncompatibleProjectVersionError, + ) + + +class TestValidateVersion: + def test_the_expected_version_passes(self, contract: MetadataContract) -> None: + contract.validate_version(EXPECTED_VERSION) + + def test_the_expected_version_spelled_in_full_passes(self, contract: MetadataContract) -> None: + contract.validate_version(SPELLED_IN_FULL) + + @pytest.mark.parametrize("actual_version", [OLDER_VERSION, NEWER_VERSION]) + def test_another_version_is_refused_naming_both(self, contract: MetadataContract, actual_version: str) -> None: + with pytest.raises(IncompatibleProjectVersionError) as refused: + contract.validate_version(actual_version) + + assert (refused.value.actual_version, refused.value.expected_version) == ( + actual_version, + EXPECTED_VERSION, + ) + + +class TestValidate: + """The full check holds the writer first and then the version, through the same version rule.""" + + def test_a_file_this_application_wrote_at_the_expected_version_passes(self, contract: MetadataContract) -> None: + contract.validate(Metadata(), EXPECTED_VERSION) + + def test_a_file_at_another_version_is_refused_for_its_version(self, contract: MetadataContract) -> None: + with pytest.raises(IncompatibleProjectVersionError) as refused: + contract.validate(Metadata(), OLDER_VERSION) + + assert refused.value.actual_version == OLDER_VERSION + + def test_a_file_another_application_wrote_is_refused_before_its_version(self, contract: MetadataContract) -> None: + with pytest.raises(InvalidMetadataError): + contract.validate(Metadata(application_name=FOREIGN_APPLICATION), OLDER_VERSION) 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..326d4e972 --- /dev/null +++ b/tests/unit/sampletones_core/data/test_model.py @@ -0,0 +1,91 @@ +from typing import Dict, Final + +import msgpack +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.data import DataModel +from sampletones_core.instructions import PulseInstruction +from sampletones_shared.exceptions import DeserializationError +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 + + +class _Levels(DataModel): + """A model carrying one value per channel, the shape a mapping field takes.""" + + levels: Dict[ChannelName, float] + + +class TestAMappingField: + """A mapping field is written key by key and read back keyed by the type it declares.""" + + def test_a_mapping_survives_a_round_trip(self) -> None: + model = _Levels(levels={ChannelName.PULSE1: 1.5, ChannelName.NOISE: 0.25}) + + loaded = _Levels.deserialize(model.serialize()) + + assert loaded.levels == {ChannelName.PULSE1: 1.5, ChannelName.NOISE: 0.25} + assert all(isinstance(key, ChannelName) for key in loaded.levels) + + def test_a_mapping_is_written_under_plain_words(self) -> None: + model = _Levels(levels={ChannelName.TRIANGLE: 2.0}) + + written: SerializedData = msgpack.unpackb(model.serialize(), raw=False) + + assert written["levels"] == {"triangle": 2.0} + + def test_a_value_that_is_no_mapping_is_refused(self) -> None: + with pytest.raises(DeserializationError, match="expects a mapping"): + _Levels.deserialize(bytes(msgpack.packb({"levels": [1.0]}, use_bin_type=True))) diff --git a/tests/unit/sampletones_core/data/test_stored.py b/tests/unit/sampletones_core/data/test_stored.py new file mode 100644 index 000000000..e2b47c47f --- /dev/null +++ b/tests/unit/sampletones_core/data/test_stored.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import Final + +import msgpack + +from sampletones_core.data.document import compress_document +from sampletones_core.data.stored import read_leading_fields + +LEADING: Final[str] = "leading" +FOLLOWING: Final[str] = "following" +TRAILING: Final[str] = "trailing" +HELD: Final[str] = "held" + + +def _stored(path: Path, payload: bytes) -> Path: + path.write_bytes(payload) + return path + + +class TestTheFieldsReadFromTheFront: + def test_the_named_fields_read_as_stored(self, tmp_path: Path) -> None: + document = {LEADING: {"version": "2.1"}, FOLLOWING: [1, 2], TRAILING: b"\x00" * 64} + path = _stored(tmp_path / "document.bin", msgpack.packb(document, use_bin_type=True)) + + fields = read_leading_fields(path, frozenset({LEADING, FOLLOWING})) + + assert fields == {LEADING: document[LEADING], FOLLOWING: document[FOLLOWING]} + + def test_a_field_the_document_holds_no_value_for_is_left_out(self, tmp_path: Path) -> None: + path = _stored(tmp_path / "document.bin", msgpack.packb({LEADING: 1}, use_bin_type=True)) + + assert read_leading_fields(path, frozenset({LEADING, HELD})) == {LEADING: 1} + + def test_the_read_ends_once_every_named_field_is_found(self, tmp_path: Path) -> None: + """The fields past the named ones are left undecoded, so what stands there never matters.""" + front = b"".join(msgpack.packb(part, use_bin_type=True) for part in (LEADING, 1, FOLLOWING, 2)) + three_fields = b"\x83" + front + msgpack.packb(TRAILING) + b"\xc1" * 16 + path = _stored(tmp_path / "document.bin", three_fields) + + assert read_leading_fields(path, frozenset({LEADING, FOLLOWING})) == {LEADING: 1, FOLLOWING: 2} + + +class TestAStoredDocumentsFraming: + def test_the_named_fields_read_through_the_framing(self, tmp_path: Path) -> None: + document = {LEADING: {"version": "2.1"}, FOLLOWING: [1, 2], TRAILING: b"\x00" * 4096} + payload = msgpack.packb(document, use_bin_type=True) + path = _stored(tmp_path / "document.bin", compress_document(payload)) + + fields = read_leading_fields(path, frozenset({LEADING, FOLLOWING})) + + assert fields == {LEADING: document[LEADING], FOLLOWING: document[FOLLOWING]} + + def test_a_document_whose_framing_is_cut_short_holds_no_fields(self, tmp_path: Path) -> None: + stored = compress_document(msgpack.packb({LEADING: "x" * 4096}, use_bin_type=True)) + path = _stored(tmp_path / "document.bin", stored[:24]) + + assert not read_leading_fields(path, frozenset({LEADING})) + + def test_a_document_whose_framing_is_damaged_holds_no_fields(self, tmp_path: Path) -> None: + stored = compress_document(msgpack.packb({LEADING: "x" * 4096}, use_bin_type=True)) + path = _stored(tmp_path / "document.bin", stored[:20] + b"\xff" * 256) + + assert not read_leading_fields(path, frozenset({LEADING})) + + +class TestAFrontThatDecodesAsNoMap: + def test_an_empty_file_holds_no_fields(self, tmp_path: Path) -> None: + path = _stored(tmp_path / "document.bin", b"") + + assert not read_leading_fields(path, frozenset({LEADING})) + + def test_a_document_of_another_shape_holds_no_fields(self, tmp_path: Path) -> None: + path = _stored(tmp_path / "document.bin", msgpack.packb([LEADING, 1], use_bin_type=True)) + + assert not read_leading_fields(path, frozenset({LEADING})) + + def test_bytes_of_no_format_hold_no_fields(self, tmp_path: Path) -> None: + path = _stored(tmp_path / "document.bin", b"\xc1\xc1\xc1\xc1") + + assert not read_leading_fields(path, frozenset({LEADING})) + + def test_a_document_cut_short_holds_no_fields(self, tmp_path: Path) -> None: + whole = msgpack.packb({LEADING: "x" * 64}, use_bin_type=True) + path = _stored(tmp_path / "document.bin", whole[:16]) + + assert not read_leading_fields(path, frozenset({LEADING})) 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..51a5d57a5 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_pulse.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_pulse.py @@ -1,7 +1,7 @@ import numpy as np from sampletones_core.constants.enums import FeatureKey -from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH, MIN_PLAYED_PITCH from sampletones_core.exporters.implementation.pulse import PulseExporter from sampletones_core.generators import PulseGenerator from sampletones_core.instructions.implementation.pulse import PulseInstruction @@ -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: @@ -110,15 +97,20 @@ def test_valid_pitch_round_trips(self) -> None: assert result.duty_cycle == 1 assert result.on is True - def test_invalid_pitch_above_max_returns_null_instruction(self) -> None: + def test_a_pitch_past_b7_sounds_at_b7_as_the_trackers_hold_it(self) -> None: dictionary = {"pitch": 10, "volume": 8, "duty_cycle": 0} result = PulseExporter._features_dictionary_to_instruction(dictionary, MAX_PITCH) - assert result.on is False + assert (result.on, result.pitch) == (True, MAX_PITCH) - def test_invalid_pitch_below_min_returns_null_instruction(self) -> None: - dictionary = {"pitch": -10, "volume": 8, "duty_cycle": 0} + def test_a_pitch_below_a0_keeps_its_note(self) -> None: + dictionary = {"pitch": -5, "volume": 8, "duty_cycle": 0} result = PulseExporter._features_dictionary_to_instruction(dictionary, MIN_PITCH) - assert result.on is False + assert (result.on, result.pitch) == (True, MIN_PITCH - 5) + + def test_a_pitch_below_c0_sounds_at_c0_as_the_trackers_hold_it(self) -> None: + dictionary = {"pitch": -20, "volume": 8, "duty_cycle": 0} + result = PulseExporter._features_dictionary_to_instruction(dictionary, MIN_PITCH) + assert (result.on, result.pitch) == (True, MIN_PLAYED_PITCH) def test_zero_volume_reconstructed_as_off(self) -> None: dictionary = {"pitch": 0, "volume": 0, "duty_cycle": 0} 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..4d3ee56e4 100644 --- a/tests/unit/sampletones_core/exporters/implementation/test_triangle.py +++ b/tests/unit/sampletones_core/exporters/implementation/test_triangle.py @@ -1,7 +1,7 @@ import numpy as np from sampletones_core.constants.enums import FeatureKey -from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH +from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH, MIN_PLAYED_PITCH from sampletones_core.exporters.implementation.triangle import TriangleExporter from sampletones_core.generators import TriangleGenerator from sampletones_core.instructions.implementation.triangle import TriangleInstruction @@ -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: @@ -105,26 +96,20 @@ def test_valid_pitch_round_trips(self) -> None: assert result.pitch == initial_pitch + arpeggio assert result.on is True - def test_invalid_pitch_above_max_returns_null_instruction(self) -> None: - initial_pitch = MAX_PITCH - arpeggio = 10 - dictionary = {"pitch": arpeggio, "volume": 10} - result = TriangleExporter._features_dictionary_to_instruction( - dictionary, - initial_pitch, - ) - assert result.on is False - assert result.pitch == MIN_PITCH + def test_a_pitch_past_b7_sounds_at_b7_as_the_trackers_hold_it(self) -> None: + dictionary = {"pitch": 10, "volume": 10} + result = TriangleExporter._features_dictionary_to_instruction(dictionary, MAX_PITCH) + assert (result.on, result.pitch) == (True, MAX_PITCH) - def test_invalid_pitch_below_min_returns_null_instruction(self) -> None: - initial_pitch = MIN_PITCH - arpeggio = -10 - dictionary = {"pitch": arpeggio, "volume": 10} - result = TriangleExporter._features_dictionary_to_instruction( - dictionary, - initial_pitch, - ) - assert result.on is False + def test_a_pitch_below_a0_keeps_its_note(self) -> None: + dictionary = {"pitch": -5, "volume": 10} + result = TriangleExporter._features_dictionary_to_instruction(dictionary, MIN_PITCH) + assert (result.on, result.pitch) == (True, MIN_PITCH - 5) + + def test_a_pitch_below_c0_sounds_at_c0_as_the_trackers_hold_it(self) -> None: + dictionary = {"pitch": -20, "volume": 10} + result = TriangleExporter._features_dictionary_to_instruction(dictionary, MIN_PITCH) + assert (result.on, result.pitch) == (True, MIN_PLAYED_PITCH) def test_zero_volume_reconstructed_as_off(self) -> None: dictionary = {"pitch": 0, "volume": 0} diff --git a/tests/unit/sampletones_core/exporters/rows/__init__.py b/tests/unit/sampletones_core/exporters/rows/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/exporters/rows/test_levels.py b/tests/unit/sampletones_core/exporters/rows/test_levels.py new file mode 100644 index 000000000..f0e5c1b29 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/rows/test_levels.py @@ -0,0 +1,207 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME +from sampletones_core.exporters.rows.levels import RowPlace, cell_volume, full_level_notes +from sampletones_core.performance.modifiers import TRIANGLE_LOUDEST_SILENT_VOLUME +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.pitch import Step +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ROWS_PER_PATTERN: Final[int] = 4 +VOICE: Final[str] = "voice" +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +QUIET_VOLUME: Final[int] = 5 +LOUD_VOLUME: Final[int] = 12 +TRANSPOSE: Final[int] = 3 + +NOTE: Final[Row] = Row(command=NoteOn(voice_id=VOICE)) +QUIET_ROW: Final[Row] = Row(volume=QUIET_VOLUME) +FULL_ROW: Final[Row] = Row(volume=MAX_VOLUME) + + +def _song( + patterns: Dict[int, List[Row]], + order: List[Optional[int]], +) -> Song: + """A song of one channel's patterns, every other channel left empty.""" + pools = { + channel_name: Channel( + name=channel_name, + patterns=( + {index: Pattern(rows=rows) for index, rows in patterns.items()} if channel_name == CHANNEL else {} + ), + ) + for channel_name in ChannelName.items() + } + return Song( + rows_per_pattern=ROWS_PER_PATTERN, + order=[{CHANNEL: index} for index in order], + channels=pools, + ) + + +def _rows(*cells: Tuple[int, Row]) -> List[Row]: + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def _place(order_position: int, pattern_index: int, row_index: int) -> RowPlace: + return RowPlace(order_position=order_position, pattern_index=pattern_index, row_index=row_index) + + +class TestTheNotesWritingTheFullLevel: + """The song starts a note at the full level wherever its row states none, while a tracker carries + the last level its column wrote into the note, so such a note is written at the full level + wherever the tracker would carry another into it. + """ + + def test_a_note_after_a_quieter_row_writes_the_full_level(self) -> None: + song = _song({0: _rows((0, QUIET_ROW), (2, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 2)} + + def test_a_note_the_channel_reaches_at_the_full_level_writes_nothing(self) -> None: + song = _song({0: _rows((0, FULL_ROW), (2, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == frozenset() + + def test_a_note_stating_its_own_level_writes_nothing_more(self) -> None: + song = _song({0: _rows((0, QUIET_ROW), (2, Row(command=NoteOn(voice_id=VOICE), volume=LOUD_VOLUME)))}, [0]) + + assert full_level_notes(song, CHANNEL) == frozenset() + + def test_a_note_after_one_the_song_starts_at_full_writes_nothing(self) -> None: + """A note whose row states no level leaves the tracker at the full level too.""" + song = _song({0: _rows((0, QUIET_ROW), (1, NOTE), (3, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 1)} + + def test_a_note_off_stating_a_level_is_carried_into_the_next_note(self) -> None: + """The song leaves the level alone on a note-off, while the tracker's column takes the level + its cell writes, so the next note is reached at that level. + """ + song = _song({0: _rows((0, NOTE), (1, Row(command=NoteOff(), volume=QUIET_VOLUME)), (3, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 3)} + + def test_a_transpose_alone_leaves_the_level_alone(self) -> None: + song = _song({0: _rows((0, QUIET_ROW), (1, Row(pitch=Step(value=TRANSPOSE))), (2, NOTE))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 2)} + + def test_the_level_crosses_into_the_next_frame(self) -> None: + song = _song({0: _rows((3, QUIET_ROW)), 1: _rows((0, NOTE))}, [0, 1]) + + assert full_level_notes(song, CHANNEL) == {_place(1, 1, 0)} + + def test_the_level_crosses_a_frame_the_channel_rests_in(self) -> None: + song = _song({0: _rows((3, QUIET_ROW)), 1: _rows((0, NOTE))}, [0, None, 1]) + + assert full_level_notes(song, CHANNEL) == {_place(2, 1, 0)} + + def test_the_level_the_song_ends_on_reaches_its_first_note(self) -> None: + """A tracker returns to the first frame at the end of the order, keeping the level the + channel was left at, so the song's first note is reached at the level it ends on. + """ + song = _song({0: _rows((0, NOTE), (3, QUIET_ROW))}, [0]) + + assert full_level_notes(song, CHANNEL) == {_place(0, 0, 0)} + + def test_a_pattern_is_reached_in_every_frame_that_plays_it(self) -> None: + song = _song({0: _rows((0, NOTE)), 1: _rows((0, QUIET_ROW))}, [0, 1, 0]) + + assert full_level_notes(song, CHANNEL) == {_place(2, 0, 0)} + + def test_a_song_stating_no_level_writes_none(self) -> None: + song = _song({0: _rows((0, NOTE), (2, NOTE))}, [0, 0]) + + assert full_level_notes(song, CHANNEL) == frozenset() + + +class TestTheLevelACellStates(BaseTestSuite): + """A cell states the row's own level, the full level on a note that writes it, and nothing + otherwise; the triangle states silence at a level the song rests it at. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: Row + channel_name: ChannelName + full_level: bool + expected: Optional[int] + + test_cases: Tuple["TestTheLevelACellStates.TestCase", ...] = ( + TestCase( + label="a stated level", + row=QUIET_ROW, + channel_name=CHANNEL, + full_level=False, + expected=QUIET_VOLUME, + ), + TestCase( + label="a note writing the full level", + row=NOTE, + channel_name=CHANNEL, + full_level=True, + expected=MAX_VOLUME, + ), + TestCase( + label="a note carrying the level", + row=NOTE, + channel_name=CHANNEL, + full_level=False, + expected=None, + ), + TestCase( + label="silence", + row=Row(volume=SILENT_VOLUME), + channel_name=CHANNEL, + full_level=False, + expected=SILENT_VOLUME, + ), + TestCase( + label="a quiet level on the pulse", + row=Row(volume=TRIANGLE_LOUDEST_SILENT_VOLUME), + channel_name=CHANNEL, + full_level=False, + expected=TRIANGLE_LOUDEST_SILENT_VOLUME, + ), + TestCase( + label="the loudest level the triangle rests at", + row=Row(volume=TRIANGLE_LOUDEST_SILENT_VOLUME), + channel_name=ChannelName.TRIANGLE, + full_level=False, + expected=SILENT_VOLUME, + ), + TestCase( + label="the quietest level the triangle sounds at", + row=Row(volume=TRIANGLE_LOUDEST_SILENT_VOLUME + 1), + channel_name=ChannelName.TRIANGLE, + full_level=False, + expected=TRIANGLE_LOUDEST_SILENT_VOLUME + 1, + ), + TestCase( + label="a triangle note writing the full level", + row=NOTE, + channel_name=ChannelName.TRIANGLE, + full_level=True, + expected=MAX_VOLUME, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_cell_states_the_level(self, test_case: "TestTheLevelACellStates.TestCase") -> None: + assert cell_volume(test_case.row, test_case.channel_name, full_level=test_case.full_level) == test_case.expected diff --git a/tests/unit/sampletones_core/exporters/rows/test_pitch.py b/tests/unit/sampletones_core/exporters/rows/test_pitch.py new file mode 100644 index 000000000..fedc8f31c --- /dev/null +++ b/tests/unit/sampletones_core/exporters/rows/test_pitch.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.general import MAX_PITCH, MIN_PLAYED_PITCH +from sampletones_core.exporters.rows.pitch import FLAT_CONTOUR_STEP, highest_step, written_pitch +from sampletones_core.utils.frequencies import transpose_pitch +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +CONTOUR: Final[Tuple[int, ...]] = (-5, 0, 5) +CONTOUR_TOP: Final[int] = max(CONTOUR) +FLAT_CONTOUR: Final[Tuple[int, ...]] = (FLAT_CONTOUR_STEP,) +MIDDLE_PITCH: Final[int] = 60 + + +class TestTheHighestStep: + def test_the_highest_step_of_a_contour(self) -> None: + assert highest_step(CONTOUR) == CONTOUR_TOP + + def test_a_contour_of_no_steps_moves_nothing(self) -> None: + assert highest_step(()) == FLAT_CONTOUR_STEP + + +class TestTheWrittenPitch(BaseTestSuite): + """The song holds each tick's transposed pitch within the range the channels play, and a tracker + moves the written note by the contour's step each tick. The written pitch is the one that keeps + each tick the song plays within that range on its own note. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + pitch: int + contour: Tuple[int, ...] + expected: int + + test_cases: Tuple["TestTheWrittenPitch.TestCase", ...] = ( + TestCase( + label="within the range", + pitch=MIDDLE_PITCH, + contour=CONTOUR, + expected=MIDDLE_PITCH, + ), + TestCase( + label="a flat contour below the range", + pitch=MIN_PLAYED_PITCH - 3, + contour=FLAT_CONTOUR, + expected=MIN_PLAYED_PITCH, + ), + TestCase( + label="a contour reaching into the range", + pitch=MIN_PLAYED_PITCH - 2, + contour=CONTOUR, + expected=MIN_PLAYED_PITCH - 2, + ), + TestCase( + label="a contour lying below the range", + pitch=MIN_PLAYED_PITCH - 10, + contour=CONTOUR, + expected=MIN_PLAYED_PITCH - CONTOUR_TOP, + ), + TestCase( + label="a flat contour above the range", + pitch=MAX_PITCH + 4, + contour=FLAT_CONTOUR, + expected=MAX_PITCH, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_pitch_written(self, test_case: "TestTheWrittenPitch.TestCase") -> None: + assert written_pitch(test_case.pitch, highest_step(test_case.contour)) == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_tick_the_song_plays_within_the_range_keeps_its_note( + self, + test_case: "TestTheWrittenPitch.TestCase", + ) -> None: + """A tick the song holds at C-0 the tracker holds there too, and the highest tick of a contour + lying below the range lands on C-0 itself. + """ + contour_top = highest_step(test_case.contour) + written = written_pitch(test_case.pitch, contour_top) + + for step in test_case.contour: + song_pitch = transpose_pitch(test_case.pitch, step) + tracker_pitch = min(MAX_PITCH, written + step) + if song_pitch > MIN_PLAYED_PITCH or step == contour_top: + assert tracker_pitch == song_pitch + else: + assert tracker_pitch <= MIN_PLAYED_PITCH diff --git a/tests/unit/sampletones_core/exporters/rows/test_transpose.py b/tests/unit/sampletones_core/exporters/rows/test_transpose.py new file mode 100644 index 000000000..261e11871 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/rows/test_transpose.py @@ -0,0 +1,300 @@ +from typing import Dict, Final, FrozenSet, List, Optional, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.exporters.rows.levels import RowPlace +from sampletones_core.exporters.rows.transpose import ( + NoteStart, + PitchWalk, + Repitch, + SoundingNote, + unreached_start, +) +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.pitch import Note, RowPitch, Step +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +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.sample import Sample +from sampletones_core.project.voices.voice import VoiceLookup, VoiceUnion, voice_reference +from tests.suite.performance import make_pulse_reconstruction + +ROWS_PER_PATTERN: Final[int] = 4 +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +REFERENCE: Final[int] = 60 +NOTE_ON_STEP: Final[int] = 5 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +TYPED_NOTE: Final[int] = 67 +BENT_NOTE: Final[int] = 65 +GONE: Final[str] = "gone" + +SAMPLE: Final[Sample] = Sample(name="voice", reconstruction=make_pulse_reconstruction(pitch=REFERENCE)) +SILENT: Final[Sample] = Sample(name="silent", reconstruction=make_pulse_reconstruction(pitch=REFERENCE)) +LEAD: Final[Instrument] = Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + initial_pitch=REFERENCE, +) +VOICE: Final[str] = SAMPLE.id +SILENT_VOICE: Final[str] = SILENT.id +VOICES: Final[Dict[str, VoiceUnion]] = {voice.id: voice for voice in (SAMPLE, SILENT, LEAD)} +LOOKUP: Final[VoiceLookup] = VOICES.get +INSTRUMENTS: Final[FrozenSet[Tuple[str, ChannelName]]] = frozenset({(VOICE, CHANNEL), (LEAD.id, CHANNEL)}) + + +def _song( + patterns: Dict[int, List[Row]], + order: List[Optional[int]], +) -> Song: + """A song of one channel's patterns, every other channel left empty.""" + pools = { + channel_name: Channel( + name=channel_name, + patterns=( + {index: Pattern(rows=rows) for index, rows in patterns.items()} if channel_name == CHANNEL else {} + ), + ) + for channel_name in ChannelName.items() + } + return Song( + rows_per_pattern=ROWS_PER_PATTERN, + order=[{CHANNEL: index} for index in order], + channels=pools, + ) + + +def _rows(*cells: Tuple[int, Row]) -> List[Row]: + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def _place(order_position: int, pattern_index: int, row_index: int) -> RowPlace: + return RowPlace(order_position=order_position, pattern_index=pattern_index, row_index=row_index) + + +def _note(pitch: Optional[RowPitch] = None, voice_id: str = VOICE) -> Row: + return Row(command=NoteOn(voice_id=voice_id), pitch=pitch) + + +def _walk(song: Song) -> PitchWalk: + return PitchWalk.walk(song, CHANNEL, INSTRUMENTS, LOOKUP) + + +def _started(step: Optional[int], voice_id: str = VOICE) -> NoteStart: + return NoteStart(voice_id=voice_id, step=step) + + +def _step_to(note: int, voice: VoiceUnion = SAMPLE) -> int: + return note - voice_reference(voice, CHANNEL) + + +class TestWhatEveryNoteOnStartsAt: + """The walk starts every note the way playback does, so the step it records is the one the song sounds.""" + + def test_a_step_starts_at_itself(self) -> None: + walk = _walk(_song({0: _rows((0, _note(Step(value=NOTE_ON_STEP))))}, [0])) + + assert walk.starts == {_place(0, 0, 0): _started(NOTE_ON_STEP)} + + def test_a_note_starts_at_the_step_reaching_it(self) -> None: + walk = _walk(_song({0: _rows((0, _note(Note(value=TYPED_NOTE))))}, [0])) + + assert walk.starts == {_place(0, 0, 0): _started(_step_to(TYPED_NOTE))} + + def test_a_sample_stating_no_pitch_starts_as_recorded_whatever_sounded_before(self) -> None: + walk = _walk(_song({0: _rows((0, _note(Note(value=TYPED_NOTE))), (2, _note()))}, [0])) + + assert walk.starts[_place(0, 0, 2)] == _started(0) + + def test_an_instrument_stating_no_pitch_starts_at_the_pitch_the_sample_was_sounding(self) -> None: + walk = _walk(_song({0: _rows((0, _note(Note(value=TYPED_NOTE))), (2, _note(voice_id=LEAD.id)))}, [0])) + + assert walk.starts[_place(0, 0, 2)] == _started(_step_to(TYPED_NOTE, LEAD), LEAD.id) + + def test_the_pitch_an_instrument_goes_on_from_follows_a_bend(self) -> None: + song = _song({0: _rows((0, _note()), (1, Row(pitch=Note(value=BENT_NOTE))), (2, _note(voice_id=LEAD.id)))}, [0]) + + walk = _walk(song) + + assert walk.starts[_place(0, 0, 2)] == _started(_step_to(BENT_NOTE, LEAD), LEAD.id) + + def test_an_instrument_stating_no_pitch_after_a_note_off_starts_nothing(self) -> None: + song = _song({0: _rows((0, _note()), (1, Row(command=NoteOff())), (2, _note(voice_id=LEAD.id)))}, [0]) + + walk = _walk(song) + + assert walk.starts[_place(0, 0, 2)] == _started(None, LEAD.id) + + def test_an_instrument_stating_no_pitch_on_a_fresh_channel_starts_nothing(self) -> None: + walk = _walk(_song({0: _rows((0, _note(voice_id=LEAD.id)))}, [0])) + + assert walk.starts == {_place(0, 0, 0): _started(None, LEAD.id)} + + def test_the_pitch_an_instrument_goes_on_from_crosses_frames(self) -> None: + song = _song({0: _rows((0, _note(Step(value=NOTE_ON_STEP)))), 1: _rows((0, _note(voice_id=LEAD.id)))}, [0, 1]) + + walk = _walk(song) + + assert walk.starts[_place(1, 1, 0)] == _started(NOTE_ON_STEP, LEAD.id) + + def test_a_pattern_two_frames_play_after_different_pitches_starts_differently_in_each(self) -> None: + song = _song( + { + 0: _rows((0, _note(Step(value=RAISED)))), + 1: _rows((0, _note(Step(value=LOWERED)))), + 2: _rows((0, _note(voice_id=LEAD.id))), + }, + [0, 2, 1, 2], + ) + + walk = _walk(song) + + assert walk.starts[_place(1, 2, 0)] == _started(RAISED, LEAD.id) + assert walk.starts[_place(3, 2, 0)] == _started(LOWERED, LEAD.id) + + def test_a_voice_without_an_instrument_on_the_channel_records_no_start_and_sounds_on(self) -> None: + """The export writes a note cut for it, while the song goes on sounding it.""" + song = _song( + {0: _rows((0, _note(Note(value=TYPED_NOTE), voice_id=SILENT_VOICE)), (2, _note(voice_id=LEAD.id)))}, [0] + ) + + walk = _walk(song) + + assert walk.starts == {_place(0, 0, 2): _started(_step_to(TYPED_NOTE, LEAD), LEAD.id)} + + def test_a_voice_the_project_lacks_leaves_a_silent_channel(self) -> None: + song = _song( + {0: _rows((0, _note(Note(value=TYPED_NOTE))), (1, _note(voice_id=GONE)), (2, _note(voice_id=LEAD.id)))}, [0] + ) + + walk = _walk(song) + + assert walk.starts == { + _place(0, 0, 0): _started(_step_to(TYPED_NOTE)), + _place(0, 0, 2): _started(None, LEAD.id), + } + + def test_the_starts_of_one_frame_are_read_by_row(self) -> None: + song = _song({0: _rows((0, _note(Step(value=RAISED))), (2, _note(Step(value=LOWERED))))}, [0, 0]) + + walk = _walk(song) + + assert walk.frame_starts(1) == {0: RAISED, 2: LOWERED} + + +class TestARowTheOrderNeverReaches: + """A pattern the order never plays is written as the song would play it from silence.""" + + def test_a_sample_starts_as_recorded(self) -> None: + assert unreached_start(_note(), CHANNEL, LOOKUP) == 0 + + def test_a_pitch_starts_at_the_step_reaching_it(self) -> None: + assert unreached_start(_note(Note(value=TYPED_NOTE), voice_id=LEAD.id), CHANNEL, LOOKUP) == _step_to( + TYPED_NOTE, LEAD + ) + + def test_an_instrument_stating_no_pitch_starts_nothing(self) -> None: + assert unreached_start(_note(voice_id=LEAD.id), CHANNEL, LOOKUP) is None + + def test_a_row_naming_no_note_starts_nothing(self) -> None: + assert unreached_start(Row(pitch=Step(value=RAISED)), CHANNEL, LOOKUP) is None + + def test_a_voice_the_project_lacks_starts_nothing(self) -> None: + assert unreached_start(_note(voice_id=GONE), CHANNEL, LOOKUP) is None + + +class TestTheNotesAPitchRowMoves: + """A row stating a pitch and no note moves the note already sounding, which keeps playing + from the tick it reached, so the walk names each such row beside the note it moves and how long + that note has sounded. + """ + + def test_a_pitch_row_is_named_beside_the_note_it_moves(self) -> None: + song = _song({0: _rows((0, _note()), (2, Row(pitch=Step(value=RAISED))))}, [0]) + + assert _walk(song).notes == ( + SoundingNote( + place=_place(0, 0, 0), + voice_id=VOICE, + step=0, + repitches=(Repitch(place=_place(0, 0, 2), step=RAISED, rows=2),), + ), + ) + + def test_a_note_bend_is_a_repitch_at_the_step_reaching_it(self) -> None: + song = _song({0: _rows((0, _note()), (2, Row(pitch=Note(value=BENT_NOTE))))}, [0]) + + (note,) = _walk(song).notes + + assert note.repitches == (Repitch(place=_place(0, 0, 2), step=_step_to(BENT_NOTE), rows=2),) + + def test_the_note_keeps_the_step_it_started_at(self) -> None: + song = _song({0: _rows((0, _note(Step(value=NOTE_ON_STEP))), (1, Row(pitch=Step(value=RAISED))))}, [0]) + + (note,) = _walk(song).notes + + assert note.step == NOTE_ON_STEP + + def test_every_pitch_row_reaching_the_note_is_counted_from_it(self) -> None: + song = _song( + {0: _rows((0, _note()), (1, Row(pitch=Step(value=RAISED))), (3, Row(pitch=Step(value=LOWERED))))}, [0] + ) + + (note,) = _walk(song).notes + + assert [(repitch.step, repitch.rows) for repitch in note.repitches] == [(RAISED, 1), (LOWERED, 3)] + + def test_rows_are_counted_across_frames(self) -> None: + song = _song({0: _rows((1, _note())), 1: _rows((2, Row(pitch=Step(value=RAISED))))}, [0, 1]) + + (note,) = _walk(song).notes + + assert note.repitches == (Repitch(place=_place(1, 1, 2), step=RAISED, rows=5),) + + def test_an_empty_frame_keeps_the_note_sounding(self) -> None: + song = _song({0: _rows((0, _note())), 1: _rows((1, Row(pitch=Step(value=RAISED))))}, [0, None, 1]) + + (note,) = _walk(song).notes + + assert note.repitches[0].rows == 2 * ROWS_PER_PATTERN + 1 + + def test_a_pattern_played_twice_is_reached_in_each_frame(self) -> None: + song = _song({0: _rows((0, _note()), (2, Row(pitch=Step(value=RAISED))))}, [0, 0]) + + notes = _walk(song).notes + + assert [note.place.order_position for note in notes] == [0, 1] + + def test_a_note_off_ends_the_note(self) -> None: + song = _song({0: _rows((0, _note()), (1, Row(command=NoteOff())), (2, Row(pitch=Step(value=RAISED))))}, [0]) + + assert _walk(song).notes == () + + def test_a_voice_without_an_instrument_on_the_channel_sounds_no_note(self) -> None: + song = _song({0: _rows((0, _note(voice_id=SILENT_VOICE)), (2, Row(pitch=Step(value=RAISED))))}, [0]) + + assert _walk(song).notes == () + + def test_a_pitch_row_before_any_note_moves_nothing(self) -> None: + song = _song({0: _rows((0, Row(pitch=Step(value=RAISED))), (2, _note()))}, [0]) + + assert _walk(song).notes == () + + def test_a_note_no_pitch_row_reaches_is_in_the_starts_and_out_of_the_notes(self) -> None: + song = _song( + {0: _rows((0, _note()), (1, Row(volume=RAISED)), (2, _note()), (3, Row(pitch=Step(value=RAISED))))}, [0] + ) + + walk = _walk(song) + + assert set(walk.starts) == {_place(0, 0, 0), _place(0, 0, 2)} + assert [note.place.row_index for note in walk.notes] == [2] diff --git a/tests/unit/sampletones_core/exporters/test_bend.py b/tests/unit/sampletones_core/exporters/test_bend.py new file mode 100644 index 000000000..976861081 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_bend.py @@ -0,0 +1,57 @@ +from typing import Final, Optional, Sequence + +from sampletones_core.constants.general import HI_PITCH_FACTOR +from sampletones_core.exporters.bend import bend_envelope +from sampletones_core.features.envelope import Envelope + +FINE_STEPS: Final[Sequence[int]] = (0, -3, -7) +COARSE_STEPS: Final[Sequence[int]] = (0, 1, 2) + + +def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]: + return Envelope[int](items=tuple(items), loop_point=loop_point) + + +class TestTheOffsetTwoDimensionsStateTogether: + def test_a_fine_dimension_alone_carries_its_steps(self) -> None: + assert bend_envelope(envelope(FINE_STEPS), None).items == tuple(FINE_STEPS) + + def test_a_coarse_step_counts_sixteen(self) -> None: + assert bend_envelope(None, envelope(COARSE_STEPS)).items == tuple( + step * HI_PITCH_FACTOR for step in COARSE_STEPS + ) + + def test_both_dimensions_reach_the_same_tick(self) -> None: + combined = bend_envelope(envelope(FINE_STEPS), envelope(COARSE_STEPS)) + assert combined.items == tuple( + fine + coarse * HI_PITCH_FACTOR for fine, coarse in zip(FINE_STEPS, COARSE_STEPS) + ) + + def test_a_channel_offering_neither_dimension_states_nothing(self) -> None: + assert bend_envelope(None, None).items == () + + def test_a_dimension_writing_no_items_states_nothing(self) -> None: + assert bend_envelope(envelope(()), envelope(())).items == () + + +class TestDimensionsOfDifferentLengths: + def test_the_pair_runs_as_long_as_the_longer_of_them(self) -> None: + combined = bend_envelope(envelope((1, 2, 3, 4)), envelope((1,))) + assert len(combined.items) == 4 + + def test_a_dimension_past_its_end_reads_at_the_value_it_holds(self) -> None: + combined = bend_envelope(envelope((0, 0, 0)), envelope((1,))) + assert combined.items == (HI_PITCH_FACTOR,) * 3 + + def test_a_circling_dimension_reads_at_the_value_it_comes_round_to(self) -> None: + combined = bend_envelope(envelope((1, 2), loop_point=0), envelope(())) + assert combined.items == (1, 2) + + +class TestThePointThePairCirclesFrom: + def test_a_pair_holding_its_last_value_states_no_point(self) -> None: + assert bend_envelope(envelope(FINE_STEPS), envelope(COARSE_STEPS)).loop_point is None + + def test_the_earliest_point_either_dimension_states_carries(self) -> None: + combined = bend_envelope(envelope((1, 2, 3), loop_point=2), envelope((0, 0, 0), loop_point=1)) + assert combined.loop_point == 1 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..56a3441b2 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_bends.py @@ -0,0 +1,174 @@ +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 +from sampletones_core.instructions.tonal import bend_steps + +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 TestReadingTheOffsetAChannelSounds: + """Both bend dimensions reach a register as one divider offset, held the way pitches are.""" + + def test_the_two_dimensions_sum_frame_by_frame(self) -> None: + offsets = PulseExporter.read_timer_offsets(_pulses()) + + assert offsets == tuple(bend_steps(fine, coarse) for fine, coarse in zip(BENDS, COARSE_BENDS)) + + def test_a_rest_carries_the_offset_the_last_sounding_frame_stated(self) -> None: + instructions = [ + TriangleInstruction(on=True, pitch=REFERENCE, detune=9, coarse_detune=1), + TriangleInstruction.null_instruction(), + TriangleInstruction(on=True, pitch=REFERENCE, detune=-4), + ] + + offsets = TriangleExporter.read_timer_offsets(instructions) + + assert offsets == (bend_steps(9, 1), bend_steps(9, 1), -4) + + def test_a_rest_before_the_first_sounding_frame_takes_its_offset(self) -> None: + instructions = [ + PulseInstruction.null_instruction(), + PulseInstruction(on=True, pitch=REFERENCE, volume=VOLUME, duty_cycle=0, coarse_detune=-2), + ] + + offsets = PulseExporter.read_timer_offsets(instructions) + + assert offsets == (bend_steps(0, -2), bend_steps(0, -2)) + + def test_a_channel_that_never_sounds_carries_no_offset(self) -> None: + offsets = PulseExporter.read_timer_offsets([PulseInstruction.null_instruction()] * 3) + + assert offsets == (0, 0, 0) + + def test_every_offset_stands_beside_the_pitch_its_frame_holds(self) -> None: + instructions = [ + PulseInstruction.null_instruction(), + *_pulses(), + PulseInstruction.null_instruction(), + ] + + _, pitches, _, _ = PulseExporter.extract_data(instructions) + + assert len(PulseExporter.read_timer_offsets(instructions)) == len(pitches) + + +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..4270f30ed 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -4,8 +4,8 @@ import numpy as np import pytest -from sampletones_core.constants.enums import FeatureKey -from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.general import MAX_VOLUME, NUM_PERIODS from sampletones_core.exporters import ( ExporterTypeUnion, Features, @@ -13,6 +13,12 @@ PulseExporter, TriangleExporter, ) +from sampletones_core.features import ( + CHANNEL_FEATURE_DEFAULTS, + FEATURE_DIMENSION_ORDER, +) +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.spec import reads_from_instrument 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,26 @@ 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 reads nothing from, of a frame or of an instrument.""" + return tuple( + feature_key + for feature_key in FEATURE_DIMENSION_ORDER + if not reads_from_instrument(self.kind, feature_key) + ) + test_cases = ( TestCase( label="pulse", exporter=PulseExporter, + kind=GeneratorName.PULSE, instruction=PulseInstruction( on=True, pitch=REFERENCE_PITCH + OCTAVE, @@ -472,23 +494,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, @@ -536,14 +564,27 @@ def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> N assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction + def test_a_bend_moves_the_noise_period_one_step_per_unit(self) -> None: + values = { + FeatureKey.VOLUME: NOISE_VOLUME, + FeatureKey.ARPEGGIO: PERIOD_STEP, + FeatureKey.PITCH: UNREAD_VALUE, + FeatureKey.DUTY_CYCLE: 1, + } + + frame = NoiseExporter.instruction_from_values(values, REFERENCE_PERIOD) + + assert frame.period == (REFERENCE_PERIOD + PERIOD_STEP + UNREAD_VALUE) % NUM_PERIODS + @pytest.mark.parametrize( "test_case", - 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_skipped.py b/tests/unit/sampletones_core/exporters/test_skipped.py new file mode 100644 index 000000000..873184bdd --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_skipped.py @@ -0,0 +1,139 @@ +from typing import Dict, List, Optional, Set, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import SkippedRow, SkipReason, find_skipped_rows, in_song_order +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.voices.note_off import NoteOff +from sampletones_core.project.voices.note_on import NoteOn + +ROWS_PER_PATTERN = 4 +KNOWN = "known" +UNKNOWN = "unknown" + + +def _song( + channels: Dict[ChannelName, Dict[int, List[Row]]], + order: List[Dict[ChannelName, Optional[int]]], +) -> Song: + pools = { + channel_name: Channel( + name=channel_name, + patterns={index: Pattern(rows=rows) for index, rows in channels.get(channel_name, {}).items()}, + ) + for channel_name in ChannelName.items() + } + return Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=pools) + + +def _rows(**commands: NoteOn) -> List[Row]: + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for position, command in commands.items(): + rows[int(position.removeprefix("row_"))] = Row(command=command) + + return rows + + +INSTRUMENTS: Set[Tuple[str, ChannelName]] = {(KNOWN, ChannelName.PULSE1)} + + +class TestFindingTheRowsLeftSilent: + def test_a_note_on_naming_a_voice_without_an_instrument_on_its_channel_is_found(self) -> None: + song = _song( + {ChannelName.PULSE2: {0: _rows(row_1=NoteOn(voice_id=KNOWN))}}, + [{ChannelName.PULSE2: 0}], + ) + + assert find_skipped_rows(song, INSTRUMENTS) == ( + SkippedRow( + voice_id=KNOWN, + channel=ChannelName.PULSE2, + order_position=0, + row_index=1, + reason=SkipReason.NO_INSTRUMENT, + ), + ) + + def test_a_note_on_with_an_instrument_on_its_channel_is_left_alone(self) -> None: + song = _song( + {ChannelName.PULSE1: {0: _rows(row_1=NoteOn(voice_id=KNOWN))}}, + [{ChannelName.PULSE1: 0}], + ) + + assert find_skipped_rows(song, INSTRUMENTS) == () + + def test_a_note_off_is_left_alone(self) -> None: + rows = _rows() + rows[2] = Row(command=NoteOff()) + song = _song({ChannelName.PULSE2: {0: rows}}, [{ChannelName.PULSE2: 0}]) + + assert find_skipped_rows(song, INSTRUMENTS) == () + + def test_a_pattern_the_order_plays_twice_is_found_in_each_frame(self) -> None: + song = _song( + {ChannelName.PULSE2: {0: _rows(row_0=NoteOn(voice_id=UNKNOWN))}}, + [{ChannelName.PULSE2: 0}, {ChannelName.PULSE2: None}, {ChannelName.PULSE2: 0}], + ) + + assert [row.order_position for row in find_skipped_rows(song, INSTRUMENTS)] == [0, 2] + + def test_a_pattern_no_frame_plays_is_not_found(self) -> None: + song = _song( + {ChannelName.PULSE2: {0: _rows(row_0=NoteOn(voice_id=UNKNOWN))}}, + [{ChannelName.PULSE2: None}], + ) + + assert find_skipped_rows(song, INSTRUMENTS) == () + + def test_the_rows_come_in_the_order_the_song_plays_them(self) -> None: + song = _song( + { + ChannelName.PULSE2: {0: _rows(row_3=NoteOn(voice_id=UNKNOWN))}, + ChannelName.TRIANGLE: {0: _rows(row_0=NoteOn(voice_id=UNKNOWN))}, + }, + [{ChannelName.PULSE2: 0, ChannelName.TRIANGLE: 0}, {ChannelName.PULSE2: 0}], + ) + + found = [(row.order_position, row.channel, row.row_index) for row in find_skipped_rows(song, INSTRUMENTS)] + + assert found == [ + (0, ChannelName.PULSE2, 3), + (0, ChannelName.TRIANGLE, 0), + (1, ChannelName.PULSE2, 3), + ] + + +def _reported( + order_position: int, + channel: ChannelName, + row_index: int, + reason: SkipReason, +) -> SkippedRow: + return SkippedRow( + voice_id=KNOWN, + channel=channel, + order_position=order_position, + row_index=row_index, + reason=reason, + ) + + +class TestTheRowsInSongOrder: + def test_rows_gathered_apart_come_frame_by_frame_then_channel_by_channel_then_row_by_row(self) -> None: + rows = ( + _reported(2, ChannelName.PULSE1, 0, SkipReason.UNREACHED_TRANSPOSE), + _reported(0, ChannelName.TRIANGLE, 1, SkipReason.NO_INSTRUMENT), + _reported(0, ChannelName.PULSE2, 3, SkipReason.UNREACHED_TRANSPOSE), + _reported(0, ChannelName.PULSE2, 1, SkipReason.NO_INSTRUMENT), + ) + + ordered = [(row.order_position, row.channel, row.row_index) for row in in_song_order(rows)] + + assert ordered == [ + (0, ChannelName.PULSE2, 1), + (0, ChannelName.PULSE2, 3), + (0, ChannelName.TRIANGLE, 1), + (2, ChannelName.PULSE1, 0), + ] diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py index 856bb3bd4..e2201a7b4 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 = sample.reconstruction.with_channel_data( + channel, + [], + sample.reconstruction.initial_pitches[channel], + (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + heard=sample.reconstruction.recorded_stem_ids, + ) + + +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_sounding.py b/tests/unit/sampletones_core/exporters/test_sounding.py new file mode 100644 index 000000000..72dc13d29 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_sounding.py @@ -0,0 +1,38 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.exporters import Features, stands_by +from sampletones_core.features.envelope import Envelope +from tests.conftest import ReconstructionFactory + +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +@pytest.fixture +def features(reconstruction_factory: ReconstructionFactory) -> Features: + """The envelopes a converted channel exports, which sound somewhere.""" + return reconstruction_factory().export()[CHANNEL] + + +class TestWhatStandsBy: + """Envelopes are read through the rebuild a regeneration makes, so the answer is the document's.""" + + def test_envelopes_that_sound_keep_the_channel_in_play(self, features: Features) -> None: + assert not stands_by(CHANNEL, features) + + def test_envelopes_written_silent_stand_by(self, features: Features) -> None: + silent = features.with_envelope(FeatureKey.VOLUME, Envelope[int](items=(0,) * features.frame_count)) + + assert stands_by(CHANNEL, silent) + + def test_a_held_volume_sounds(self, features: Features) -> None: + """A volume left to the channel plays at the level a channel holds from the start of a song.""" + held = features.leave_to_channel((FeatureKey.VOLUME,)) + + assert held.has_frames + assert not stands_by(CHANNEL, held) + + def test_envelopes_describing_no_frame_stand_by(self, features: Features) -> None: + assert stands_by(CHANNEL, features.leave_to_channel(features.envelopes)) diff --git a/tests/unit/sampletones_core/exporters/test_truncation.py b/tests/unit/sampletones_core/exporters/test_truncation.py index 71803658d..c38708209 100644 --- a/tests/unit/sampletones_core/exporters/test_truncation.py +++ b/tests/unit/sampletones_core/exporters/test_truncation.py @@ -1,27 +1,51 @@ -from typing import Final +from typing import Final, Optional -import pytest - -from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exporters.feature import Features +from sampletones_core.exporters.truncation import ( + EnvelopeTruncation, + instrument_truncation, + is_shortened, +) +from sampletones_core.features.envelope import Envelope ITEM_LIMIT: Final[int] = 252 +REFERENCE_PITCH: Final[int] = 60 +LONG_VOLUME: Final[int] = 600 +LONGER_DIMENSION: Final[int] = 700 -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 capped(feature_key: FeatureKey, envelope: Envelope[int]) -> int: + """A format storing every dimension up to one limit.""" + return min(len(envelope.items), ITEM_LIMIT) + + +def contour_whole(feature_key: FeatureKey, envelope: Envelope[int]) -> int: + """A format storing the contour whole and every other dimension up to one limit.""" + if feature_key is FeatureKey.ARPEGGIO: + return len(envelope.items) + + return capped(feature_key, envelope) - 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 +def items(count: int) -> Envelope[int]: + return Envelope[int](items=(0,) * count) + + +def build( + volume: int, + arpeggio: int, + *, + pitch: Optional[int] = None, +) -> Features: + return Features( + initial_pitch=REFERENCE_PITCH, + volume=items(volume), + arpeggio=items(arpeggio), + pitch=None if pitch is None else items(pitch), + hi_pitch=None, + duty_cycle=None, + ) class TestEnvelopeTruncationSummarize: @@ -40,3 +64,44 @@ def test_the_summary_spans_every_shortened_instrument(self) -> None: ] ) assert summary == EnvelopeTruncation(frames=ITEM_LIMIT, source_frames=480, instruments=2) + + +class TestWhetherAFormatShortensADimension: + def test_a_dimension_the_format_stores_whole_is_kept(self) -> None: + assert is_shortened(FeatureKey.VOLUME, items(ITEM_LIMIT), capped) is False + + def test_a_dimension_past_what_the_format_stores_is_shortened(self) -> None: + assert is_shortened(FeatureKey.VOLUME, items(ITEM_LIMIT + 1), capped) is True + + def test_each_format_answers_by_its_own_length(self) -> None: + contour = items(ITEM_LIMIT + 1) + assert is_shortened(FeatureKey.ARPEGGIO, contour, capped) is True + assert is_shortened(FeatureKey.ARPEGGIO, contour, contour_whole) is False + + +class TestWhatAnInstrumentReportsLeavingOut: + def test_dimensions_within_what_the_format_stores_report_nothing(self) -> None: + assert instrument_truncation(build(ITEM_LIMIT, ITEM_LIMIT), capped) is None + + def test_a_shortened_instrument_reports_both_counts(self) -> None: + assert instrument_truncation(build(LONG_VOLUME, ITEM_LIMIT), capped) == EnvelopeTruncation( + frames=ITEM_LIMIT, + source_frames=LONG_VOLUME, + instruments=1, + ) + + def test_the_longest_shortened_dimension_states_the_source(self) -> None: + truncation = instrument_truncation(build(LONG_VOLUME, ITEM_LIMIT, pitch=LONGER_DIMENSION), capped) + assert truncation is not None + assert truncation.source_frames == LONGER_DIMENSION + + def test_a_dimension_a_format_holds_whole_leaves_the_report_to_the_others(self) -> None: + """A contour stored whole is no part of what the format left out, however long it runs.""" + assert instrument_truncation(build(LONG_VOLUME, LONGER_DIMENSION), contour_whole) == EnvelopeTruncation( + frames=ITEM_LIMIT, + source_frames=LONG_VOLUME, + instruments=1, + ) + + def test_a_long_dimension_the_format_holds_whole_reports_nothing(self) -> None: + assert instrument_truncation(build(ITEM_LIMIT, LONGER_DIMENSION), contour_whole) 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/exports/test_bitphase.py b/tests/unit/sampletones_core/exports/test_bitphase.py new file mode 100644 index 000000000..038711e8e --- /dev/null +++ b/tests/unit/sampletones_core/exports/test_bitphase.py @@ -0,0 +1,374 @@ +import gzip +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Final, List, Optional + +import numpy as np +import pytest + +from sampletones_core.constants.enums import 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.bitphase import ( + BitphaseBackend, + BitphasePresetBackend, +) +from sampletones_core.exports.request import ( + InstrumentExport, + ProjectExport, + SampleExport, +) +from sampletones_core.exports.scope import ExportScope +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH, NesMacroField +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.voice import voice_channels +from sampletones_shared.music import Tuning +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from tests.suite.silent_rows import name_a_missing_voice + +NES_FREQUENCY: Final[int] = 60 +REFERENCE_PITCH: Final[int] = 60 +ENVELOPE_FRAMES: Final[int] = 16 +LONG_ENVELOPE_FRAMES: Final[int] = 600 +LONGER_ENVELOPE_FRAMES: Final[int] = 700 +PROJECT_TITLE: Final[str] = "Demo" + + +PRESET_SCOPES: List[ExportScope] = [ExportScope.INSTRUMENT, ExportScope.SAMPLE] + + +def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: + duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) + return Features( + initial_pitch=REFERENCE_PITCH, + volume=Envelope(items=(15,) * frames), + arpeggio=Envelope(items=(0,) * frames), + pitch=None, + hi_pitch=None, + duty_cycle=duty_cycle, + ) + + +def build_instrument(name: str, frames: int) -> InstrumentExport: + return InstrumentExport( + name=name, + channel=ChannelName.PULSE1, + features=build_features(frames), + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport( + name=name, + instruments=instruments, + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + + +def read_document(destination: Path) -> Dict[str, Any]: + document: Dict[str, Any] = json.loads(gzip.decompress(destination.read_bytes())) + return document + + +@pytest.fixture(name="backend") +def backend_fixture() -> BitphaseBackend: + return BitphaseBackend() + + +@pytest.fixture(name="preset_backend") +def preset_backend_fixture() -> BitphasePresetBackend: + return BitphasePresetBackend() + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + return Project.create(title=PROJECT_TITLE, author="Tester", settings=ProjectSettings()) + + +class TestFormatDeclaration: + def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None: + assert backend.export_format == ExportFormat.BITPHASE + + def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) + + @pytest.mark.parametrize("scope", list(ExportScope)) + def test_every_scope_carries_the_document_extension(self, backend: BitphaseBackend, scope: ExportScope) -> None: + assert backend.extension(scope) == EXT_FILE_BITPHASE + + +class TestWriteInstrument: + def test_the_file_is_written_and_reported(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" + + artifact = backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) + + assert destination.exists() + assert artifact.paths == (destination,) + + def test_the_document_holds_the_slice(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" + backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) + + document = read_document(destination) + + assert [instrument["name"] for instrument in document["instruments"]] == ["Short"] + + def test_a_long_envelope_reaches_the_values_a_macro_stores( + self, + backend: BitphaseBackend, + tmp_path: Path, + ) -> None: + """A macro holds the values of one dimension, and a longer envelope keeps its opening + values, while the contour a table carries crosses over whole. + """ + destination = tmp_path / f"Long{EXT_FILE_BITPHASE}" + backend.write_instrument(destination, build_instrument("Long", LONG_ENVELOPE_FRAMES)) + + document = read_document(destination) + + macros = document["instruments"][0]["macros"] + assert len(macros[NesMacroField.VOLUME_OR_RATE]["values"]) == MAX_MACRO_LENGTH + assert len(document["tables"][0]["rows"]) == LONG_ENVELOPE_FRAMES + + def test_an_envelope_within_a_macro_reports_nothing(self, backend: BitphaseBackend, tmp_path: Path) -> None: + artifact = backend.write_instrument( + tmp_path / f"Short{EXT_FILE_BITPHASE}", + build_instrument("Short", MAX_MACRO_LENGTH), + ) + assert artifact.truncation is None + + def test_an_envelope_beyond_a_macro_reports_both_counts(self, backend: BitphaseBackend, tmp_path: Path) -> None: + artifact = backend.write_instrument( + tmp_path / f"Long{EXT_FILE_BITPHASE}", + build_instrument("Long", LONG_ENVELOPE_FRAMES), + ) + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ENVELOPE_FRAMES, + instruments=1, + ) + + +class TestWriteSample: + def test_every_slice_lands_in_one_document(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + request = build_sample( + "Kick", + build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), + build_instrument("Kick (noise)", ENVELOPE_FRAMES), + ) + + artifact = backend.write_sample(destination, request) + + assert artifact.paths == (destination,) + assert len(read_document(destination)["instruments"]) == 2 + + def test_the_document_is_named_after_the_reconstruction(self, backend: BitphaseBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" + backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) + + assert read_document(destination)["name"] == "Kick" + + def test_the_report_spans_every_shortened_slice(self, backend: BitphaseBackend, tmp_path: Path) -> None: + request = build_sample( + "Pad", + build_instrument("Short", ENVELOPE_FRAMES), + build_instrument("Long", LONG_ENVELOPE_FRAMES), + build_instrument("Longer", LONGER_ENVELOPE_FRAMES), + ) + + artifact = backend.write_sample(tmp_path / f"Pad{EXT_FILE_BITPHASE}", request) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONGER_ENVELOPE_FRAMES, + instruments=2, + ) + + +class TestWriteProject: + def test_the_document_is_written_and_reported( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert artifact.paths == (destination,) + assert destination.exists() + + def test_the_document_takes_the_project_title( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + backend.write_project(destination, ProjectExport(project=project)) + + assert read_document(destination)["name"] == PROJECT_TITLE + + def test_a_row_with_no_instrument_is_reported_beside_the_written_file( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" + silent = name_a_missing_voice(project) + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert destination.exists() + assert artifact.skipped_rows == (silent,) + + def test_a_project_with_an_instrument_for_every_row_reports_none( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_BITPHASE}", ProjectExport(project=project)) + + assert artifact.skipped_rows == () + + def test_a_project_whose_slices_outrun_a_macro_reports_it( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + """An instrument becomes a slice on every channel it sounds on, so each of them + counts as a shortened instrument. + """ + pad = Instrument( + name="Pad", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,) * LONG_ENVELOPE_FRAMES)), + ) + project.voices.append(pad) + + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_BITPHASE}", ProjectExport(project=project)) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ENVELOPE_FRAMES, + instruments=len(voice_channels(pad)), + ) + + def test_a_project_within_a_macro_reports_nothing( + self, + backend: BitphaseBackend, + project: Project, + tmp_path: Path, + ) -> None: + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_BITPHASE}", ProjectExport(project=project)) + + assert artifact.truncation is None + + +class TestThePresetBackend: + def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None: + 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}) + + @pytest.mark.parametrize("scope", PRESET_SCOPES, ids=lambda scope: str(scope)) + def test_every_supported_scope_carries_the_preset_extension( + self, + preset_backend: BitphasePresetBackend, + scope: ExportScope, + ) -> None: + assert preset_backend.extension(scope) == EXT_FILE_JSON + + def test_one_slice_lands_in_a_file(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Lead{EXT_FILE_JSON}" + + artifact = preset_backend.write_instrument(destination, build_instrument("Lead", ENVELOPE_FRAMES)) + + assert artifact.paths == (destination,) + assert json.loads(destination.read_text(encoding="utf-8"))["name"] == "Lead" + + def test_each_slice_lands_beside_the_destination_named_after_its_instrument( + self, + preset_backend: BitphasePresetBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Kick{EXT_FILE_JSON}" + request = build_sample( + "Kick", + build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), + build_instrument("Kick (noise)", ENVELOPE_FRAMES), + ) + + artifact = preset_backend.write_sample(destination, request) + + assert artifact.paths == ( + tmp_path / f"Kick (pulse1){EXT_FILE_JSON}", + tmp_path / f"Kick (noise){EXT_FILE_JSON}", + ) + assert all(path.exists() for path in artifact.paths) + + def test_a_missing_directory_is_created(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: + destination = tmp_path / "nested" / f"Kick{EXT_FILE_JSON}" + + preset_backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) + + assert destination.parent.is_dir() + + def test_a_preset_beyond_a_macro_reports_both_counts( + self, + preset_backend: BitphasePresetBackend, + tmp_path: Path, + ) -> None: + artifact = preset_backend.write_instrument( + tmp_path / f"Long{EXT_FILE_JSON}", + build_instrument("Long", LONG_ENVELOPE_FRAMES), + ) + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ENVELOPE_FRAMES, + instruments=1, + ) + + def test_a_set_of_presets_reports_every_shortened_slice( + self, + preset_backend: BitphasePresetBackend, + tmp_path: Path, + ) -> None: + request = build_sample( + "Pad", + build_instrument("Short", ENVELOPE_FRAMES), + build_instrument("Long", LONG_ENVELOPE_FRAMES), + build_instrument("Longer", LONGER_ENVELOPE_FRAMES), + ) + + artifact = preset_backend.write_sample(tmp_path / f"Pad{EXT_FILE_JSON}", request) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONGER_ENVELOPE_FRAMES, + instruments=2, + ) + + def test_a_project_is_refused( + self, + preset_backend: BitphasePresetBackend, + project: Project, + tmp_path: Path, + ) -> None: + with pytest.raises(ValueError, match="one instrument"): + preset_backend.write_project(tmp_path / f"Demo{EXT_FILE_JSON}", ProjectExport(project=project)) diff --git a/tests/unit/sampletones_core/exports/test_ceilings.py b/tests/unit/sampletones_core/exports/test_ceilings.py new file mode 100644 index 000000000..5e3327df6 --- /dev/null +++ b/tests/unit/sampletones_core/exports/test_ceilings.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.exports.ceilings import ( + FORMAT_STORED_LENGTHS, + FormatShortening, + format_shortenings, +) +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.registry import build_tracker_backends +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +SHORT_ENVELOPE: Final[int] = 16 +PAST_A_SEQUENCE: Final[int] = 300 +PAST_A_MACRO: Final[int] = 600 + + +def items(count: int) -> Envelope[int]: + return Envelope[int](items=(0,) * count) + + +class TestTheFormatsThatStoreADimension: + def test_every_tracker_format_states_what_it_stores(self) -> None: + assert set(FORMAT_STORED_LENGTHS) == set(build_tracker_backends()) + + +class TestWhatEachFormatKeeps(BaseTestSuite): + """Each format keeps as many items as its own specification bounds a dimension to.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + feature_key: FeatureKey + item_count: int + expected: Tuple[FormatShortening, ...] + + test_cases = ( + TestCase( + feature_key=FeatureKey.VOLUME, + item_count=SHORT_ENVELOPE, + expected=(), + label="short_volume", + ), + TestCase( + feature_key=FeatureKey.VOLUME, + item_count=PAST_A_SEQUENCE, + expected=(FormatShortening(export_format=ExportFormat.FAMITRACKER, kept=MAX_SEQUENCE_ITEMS),), + label="volume_past_a_sequence", + ), + TestCase( + feature_key=FeatureKey.VOLUME, + item_count=PAST_A_MACRO, + expected=( + FormatShortening(export_format=ExportFormat.FAMITRACKER, kept=MAX_SEQUENCE_ITEMS), + FormatShortening(export_format=ExportFormat.BITPHASE, kept=MAX_MACRO_LENGTH), + FormatShortening(export_format=ExportFormat.BITPHASE_PRESET, kept=MAX_MACRO_LENGTH), + ), + label="volume_past_a_macro", + ), + TestCase( + feature_key=FeatureKey.ARPEGGIO, + item_count=PAST_A_MACRO, + expected=( + FormatShortening(export_format=ExportFormat.FAMITRACKER, kept=MAX_SEQUENCE_ITEMS), + FormatShortening(export_format=ExportFormat.BITPHASE_PRESET, kept=MAX_MACRO_LENGTH), + ), + label="arpeggio_past_a_macro", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_each_format_names_what_it_keeps(self, test_case: TestCase) -> None: + assert format_shortenings(test_case.feature_key, items(test_case.item_count)) == test_case.expected diff --git a/tests/unit/sampletones_core/exports/test_extensions.py b/tests/unit/sampletones_core/exports/test_extensions.py new file mode 100644 index 000000000..675b7480d --- /dev/null +++ b/tests/unit/sampletones_core/exports/test_extensions.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional + +import pytest + +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, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) + +UNKNOWN_EXTENSION: Final[str] = ".xm" +NO_EXTENSION: Final[str] = "" + + +@dataclass(frozen=True) +class ExtensionCase: + scope: ExportScope + extension: str + expected: Optional[ExportFormat] + + +EXTENSION_CASES: Final[List[ExtensionCase]] = [ + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_INSTRUMENT, + expected=ExportFormat.FAMITRACKER, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_BITPHASE, + expected=ExportFormat.BITPHASE, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=EXT_FILE_JSON, + expected=ExportFormat.BITPHASE_PRESET, + ), + ExtensionCase( + scope=ExportScope.SAMPLE, + extension=EXT_FILE_JSON, + expected=ExportFormat.BITPHASE_PRESET, + ), + ExtensionCase( + scope=ExportScope.PROJECT, + extension=EXT_FILE_MODULE, + expected=ExportFormat.FAMITRACKER, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=UNKNOWN_EXTENSION, + expected=None, + ), + ExtensionCase( + scope=ExportScope.INSTRUMENT, + extension=NO_EXTENSION, + expected=None, + ), +] + + +@pytest.fixture(name="backends") +def backends_fixture() -> Dict[ExportFormat, ExportBackend]: + return build_tracker_backends() + + +class TestFormatForExtension: + @pytest.mark.parametrize( + "case", + EXTENSION_CASES, + ids=lambda case: f"{case.scope}{case.extension}", + ) + def test_the_extension_names_the_format_that_writes_it( + self, + backends: Dict[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[ExportFormat, ExportBackend], + ) -> None: + assert ( + format_for_extension(backends, ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT.upper()) + == ExportFormat.FAMITRACKER + ) + + def test_a_format_that_cannot_express_the_scope_stays_unmatched( + self, + backends: Dict[ExportFormat, ExportBackend], + ) -> None: + """A preset holds one instrument, so a project named with its extension resolves + to no format at all. + """ + assert format_for_extension(backends, ExportScope.PROJECT, EXT_FILE_JSON) is None + + @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) + def test_every_extension_a_backend_writes_resolves_back_to_it( + self, + backends: Dict[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 export_format, backend in backends.items(): + if scope in backend.supported_scopes: + assert format_for_extension(backends, scope, backend.extension(scope)) == export_format diff --git a/tests/unit/sampletones_core/exports/test_famitracker.py b/tests/unit/sampletones_core/exports/test_famitracker.py new file mode 100644 index 000000000..2164ecae2 --- /dev/null +++ b/tests/unit/sampletones_core/exports/test_famitracker.py @@ -0,0 +1,286 @@ +from pathlib import Path +from typing import Final, Optional + +import numpy as np +import pytest + +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, ProjectExport, 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.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_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 +from tests.suite.silent_rows import name_a_missing_voice + +NES_FREQUENCY: Final[int] = 60 +ENVELOPE_FRAMES: Final[int] = 4 +AFTER_THE_FIRST_FILE: Final[int] = 2 +ONE_FILE: Final[int] = 1 +LONG_ENVELOPE_FRAMES: Final[int] = 600 + + +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=Envelope(items=(15,) * frames), + arpeggio=Envelope(items=(0,) * frames), + pitch=None, + hi_pitch=None, + duty_cycle=duty_cycle, + ) + + +def build_instrument(name: str, frames: int) -> InstrumentExport: + return InstrumentExport( + name=name, + channel=ChannelName.PULSE1, + features=build_features(frames), + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + + +def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: + return SampleExport( + name=name, + instruments=instruments, + nes_frequency=NES_FREQUENCY, + tuning=Tuning(), + ) + + +@pytest.fixture(name="backend") +def backend_fixture() -> FamiTrackerBackend: + return FamiTrackerBackend() + + +class TestFormatDeclaration: + def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None: + assert backend.export_format == ExportFormat.FAMITRACKER + + def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None: + assert backend.supported_scopes == frozenset(ExportScope) + + @pytest.mark.parametrize( + ("scope", "expected"), + [ + (ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT), + (ExportScope.SAMPLE, EXT_FILE_INSTRUMENT), + (ExportScope.PROJECT, EXT_FILE_MODULE), + ], + ) + def test_instruments_carry_the_instrument_extension_and_a_project_the_module_one( + self, + backend: FamiTrackerBackend, + scope: ExportScope, + expected: str, + ) -> None: + assert backend.extension(scope) == expected + + +class TestWriteInstrument: + def test_the_file_is_written_and_reported(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Short{EXT_FILE_INSTRUMENT}" + + artifact = backend.write_instrument(destination, build_instrument("Short", 16)) + + assert destination.exists() + assert artifact.paths == (destination,) + + def test_an_envelope_within_the_limit_reports_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + artifact = backend.write_instrument( + tmp_path / f"Short{EXT_FILE_INSTRUMENT}", + build_instrument("Short", MAX_SEQUENCE_ITEMS), + ) + assert artifact.truncation is None + + def test_an_envelope_beyond_the_limit_reports_both_counts( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + artifact = backend.write_instrument( + tmp_path / f"Long{EXT_FILE_INSTRUMENT}", + build_instrument("Long", 300), + ) + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=300, + instruments=1, + ) + + def test_a_shortened_export_still_writes_the_file(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / f"Long{EXT_FILE_INSTRUMENT}" + backend.write_instrument(destination, build_instrument("Long", 300)) + assert destination.exists() + + +class TestWriteSample: + def test_each_slice_lands_beside_the_destination_named_after_its_instrument( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Kick{EXT_FILE_INSTRUMENT}" + request = build_sample( + "Kick", + build_instrument("Kick (pulse1)", 16), + build_instrument("Kick (noise)", 16), + ) + + artifact = backend.write_sample(destination, request) + + assert artifact.paths == ( + tmp_path / f"Kick (pulse1){EXT_FILE_INSTRUMENT}", + tmp_path / f"Kick (noise){EXT_FILE_INSTRUMENT}", + ) + assert all(path.exists() for path in artifact.paths) + + def test_a_missing_directory_is_created(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + destination = tmp_path / "nested" / f"Kick{EXT_FILE_INSTRUMENT}" + + backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", 16))) + + assert destination.parent.is_dir() + + def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + request = build_sample( + "Kick", + build_instrument("Short", 16), + build_instrument("Long", 300), + build_instrument("Longer", 410), + ) + + artifact = backend.write_sample(tmp_path / "Kick", request) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=410, + instruments=2, + ) + + def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: + request = build_sample("Kick", build_instrument("Short", 16)) + + artifact = backend.write_sample(tmp_path / "Kick", request) + + assert artifact.truncation is None + + +class TestWriteProject: + @staticmethod + def _project() -> Project: + return Project.create(title="Demo", author="Tester", settings=ProjectSettings()) + + def test_a_row_with_no_instrument_is_reported_beside_the_written_file( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / f"Demo{EXT_FILE_MODULE}" + project = self._project() + silent = name_a_missing_voice(project) + + artifact = backend.write_project(destination, ProjectExport(project=project)) + + assert destination.exists() + assert artifact.skipped_rows == (silent,) + + def test_a_project_with_an_instrument_for_every_row_reports_none( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_MODULE}", ProjectExport(project=self._project())) + + assert artifact.skipped_rows == () + + def test_a_project_whose_instruments_outrun_a_sequence_reports_it( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + """An instrument is one FamiTracker instrument, whichever channels it sounds on.""" + project = self._project() + project.voices.append( + Instrument( + name="Pad", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(15,) * LONG_ENVELOPE_FRAMES)), + ) + ) + + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_MODULE}", ProjectExport(project=project)) + + assert artifact.truncation == EnvelopeTruncation( + frames=MAX_SEQUENCE_ITEMS, + source_frames=LONG_ENVELOPE_FRAMES, + instruments=1, + ) + + def test_a_project_within_a_sequence_reports_nothing( + self, + backend: FamiTrackerBackend, + tmp_path: Path, + ) -> None: + artifact = backend.write_project(tmp_path / f"Demo{EXT_FILE_MODULE}", ProjectExport(project=self._project())) + + 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_limits.py b/tests/unit/sampletones_core/features/test_limits.py new file mode 100644 index 000000000..263cfc7ca --- /dev/null +++ b/tests/unit/sampletones_core/features/test_limits.py @@ -0,0 +1,72 @@ +from typing import Final, List, Optional, Sequence + +from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.features.limits import within_limit + +LIMIT: Final[int] = 4 +RELEASE: Final[int] = 0 +SOUNDING_LEVEL: Final[int] = 8 + + +def envelope(items: Sequence[int], loop_point: Optional[int] = None) -> Envelope[int]: + return Envelope[int](items=tuple(items), loop_point=loop_point) + + +def released(length: int) -> List[int]: + return [SOUNDING_LEVEL] * (length - 1) + [RELEASE] + + +def sounding(length: int) -> List[int]: + return [SOUNDING_LEVEL] * length + + +class TestADimensionWithinTheLimit: + def test_a_shorter_dimension_is_kept_whole(self) -> None: + source = envelope(sounding(LIMIT - 1)) + assert within_limit(FeatureKey.VOLUME, source, LIMIT) == source + + def test_a_dimension_at_the_limit_is_kept_whole(self) -> None: + source = envelope(released(LIMIT)) + assert within_limit(FeatureKey.VOLUME, source, LIMIT) == source + + def test_a_longer_dimension_keeps_its_opening_items(self) -> None: + source = sounding(LIMIT + 3) + assert within_limit(FeatureKey.ARPEGGIO, envelope(source), LIMIT).items == tuple(source[:LIMIT]) + + def test_the_point_it_repeats_from_stays_within_what_is_kept(self) -> None: + kept = within_limit(FeatureKey.ARPEGGIO, envelope(sounding(LIMIT + 3), loop_point=LIMIT + 1), LIMIT) + assert kept.loop_point == LIMIT - 1 + + +class TestTheReleaseAVolumeEndsOn: + """A volume dimension ending at silence is what releases a note, so a format keeps that item. + + A dimension halting on its last item holds that value for as long as the note sounds, so a + shortened volume that dropped its silence would sound on. + """ + + def test_a_released_dimension_past_the_limit_still_ends_at_its_release(self) -> None: + kept = within_limit(FeatureKey.VOLUME, envelope(released(LIMIT + 1)), LIMIT) + assert len(kept.items) == LIMIT + assert kept.items[-1] == RELEASE + + def test_the_release_displaces_the_last_item_that_would_not_fit(self) -> None: + source = released(LIMIT + 1) + kept = within_limit(FeatureKey.VOLUME, envelope(source), LIMIT) + assert kept.items == tuple(source[: LIMIT - 1]) + (RELEASE,) + + def test_a_dimension_that_goes_on_sounding_keeps_its_opening_items(self) -> None: + source = sounding(LIMIT + 3) + kept = within_limit(FeatureKey.VOLUME, envelope(source), LIMIT) + assert kept.items == tuple(source[:LIMIT]) + + def test_a_circling_dimension_reads_its_final_silence_as_part_of_the_cycle(self) -> None: + source = released(LIMIT + 1) + kept = within_limit(FeatureKey.VOLUME, envelope(source, loop_point=0), LIMIT) + assert kept.items == tuple(source[:LIMIT]) + + def test_a_dimension_other_than_volume_keeps_its_opening_items(self) -> None: + source = released(LIMIT + 1) + kept = within_limit(FeatureKey.ARPEGGIO, envelope(source), LIMIT) + assert kept.items == tuple(source[:LIMIT]) diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index 012d27341..d1120efd4 100644 --- a/tests/unit/sampletones_core/features/test_spec.py +++ b/tests/unit/sampletones_core/features/test_spec.py @@ -1,18 +1,22 @@ +from typing import Tuple + 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, ) +from sampletones_core.features.spec import reads_from_instrument from sampletones_core.formats.famitracker.specification.sequences import ( FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind, @@ -20,41 +24,66 @@ 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_exporter_attribute_maps_match_what_each_channel_reads() -> None: + def read(kind: GeneratorName) -> Tuple[FeatureKey, ...]: + return tuple(feature for feature in FEATURE_DIMENSION_ORDER if reads_from_instrument(kind, feature)) + + assert read(GeneratorName.PULSE) == tuple(PulseExporter._ATTRIBUTE_MAP) + assert read(GeneratorName.TRIANGLE) == tuple(TriangleExporter._ATTRIBUTE_MAP) + assert read(GeneratorName.NOISE) == tuple(NoiseExporter._ATTRIBUTE_MAP) + +def test_a_channel_reads_every_dimension_its_generator_offers() -> None: + for kind in GeneratorName: + assert all(reads_from_instrument(kind, feature) for feature in supported_features(kind)) -def test_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) + +def test_the_noise_channel_reads_an_instruments_bend_and_no_hi_pitch() -> None: + assert reads_from_instrument(GeneratorName.NOISE, FeatureKey.PITCH) + assert not reads_from_instrument(GeneratorName.NOISE, FeatureKey.HI_PITCH) + assert not reads_from_instrument(GeneratorName.TRIANGLE, FeatureKey.DUTY_CYCLE) def test_feature_dimension_order_matches_famitracker_sequence_slots() -> None: @@ -66,3 +95,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/cqt/test_transform.py b/tests/unit/sampletones_core/fft/cqt/test_transform.py index 64d94cbe5..16ab2ee88 100644 --- a/tests/unit/sampletones_core/fft/cqt/test_transform.py +++ b/tests/unit/sampletones_core/fft/cqt/test_transform.py @@ -1,14 +1,22 @@ +from dataclasses import dataclass + import numpy as np import pytest +from sampletones_core.constants.enums import CQTWindow from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY +from sampletones_core.fft.cqt import transform as transform_module from sampletones_core.fft.cqt.frequencies import calculate_cqt_frequencies +from sampletones_core.fft.cqt.kernel import build_cqt_kernel from sampletones_core.fft.cqt.transform import calculate_cqt, calculate_cqt_frames from sampletones_core.fft.spectrum.cqt import calculate_cqt_spectrum_columns from sampletones_core.fft.utils import calculate_n_bins +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase SAMPLE_RATE = 22050 HOP_LENGTH = 512 +ROUNDING_SHARE = 1e-5 def _bin_count() -> int: @@ -45,6 +53,33 @@ def test_single_frame_returns_one_column(self) -> None: assert cqt.shape == (_bin_count(), 1) +class TestGatheringInBlocks(BaseTestSuite): + """The frames are gathered a block of columns at a time, and every column comes out as one pass + over the whole recording gives it, within the float32 rounding of a product that sums each frame + in another order.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + columns_per_block: int + + test_cases = ( + TestCase(label="one column a block", columns_per_block=1), + TestCase(label="blocks leaving a shorter last one", columns_per_block=7), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_columns_equal_one_pass(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None: + signal = _tone(440.0, 2 * SAMPLE_RATE) + whole = calculate_cqt_frames(signal, SAMPLE_RATE, HOP_LENGTH) + kernel = build_cqt_kernel(SAMPLE_RATE, _bin_count(), CQT_CUTOFF_FREQUENCY, BINS_PER_OCTAVE, CQTWindow.HANN) + monkeypatch.setattr(transform_module, "MAX_GATHERED_SAMPLES", test_case.columns_per_block * kernel.frame_length) + + blocked = calculate_cqt_frames(signal, SAMPLE_RATE, HOP_LENGTH) + + assert blocked.shape == whole.shape + assert float(np.abs(blocked - whole).max()) <= ROUNDING_SHARE * float(np.abs(whole).max()) + + class TestToneNormalization: def test_normalized_peak_is_bin_comparable(self) -> None: frequencies = _bin_frequencies() diff --git a/tests/unit/sampletones_core/fft/features/__init__.py b/tests/unit/sampletones_core/fft/features/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/fft/features/test_cqt.py b/tests/unit/sampletones_core/fft/features/test_cqt.py new file mode 100644 index 000000000..f678982cd --- /dev/null +++ b/tests/unit/sampletones_core/fft/features/test_cqt.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.constants.spectrum import CQT_REFERENCE_CONTEXT_FACTOR +from sampletones_core.fft import Window +from sampletones_core.generators.implementation.noise import NoiseGenerator +from sampletones_core.instructions import NoiseInstruction +from tests.suite.analysis import analyzed_config +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestTheReferenceReadsASeamlessStretch(BaseTestSuite): + """A constant-Q reference feature reads a stretch of its sample several windows long. The slowest + long-mode noise outlasts every stored sample and loops with a seam, so the sample holds the whole + stretch the reference reads, at every sample rate.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + sample_rate: int + + test_cases = ( + TestCase(label="22050 Hz", sample_rate=22050), + TestCase(label="44100 Hz", sample_rate=44100), + TestCase(label="96000 Hz", sample_rate=96000), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_slowest_noise_sample_holds_the_reference_context(self, test_case: TestCase) -> None: + config = analyzed_config(SpectrumMethod.CQT, gamma=0) + config = config.model_copy( + update={"library": config.library.model_copy(update={"sample_rate": test_case.sample_rate})} + ) + window = Window.from_config(config) + + sample = NoiseGenerator(config).generate_sample(NoiseInstruction(on=True, period=0, volume=15, short=False)) + + assert len(sample.array) >= CQT_REFERENCE_CONTEXT_FACTOR * window.size diff --git a/tests/unit/sampletones_core/fft/features/test_windowed.py b/tests/unit/sampletones_core/fft/features/test_windowed.py new file mode 100644 index 000000000..5b01bc93b --- /dev/null +++ b/tests/unit/sampletones_core/fft/features/test_windowed.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from typing import Final + +import numpy as np +import pytest + +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.generators import PulseGenerator +from sampletones_core.reconstructions.criterion import Criterion +from sampletones_shared.array import to_numpy +from tests.suite.analysis import LIBRARY_TONE, analyzed_config +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +TILED_FRAMES: Final[int] = 12 +EDGE_FRAMES: Final[int] = 1 +SIGNAL_LENGTH: Final[int] = 1 << 20 +OWN_ENTRY_SHARE: Final[float] = 1e-3 + + +class TestAToneScoresItsOwnEntry(BaseTestSuite): + """ + A pulse tone repeats one waveform, so every frame of it carries the spectrum its phases average + to, and its library entry scores each frame as a match at every gamma: a sliver of what silence + costs the frame. The frames at either end are read across the edge of the audio, so the interior + is where the tone stands whole. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: None = None + method: SpectrumMethod + gamma: int + + @property + def label(self) -> str: + return f"{self.method.value}_gamma_{self.gamma}" + + test_cases = ( + TestCase(method=SpectrumMethod.FFT, gamma=0), + TestCase(method=SpectrumMethod.FFT, gamma=50), + TestCase(method=SpectrumMethod.FFT, gamma=100), + TestCase(method=SpectrumMethod.LOG_SPACED_FFT, gamma=0), + TestCase(method=SpectrumMethod.LOG_SPACED_FFT, gamma=50), + TestCase(method=SpectrumMethod.LOG_SPACED_FFT, gamma=100), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_frame_of_a_tone_matches_its_reference_feature(self, test_case: TestCase) -> None: + config = analyzed_config(test_case.method, gamma=test_case.gamma) + window = Window.from_config(config) + extractor = get_feature_extractor(config, window) + sample = PulseGenerator(config, ChannelName.PULSE1).generate_sample(LIBRARY_TONE) + + reference = extractor.reference_feature(sample) + frames = extractor.extract(np.asarray(sample.get_fragment(0, config.library.frame_length * TILED_FRAMES))) + criterion = Criterion(config, window, SIGNAL_LENGTH) + silence = reference.values * 0.0 + shares = [ + float(to_numpy(criterion.spectral_loss(frame.feature, reference))[0]) + / float(to_numpy(criterion.spectral_loss(frame.feature, silence))[0]) + for frame in frames[EDGE_FRAMES:-EDGE_FRAMES] + ] + + assert max(shares) < OWN_ENTRY_SHARE diff --git a/tests/unit/sampletones_core/fft/test_axis.py b/tests/unit/sampletones_core/fft/test_axis.py index b94a5553d..6ad2f9981 100644 --- a/tests/unit/sampletones_core/fft/test_axis.py +++ b/tests/unit/sampletones_core/fft/test_axis.py @@ -5,7 +5,8 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY +from sampletones_core.constants.general import MIN_TRIANGLE_FREQUENCY +from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY, LOG_FFT_CUTOFF_FREQUENCY from sampletones_core.fft import ( FFTTransformer, Window, @@ -13,6 +14,7 @@ calculate_weights_from_edges, ) from sampletones_core.fft.features import get_feature_extractor +from sampletones_core.fft.spectrum.spectrum import calculate_spectrum def _config(method: SpectrumMethod) -> Config: @@ -38,6 +40,28 @@ def test_cqt_window_holds_the_lowest_filter(self) -> None: assert library.window_size == max(library.frame_length, expected) +class TestEachMethodsFloor: + """The constant-Q transform reaches the lowest note the chip sounds, the triangle's an octave below + the pulse's. The log-spaced FFT starts where its window spans two cycles, whatever the constant-Q + floor is.""" + + def test_the_constant_q_axis_reaches_the_triangles_lowest_note(self) -> None: + config = _config(SpectrumMethod.CQT) + window = Window.from_config(config) + + spectrum = calculate_spectrum(SpectrumMethod.CQT, _signal(window), config.library.sample_rate) + + assert float(spectrum.edges[0]) <= MIN_TRIANGLE_FREQUENCY + + def test_the_log_spaced_fft_axis_starts_at_its_own_floor(self) -> None: + config = _config(SpectrumMethod.LOG_SPACED_FFT) + window = Window.from_config(config) + + spectrum = calculate_spectrum(SpectrumMethod.LOG_SPACED_FFT, _signal(window), config.library.sample_rate) + + assert float(spectrum.edges[0]) == pytest.approx(LOG_FFT_CUTOFF_FREQUENCY) + + class TestFragmentAxis: def test_fft_feature_edges_reach_nyquist(self) -> None: config = _config(SpectrumMethod.FFT) 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..041ca1ddf --- /dev/null +++ b/tests/unit/sampletones_core/fft/test_instantaneous.py @@ -0,0 +1,246 @@ +import math +from typing import Final, List, Optional + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.fft.instantaneous import FundamentalReading, InstantaneousPitch +from sampletones_core.generators.implementation.triangle import TriangleGenerator +from sampletones_core.instructions import TriangleInstruction + +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 +TRIANGLE_FRAMES: Final[int] = 120 +SETTLED_FRAMES: Final[int] = 30 +TRIANGLE_CENT_TOLERANCE: Final[float] = 20.0 +BASS_SECONDS: Final[float] = 2.0 +BASS_LEVEL: Final[float] = 0.3 +LOW_BASS_FREQUENCY: Final[float] = 36.71 +SHARE_TOLERANCE: Final[float] = 0.1 +HIGH_FREQUENCIES: Final[tuple[float, ...]] = (1760.0, 3520.0) +FAR_INSIDE_CENTS: Final[tuple[float, ...]] = (-40.0, 40.0) +PULSE_A6_DIVIDER_FREQUENCY: Final[float] = 1747.8 +PAST_HALF_A_SEMITONE_CENTS: Final[float] = 52.0 +VIBRATO_FREQUENCY: Final[float] = 1760.0 +VIBRATO_DEPTH_CENTS: Final[float] = 35.0 +VIBRATO_RATE: Final[float] = 5.5 +VIBRATO_SECONDS: Final[float] = 2.0 +VIBRATO_CENT_TOLERANCE: Final[float] = 10.0 +BASS_FREQUENCY: Final[float] = 65.41 +BESIDE_FIFTH_HARMONIC: Final[float] = 349.23 +ON_FOURTH_HARMONIC: Final[float] = 4 * BASS_FREQUENCY + + +def _harmonic(frequency: float, seed: int = 0, seconds: float = SECONDS) -> 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 + + scaled: np.ndarray = (audio / np.abs(audio).max() * 0.3).astype(np.float32) + return scaled + + +def _bass(frequency: float, level: float) -> np.ndarray: + """A pure low tone lasting long enough for the lowest bins to settle.""" + time = np.arange(int(SAMPLE_RATE * BASS_SECONDS)) / SAMPLE_RATE + audio: np.ndarray = (level * np.sin(2 * np.pi * frequency * time)).astype(np.float32) + return audio + + +def _triangle(frequency: float) -> np.ndarray: + """A triangle wave, whose odd harmonics leave every other harmonic bin to the voices around it.""" + phase = frequency * np.arange(int(SAMPLE_RATE * BASS_SECONDS)) / SAMPLE_RATE + audio: np.ndarray = (BASS_LEVEL * (1.0 - 4.0 * np.abs((phase % 1.0) - 0.5))).astype(np.float32) + return audio + + +def _bass_cents_under(melody_frequency: float) -> float: + """Where a triangle bass is read, in cents from where it stands, under a melody note.""" + melody = _harmonic(melody_frequency, seconds=BASS_SECONDS) + readings = _readings(_triangle(BASS_FREQUENCY) + melody, BASS_FREQUENCY) + return _median_cents(readings, BASS_FREQUENCY) + + +def _melody_share(bass: np.ndarray) -> float: + """The median confidence of a melody note read over a bass.""" + melody = _harmonic(A4_FREQUENCY, seconds=BASS_SECONDS) + readings = _readings(melody + bass, A4_FREQUENCY) + return float(np.median([reading.confidence for reading in readings])) + + +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 + + +def _vibrato() -> tuple[np.ndarray, np.ndarray]: + """A square-wave vibrato and the frequency it sounds at between each pair of columns.""" + time = np.arange(int(SAMPLE_RATE * VIBRATO_SECONDS)) / SAMPLE_RATE + depth = VIBRATO_DEPTH_CENTS / CENTS_PER_OCTAVE + frequency = VIBRATO_FREQUENCY * 2 ** (depth * np.sin(2 * np.pi * VIBRATO_RATE * time)) + phase = np.cumsum(frequency) / SAMPLE_RATE + audio = (0.3 * np.where(phase % 1.0 < 0.5, 1.0, -1.0)).astype(np.float32) + frames = len(time) // HOP + sounding = frequency[: frames * HOP].reshape(frames, HOP).mean(axis=1) + return audio, sounding + + +class TestTheTopOfTheRange: + """A reading repeats every ``sample_rate / hop`` hertz, a span narrower than a note's room from about + 1 kHz up. A tone far inside its room there is read on its own side.""" + + @pytest.mark.parametrize("frequency", HIGH_FREQUENCIES, ids=lambda frequency: f"{frequency:g} Hz") + @pytest.mark.parametrize("cents", FAR_INSIDE_CENTS, ids=lambda cents: f"{cents:+.0f}c") + def test_a_tone_far_inside_its_room_is_read_where_it_stands(self, frequency: float, cents: float) -> None: + truth = frequency * 2 ** (cents / CENTS_PER_OCTAVE) + + readings = _readings(_harmonic(truth), frequency) + + assert abs(_median_cents(readings, frequency) - cents) < CENT_TOLERANCE + + def test_a_tone_nearest_a_note_whose_divider_stands_flat_is_read_where_it_stands(self) -> None: + """The pulse's A6 divider sounds 12 cents flat, so a tone 40 cents sharp of A6 stands 52 cents + from the note the decoder names, which is still the nearest one.""" + cents = PAST_HALF_A_SEMITONE_CENTS + truth = PULSE_A6_DIVIDER_FREQUENCY * 2 ** (cents / CENTS_PER_OCTAVE) + + readings = _readings(_harmonic(truth), PULSE_A6_DIVIDER_FREQUENCY) + + assert abs(_median_cents(readings, PULSE_A6_DIVIDER_FREQUENCY) - cents) < CENT_TOLERANCE + + def test_a_vibrato_is_read_on_its_own_side_at_every_frame(self) -> None: + audio, sounding = _vibrato() + reader = InstantaneousPitch(audio, SAMPLE_RATE, HOP) + + errors = [ + abs(CENTS_PER_OCTAVE * math.log2(reading.frequency / sounding[frame])) + for frame in range(SETTLED_FRAMES, len(sounding) - SETTLED_FRAMES) + if (reading := reader.at(frame, VIBRATO_FREQUENCY)) is not None + ] + + assert errors + assert max(errors) < VIBRATO_CENT_TOLERANCE + + +class TestTheTrianglesLowestOctave: + """The triangle sounds an octave below the pulse at the same divider, so its lowest notes stand + below every pulse note. The transform reaches them, and their fundamental is read where the + channel sounds it on every frame, the frames a recording's edges cut short aside.""" + + @pytest.mark.parametrize("pitch", (33, 37, 41, 44), ids=lambda pitch: f"pitch {pitch}") + def test_every_settled_frame_is_read_where_the_channel_sounds(self, pitch: int) -> None: + generator = TriangleGenerator(Config()) + instruction = TriangleInstruction(on=True, pitch=pitch) + audio = np.concatenate([generator(instruction, save=True) for _ in range(TRIANGLE_FRAMES)]) + sounding = generator.sounds_at(pitch, 0) + reader = InstantaneousPitch(audio, SAMPLE_RATE, HOP) + + readings = [reader.at(frame, sounding) for frame in range(SETTLED_FRAMES, TRIANGLE_FRAMES - SETTLED_FRAMES)] + + assert all(reading is not None for reading in readings) + assert all( + abs(CENTS_PER_OCTAVE * math.log2(reading.frequency / sounding)) < TRIANGLE_CENT_TOLERANCE + for reading in readings + if reading is not None + ) + + +class TestAnotherVoiceBesideTheHarmonics: + """A harmonic reads where its partial stands, so a partial another voice sounds a bin away from one + of a note's harmonics would pull the note's reading toward it.""" + + def test_a_voice_beside_a_harmonic_leaves_the_reading_in_place(self) -> None: + assert abs(_bass_cents_under(BESIDE_FIFTH_HARMONIC)) < CENT_TOLERANCE + + def test_a_voice_on_a_harmonic_agrees_with_the_note(self) -> None: + """The triangle has no fourth harmonic, so the melody's fundamental fills that bin alone, at the + frequency the note puts it.""" + assert abs(_bass_cents_under(ON_FOURTH_HARMONIC)) < CENT_TOLERANCE + + +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)]) + ) + + def test_a_bass_takes_the_same_share_in_every_register(self) -> None: + """A bass an octave lower is no louder, so a melody over it keeps the share it holds.""" + low = _melody_share(_bass(LOW_BASS_FREQUENCY, BASS_LEVEL)) + higher = _melody_share(_bass(2 * LOW_BASS_FREQUENCY, BASS_LEVEL)) + + assert abs(low - higher) < SHARE_TOLERANCE * higher + + def test_a_louder_bass_takes_a_larger_share(self) -> None: + quiet = _melody_share(_bass(LOW_BASS_FREQUENCY, BASS_LEVEL)) + loud = _melody_share(_bass(LOW_BASS_FREQUENCY, 2 * BASS_LEVEL)) + + assert loud < quiet diff --git a/tests/unit/sampletones_core/fft/test_spectrum_scaling.py b/tests/unit/sampletones_core/fft/test_spectrum_scaling.py index 5a8a5e6cf..90dbb2c9c 100644 --- a/tests/unit/sampletones_core/fft/test_spectrum_scaling.py +++ b/tests/unit/sampletones_core/fft/test_spectrum_scaling.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from sampletones_core.constants.algorithm import PERCEPTUAL_EXPONENT +from sampletones_core.configs import Config from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY from sampletones_core.fft.cqt.frequencies import calculate_cqt_frequencies @@ -179,7 +179,7 @@ def test_weight_shares_match_across_spectrum_methods(self) -> None: shares = np.asarray( octave_weight_shares( edges, - perceptual_exponent=PERCEPTUAL_EXPONENT, + perceptual_exponent=Config().generation.metric.perceptual_exponent, bands=OCTAVE_BANDS, ) ) @@ -195,7 +195,7 @@ def test_weight_shares_are_stable_across_window_sizes(self) -> None: shares = np.asarray( octave_weight_shares( edges, - perceptual_exponent=PERCEPTUAL_EXPONENT, + perceptual_exponent=Config().generation.metric.perceptual_exponent, bands=OCTAVE_BANDS, ) ) diff --git a/tests/unit/sampletones_core/fft/test_transformer.py b/tests/unit/sampletones_core/fft/test_transformer.py index 8958abe41..72a6e817b 100644 --- a/tests/unit/sampletones_core/fft/test_transformer.py +++ b/tests/unit/sampletones_core/fft/test_transformer.py @@ -1,13 +1,13 @@ from dataclasses import dataclass from enum import StrEnum from functools import partial -from typing import Optional, Tuple, Type, Union +from typing import Final, Optional, Tuple, Type, Union from unittest.mock import patch import numpy as np import pytest -from sampletones_core.constants.algorithm import SPECTRUM_FLOOR +from sampletones_core.constants.spectrum import SPECTRUM_FLOOR from sampletones_core.fft.transformer import FFTTransformer from sampletones_core.structures.histogram import Histogram from sampletones_shared.types.array import ( @@ -1785,127 +1785,3 @@ def test_divide(self, test_case: TestCase, request: pytest.FixtureRequest) -> No assert isinstance(test_case.expected, Histogram) assert_array_equal(result.edges, test_case.expected.edges) assert_array_equal(result.values, test_case.expected.values) - - -class TestMean(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - expected: Union[Histogram, Type[Exception]] - transformer: TransformerFixture - features: Tuple[Histogram, ...] - - test_cases = ( - TestCase( - transformer=TransformerFixture.SQUARE, - features=( - Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float32), - values=np.array([300.0, 400.0], dtype=np.float32), - ), - ), - expected=Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float32), - values=np.array([300.0, 400.0], dtype=np.float32), - ), - label="single_histogram", - ), - TestCase( - transformer=TransformerFixture.SQUARE, - features=( - Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float64), - values=np.array([7.0, 17.0], dtype=np.float64), - ), - Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float64), - values=np.array([1.0, 7.0], dtype=np.float64), - ), - ), - expected=Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float64), - values=np.array([5.0, 13.0], dtype=np.float64), - ), - label="two_histograms", - ), - TestCase( - transformer=TransformerFixture.SQUARE, - features=( - Histogram( - edges=np.array([0.0, 100.0, 200.0, 300.0], dtype=np.float32), - values=np.array([0.0, 1.0, 2.0], dtype=np.float32), - ), - Histogram( - edges=np.array([0.0, 100.0, 200.0, 300.0], dtype=np.float32), - values=np.array([0.0, 1.0, 2.0], dtype=np.float32), - ), - Histogram( - edges=np.array([0.0, 100.0, 200.0, 300.0], dtype=np.float32), - values=np.array([0.0, 1.0, 2.0], dtype=np.float32), - ), - ), - expected=Histogram( - edges=np.array([0.0, 100.0, 200.0, 300.0], dtype=np.float32), - values=np.array([0.0, 1.0, 2.0], dtype=np.float32), - ), - label="three_histograms", - ), - TestCase( - transformer=TransformerFixture.IDENTITY, - features=( - Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float32), - values=np.array([300.0, 500.0], dtype=np.float32), - ), - Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float32), - values=np.array([400.0, 300.0], dtype=np.float32), - ), - ), - expected=Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float32), - values=np.array([350.0, 400.0], dtype=np.float32), - ), - label="identity_two_histograms", - ), - TestCase( - transformer=TransformerFixture.SQUARE, - features=(), - expected=ValueError, - label="empty_features", - ), - TestCase( - transformer=TransformerFixture.SQUARE, - features=( - Histogram( - edges=np.array([0.0, 100.0, 200.0], dtype=np.float32), - values=np.array([300.0, 400.0], dtype=np.float32), - ), - Histogram( - edges=np.array([0.0, 100.0, 300.0], dtype=np.float32), - values=np.array([400.0, 500.0], dtype=np.float32), - ), - ), - expected=ValueError, - label="inconsistent_edges", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_mean(self, test_case: TestCase, request: pytest.FixtureRequest) -> None: - transformer = test_case.transformer.get_fixture(request) - - if not expect_error( - transformer.mean, - test_case.expected, - test_case.features, - ): - result = transformer.mean(test_case.features) - assert isinstance(result, Histogram) - assert isinstance(test_case.expected, Histogram) - assert_array_equal(result.edges, test_case.expected.edges) - assert_array_equal(result.values, test_case.expected.values) - assert_array_equal(result.values, test_case.expected.values) diff --git a/tests/unit/sampletones_core/fft/window/__init__.py b/tests/unit/sampletones_core/fft/window/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/fft/window/test_cyclic.py b/tests/unit/sampletones_core/fft/window/test_cyclic.py new file mode 100644 index 000000000..6b3ceec4b --- /dev/null +++ b/tests/unit/sampletones_core/fft/window/test_cyclic.py @@ -0,0 +1,39 @@ +from typing import Final, Tuple + +import numpy as np +import pytest + +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.fft import Window +from sampletones_core.fft.window.cyclic import CyclicArray +from tests.suite.analysis import analyzed_config + +SAMPLE_LENGTH: Final[int] = 500 +SHIFTS: Final[Tuple[int, ...]] = (0, 137, SAMPLE_LENGTH - 1) +TAPER_EXTENSIONS: Final[Tuple[int, ...]] = (0, 1) +SEED: Final[int] = 7 + + +class TestAFrameIsTheMiddleOfItsWindow: + """A sample's frame is the middle of the windowed fragment the same shift reads, under the envelope + there: for the flat constant-Q envelope, the FFT's tapered one, and a taper an odd number of samples + long, whose frame reaches one tapered sample. The sample is shorter than a frame, so the reading + wraps around it.""" + + @pytest.mark.parametrize("shift", SHIFTS) + @pytest.mark.parametrize("extension", TAPER_EXTENSIONS) + @pytest.mark.parametrize("method", (SpectrumMethod.CQT, SpectrumMethod.FFT)) + def test_the_frame_equals_the_middle_of_the_windowed_fragment( + self, + method: SpectrumMethod, + extension: int, + shift: int, + ) -> None: + config = analyzed_config(method, gamma=0) + window = Window.from_config(config, custom_size=Window.from_config(config).size + extension) + audio = np.random.default_rng(SEED).standard_normal(SAMPLE_LENGTH).astype(np.float32) + sample = CyclicArray(array=audio, sample_rate=config.library.sample_rate) + + expected = window.get_frame_from_window(sample.get_windowed_fragment(shift, window)) + + np.testing.assert_array_equal(sample.get_frame(shift, window), expected) 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..f86a26215 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 @@ -15,17 +17,19 @@ def build_features( *, arpeggio: Optional[Sequence[int]] = None, duty_cycle: Optional[Sequence[int]] = None, + bend: Optional[Sequence[int]] = None, + coarse_bend: 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, - pitch=None, - hi_pitch=None, - duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + volume=Envelope(items=tuple(volume)), + arpeggio=Envelope(items=contour), + pitch=None if bend is None else Envelope(items=tuple(bend)), + hi_pitch=None if coarse_bend is None else Envelope(items=tuple(coarse_bend)), + duty_cycle=None if duty_cycle is None else Envelope(items=tuple(duty_cycle)), ) @@ -33,17 +37,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/model/__init__.py b/tests/unit/sampletones_core/formats/bitphase/model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/formats/bitphase/model/test_table.py b/tests/unit/sampletones_core/formats/bitphase/model/test_table.py new file mode 100644 index 000000000..df860d7a7 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/model/test_table.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START +from tests.suite.base import BaseTestSuite +from tests.suite.bitphase import LoadedTable +from tests.suite.case import BaseRegularTestCase + +STEPS: Final[Tuple[int, ...]] = (0, 4, 7, 12, 7, 4) +MIDDLE_LOOP: Final[int] = 3 +HELD_LOOP: Final[int] = len(STEPS) - 1 +TICKS_PLAYED: Final[int] = 40 +SHIFT: Final[int] = -5 +TABLE_ID: Final[int] = 7 +NAME: Final[str] = "Moved" + + +def table(loop: int) -> BitphaseTable: + return BitphaseTable(id=0, rows=STEPS, loop=loop, name="Contour") + + +def engine_step(source: BitphaseTable, tick: int) -> int: + """The step Bitphase's own reading of a table plays on a tick, from the suite's copy of the engine.""" + loaded = LoadedTable(id=source.id, loop=source.loop, name=source.name, rows=list(source.rows), additive=False) + return loaded.step(tick) + + +class TestTheStepATableStandsAt(BaseTestSuite): + """Playback advances a table a step per tick and circles from its loop, from the first step where + the loop stands at the start, so the step a transpose row places its table at is the one the + engine would have reached. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + loop: int + + test_cases: Tuple["TestTheStepATableStandsAt.TestCase", ...] = ( + TestCase(label="circling whole", loop=LOOP_FROM_START), + TestCase(label="circling from the middle", loop=MIDDLE_LOOP), + TestCase(label="holding the last step", loop=HELD_LOOP), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_step_is_the_one_the_engine_plays(self, test_case: "TestTheStepATableStandsAt.TestCase") -> None: + source = table(test_case.loop) + + assert [source.rows[source.position_at(tick)] for tick in range(TICKS_PLAYED)] == [ + engine_step(source, tick) for tick in range(TICKS_PLAYED) + ] + + +class TestAMovedTable(BaseTestSuite): + """A moved table plays this table's steps moved by the shift from the step it opens on, so a + transpose row reaching a step no ornament position names still sounds where the note stood. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + loop: int + start: int + + test_cases: Tuple["TestAMovedTable.TestCase", ...] = ( + TestCase(label="opening on the first step", loop=MIDDLE_LOOP, start=0), + TestCase(label="opening before the loop", loop=MIDDLE_LOOP, start=MIDDLE_LOOP - 1), + TestCase(label="opening past the loop", loop=MIDDLE_LOOP, start=MIDDLE_LOOP + 1), + TestCase(label="opening within a whole circle", loop=LOOP_FROM_START, start=MIDDLE_LOOP), + TestCase(label="opening on a held step", loop=HELD_LOOP, start=HELD_LOOP), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_plays_what_the_table_plays_from_its_start(self, test_case: "TestAMovedTable.TestCase") -> None: + source = table(test_case.loop) + moved = source.moved(table_id=TABLE_ID, shift=SHIFT, start=test_case.start, name=NAME) + + assert [engine_step(moved, tick) for tick in range(TICKS_PLAYED)] == [ + engine_step(source, test_case.start + tick) + SHIFT for tick in range(TICKS_PLAYED) + ] + + def test_it_takes_the_id_and_the_name_it_is_given(self) -> None: + moved = table(MIDDLE_LOOP).moved(table_id=TABLE_ID, shift=SHIFT, start=0, name=NAME) + + assert (moved.id, moved.name) == (TABLE_ID, NAME) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_bends.py b/tests/unit/sampletones_core/formats/bitphase/test_bends.py new file mode 100644 index 000000000..6e89e970c --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_bends.py @@ -0,0 +1,187 @@ +from dataclasses import dataclass +from typing import Final, List, Optional, Sequence + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import HI_PITCH_FACTOR, MAX_TIMER, MIN_TIMER +from sampletones_core.formats.bitphase.envelopes import features_to_envelopes +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.preset import instrument_to_preset +from sampletones_core.formats.bitphase.specification.chip import ( + DEFAULT_A4_TUNING, + DEFAULT_CPU_FREQUENCY, + PERIOD_OVER_TIMER, +) +from sampletones_core.formats.bitphase.specification.instruments import ( + MAX_TONE_ADD, + MIN_TONE_ADD, + NO_TONE_OFFSET, +) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE, generate_tuning_table +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +from .conftest import REFERENCE_PITCH, build_features, build_instrument + +VOLUME_ENVELOPE: Final[List[int]] = [15, 12, 8, 4] +BEND_ENVELOPE: Final[List[int]] = [0, -3, -7, -12] +PITCH_CONTOUR: Final[List[int]] = [0, 5, 7, 12] +BASE_INDEX: Final[int] = pitch_to_note_index(REFERENCE_PITCH) +BASE_PERIOD: Final[int] = DEFAULT_TUNING_TABLE[BASE_INDEX] +LOW_PITCH: Final[int] = 33 +LOWERED_A4_TUNING: Final[float] = 432.0 +DOWNWARD_BEND: Final[int] = 100 +SHORTEST_PERIOD: Final[int] = MIN_TIMER + PERIOD_OVER_TIMER +LONGEST_PERIOD: Final[int] = MAX_TIMER + PERIOD_OVER_TIMER + + +def offsets( + channel: ChannelName = ChannelName.PULSE1, + *, + arpeggio: Optional[Sequence[int]] = None, + bend: Optional[Sequence[int]] = None, + coarse_bend: Optional[Sequence[int]] = None, +) -> List[int]: + features = build_features( + VOLUME_ENVELOPE, + arpeggio=arpeggio, + bend=bend, + coarse_bend=coarse_bend, + ) + macro = features_to_envelopes( + features, + channel, + tuning_table=DEFAULT_TUNING_TABLE, + ).macros.get(NesMacroField.TONE_ADD) + return [] if macro is None else list(macro.values) + + +class TestTheBendReachesTheToneOffset: + def test_each_step_offsets_the_period_by_that_many(self) -> None: + assert offsets(bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_a_coarse_step_counts_sixteen(self) -> None: + """The two dimensions state one bend together, at one step and at sixteen apiece.""" + assert offsets(bend=[1, 1], coarse_bend=[0, 2]) == [1, 1 + 2 * HI_PITCH_FACTOR] + + def test_a_slice_sounding_on_its_note_writes_no_offset(self) -> None: + assert offsets(bend=[0, 0, 0, 0]) == [] + + def test_a_slice_without_the_dimension_writes_no_offset(self) -> None: + assert offsets(arpeggio=PITCH_CONTOUR) == [] + + def test_the_triangle_channel_bends_its_period(self) -> None: + assert offsets(ChannelName.TRIANGLE, bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_the_noise_channel_moves_its_period_one_step_per_unit(self) -> None: + """Bitphase adds the offset to the noise note, and the note carries the period itself.""" + assert offsets(ChannelName.NOISE, bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_the_bend_circles_from_the_point_it_states(self) -> None: + written = features_to_envelopes( + build_features(VOLUME_ENVELOPE, bend=BEND_ENVELOPE), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, + ) + assert written.macros[NesMacroField.TONE_ADD].loop == len(BEND_ENVELOPE) - 1 + + +class TestTheNoteABendIsMeasuredFrom: + """The table moves the note before Bitphase resolves its period, and the offset is added + to that period, so each tick is measured from the note its own contour step reaches. + """ + + def test_a_moved_note_keeps_the_steps_the_slice_states(self) -> None: + assert offsets(arpeggio=PITCH_CONTOUR, bend=BEND_ENVELOPE) == BEND_ENVELOPE + + def test_a_contour_holding_its_last_step_carries_the_bend_on(self) -> None: + assert offsets(arpeggio=[0, 12], bend=[-4, -4, -4, -4]) == [-4, -4, -4, -4] + + +class TestABendThePeriodRangeHolds: + @pytest.mark.parametrize("steps", [MIN_TONE_ADD, MAX_TONE_ADD], ids=["down", "up"]) + def test_the_period_it_reaches_stays_within_the_timer(self, steps: int) -> None: + written = offsets(bend=[steps] * len(VOLUME_ENVELOPE)) + assert all(SHORTEST_PERIOD <= BASE_PERIOD + offset <= LONGEST_PERIOD for offset in written) + + def test_a_bend_past_the_shortest_period_stops_there(self) -> None: + assert offsets(bend=[-BASE_PERIOD - 100] * len(VOLUME_ENVELOPE))[0] == SHORTEST_PERIOD - BASE_PERIOD + + def test_every_offset_fits_the_field(self) -> None: + written = offsets(bend=[MAX_TONE_ADD] * len(VOLUME_ENVELOPE)) + assert all(MIN_TONE_ADD <= offset <= MAX_TONE_ADD for offset in written) + + +class TestTheTableABendIsBoundedBy(BaseTestSuite): + """Bitphase adds the offset to the period the song's own table gives the note, so the offset + is measured against that table. A lowered tuning holds a low note at the longest period the + table holds, one step short of the longest timer, and a downward bend reaches that timer. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + a4_tuning: float + + test_cases = ( + TestCase( + a4_tuning=DEFAULT_A4_TUNING, + label="concert", + ), + TestCase( + a4_tuning=LOWERED_A4_TUNING, + label="lowered", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_bend_past_the_longest_period_stops_there(self, test_case: TestCase) -> None: + tuning_table = generate_tuning_table(DEFAULT_CPU_FREQUENCY, a4_tuning=test_case.a4_tuning) + features = build_features( + VOLUME_ENVELOPE, + bend=[DOWNWARD_BEND] * len(VOLUME_ENVELOPE), + initial_pitch=LOW_PITCH, + ) + written = features_to_envelopes( + features, + ChannelName.PULSE1, + tuning_table=tuning_table, + ).macros[NesMacroField.TONE_ADD] + + base_period = tuning_table[pitch_to_note_index(LOW_PITCH)] + assert all(base_period + offset == LONGEST_PERIOD for offset in written.values) + + +class TestAPresetCarriesBothMovements: + """A preset holds no table, so its tone offset carries the contour and the bend together.""" + + def test_its_offsets_add_the_bend_to_the_contour(self) -> None: + preset = instrument_to_preset( + build_instrument( + "Lead", + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR, bend=BEND_ENVELOPE), + ), + ) + expected = [ + DEFAULT_TUNING_TABLE[BASE_INDEX + semitones] - BASE_PERIOD + steps + for semitones, steps in zip(PITCH_CONTOUR, BEND_ENVELOPE) + ] + assert list(preset.macros[NesMacroField.TONE_ADD].values) == expected + + def test_an_unbent_slice_carries_its_contour_alone(self) -> None: + preset = instrument_to_preset( + build_instrument("Pad", build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR)), + ) + expected = [DEFAULT_TUNING_TABLE[BASE_INDEX + semitones] - BASE_PERIOD for semitones in PITCH_CONTOUR] + assert list(preset.macros[NesMacroField.TONE_ADD].values) == expected + + def test_a_noise_slice_carries_its_bend_in_period_steps(self) -> None: + preset = instrument_to_preset( + build_instrument( + "Hat", + build_features(VOLUME_ENVELOPE, bend=BEND_ENVELOPE, initial_pitch=4), + channel=ChannelName.NOISE, + ), + ) + assert list(preset.macros[NesMacroField.TONE_ADD].values)[: len(BEND_ENVELOPE)] == BEND_ENVELOPE diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index dc7b3abaa..54acd2946 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 @@ -34,6 +34,8 @@ "patterns", "tuningTable", "initialSpeed", + "tempo", + "defaultPatternLength", "chipType", "chipVariant", "chipFrequency", @@ -43,20 +45,9 @@ ] PATTERN_KEYS: Final[List[str]] = ["id", "length", "channels", "patternRows"] ROW_KEYS: Final[List[str]] = ["note", "effects", "instrument", "table", "volume"] -INSTRUMENT_KEYS: Final[List[str]] = ["id", "chipType", "rows", "loop", "name"] -INSTRUMENT_ROW_KEYS: Final[List[str]] = [ - "pulseWidth", - "volumeOrRate", - "retrigger", - "soundLength", - "envelope", - "toneAdd", - "toneAccumulation", - "sweep", - "sweepRate", - "sweepShift", -] -TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name"] +INSTRUMENT_KEYS: Final[List[str]] = ["id", "chipType", "name", "macros"] +MACRO_FIELDS: Final[List[str]] = ["volumeOrRate", "pulseWidth"] +TABLE_KEYS: Final[List[str]] = ["id", "rows", "loop", "name", "additive"] @pytest.fixture(name="project") @@ -68,7 +59,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, ), ) ) @@ -120,9 +111,13 @@ def test_the_row_holds_its_field(self, document: Dict[str, Any], key: str) -> No def test_the_instrument_holds_its_field(self, document: Dict[str, Any], key: str) -> None: assert key in document["instruments"][0] - @pytest.mark.parametrize("key", INSTRUMENT_ROW_KEYS) - def test_the_instrument_row_holds_its_field(self, document: Dict[str, Any], key: str) -> None: - assert key in document["instruments"][0]["rows"][0] + @pytest.mark.parametrize("field", MACRO_FIELDS) + def test_the_instrument_states_the_fields_it_decides(self, document: Dict[str, Any], field: str) -> None: + assert field in document["instruments"][0]["macros"] + + def test_a_macro_carries_its_values_and_the_point_they_circle_from(self, document: Dict[str, Any]) -> None: + macro = document["instruments"][0]["macros"]["volumeOrRate"] + assert set(macro) == {"values", "loop"} @pytest.mark.parametrize("key", TABLE_KEYS) def test_the_table_holds_its_field(self, document: Dict[str, Any], key: str) -> None: @@ -134,7 +129,23 @@ def test_a_note_names_a_semitone_and_an_octave(self, document: Dict[str, Any]) - def test_a_channel_names_the_channel_it_drives(self, document: Dict[str, Any]) -> None: channel = document["songs"][0]["patterns"][0]["channels"][0] - assert set(channel) == {"rows", "label"} + assert set(channel) == {"rows", "label", "effectColumnCount"} + + def test_every_effect_names_the_table_it_reads_or_none(self, document: Dict[str, Any]) -> None: + """Bitphase reads an effect from a table wherever the cell names an index of zero or + above, and an empty value counts as an index, so a parameter-driven effect states one + below. + """ + cells = [ + effect + for song in document["songs"] + for pattern in song["patterns"] + for channel in pattern["channels"] + for row in channel["rows"] + for effect in row["effects"] + if effect is not None + ] + assert all(isinstance(effect["tableIndex"], int) for effect in cells) class TestTheDocumentReadsAsNes: @@ -154,20 +165,17 @@ def test_the_order_names_patterns_the_song_holds(self, document: Dict[str, Any]) class TestTheEnvelopesSurvive: def test_the_volume_envelope_crosses_over_whole(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][0]["rows"] - assert [row["volumeOrRate"] for row in rows] == VOLUME_ENVELOPE + assert document["instruments"][0]["macros"]["volumeOrRate"]["values"] == VOLUME_ENVELOPE def test_the_pitch_contour_crosses_over_whole(self, document: Dict[str, Any]) -> None: assert document["tables"][0]["rows"] == PITCH_CONTOUR def test_the_noise_mode_reaches_the_waveform_field(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][1]["rows"] - assert [row["pulseWidth"] for row in rows] == [1, 1, 0, 0] - - def test_the_rows_read_their_level_as_a_literal_volume(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][0]["rows"] - assert all(row["envelope"] is False for row in rows) + assert document["instruments"][1]["macros"]["pulseWidth"]["values"] == [1, 1, 0, 0] - def test_the_rows_hold_the_note_for_as_long_as_the_envelope_runs(self, document: Dict[str, Any]) -> None: - rows = document["instruments"][0]["rows"] - assert all(row["soundLength"] == 0 for row in rows) + def test_the_fields_a_reconstruction_leaves_alone_are_left_out(self, document: Dict[str, Any]) -> None: + """Bitphase reads a field the instrument states no macro for at its own default, which is + the literal volume, the sustained note and the silent sweep a reconstruction asks for. + """ + written = set(document["instruments"][0]["macros"]) + assert written.isdisjoint({"envelope", "soundLength", "sweep", "toneAccumulation"}) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py index 7494578f5..f3045a54a 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py @@ -1,10 +1,12 @@ import math +from dataclasses import dataclass, replace from typing import Final, List 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.exports.request import InstrumentExport from sampletones_core.formats.bitphase.builder import ( PREVIEW_REST_PATTERN_ID, PREVIEW_SPEED, @@ -23,7 +25,12 @@ CHANNEL_COUNT, ChannelIndex, ) -from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES +from sampletones_core.formats.bitphase.specification.chip import ( + CHIP_TYPE_NES, + MAX_A4_TUNING, + MIN_A4_TUNING, + TUNING_A4_INDEX, +) from sampletones_core.formats.bitphase.specification.instruments import ( MAX_TABLE_ID, MIN_INSTRUMENT_ID, @@ -36,9 +43,15 @@ MIN_PATTERN_LENGTH, NO_INSTRUMENT_CHANGE, NO_TABLE_CHANGE, + NOTE_INDEX_PITCH_OFFSET, TABLE_COLUMN_OFFSET, NoteName, ) +from sampletones_core.formats.bitphase.tuning import concert_frequency, generate_tuning_table +from sampletones_shared.constants.music import OCTAVE_SEMITONES +from sampletones_shared.music import Tuning +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase from .conftest import ( NES_FREQUENCY, @@ -51,6 +64,18 @@ VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] NOISE_PERIOD: Final[int] = 4 LONG_ENVELOPE_FRAMES: Final[int] = 4000 +BEND_ENVELOPE: Final[List[int]] = [0, -3, -7, -12] +LOWERED_A4_FREQUENCY: Final[float] = 432.0 +C5_PITCH: Final[int] = 72 +A3_PITCH: Final[int] = 57 +A5_PITCH: Final[int] = 81 +SEMITONES_FROM_A4_TO_C5: Final[int] = 3 +FLAT_A5_FREQUENCY: Final[float] = 400.0 +SHARP_A3_FREQUENCY: Final[float] = 500.0 +NAMED_AT_C5: Final[Tuning] = Tuning( + a4_frequency=LOWERED_A4_FREQUENCY * 2 ** (SEMITONES_FROM_A4_TO_C5 / OCTAVE_SEMITONES), + a4_pitch=C5_PITCH, +) @pytest.fixture(name="project") @@ -62,7 +87,7 @@ def project_fixture() -> BitphaseProject: build_instrument( "Kick (noise)", build_features(VOLUME_ENVELOPE, initial_pitch=NOISE_PERIOD), - generator=GeneratorName.NOISE, + channel=ChannelName.NOISE, ), ) ) @@ -154,6 +179,17 @@ def test_every_channel_of_the_pattern_is_as_long_as_the_pattern(self, project: B pattern = project.songs[0].patterns[0] assert all(len(channel.rows) == pattern.length for channel in pattern.channels) + def test_the_song_states_the_length_its_patterns_were_built_at(self, project: BitphaseProject) -> None: + song = project.songs[0] + assert song.default_pattern_length == song.patterns[0].length + + def test_a_channel_lays_out_a_column_for_every_effect_its_lines_carry( + self, + project: BitphaseProject, + ) -> None: + channels = project.songs[0].patterns[0].channels + assert all(channel.effect_column_count == max(len(row.effects) for row in channel.rows) for channel in channels) + class TestTheOrderCoversTheLongestInstrument: """Playback returns to the start of the order, so a document whose order runs out @@ -207,7 +243,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 +258,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) @@ -249,3 +285,88 @@ def test_a_document_past_the_table_column_is_refused(self) -> None: ) with pytest.raises(ValueError, match="tables"): sample_to_bitphase(request) + + +def tuned_slice(tuning: Tuning) -> InstrumentExport: + """One bent slice of a reconstruction built against ``tuning``.""" + return replace( + build_instrument("Bent", build_features(VOLUME_ENVELOPE, bend=BEND_ENVELOPE)), + tuning=tuning, + ) + + +def tuned_document(tuning: Tuning) -> BitphaseProject: + """The document of a reconstruction built against ``tuning``.""" + return sample_to_bitphase(replace(build_sample("Bent", tuned_slice(tuning)), tuning=tuning)) + + +class TestTheDocumentSoundsTheRequestsTuning(BaseTestSuite): + """A song stores its concert pitch beside the table built from it, and Bitphase builds the + table again from that pitch when it loads the song, so a document written at the + reconstruction's own tuning plays the pitches the reconstruction was built against. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + tuning: Tuning + + test_cases = ( + TestCase( + tuning=Tuning(), + label="concert", + ), + TestCase( + tuning=Tuning(a4_frequency=LOWERED_A4_FREQUENCY), + label="lowered", + ), + TestCase( + tuning=NAMED_AT_C5, + label="named_at_c5", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_song_centers_on_the_frequency_of_its_concert_index(self, test_case: TestCase) -> None: + song = tuned_document(test_case.tuning).songs[0] + assert song.a4_tuning_hz == test_case.tuning.frequency(TUNING_A4_INDEX + NOTE_INDEX_PITCH_OFFSET) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_table_is_the_one_bitphase_builds_from_the_song(self, test_case: TestCase) -> None: + song = tuned_document(test_case.tuning).songs[0] + assert generate_tuning_table(song.chip_frequency, a4_tuning=song.a4_tuning_hz) == song.tuning_table + + def test_a_reference_named_at_another_pitch_writes_the_same_song(self) -> None: + at_a4 = tuned_document(Tuning(a4_frequency=LOWERED_A4_FREQUENCY)) + at_c5 = tuned_document(NAMED_AT_C5) + assert at_c5.songs[0].tuning_table == at_a4.songs[0].tuning_table + assert at_c5.songs[0].a4_tuning_hz == pytest.approx(at_a4.songs[0].a4_tuning_hz) + assert at_c5.instruments == at_a4.instruments + + @pytest.mark.parametrize( + "tuning", + [ + Tuning(a4_frequency=MIN_A4_TUNING * 2, a4_pitch=A5_PITCH), + Tuning(a4_frequency=MAX_A4_TUNING / 2, a4_pitch=A3_PITCH), + ], + ids=["lowest", "highest"], + ) + def test_a_tuning_at_the_edge_of_the_range_is_written(self, tuning: Tuning) -> None: + assert tuned_document(tuning).songs[0].a4_tuning_hz == concert_frequency(tuning) + + @pytest.mark.parametrize( + "tuning", + [ + Tuning(a4_frequency=FLAT_A5_FREQUENCY, a4_pitch=A5_PITCH), + Tuning(a4_frequency=SHARP_A3_FREQUENCY, a4_pitch=A3_PITCH), + ], + ids=["below", "above"], + ) + def test_a_tuning_past_the_range_a_song_takes_is_refused(self, tuning: Tuning) -> None: + assert not MIN_A4_TUNING <= concert_frequency(tuning) <= MAX_A4_TUNING + with pytest.raises(ValueError): + tuned_document(tuning) + + def test_an_instrument_document_takes_its_slices_tuning(self) -> None: + tuning = Tuning(a4_frequency=LOWERED_A4_FREQUENCY) + song = instrument_to_bitphase(tuned_slice(tuning)).songs[0] + assert song.a4_tuning_hz == concert_frequency(tuning) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index 414cbc07f..f7eec5d24 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, @@ -18,217 +18,259 @@ NOISE_MODE_SHORT, SILENT_VOLUME, ) +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH, NesMacroField +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE -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.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] PITCH_CONTOUR: Final[List[int]] = [0, 2, 4, 5, 7] -class TestRowsCarryTheEnvelopes: - def test_each_volume_item_becomes_one_row(self) -> None: +def volume_values(envelopes: ChannelEnvelopes) -> List[int]: + return list(envelopes.macros[NesMacroField.VOLUME_OR_RATE].values) + + +def waveform_values(envelopes: ChannelEnvelopes) -> List[int]: + return list(envelopes.macros[NesMacroField.PULSE_WIDTH].values) + + +class TestEachDimensionBecomesItsOwnMacro: + def test_the_volume_envelope_becomes_the_level_a_tick_takes(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, - loop=False, + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert [row.volume_or_rate for row in envelopes.rows] == VOLUME_ENVELOPE + assert volume_values(envelopes) == 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, + tuning_table=DEFAULT_TUNING_TABLE, ) 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, + tuning_table=DEFAULT_TUNING_TABLE, + ) + assert waveform_values(envelopes) == [case.pulse_width] + + def test_the_triangle_channel_leaves_its_one_waveform_alone(self) -> None: + """The triangle plays a single waveform, so the field it would name stays at its default.""" + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE, duty_cycle=[3] * len(VOLUME_ENVELOPE)), + ChannelName.TRIANGLE, + tuning_table=DEFAULT_TUNING_TABLE, + ) + assert set(envelopes.macros) == {NesMacroField.VOLUME_OR_RATE} + + def test_a_channel_without_a_duty_envelope_holds_one_waveform(self) -> None: + envelopes = features_to_envelopes( + build_features(VOLUME_ENVELOPE), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert envelopes.rows[0].pulse_width == case.pulse_width + assert waveform_values(envelopes) == [FLAT_PULSE_WIDTH] - def test_a_channel_without_a_duty_envelope_plays_one_waveform(self) -> None: + def test_a_noise_channel_without_a_duty_envelope_holds_the_long_mode(self) -> None: + """Every note starts on the long mode where the slice leaves its mode, as a note does in the app.""" envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), - GeneratorName.TRIANGLE, - loop=False, + ChannelName.NOISE, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert {row.pulse_width for row in envelopes.rows} == {FLAT_PULSE_WIDTH} + assert waveform_values(envelopes) == [NOISE_MODE_LONG] def test_a_noise_contour_takes_the_offsets_that_move_its_period(self) -> None: + """The note index carries the period itself, so a step moves the index by as much, wrapped + into the sixteen periods the channel has. + """ steps = [0, 1, -1, 5] envelopes = features_to_envelopes( build_features([15] * len(steps), arpeggio=steps), - GeneratorName.NOISE, - loop=False, + ChannelName.NOISE, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert list(envelopes.table_rows) == [(-step) % NUM_PERIODS for step in steps] + assert list(envelopes.table_rows) == [step % NUM_PERIODS for step in steps] -class TestTheDimensionsStayInStep: - """Instrument rows and table rows advance on their own per-tick counters, so a - length they share is what keeps the volume envelope aligned with the pitch contour. +class TestEachDimensionKeepsItsOwnLength: + """Bitphase advances every field on a counter of its own, so a dimension holding one + value all through costs that one value however long the others run. """ - @pytest.mark.parametrize("loop", [True, False], ids=["looping", "one_shot"]) - def test_the_rows_and_the_table_share_a_length(self, loop: bool) -> None: - envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:3]), - GeneratorName.PULSE1, - loop=loop, - ) - assert len(envelopes.rows) == len(envelopes.table_rows) - - def test_a_looping_slice_takes_the_shortest_dimension(self) -> None: + def test_a_dimension_holds_the_length_it_was_written_at(self) -> None: envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), - GeneratorName.PULSE1, - loop=True, + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert len(envelopes.rows) == 2 + assert len(volume_values(envelopes)) == len(VOLUME_ENVELOPE) + assert len(envelopes.table_rows) == 2 - def test_a_one_shot_holds_the_shorter_dimension_to_the_end(self) -> None: + def test_a_slice_without_a_contour_holds_one_step(self) -> None: envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), - GeneratorName.PULSE1, - loop=False, + build_features(VOLUME_ENVELOPE, arpeggio=[]), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert list(envelopes.table_rows) == [0, 2, 2, 2, 2] + assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] - def test_a_slice_without_a_contour_holds_its_note(self) -> None: + def test_the_instrument_runs_as_long_as_its_longest_dimension(self) -> None: envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE, arpeggio=[]), - GeneratorName.PULSE1, - loop=False, + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR[:2]), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert list(envelopes.table_rows) == [NO_TABLE_OFFSET] * len(VOLUME_ENVELOPE) + assert envelopes.ticks == len(VOLUME_ENVELOPE) -class TestTheLoopPoint: - def test_a_looping_slice_returns_to_its_first_row(self) -> None: +class TestThePointADimensionRepeatsFrom: + def test_a_looping_dimension_states_the_point_it_circles_from(self) -> None: envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE), - GeneratorName.PULSE1, - loop=True, + looping(build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), 0), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert envelopes.loop == LOOP_FROM_START + assert envelopes.macros[NesMacroField.VOLUME_OR_RATE].loop == LOOP_FROM_START + assert envelopes.table_loop == LOOP_FROM_START - def test_a_one_shot_rests_on_its_last_row(self) -> None: + def test_a_one_shot_holds_its_final_value(self) -> None: + """Circling from the last value is what holds it, since playback never halts.""" envelopes = features_to_envelopes( - build_features(VOLUME_ENVELOPE), - GeneratorName.PULSE1, - loop=False, + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert envelopes.loop == len(envelopes.rows) - 1 + assert envelopes.macros[NesMacroField.VOLUME_OR_RATE].loop == len(VOLUME_ENVELOPE) - 1 + assert envelopes.table_loop == len(PITCH_CONTOUR) - 1 def test_a_one_shot_rests_in_silence(self) -> None: - """Playback always returns to the loop row, so a slice that has played through - rests on the note-off item its volume envelope ends with. - """ + """A slice that has played through rests on the note-off item its volume ends with.""" envelopes = features_to_envelopes( build_features(VOLUME_ENVELOPE), - GeneratorName.PULSE1, - loop=False, + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert envelopes.rows[envelopes.loop].volume_or_rate == SILENT_VOLUME + macro = envelopes.macros[NesMacroField.VOLUME_OR_RATE] + assert macro.values[macro.loop] == 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: + def test_every_point_stands_among_the_values_it_circles(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, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert envelopes.loop < len(envelopes.rows) - assert envelopes.loop < len(envelopes.table_rows) + assert all(macro.loop < len(macro.values) for macro in envelopes.macros.values()) + assert envelopes.table_loop < len(envelopes.table_rows) class TestASliceThatLeavesItsVolumeToTheChannel: """An instrument with no volume envelope sounds at the level its channel carries, so - every frame it describes reaches Bitphase as a full-level row. + one full level is what it writes. """ - def test_it_holds_a_full_row_per_frame(self) -> None: + def test_it_holds_one_full_level(self) -> None: envelopes = features_to_envelopes( build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, - loop=False, + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) + assert volume_values(envelopes) == [MAX_VOLUME_OR_RATE] 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, + tuning_table=DEFAULT_TUNING_TABLE, ) assert list(envelopes.table_rows) == PITCH_CONTOUR - def test_its_duty_envelope_still_reaches_the_rows(self) -> None: + def test_its_duty_envelope_still_reaches_the_waveform(self) -> None: duty_cycles = [0, 1, 2, 3] envelopes = features_to_envelopes( build_features([], duty_cycle=duty_cycles), - GeneratorName.PULSE1, - loop=False, + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert [row.pulse_width for row in envelopes.rows] == duty_cycles + assert waveform_values(envelopes) == duty_cycles + - def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: +class TestADimensionPastWhatAMacroStores: + def test_it_keeps_its_opening_values(self) -> None: + contour = list(range(MAX_MACRO_LENGTH + 8)) envelopes = features_to_envelopes( - build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, - loop=False, + build_features([MAX_VOLUME_OR_RATE] * (MAX_MACRO_LENGTH + 8), arpeggio=contour), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE + assert len(volume_values(envelopes)) == MAX_MACRO_LENGTH - def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: + def test_a_volume_ending_in_silence_keeps_that_silence(self) -> None: + """A shortened volume that dropped its note-off would sound on for as long as the note does.""" + volume = [MAX_VOLUME_OR_RATE] * (MAX_MACRO_LENGTH + 8) + [SILENT_VOLUME] envelopes = features_to_envelopes( - build_features([], arpeggio=PITCH_CONTOUR), - GeneratorName.PULSE1, - loop=True, + build_features(volume), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, ) - assert len(envelopes.rows) == len(PITCH_CONTOUR) - assert envelopes.loop == LOOP_FROM_START + assert volume_values(envelopes)[-1] == SILENT_VOLUME + + def test_the_table_carries_a_contour_of_any_length(self) -> None: + contour = list(range(MAX_MACRO_LENGTH + 8)) + envelopes = features_to_envelopes( + build_features([MAX_VOLUME_OR_RATE], arpeggio=contour), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, + ) + assert len(envelopes.table_rows) == len(contour) class TestAnEmptySlice: - """An instrument holds at least one row, so a slice with no volume envelope still + """An instrument holds at least one value, so a slice with no volume envelope still reaches Bitphase as a playable silent instrument. """ @pytest.fixture(name="envelopes") def envelopes_fixture(self) -> ChannelEnvelopes: - return features_to_envelopes(build_features([]), GeneratorName.PULSE1, loop=False) + return features_to_envelopes( + build_features([]), + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, + ) - def test_it_holds_one_silent_row(self, envelopes: ChannelEnvelopes) -> None: - assert [row.volume_or_rate for row in envelopes.rows] == [SILENT_VOLUME] + def test_it_holds_one_silent_level(self, envelopes: ChannelEnvelopes) -> None: + assert volume_values(envelopes) == [SILENT_VOLUME] def test_its_table_holds_one_flat_offset(self, envelopes: ChannelEnvelopes) -> None: assert envelopes.table_rows == (NO_TABLE_OFFSET,) - def test_it_loops_on_that_row(self, envelopes: ChannelEnvelopes) -> None: - assert envelopes.loop == LOOP_FROM_START + def test_it_circles_that_value(self, envelopes: ChannelEnvelopes) -> None: + assert envelopes.macros[NesMacroField.VOLUME_OR_RATE].loop == LOOP_FROM_START + assert envelopes.table_loop == LOOP_FROM_START 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..f71f856f6 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_instrument_document.py @@ -0,0 +1,91 @@ +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.formats.bitphase.specification.macros import NesMacroField +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: + project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN) + project.song[channel].set_row(0, 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.macros[NesMacroField.VOLUME_OR_RATE].values) == 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.macros[NesMacroField.VOLUME_OR_RATE].loop == 1 for instrument in document.instruments) + + def test_a_one_shot_holds_its_final_value(self) -> None: + project, _ = _project(ChannelName.PULSE1) + + document = project_to_bitphase(project) + + assert all( + macro.loop == len(macro.values) - 1 + for instrument in document.instruments + for macro in instrument.macros.values() + ) + + 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_every_point_stands_among_the_values_it_circles(self) -> None: + project, _ = _project(ChannelName.PULSE1, loop_point=LOOP_FROM_START) + + document = project_to_bitphase(project) + + assert all( + macro.loop < len(macro.values) + for instrument in document.instruments + for macro in instrument.macros.values() + ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_notes.py b/tests/unit/sampletones_core/formats/bitphase/test_notes.py index 0f69224fd..6d620f4a9 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_notes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_notes.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.constants.general import NUM_PERIODS +from sampletones_core.constants.general import NOISE_PERIODS, NUM_PERIODS from sampletones_core.formats.bitphase.notes import ( noise_arpeggio_to_table_offset, noise_period_to_note_index, @@ -19,6 +19,7 @@ NOTE_RANGE, NoteName, ) +from tests.suite.bitphase import BITPHASE_NOISE_TIMERS, noise_register from tests.suite.case import BaseRegularTestCase LOWEST_STEP: Final[int] = -NUM_PERIODS @@ -30,9 +31,9 @@ def bitphase_note_value(name: int, octave: int) -> int: return name - int(NoteName.C) + (octave - FIRST_OCTAVE) * NOTE_RANGE -def bitphase_noise_period(index: int) -> int: - """The noise period Bitphase's playback selects for a note index.""" - return NUM_PERIODS - 1 - index % NUM_PERIODS +def sounded_noise_timer(index: int) -> int: + """The noise timer Bitphase's playback sounds for a note index.""" + return BITPHASE_NOISE_TIMERS[noise_register(index)] class TestPitchToNoteIndex: @@ -95,9 +96,18 @@ def test_every_cell_names_a_pitched_semitone(self) -> None: class TestNoisePeriods: + """The project counts noise periods from the slowest, while Bitphase's register counts them + from the fastest, so the note a period is written at sounds the timer the project gives it. + """ + + @pytest.mark.parametrize("period", range(NUM_PERIODS)) + def test_a_period_reaches_the_note_index_that_sounds_it(self, period: int) -> None: + assert sounded_noise_timer(noise_period_to_note_index(period)) == NOISE_PERIODS[period] + @pytest.mark.parametrize("period", range(NUM_PERIODS)) - def test_a_period_reaches_the_note_index_that_selects_it(self, period: int) -> None: - assert bitphase_noise_period(noise_period_to_note_index(period)) == period + def test_a_period_past_the_cycle_wraps_into_the_sixteen_the_channel_has(self, period: int) -> None: + """A transpose walks the period around the sixteen, so a period a cycle up sounds as the one it wraps to.""" + assert noise_period_to_note_index(period + NUM_PERIODS) == noise_period_to_note_index(period) @pytest.mark.parametrize("period", range(NUM_PERIODS)) def test_a_base_note_leaves_a_whole_cycle_of_offsets_playable(self, period: int) -> None: @@ -111,7 +121,7 @@ def test_an_arpeggio_step_moves_the_period_by_that_much(self, step: int) -> None """ for period in range(NUM_PERIODS): index = noise_period_to_note_index(period) + noise_arpeggio_to_table_offset(step) - assert bitphase_noise_period(index) == (period + step) % NUM_PERIODS + assert sounded_noise_timer(index) == NOISE_PERIODS[(period + step) % NUM_PERIODS] @pytest.mark.parametrize("step", range(LOWEST_STEP, HIGHEST_STEP + 1)) def test_every_reached_note_stays_inside_the_tuning_table(self, step: int) -> None: diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index ed3b8e11f..07bb98bfc 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -1,17 +1,14 @@ import json +from dataclasses import replace from pathlib import Path from typing import Any, Dict, Final, List 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 ( - PRESET_TUNING_TABLE, - instrument_to_preset, - write_preset, -) +from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES from sampletones_core.formats.bitphase.specification.instruments import ( LOOP_FROM_START, @@ -19,6 +16,9 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) +from sampletones_core.formats.bitphase.specification.macros import NesMacroField +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE +from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument @@ -26,7 +26,17 @@ VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0] PITCH_CONTOUR: Final[List[int]] = [0, 3, 7, 12] NOISE_PERIOD: Final[int] = 4 -PRESET_KEYS: Final[List[str]] = ["chipType", "name", "loop", "rows"] +PRESET_KEYS: Final[List[str]] = ["chipType", "name", "macros"] +BEND_ENVELOPE: Final[List[int]] = [0, -3, -7, -12] +LOWERED_A4_FREQUENCY: Final[float] = 432.0 + + +def levels(preset: BitphaseInstrumentPreset) -> List[int]: + return list(preset.macros[NesMacroField.VOLUME_OR_RATE].values) + + +def offsets(preset: BitphaseInstrumentPreset) -> List[int]: + return list(preset.macros[NesMacroField.TONE_ADD].values) @pytest.fixture(name="preset") @@ -47,55 +57,70 @@ class TestThePresetCarriesTheSlice: def test_it_takes_the_slice_name(self, preset: BitphaseInstrumentPreset) -> None: assert preset.name == "Lead" - def test_it_holds_one_row_per_envelope_item(self, preset: BitphaseInstrumentPreset) -> None: - assert [row.volume_or_rate for row in preset.rows] == VOLUME_ENVELOPE + def test_it_holds_one_level_per_envelope_item(self, preset: BitphaseInstrumentPreset) -> None: + assert levels(preset) == VOLUME_ENVELOPE - def test_a_one_shot_rests_on_its_last_row(self, preset: BitphaseInstrumentPreset) -> None: - assert preset.loop == len(preset.rows) - 1 + def test_a_one_shot_holds_its_final_value(self, preset: BitphaseInstrumentPreset) -> None: + assert all(macro.loop == len(macro.values) - 1 for macro in preset.macros.values()) - def test_a_looping_slice_returns_to_its_first_row(self) -> None: + def test_a_looping_slice_circles_from_its_first_value(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 + assert preset.macros[NesMacroField.VOLUME_OR_RATE].loop == LOOP_FROM_START class TestThePitchContourRidesInTheToneOffset: - """A preset carries rows alone, so the movement a table would drive is expressed as - the per-tick period offset each row adds to the note's own period. + """A preset carries macros alone, so the movement a table would drive is expressed as + the per-tick period offset each tick adds to the note's own period. """ - def test_each_row_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: + def test_each_tick_offsets_the_period_its_semitone_asks_for(self, preset: BitphaseInstrumentPreset) -> None: base_index = pitch_to_note_index(REFERENCE_PITCH) - base_period = PRESET_TUNING_TABLE[base_index] - expected = [PRESET_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] - assert [row.tone_add for row in preset.rows] == expected + base_period = DEFAULT_TUNING_TABLE[base_index] + expected = [DEFAULT_TUNING_TABLE[base_index + semitones] - base_period for semitones in PITCH_CONTOUR] + assert offsets(preset) == expected - def test_the_first_row_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: - assert preset.rows[0].tone_add == NO_TONE_OFFSET + def test_the_first_tick_plays_the_reconstructed_pitch(self, preset: BitphaseInstrumentPreset) -> None: + assert offsets(preset)[0] == NO_TONE_OFFSET def test_a_rising_contour_shortens_the_period(self, preset: BitphaseInstrumentPreset) -> None: - offsets = [row.tone_add for row in preset.rows] - assert all(later <= earlier for earlier, later in zip(offsets, offsets[1:])) + written = offsets(preset) + assert all(later <= earlier for earlier, later in zip(written, written[1:])) def test_every_offset_fits_the_field(self, preset: BitphaseInstrumentPreset) -> None: - assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + assert all(MIN_TONE_ADD <= offset <= MAX_TONE_ADD for offset in offsets(preset)) def test_a_contour_reaching_past_the_tuning_table_holds_its_edge(self) -> None: preset = instrument_to_preset( build_instrument("Sweep", build_features(VOLUME_ENVELOPE, arpeggio=[0, 40, 80, 120])), ) - assert all(MIN_TONE_ADD <= row.tone_add <= MAX_TONE_ADD for row in preset.rows) + assert all(MIN_TONE_ADD <= offset <= MAX_TONE_ADD for offset in offsets(preset)) - def test_a_noise_slice_takes_its_period_from_the_note(self) -> None: + def test_a_noise_slice_carries_its_contour_in_period_steps(self) -> None: + """Bitphase adds the offset to the noise note, which carries the period itself.""" preset = instrument_to_preset( build_instrument( "Hat", 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} + assert list(offsets(preset))[:4] == [0, 1, 2, 3] + + +class TestAPresetStaysAtConcertPitch: + """A preset loads into a document that keeps its own tuning, so its offsets are measured under + the tuning a new Bitphase document plays whatever the slice was tuned at. + """ + + def test_its_offsets_are_the_ones_a_concert_pitch_slice_writes(self) -> None: + slice_at_concert_pitch = build_instrument( + "Lead", + build_features(VOLUME_ENVELOPE, arpeggio=PITCH_CONTOUR, bend=BEND_ENVELOPE), + ) + retuned = replace(slice_at_concert_pitch, tuning=Tuning(a4_frequency=LOWERED_A4_FREQUENCY)) + assert offsets(instrument_to_preset(retuned)) == offsets(instrument_to_preset(slice_at_concert_pitch)) class TestThePresetFile: @@ -103,7 +128,7 @@ class TestThePresetFile: def test_it_holds_every_field_the_panel_reads(self, document: Dict[str, Any], key: str) -> None: assert key in document - def test_it_names_the_chip_whose_rows_it_holds(self, document: Dict[str, Any]) -> None: + def test_it_names_the_chip_whose_fields_it_holds(self, document: Dict[str, Any]) -> None: assert document["chipType"] == CHIP_TYPE_NES def test_it_is_indented_the_way_bitphase_writes_its_own( @@ -113,5 +138,8 @@ def test_it_is_indented_the_way_bitphase_writes_its_own( write_preset(destination, preset) assert '\n "name"' in destination.read_text(encoding="utf-8") - def test_its_rows_carry_the_field_names_the_panel_reads(self, document: Dict[str, Any]) -> None: - assert {"pulseWidth", "volumeOrRate", "toneAdd"} <= set(document["rows"][0]) + def test_its_macros_carry_the_field_names_the_panel_reads(self, document: Dict[str, Any]) -> None: + assert {"pulseWidth", "volumeOrRate", "toneAdd"} <= set(document["macros"]) + + def test_each_macro_carries_its_values_and_the_point_they_circle_from(self, document: Dict[str, Any]) -> None: + assert all(set(macro) == {"values", "loop"} for macro in document["macros"].values()) 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..64bb31dc7 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple @@ -5,45 +6,68 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName -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 +from sampletones_core.configs.library import InstructionsLibraryConfig +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) +from sampletones_core.constants.general import MIN_PITCH, MIN_PLAYED_PITCH, SILENT_VOLUME +from sampletones_core.exporters.skipped import SkippedRow, SkipReason +from sampletones_core.features.envelope import Envelope +from sampletones_core.formats.bitphase.builder import build_bitphase, project_to_bitphase +from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell, NoteCell from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.notes import ( note_index_to_note_cell, pitch_to_note_index, ) from sampletones_core.formats.bitphase.specification.channels import ChannelIndex +from sampletones_core.formats.bitphase.specification.chip import DEFAULT_A4_TUNING, DEFAULT_CPU_FREQUENCY from sampletones_core.formats.bitphase.specification.effects import ( - NO_EFFECT_PARAMETER, + NO_EFFECT_TABLE, SPEED_EFFECT_DELAY, EffectId, ) -from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START from sampletones_core.formats.bitphase.specification.patterns import ( + FULL_VOLUME, + MAX_NOTE_INDEX, + MIN_NOTE_INDEX, NO_INSTRUMENT_CHANGE, NO_TABLE_CHANGE, NO_VOLUME_CHANGE, + NOTE_INDEX_PITCH_OFFSET, SYMBOL_BASE, TABLE_COLUMN_OFFSET, VOLUME_OFF, NoteName, ) +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE, generate_tuning_table 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.performance.modifiers import triangle_sounds_at from sampletones_core.project.patterns.channel import Channel from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.pitch import Step 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.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.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_reference from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_core.utils.frequencies import transpose_pitch +from tests.suite.base import BaseTestSuite +from tests.suite.bitphase import BITPHASE_OPENING_PATTERN_VOLUME, cell_pitch, pattern_volume +from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import song_row_volumes +from tests.suite.stems import single_entry_stems_data RECONSTRUCTION_LENGTH: Final[int] = 4 ROWS_PER_PATTERN: Final[int] = 8 @@ -58,86 +82,134 @@ SILENCED_ROW: Final[int] = 7 GROOVE_TEMPO: Final[int] = 210 GROOVE_TICKS: Final[Tuple[int, ...]] = (5, 4, 4, 4, 5, 4, 4, 4) +RETUNED_A4_FREQUENCY: Final[float] = 432.0 +QUIET_VOLUME: Final[int] = 4 +NOTE_OFF_VOLUME: Final[int] = 6 +CLOSING_VOLUME: Final[int] = 9 +FRAME_CLOSING_VOLUME: Final[int] = 2 +QUIET_TRIANGLE_VOLUME: Final[int] = 5 +LOUD_TRIANGLE_VOLUME: Final[int] = 12 +RESTING_TRIANGLE_VOLUME: Final[int] = 7 +NOTE_TRIANGLE_VOLUME: Final[int] = 3 +PLAYED_PASSES: Final[int] = 2 +LOW_PITCH: Final[int] = 36 +LOW_TRANSPOSE: Final[int] = -10 +CONTOUR_PITCHES: Final[Tuple[int, ...]] = (40, 50) +SUBMERGED_TRANSPOSE: Final[int] = -20 def build_reconstruction( - instructions: Mapping[GeneratorName, Sequence[Instruction]], + instructions: Mapping[ChannelName, Sequence[Instruction]], + *, + config: Config, ) -> 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(), + config=config, coefficient=1.0, - audio_filepath=Path("/dev/null"), + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data(list(DEFAULT_CHANNELS), instructions), ) -def pulse_sample(name: str, pitch: int) -> Sample: +def pulse_sample( + name: str, + pitch: int, + *, + config: Config, +) -> 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}, + config=config, + ), ) -def triangle_sample(name: str, pitch: int) -> Sample: +def contour_sample( + name: str, + pitches: Sequence[int], + *, + config: Config, +) -> Sample: + """A pulse sample sounding each pitch for one frame, so its table moves the note between them.""" + instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0) for pitch in pitches] + return Sample( + name=name, + reconstruction=build_reconstruction( + {ChannelName.PULSE1: instructions}, + config=config, + ), + ) + + +def triangle_sample( + name: str, + pitch: int, + *, + config: Config, +) -> Sample: instructions = [TriangleInstruction(on=True, pitch=pitch)] return Sample( name=name, - reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions}), + reconstruction=build_reconstruction( + {ChannelName.TRIANGLE: instructions}, + config=config, + ), ) @pytest.fixture(name="lead") def lead_fixture() -> Sample: - return pulse_sample("Lead", LEAD_PITCH) + return pulse_sample("Lead", LEAD_PITCH, config=Config()) @pytest.fixture(name="bass") def bass_fixture() -> Sample: - return triangle_sample("Bass", BASS_PITCH) + return triangle_sample("Bass", BASS_PITCH, config=Config()) @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), - transpose=0, + command=NoteOn(voice_id=lead.id), + pitch=Step(value=0), volume=ROW_VOLUME, ) pulse_rows[NOTE_OFF_ROW] = Row(command=NoteOff()) pulse_rows[TRANSPOSED_ROW] = Row( - command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), - transpose=TRANSPOSE, + command=NoteOn(voice_id=lead.id), + pitch=Step(value=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), - transpose=0, + command=NoteOn(voice_id=bass.id), + pitch=Step(value=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 @@ -230,8 +302,18 @@ def test_a_row_volume_reaches_the_volume_column(self, document: BitphaseProject) row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRIGGER_ROW] assert row.volume == ROW_VOLUME - def test_a_row_that_sets_no_volume_leaves_the_column_alone(self, document: BitphaseProject) -> None: + def test_a_note_after_a_quieter_row_starts_at_the_full_level(self, document: BitphaseProject) -> None: + """The song starts a note stating no level at the full level, while Bitphase carries the + level the trigger row set into it, so the note writes the full level. + """ row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] + assert row.volume == FULL_VOLUME + + def test_a_note_the_channel_reaches_at_the_full_level_leaves_the_column_alone( + self, + document: BitphaseProject, + ) -> None: + row = document.songs[0].patterns[0].channels[int(ChannelIndex.TRIANGLE)].rows[TRIGGER_ROW] assert row.volume == NO_VOLUME_CHANGE def test_a_row_asking_for_silence_silences_the_channel(self, document: BitphaseProject) -> None: @@ -258,72 +340,399 @@ def test_a_blank_line_leaves_every_column_alone(self, document: BitphaseProject) ) -class TestTheTempoBecomesAGroove: - """A Bitphase song holds one speed value per row, so the fractional row rate most tempi - ask for is carried by a groove: whole tick counts that vary from row to row. The groove - reaches the engine as a table a speed effect reads a row at a time, triggered from the - channel this exporter leaves silent. A tempo whose rows all last alike is carried by the - song's initial speed alone. +def played_speeds(document: BitphaseProject) -> List[Tuple[int, ...]]: + """The ticks each row of every pattern lasts, the order played once through. + + The song starts at its initial speed, and a speed effect sets the speed from its row on. + """ + song = document.songs[0] + patterns = {pattern.id: pattern for pattern in song.patterns} + speed = song.initial_speed + played: List[Tuple[int, ...]] = [] + for pattern_id in document.pattern_order: + pattern = patterns[pattern_id] + rows: List[int] = [] + for row_index in range(pattern.length): + for channel in pattern.channels: + for effect in channel.rows[row_index].effects: + if effect is not None and effect.effect == int(EffectId.SPEED): + speed = effect.parameter + + rows.append(speed) + + played.append(tuple(rows)) + + return played + + +class TestTheTempoBecomesSpeeds: + """A Bitphase song holds one speed value per row, so the fractional row rate most tempi ask for + is carried by speed effects that change it where the rows' lengths change. They ride the channel + this exporter leaves silent. A tempo whose rows all last alike is carried by the song's initial + speed alone. """ - def test_a_tempo_the_speed_column_states_needs_no_table(self, document: BitphaseProject) -> None: + def test_the_document_holds_one_table_per_slice( + self, + document: BitphaseProject, + grooved_document: BitphaseProject, + ) -> None: assert len(document.tables) == len(document.instruments) + assert len(grooved_document.tables) == len(grooved_document.instruments) - def test_a_tempo_the_speed_column_states_leaves_the_groove_channel_resting( + def test_a_tempo_the_speed_column_states_leaves_the_speed_channel_resting( self, document: BitphaseProject, ) -> None: - assert all(row == BitphaseRow() for row in groove_channel_rows(document, 0)) - - def test_a_groove_takes_the_table_above_the_slices(self, grooved_document: BitphaseProject) -> None: - table = grooved_document.tables[-1] - assert table.id == len(grooved_document.instruments) - assert table.loop == LOOP_FROM_START - - def test_the_table_holds_the_ticks_each_row_lasts(self, grooved_document: BitphaseProject) -> None: - assert grooved_document.tables[-1].rows == GROOVE_TICKS + for pattern_index in range(len(document.songs[0].patterns)): + assert all(row == BitphaseRow() for row in groove_channel_rows(document, pattern_index)) def test_the_song_starts_on_the_ticks_its_first_row_lasts(self, grooved_document: BitphaseProject) -> None: assert grooved_document.songs[0].initial_speed == GROOVE_TICKS[TRIGGER_ROW] - def test_every_pattern_triggers_the_groove_on_its_first_row(self, grooved_document: BitphaseProject) -> None: - """The speed table advances one entry per row and returns to the entry the trigger - names, so triggering it again at each pattern start holds every row on the entry that - describes it however the order jumps. - """ - trigger = EffectCell( - effect=int(EffectId.SPEED), - delay=SPEED_EFFECT_DELAY, - parameter=NO_EFFECT_PARAMETER, - table_index=grooved_document.tables[-1].id, + def test_every_row_plays_the_ticks_the_songs_timing_gives_it( + self, + grooved_document: BitphaseProject, + source: Project, + ) -> None: + """At tempo 210 an 8-row pattern lasts 34 2/7 ticks, so the second frame plays one tick more.""" + timing = SongTiming.from_project(source, bounds=SONG_TICK_BOUNDS) + expected = [timing.groove(frame).ticks for frame in range(source.song.order_length())] + + assert played_speeds(grooved_document) == expected + assert expected[0] == GROOVE_TICKS + assert sum(expected[1]) == sum(GROOVE_TICKS) + 1 + + def test_a_speed_is_stated_only_where_a_row_lasts_differently_from_the_row_before( + self, + grooved_document: BitphaseProject, + ) -> None: + """The song comes round to its first row after the last, so that is the row before the first.""" + speeds = [speed for pattern in played_speeds(grooved_document) for speed in pattern] + changes = sum(1 for index, speed in enumerate(speeds) if speed != speeds[index - 1]) + stated = sum( + 1 + for pattern_index in range(len(grooved_document.songs[0].patterns)) + for row in groove_channel_rows(grooved_document, pattern_index) + if row != BitphaseRow() ) - triggers = [ - groove_channel_rows(grooved_document, index)[TRIGGER_ROW].effects - for index in range(len(grooved_document.songs[0].patterns)) - ] - assert triggers == [(trigger,)] * len(triggers) - def test_the_groove_channel_carries_nothing_but_the_trigger(self, grooved_document: BitphaseProject) -> None: - rows = groove_channel_rows(grooved_document, 0) - assert all(row == BitphaseRow() for row in rows[TRIGGER_ROW + 1 :]) + assert stated == changes + + def test_the_speed_channel_carries_nothing_but_speeds(self, grooved_document: BitphaseProject) -> None: + speeds = {GROOVE_TICKS[TRIGGER_ROW], *GROOVE_TICKS} + for pattern_index in range(len(grooved_document.songs[0].patterns)): + for row in groove_channel_rows(grooved_document, pattern_index): + if row == BitphaseRow(): + continue + + (effect,) = row.effects + assert effect is not None + assert (effect.effect, effect.delay, effect.table_index) == ( + int(EffectId.SPEED), + SPEED_EFFECT_DELAY, + NO_EFFECT_TABLE, + ) + assert effect.parameter in speeds def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: BitphaseProject) -> None: - """The groove rides the silent channel, so every channel that plays keeps the one + """The speeds ride the silent channel, so every channel that plays keeps the one effect column the chip gives it. """ channels = grooved_document.songs[0].patterns[0].channels[: int(ChannelIndex.DPCM)] assert all(row.effects == (None,) for channel in channels for row in channel.rows) -class TestAnUnbuildableRow: - def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: +class TestARowWithNoInstrumentOnItsChannel: + """A voice sounds on the channels its instruments cover, so a row naming it elsewhere plays + nothing in the song. The export writes a note cut there and reports the row.""" + + @staticmethod + def _with_lead_on_pulse2(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), volume=ROW_VOLUME) + 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 + + def test_the_row_is_written_as_a_note_cut(self, source: Project, lead: Sample) -> None: + self._with_lead_on_pulse2(source, lead) + + document = project_to_bitphase(source) + + cut = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE2)].rows[TRIGGER_ROW] + assert cut.note is not None + assert cut.note.name == int(NoteName.OFF) + assert cut.volume == ROW_VOLUME + + def test_the_row_is_reported_where_the_tracker_shows_it(self, source: Project, lead: Sample) -> None: + self._with_lead_on_pulse2(source, lead) + + skipped = build_bitphase(source).skipped_rows + + assert skipped == ( + SkippedRow( + voice_id=lead.id, + channel=ChannelName.PULSE2, + order_position=0, + row_index=TRIGGER_ROW, + reason=SkipReason.NO_INSTRUMENT, + ), + ) + + def test_a_pattern_the_order_plays_twice_reports_each_frame(self, source: Project, lead: Sample) -> None: + self._with_lead_on_pulse2(source, lead) + source.song.order[1][ChannelName.PULSE2] = 0 + + skipped = build_bitphase(source).skipped_rows + + assert [row.order_position for row in skipped] == [0, 1] + + def test_a_project_naming_only_voices_with_instruments_reports_nothing(self, source: Project) -> None: + assert build_bitphase(source).skipped_rows == () + + +class TestAProjectSoundsItsSamplesTuning: + """A song plays at one tuning, and a project's samples state it by agreeing on it, so the song + takes the tuning they were reconstructed at. + """ + + @pytest.fixture(name="retuned_config") + def retuned_config_fixture(self) -> Config: + return Config(library=InstructionsLibraryConfig(a4_frequency=RETUNED_A4_FREQUENCY)) + + @pytest.fixture(name="lead") + def lead_fixture(self, retuned_config: Config) -> Sample: + return pulse_sample("Lead", LEAD_PITCH, config=retuned_config) + + @pytest.fixture(name="bass") + def bass_fixture(self, retuned_config: Config) -> Sample: + return triangle_sample("Bass", BASS_PITCH, config=retuned_config) + + def test_the_song_takes_the_tuning_its_samples_share(self, document: BitphaseProject) -> None: + song = document.songs[0] + assert song.a4_tuning_hz == RETUNED_A4_FREQUENCY + assert song.tuning_table == generate_tuning_table(DEFAULT_CPU_FREQUENCY, a4_tuning=RETUNED_A4_FREQUENCY) + + def test_samples_reconstructed_at_different_tunings_are_refused(self, source: Project) -> None: + """One table sounds one tuning, so a project whose samples disagree has no song to write.""" + source.voices.append(triangle_sample("Concert bass", BASS_PITCH, config=Config())) + with pytest.raises(ValueError, match=str(RETUNED_A4_FREQUENCY)): + build_bitphase(source) + + def test_a_project_of_instruments_alone_plays_at_concert_pitch(self) -> None: + voices: IdentifiedCollection[VoiceUnion] = IdentifiedCollection() + voices.append(Instrument(name="Lead", envelopes=InstrumentEnvelopes(volume=Envelope(items=(15, 0))))) + project = Project.create(title="Instruments", author="Tester", settings=ProjectSettings()) + project.voices = voices + + song = project_to_bitphase(project).songs[0] + + assert song.a4_tuning_hz == DEFAULT_A4_TUNING + assert song.tuning_table == DEFAULT_TUNING_TABLE + + +def rows_with(*cells: Tuple[int, Row]) -> List[Row]: + """A pattern's rows, blank apart from the ones given by their index.""" + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def arranged_project( + voices: Sequence[Sample], + patterns: Mapping[ChannelName, Mapping[int, List[Row]]], + order: List[Dict[ChannelName, Optional[int]]], +) -> Project: + """A project playing ``voices`` through the channel patterns ``order`` names.""" + pool: IdentifiedCollection[Sample] = IdentifiedCollection() + for voice in voices: + pool.append(voice) + + channels = { + channel_name: Channel( + name=channel_name, + patterns={index: Pattern(rows=rows) for index, rows in patterns.get(channel_name, {}).items()}, + ) + for channel_name in ChannelName.items() + } + project = Project.create(title="Rows", author="Tester", settings=ProjectSettings()) + project.voices = pool + project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) + return project + + +def played_volumes(document: BitphaseProject, channel: ChannelIndex) -> List[int]: + """The level Bitphase plays one channel at on each row, the order played through and round again. + + The document returns to its loop point, the first order position, keeping every channel's level, + so a second pass shows what the level the song ends on does to the notes it opens with. + """ + patterns = {pattern.id: pattern for pattern in document.songs[0].patterns} + level = BITPHASE_OPENING_PATTERN_VOLUME + levels: List[int] = [] + for _ in range(PLAYED_PASSES): + for pattern_id in document.pattern_order: + for row in patterns[pattern_id].channels[int(channel)].rows: + level = pattern_volume(level, row.volume) + levels.append(level) + + return levels + + +class TestTheLevelsAPlayedSongCarries: + """The song starts a note stating no level at the full level and sounds the triangle only above + half volume, while Bitphase carries the last level a cell wrote into every note and sounds the + triangle at any level above silence. The document therefore writes its volume column so that + Bitphase, playing the order through and round again, plays each row the song sounds at the song's + own level. + """ + + @pytest.fixture(name="arranged") + def arranged_fixture(self, lead: Sample, bass: Sample) -> Project: + lead_note = Row(command=NoteOn(voice_id=lead.id)) + bass_note = Row(command=NoteOn(voice_id=bass.id)) + pulse = { + 0: rows_with( + (0, lead_note), + (1, Row(volume=QUIET_VOLUME)), + (2, Row(pitch=Step(value=TRANSPOSE))), + (3, lead_note), + (4, Row(command=NoteOff(), volume=NOTE_OFF_VOLUME)), + (5, lead_note), + (6, Row(volume=CLOSING_VOLUME)), + ), + 1: rows_with( + (0, lead_note), + (3, Row(volume=FRAME_CLOSING_VOLUME)), + ), + } + triangle = { + 0: rows_with( + (0, bass_note), + (2, Row(volume=QUIET_TRIANGLE_VOLUME)), + (4, Row(volume=LOUD_TRIANGLE_VOLUME)), + (6, bass_note), + ), + 1: rows_with( + (0, Row(volume=RESTING_TRIANGLE_VOLUME)), + (2, bass_note), + (5, Row(command=NoteOn(voice_id=bass.id), volume=NOTE_TRIANGLE_VOLUME)), + ), + } + order: List[Dict[ChannelName, Optional[int]]] = [ + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 1, ChannelName.TRIANGLE: 1}, + {ChannelName.PULSE1: None, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: None}, + ] + return arranged_project( + (lead, bass), + {ChannelName.PULSE1: pulse, ChannelName.TRIANGLE: triangle}, + order, + ) + + def test_the_pulse_plays_every_sounding_row_at_the_song_level(self, arranged: Project) -> None: + played = played_volumes(project_to_bitphase(arranged), ChannelIndex.SQUARE1) + song = song_row_volumes(arranged, ChannelName.PULSE1) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level for level, _ in sounding] == [volume for _, volume in sounding] + + def test_the_triangle_sounds_on_every_row_the_song_sounds_it(self, arranged: Project) -> None: + played = played_volumes(project_to_bitphase(arranged), ChannelIndex.TRIANGLE) + song = song_row_volumes(arranged, ChannelName.TRIANGLE) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level > 0 for level, _ in sounding] == [triangle_sounds_at(volume) for _, volume in sounding] + + def test_a_note_the_song_ends_quieter_than_writes_the_full_level(self, arranged: Project) -> None: + """The order returns to its first frame carrying the level its last frame set, so the song's + opening note is written at the full level although the first pass reaches it there anyway. + """ + row = project_to_bitphase(arranged).songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[0] + assert row.volume == FULL_VOLUME + + +class TestTheTriangleSoundsAboveHalfVolume(BaseTestSuite): + """The song sounds the triangle while a row asks for more than half volume, while Bitphase sounds + it at any pattern level above silence, so a quieter row writes the value that silences it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + volume: int + expected: int + + test_cases: Tuple["TestTheTriangleSoundsAboveHalfVolume.TestCase", ...] = ( + TestCase(label="a level the triangle rests at", volume=QUIET_TRIANGLE_VOLUME, expected=VOLUME_OFF), + TestCase(label="a level the triangle sounds at", volume=LOUD_TRIANGLE_VOLUME, expected=LOUD_TRIANGLE_VOLUME), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_row_writes_the_triangle_gate( + self, + bass: Sample, + test_case: "TestTheTriangleSoundsAboveHalfVolume.TestCase", + ) -> None: + project = arranged_project( + (bass,), + {ChannelName.TRIANGLE: {0: rows_with((0, Row(command=NoteOn(voice_id=bass.id), volume=test_case.volume)))}}, + [{ChannelName.TRIANGLE: 0}], + ) + + row = project_to_bitphase(project).songs[0].patterns[0].channels[int(ChannelIndex.TRIANGLE)].rows[0] + + assert row.volume == test_case.expected + + +class TestALowTransposeKeepsTheSongsPitch: + """The song and Bitphase both hold every tick's transposed pitch within 24-119, the span of the tuning + table. Bitphase writes no note below index 0, so a transpose below it writes pitch 24. + """ + + @staticmethod + def _transposed(sample: Sample, transpose: int) -> Tuple[BitphaseProject, BitphaseRow]: + project = arranged_project( + (sample,), + { + ChannelName.PULSE1: { + 0: rows_with((0, Row(command=NoteOn(voice_id=sample.id), pitch=Step(value=transpose)))) + } + }, + [{ChannelName.PULSE1: 0}], + ) + document = project_to_bitphase(project) + return ( + document, + document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[0], + ) + + def test_a_flat_voice_transposed_below_a0_writes_its_own_note(self) -> None: + _, row = self._transposed(pulse_sample("Low", LOW_PITCH, config=Config()), LOW_TRANSPOSE) + + assert cell_pitch(row.note) == LOW_PITCH + LOW_TRANSPOSE < MIN_PITCH + + def test_a_flat_voice_transposed_below_the_table_writes_its_lowest_note(self) -> None: + _, row = self._transposed(pulse_sample("Low", LOW_PITCH, config=Config()), SUBMERGED_TRANSPOSE) + + assert cell_pitch(row.note) == MIN_PLAYED_PITCH + + def test_a_contour_reaching_below_the_table_plays_every_tick_where_the_song_does(self) -> None: + sample = contour_sample("Contour", CONTOUR_PITCHES, config=Config()) + reference = voice_reference(sample, ChannelName.PULSE1) + transpose = MIN_PLAYED_PITCH + 1 - reference + document, row = self._transposed(sample, transpose) + + steps = document.tables[0].rows + written = cell_pitch(row.note) - NOTE_INDEX_PITCH_OFFSET + tracker = [min(max(written + step, MIN_NOTE_INDEX), MAX_NOTE_INDEX) + NOTE_INDEX_PITCH_OFFSET for step in steps] + song = [transpose_pitch(reference + step, transpose) for step in steps] - with pytest.raises(ValueError, match="has no instrument"): - project_to_bitphase(source) + assert cell_pitch(row.note) == reference + transpose + assert min(song) == MIN_PLAYED_PITCH + assert tracker == song diff --git a/tests/unit/sampletones_core/formats/bitphase/test_transposes.py b/tests/unit/sampletones_core/formats/bitphase/test_transposes.py new file mode 100644 index 000000000..46be0527d --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_transposes.py @@ -0,0 +1,549 @@ +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MIN_PLAYED_PITCH, NUM_PERIODS +from sampletones_core.formats.bitphase.btp import project_to_bytes +from sampletones_core.formats.bitphase.builder import BITPHASE_TICK_BOUNDS, build_bitphase, project_to_bitphase +from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell +from sampletones_core.formats.bitphase.model.project import BitphaseProject +from sampletones_core.formats.bitphase.model.table import BitphaseTable +from sampletones_core.formats.bitphase.notes import pitch_to_note_index +from sampletones_core.formats.bitphase.specification.channels import ( + CHANNEL_LABELS, + CHANNEL_TO_INDEX, + ChannelIndex, +) +from sampletones_core.formats.bitphase.specification.effects import ( + MAX_ORNAMENT_POSITION, + NO_EFFECT_TABLE, + ORNAMENT_POSITION_DELAY, + EffectId, +) +from sampletones_core.formats.bitphase.specification.instruments import MAX_TABLE_ID, MIN_TABLE_ID +from sampletones_core.formats.bitphase.specification.patterns import ( + NO_INSTRUMENT_CHANGE, + NO_TABLE_CHANGE, + TABLE_COLUMN_OFFSET, + NoteName, +) +from sampletones_core.project.patterns.pitch import Step +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.note_off import NoteOff +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import voice_reference +from sampletones_core.timing import SongTiming +from tests.suite.base import BaseTestSuite +from tests.suite.bitphase import cell_pitch, noise_register, note_value, parse_btp, played_notes, replayed_pitches +from tests.suite.case import BaseRegularTestCase +from tests.suite.transposes import ( + TRANSPOSED_ROWS_PER_PATTERN, + contour_sample, + flat_instrument, + flat_sample, + note, + one_channel_project, + rows_with, + sounded_pitches, +) + +UNIFORM_SETTINGS: Final[ProjectSettings] = ProjectSettings() +UNIFORM_ROW_TICKS: Final[int] = UNIFORM_SETTINGS.speed +GROOVE_SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=210) +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +FRAMES: Final[int] = 240 +LONG_FRAMES: Final[int] = 420 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +NOTE_TRANSPOSE: Final[int] = 5 +AFTER_RESET_TRANSPOSE: Final[int] = 1 +RAISED_ROW: Final[int] = 3 +LOWERED_ROW: Final[int] = 7 +RESET_ROW: Final[int] = 10 +AFTER_RESET_ROW: Final[int] = 13 +LATE_NOTE_ROW: Final[int] = 12 +FLAT_PITCH: Final[int] = 40 +FLAT_FRAMES: Final[int] = 8 +SUBMERGED_TRANSPOSE: Final[int] = -20 +LOW_FLAT_PITCH: Final[int] = 36 +NOISE_PERIOD: Final[int] = 3 +NOISE_TRANSPOSE: Final[int] = -5 +LATE_FRAME: Final[int] = 3 +ORGAN_PITCH: Final[int] = 72 +ORGAN_PERIOD: Final[int] = 6 +BEND_ROW: Final[int] = 2 +INSTRUMENT_ROW: Final[int] = 6 +LATER_BEND_ROW: Final[int] = 9 +LATER_NOTE_OFF_ROW: Final[int] = 11 +SILENT_INSTRUMENT_ROW: Final[int] = 13 +SHORT_ORDER: Final[Tuple[Optional[int], ...]] = (0, 1, 2) +LONG_ORDER: Final[Tuple[Optional[int], ...]] = (0, None, None, 1, 2) + + +def channel_row( + document: BitphaseProject, + frame: int, + row_index: int, + channel: ChannelName = CHANNEL, +) -> BitphaseRow: + return document.songs[0].patterns[frame].channels[int(CHANNEL_TO_INDEX[channel])].rows[row_index] + + +def named_table(document: BitphaseProject, row: BitphaseRow) -> BitphaseTable: + return next(table for table in document.tables if table.id == row.table - TABLE_COLUMN_OFFSET) + + +def frame_ticks(project: Project, frame: int) -> Tuple[int, ...]: + """The ticks each row of a frame lasts in the document, as the song's timing gives them.""" + return SongTiming.from_project(project, bounds=BITPHASE_TICK_BOUNDS).groove(frame).ticks + + +def ornament(position: int) -> Tuple[Optional[EffectCell], ...]: + return ( + EffectCell( + effect=int(EffectId.ORNAMENT_POSITION), + delay=ORNAMENT_POSITION_DELAY, + parameter=position, + table_index=NO_EFFECT_TABLE, + ), + ) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return contour_sample(CHANNEL, FRAMES) + + +def moved_project( + voice: Sample, + *cells: Tuple[int, Row], + settings: ProjectSettings, +) -> Project: + return one_channel_project((voice,), CHANNEL, {0: rows_with(*cells)}, [0], settings=settings) + + +class TestTheCellsATransposeRowWrites: + """A table cell alone attaches a table while the instrument plays on, so a transpose row names the + note's table moved by the distance between the note its transpose would write and the note already + written, and places it at the step the note has reached. + """ + + @pytest.fixture(name="raised") + def raised_fixture(self, lead: Sample) -> BitphaseProject: + return project_to_bitphase( + moved_project(lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=RAISED))), settings=UNIFORM_SETTINGS) + ) + + def test_the_row_names_the_notes_table_moved_by_the_transpose(self, raised: BitphaseProject) -> None: + trigger = named_table(raised, channel_row(raised, 0, 0)) + moved = named_table(raised, channel_row(raised, 0, RAISED_ROW)) + + assert moved.rows == tuple(step + RAISED for step in trigger.rows) + assert moved.loop == trigger.loop + + def test_the_row_leaves_the_note_and_the_instrument_alone(self, raised: BitphaseProject) -> None: + """A note or an instrument cell restarts the instrument, so the row writes neither.""" + row = channel_row(raised, 0, RAISED_ROW) + + assert (row.note.name, row.instrument) == (int(NoteName.NONE), NO_INSTRUMENT_CHANGE) + + def test_the_row_places_the_table_at_the_step_the_note_reached(self, raised: BitphaseProject) -> None: + assert channel_row(raised, 0, RAISED_ROW).effects == ornament(RAISED_ROW * UNIFORM_ROW_TICKS) + + def test_the_moved_table_takes_an_id_above_the_slices(self, raised: BitphaseProject) -> None: + moved = named_table(raised, channel_row(raised, 0, RAISED_ROW)) + + assert moved.id == len(raised.instruments) + MIN_TABLE_ID + + def test_a_second_row_moves_the_note_from_where_it_was_written(self, lead: Sample) -> None: + """Every transpose is measured from the voice's reference, so each row's table is moved from the + note the note-on wrote, whatever an earlier row moved it to. + """ + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (LOWERED_ROW, Row(pitch=Step(value=LOWERED))), + settings=UNIFORM_SETTINGS, + ) + ) + + trigger = named_table(document, channel_row(document, 0, 0)) + moved = named_table(document, channel_row(document, 0, LOWERED_ROW)) + + assert moved.rows == tuple(step + LOWERED for step in trigger.rows) + assert channel_row(document, 0, LOWERED_ROW).effects == ornament(LOWERED_ROW * UNIFORM_ROW_TICKS) + + def test_the_shift_counts_from_the_transpose_the_note_started_at(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead, NOTE_TRANSPOSE)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + settings=UNIFORM_SETTINGS, + ) + ) + + trigger = named_table(document, channel_row(document, 0, 0)) + moved = named_table(document, channel_row(document, 0, RAISED_ROW)) + + assert moved.rows == tuple(step + RAISED - NOTE_TRANSPOSE for step in trigger.rows) + + def test_a_note_on_starts_from_its_own_table_again(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (RESET_ROW, note(lead)), + (AFTER_RESET_ROW, Row(pitch=Step(value=LOWERED))), + settings=UNIFORM_SETTINGS, + ) + ) + + trigger = channel_row(document, 0, 0) + after = channel_row(document, 0, AFTER_RESET_ROW) + + assert channel_row(document, 0, RESET_ROW).table == trigger.table + assert named_table(document, after).rows == tuple( + step + LOWERED for step in named_table(document, trigger).rows + ) + assert after.effects == ornament((AFTER_RESET_ROW - RESET_ROW) * UNIFORM_ROW_TICKS) + + def test_a_row_returning_to_the_notes_transpose_names_the_notes_own_table(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (LOWERED_ROW, Row(pitch=Step(value=0))), + settings=UNIFORM_SETTINGS, + ) + ) + + assert channel_row(document, 0, LOWERED_ROW).table == channel_row(document, 0, 0).table + + def test_a_row_restating_the_transpose_in_force_writes_no_table(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (LOWERED_ROW, Row(pitch=Step(value=RAISED))), + settings=UNIFORM_SETTINGS, + ) + ) + + assert channel_row(document, 0, LOWERED_ROW).table == NO_TABLE_CHANGE + + def test_rows_moving_a_slice_alike_share_one_table(self, lead: Sample) -> None: + document = project_to_bitphase( + moved_project( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (RESET_ROW, note(lead)), + (AFTER_RESET_ROW, Row(pitch=Step(value=RAISED))), + settings=UNIFORM_SETTINGS, + ) + ) + + assert channel_row(document, 0, AFTER_RESET_ROW).table == channel_row(document, 0, RAISED_ROW).table + + def test_a_transpose_below_the_range_moves_the_note_where_a_note_on_would_write_it(self) -> None: + """A note-on at the new transpose writes its note by the low-pitch rule, so the moved table + reaches the note that rule writes, and a flat slice sounds the lowest pitch the song plays. + """ + flat = flat_sample(CHANNEL, LOW_FLAT_PITCH, FLAT_FRAMES) + document = project_to_bitphase( + moved_project( + flat, + (0, note(flat)), + (RAISED_ROW, Row(pitch=Step(value=SUBMERGED_TRANSPOSE))), + settings=UNIFORM_SETTINGS, + ) + ) + + moved = named_table(document, channel_row(document, 0, RAISED_ROW)) + + assert moved.rows == (pitch_to_note_index(MIN_PLAYED_PITCH) - pitch_to_note_index(LOW_FLAT_PITCH),) + + +class TestTheStepANoteHasReached: + """A note's table advances a step every tick from the note on, so the step a transpose row places + its table at counts the ticks every row since the note lasts, across the frames between them. + """ + + def test_a_groove_counts_each_row_at_its_own_length(self, lead: Sample) -> None: + project = moved_project( + lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=RAISED))), settings=GROOVE_SETTINGS + ) + document = project_to_bitphase(project) + + assert channel_row(document, 0, RAISED_ROW).effects == ornament(sum(frame_ticks(project, 0)[:RAISED_ROW])) + + def test_the_rows_of_every_frame_between_are_counted(self, lead: Sample) -> None: + """A frame leaving the channel empty plays on with the note it carries.""" + project = one_channel_project( + (lead,), + CHANNEL, + {0: rows_with((LATE_NOTE_ROW, note(lead))), 1: rows_with((RAISED_ROW, Row(pitch=Step(value=RAISED))))}, + [0, None, 1], + settings=GROOVE_SETTINGS, + ) + document = project_to_bitphase(project) + + elapsed = ( + sum(frame_ticks(project, 0)[LATE_NOTE_ROW:]) + + sum(frame_ticks(project, 1)) + + sum(frame_ticks(project, 2)[:RAISED_ROW]) + ) + + assert channel_row(document, 2, RAISED_ROW).effects == ornament(elapsed) + + def test_a_step_past_the_ornament_position_names_a_table_opening_on_it(self) -> None: + long = contour_sample(CHANNEL, LONG_FRAMES) + project = one_channel_project( + (long,), + CHANNEL, + {0: rows_with((0, note(long))), 1: rows_with((0, Row(pitch=Step(value=RAISED))))}, + [0, None, None, 1], + settings=UNIFORM_SETTINGS, + ) + document = project_to_bitphase(project) + reached = LATE_FRAME * TRANSPOSED_ROWS_PER_PATTERN * UNIFORM_ROW_TICKS + + trigger = named_table(document, channel_row(document, 0, 0)) + row = channel_row(document, LATE_FRAME, 0) + opening = named_table(document, row) + + assert reached > MAX_ORNAMENT_POSITION + assert row.effects == (None,) + assert [opening.rows[opening.position_at(tick)] for tick in range(LONG_FRAMES)] == [ + trigger.rows[trigger.position_at(reached + tick)] + RAISED for tick in range(LONG_FRAMES) + ] + + +class TestTheTablesADocumentHolds: + """The table column names one base-36 digit, so a document whose transpose rows move its notes to + more tables than it can name is refused. + """ + + @staticmethod + def _spread(transposes: int) -> Project: + """A flat slice moved by ``transposes`` distinct transposes, one per row after its note.""" + flat = flat_sample(CHANNEL, FLAT_PITCH, FLAT_FRAMES) + rows_per_frame = TRANSPOSED_ROWS_PER_PATTERN - 1 + patterns = { + frame: rows_with( + (0, note(flat)), + *( + (row_index, Row(pitch=Step(value=frame * rows_per_frame + row_index))) + for row_index in range(1, TRANSPOSED_ROWS_PER_PATTERN) + if frame * rows_per_frame + row_index <= transposes + ), + ) + for frame in range((transposes + rows_per_frame - 1) // rows_per_frame) + } + return one_channel_project((flat,), CHANNEL, patterns, list(patterns), settings=UNIFORM_SETTINGS) + + def test_a_document_filling_the_table_column_is_written(self) -> None: + room = MAX_TABLE_ID - MIN_TABLE_ID + document = project_to_bitphase(self._spread(room)) + + assert max(table.id for table in document.tables) == MAX_TABLE_ID + + def test_a_document_needing_more_tables_is_refused(self) -> None: + room = MAX_TABLE_ID - MIN_TABLE_ID + with pytest.raises(ValueError, match="tables"): + project_to_bitphase(self._spread(room + 1)) + + +class TestANoiseTransposeWalksThePeriods: + """A noise transpose walks the period around the sixteen the channel has, and the moved table takes + whatever note the noise mapping writes for the new period, so it follows that mapping wherever the + note index sits. + """ + + def test_the_moved_table_sounds_the_walked_period(self) -> None: + drum = flat_sample(ChannelName.NOISE, NOISE_PERIOD, FLAT_FRAMES) + project = one_channel_project( + (drum,), + ChannelName.NOISE, + {0: rows_with((0, note(drum)), (RAISED_ROW, Row(pitch=Step(value=NOISE_TRANSPOSE))))}, + [0], + settings=UNIFORM_SETTINGS, + ) + document = project_to_bitphase(project) + + trigger = channel_row(document, 0, 0, ChannelName.NOISE) + moved = named_table(document, channel_row(document, 0, RAISED_ROW, ChannelName.NOISE)) + index = note_value(trigger.note) + moved.rows[0] # type: ignore[arg-type] + + assert noise_register(index) == MAX_PERIOD - (NOISE_PERIOD + NOISE_TRANSPOSE) % NUM_PERIODS + + +class TestAnInstrumentPlacedWithoutAPitch: + """The song starts such an instrument on the pitch the channel is sounding, whichever voice sounded + it, and leaves a silent channel silent. Each frame is a pattern of its own, so the cell writes the + note the channel was sounding in that frame beside the instrument, and stays empty where the + song sounds none. + """ + + def test_the_cell_writes_the_note_the_sample_was_sounding(self, lead: Sample) -> None: + organ = flat_instrument(CHANNEL, ORGAN_PITCH) + project = one_channel_project( + (lead, organ), + CHANNEL, + {0: rows_with((0, note(lead, NOTE_TRANSPOSE)), (INSTRUMENT_ROW, note(organ)))}, + [0], + settings=UNIFORM_SETTINGS, + ) + + row = channel_row(project_to_bitphase(project), 0, INSTRUMENT_ROW) + + assert cell_pitch(row.note) == voice_reference(lead, CHANNEL) + NOTE_TRANSPOSE + assert row.instrument != NO_INSTRUMENT_CHANGE + + def test_a_silent_channel_leaves_the_cell_empty(self) -> None: + organ = flat_instrument(CHANNEL, ORGAN_PITCH) + project = one_channel_project( + (organ,), + CHANNEL, + {0: rows_with((INSTRUMENT_ROW, note(organ)))}, + [0], + settings=UNIFORM_SETTINGS, + ) + + built = build_bitphase(project) + + assert channel_row(built.document, 0, INSTRUMENT_ROW) == BitphaseRow() + assert built.skipped_rows == () + + def test_each_frame_reaching_the_cell_writes_the_pitch_it_sounds_there(self, lead: Sample) -> None: + organ = flat_instrument(CHANNEL, ORGAN_PITCH) + project = one_channel_project( + (lead, organ), + CHANNEL, + { + 0: rows_with((0, note(lead, RAISED))), + 1: rows_with((0, note(lead, LOWERED))), + 2: rows_with((INSTRUMENT_ROW, note(organ))), + }, + [0, 2, 1, 2], + settings=UNIFORM_SETTINGS, + ) + + built = build_bitphase(project) + + assert ( + cell_pitch(channel_row(built.document, 1, INSTRUMENT_ROW).note) == voice_reference(lead, CHANNEL) + RAISED + ) + assert ( + cell_pitch(channel_row(built.document, 3, INSTRUMENT_ROW).note) == voice_reference(lead, CHANNEL) + LOWERED + ) + assert built.skipped_rows == () + + +class TestAnInstrumentTakingTheChannelsPitchSoundsTheSongsPitch(BaseTestSuite): + """Played the way Bitphase reads its rows, an instrument that takes the pitch a sample left, is bent + afterwards, and is placed again on a silent channel sounds the pitch the song's walk sounds on every + tick the song sounds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + + test_cases: Tuple["TestAnInstrumentTakingTheChannelsPitchSoundsTheSongsPitch.TestCase", ...] = ( + TestCase(label="pulse", channel=ChannelName.PULSE1), + TestCase(label="triangle", channel=ChannelName.TRIANGLE), + TestCase(label="noise", channel=ChannelName.NOISE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_sounding_tick_plays_the_songs_pitch( + self, + test_case: "TestAnInstrumentTakingTheChannelsPitchSoundsTheSongsPitch.TestCase", + ) -> None: + lead = contour_sample(test_case.channel, FRAMES) + organ = flat_instrument( + test_case.channel, + ORGAN_PERIOD if test_case.channel == ChannelName.NOISE else ORGAN_PITCH, + ) + patterns = { + 0: rows_with( + (0, note(lead, NOTE_TRANSPOSE)), + (BEND_ROW, Row(pitch=Step(value=RAISED))), + (INSTRUMENT_ROW, note(organ)), + (LATER_BEND_ROW, Row(pitch=Step(value=LOWERED))), + (LATER_NOTE_OFF_ROW, Row(command=NoteOff())), + (SILENT_INSTRUMENT_ROW, note(organ)), + ), + } + project = one_channel_project((lead, organ), test_case.channel, patterns, [0], settings=UNIFORM_SETTINGS) + + sounded = sounded_pitches(project, test_case.channel) + played = replayed_pitches(project_to_bitphase(project), test_case.channel) + + assert len(played) == len(sounded) + assert [tick for tick, pitch in enumerate(sounded) if pitch is not None and played[tick] != pitch] == [] + assert played[-1] is None + + +class TestATransposedSongSoundsTheSongsPitch(BaseTestSuite): + """Played the way Bitphase reads its rows, a song whose transpose rows move a sounding note, cross + frames under a groove, and meet a note-on starting over sounds the pitch the song's walk sounds on + every tick the song sounds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + frames: int + order: Tuple[Optional[int], ...] + + test_cases: Tuple["TestATransposedSongSoundsTheSongsPitch.TestCase", ...] = ( + TestCase(label="pulse", channel=ChannelName.PULSE1, frames=FRAMES, order=SHORT_ORDER), + TestCase(label="triangle", channel=ChannelName.TRIANGLE, frames=FRAMES, order=SHORT_ORDER), + TestCase(label="noise", channel=ChannelName.NOISE, frames=FRAMES, order=SHORT_ORDER), + TestCase(label="pulse_past_the_ornament", channel=ChannelName.PULSE1, frames=LONG_FRAMES, order=LONG_ORDER), + TestCase( + label="triangle_past_the_ornament", channel=ChannelName.TRIANGLE, frames=LONG_FRAMES, order=LONG_ORDER + ), + TestCase(label="noise_past_the_ornament", channel=ChannelName.NOISE, frames=LONG_FRAMES, order=LONG_ORDER), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_sounding_tick_plays_the_songs_pitch( + self, + test_case: "TestATransposedSongSoundsTheSongsPitch.TestCase", + ) -> None: + voice = contour_sample(test_case.channel, test_case.frames) + patterns = { + 0: rows_with((0, note(voice)), (RAISED_ROW, Row(pitch=Step(value=RAISED)))), + 1: rows_with( + (2, Row(pitch=Step(value=LOWERED))), + (RESET_ROW, note(voice)), + (AFTER_RESET_ROW, Row(pitch=Step(value=AFTER_RESET_TRANSPOSE))), + ), + 2: rows_with((RAISED_ROW, Row(pitch=Step(value=LOWERED))), (LOWERED_ROW, Row(pitch=Step(value=0)))), + } + project = one_channel_project( + (voice,), + test_case.channel, + patterns, + list(test_case.order), + settings=GROOVE_SETTINGS, + ) + + sounded = sounded_pitches(project, test_case.channel) + played = replayed_pitches(project_to_bitphase(project), test_case.channel) + + assert len(played) == len(sounded) + assert [tick for tick, pitch in enumerate(sounded) if pitch is not None and played[tick] != pitch] == [] diff --git a/tests/unit/sampletones_core/formats/bitphase/test_truncation.py b/tests/unit/sampletones_core/formats/bitphase/test_truncation.py new file mode 100644 index 000000000..68f1a8185 --- /dev/null +++ b/tests/unit/sampletones_core/formats/bitphase/test_truncation.py @@ -0,0 +1,87 @@ +from typing import Final, List + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import MAX_VOLUME, SILENT_VOLUME +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.formats.bitphase.envelopes import features_to_envelopes +from sampletones_core.formats.bitphase.preset import instrument_to_preset +from sampletones_core.formats.bitphase.specification.macros import MAX_MACRO_LENGTH, NesMacroField +from sampletones_core.formats.bitphase.truncation import document_truncation, preset_truncation +from sampletones_core.formats.bitphase.tuning import DEFAULT_TUNING_TABLE + +from .conftest import build_features, build_instrument + +SHORT_ENVELOPE: Final[int] = 16 +LONG_ENVELOPE: Final[int] = 600 + + +def held(length: int) -> List[int]: + """A volume of ``length`` items that goes on sounding.""" + return [MAX_VOLUME] * length + + +def released(length: int) -> List[int]: + """A volume of ``length`` items whose last one releases the note.""" + return held(length - 1) + [SILENT_VOLUME] + + +class TestWhatADocumentReportsLeavingOut: + def test_a_slice_within_a_macro_reports_nothing(self) -> None: + assert document_truncation(build_features(held(MAX_MACRO_LENGTH))) is None + + def test_a_slice_past_a_macro_reports_both_counts(self) -> None: + assert document_truncation(build_features(held(LONG_ENVELOPE))) == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ENVELOPE, + instruments=1, + ) + + def test_a_document_keeps_the_contour_whole(self) -> None: + """The contour rides a table, which holds any length.""" + features = build_features(held(SHORT_ENVELOPE), arpeggio=[0] * LONG_ENVELOPE) + assert document_truncation(features) is None + + def test_a_volume_keeps_its_release_within_the_count_reported(self) -> None: + """The note has to end, so the release is among the values the report counts.""" + features = build_features(released(LONG_ENVELOPE)) + truncation = document_truncation(features) + macro = features_to_envelopes( + features, + ChannelName.PULSE1, + tuning_table=DEFAULT_TUNING_TABLE, + ).macros[NesMacroField.VOLUME_OR_RATE] + + assert truncation is not None + assert len(macro.values) == truncation.frames + assert macro.values[-1] == SILENT_VOLUME + + +class TestWhatAPresetReportsLeavingOut: + def test_a_slice_within_a_macro_reports_nothing(self) -> None: + assert preset_truncation(build_features(held(MAX_MACRO_LENGTH))) is None + + def test_a_slice_past_a_macro_reports_both_counts(self) -> None: + assert preset_truncation(build_features(held(LONG_ENVELOPE))) == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=LONG_ENVELOPE, + instruments=1, + ) + + def test_a_preset_shortens_the_contour(self) -> None: + """A preset folds the contour into the tone offset, which a macro carries.""" + features = build_features(held(SHORT_ENVELOPE), arpeggio=[0] * LONG_ENVELOPE) + offsets = instrument_to_preset(build_instrument("Sweep", features)).macros[NesMacroField.TONE_ADD] + + assert preset_truncation(features) == EnvelopeTruncation( + frames=len(offsets.values), + source_frames=LONG_ENVELOPE, + instruments=1, + ) + + def test_a_bend_past_a_macro_is_reported(self) -> None: + features = build_features(held(SHORT_ENVELOPE), bend=[1] * LONG_ENVELOPE) + assert preset_truncation(features) == EnvelopeTruncation( + frames=MAX_MACRO_LENGTH, + source_frames=len(features.envelopes[FeatureKey.PITCH].items), + instruments=1, + ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py index 7763628aa..c7fd439e8 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py @@ -13,7 +13,10 @@ TUNING_TABLE_LENGTH, ChipVariant, ) -from sampletones_core.formats.bitphase.tuning import generate_tuning_table +from sampletones_core.formats.bitphase.tuning import concert_frequency, generate_tuning_table +from sampletones_shared.constants.music import OCTAVE_SEMITONES +from sampletones_shared.music import Tuning +from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase BITPHASE_NTSC_TABLE: Final[Tuple[int, ...]] = ( @@ -39,6 +42,8 @@ class PeriodCase: RAISED_A4_TUNING: Final[float] = 432.0 RAISED_A4_PERIOD: Final[int] = 259 NARROW_TIMER_LIMIT: Final[int] = 255 +C5_PITCH: Final[int] = 72 +SEMITONES_FROM_A4_TO_C5: Final[int] = 3 @pytest.fixture(name="ntsc_table") @@ -52,7 +57,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( @@ -178,3 +183,39 @@ def test_a_narrower_timer_holds_the_longest_period(self) -> None: max_period=NARROW_TIMER_LIMIT, ) assert max(table) == NARROW_TIMER_LIMIT + + +class TestTheFrequencyASongCentersOn(BaseTestSuite): + """A song names its tuning by the frequency of its concert-pitch index, so a tuning whose + reference names another pitch is read out at that index. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + tuning: Tuning + expected: float + + test_cases = ( + TestCase( + tuning=Tuning(), + expected=DEFAULT_A4_TUNING, + label="concert", + ), + TestCase( + tuning=Tuning(a4_frequency=RAISED_A4_TUNING), + expected=RAISED_A4_TUNING, + label="retuned", + ), + TestCase( + tuning=Tuning( + a4_frequency=RAISED_A4_TUNING * 2 ** (SEMITONES_FROM_A4_TO_C5 / OCTAVE_SEMITONES), + a4_pitch=C5_PITCH, + ), + expected=RAISED_A4_TUNING, + label="named_at_c5", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_is_the_frequency_the_concert_index_sounds_at(self, test_case: TestCase) -> None: + assert concert_frequency(test_case.tuning) == pytest.approx(test_case.expected) diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index aaf858000..ed7565029 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -6,49 +6,69 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + 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.pitch import Step 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(DEFAULT_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}), + ) + + +def contour_sample(name: str, pitches: Sequence[int]) -> Sample: + """A pulse sample sounding each pitch for one frame, so its arpeggio moves the note between them.""" + instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0) for pitch in pitches] + return Sample( + name=name, + reconstruction=build_reconstruction({ChannelName.PULSE1: instructions}), + ) + + +def triangle_sample(name: str, pitch: int) -> Sample: + instructions = [TriangleInstruction(on=True, pitch=pitch)] + return Sample( + name=name, + reconstruction=build_reconstruction({ChannelName.TRIANGLE: instructions}), ) @@ -56,14 +76,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,18 +100,18 @@ 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), - transpose=0, + command=NoteOn(voice_id=lead.id), + pitch=Step(value=0), volume=10, ) pulse_rows[2] = Row(command=NoteOff()) @@ -99,36 +119,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), - transpose=0, + command=NoteOn(voice_id=drum.id), + pitch=Step(value=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..1d53f0e03 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -1,196 +1,336 @@ -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.exporters.truncation import is_shortened +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, + features_truncation, + stored_length, ) from sampletones_core.formats.famitracker.specification.sequences import ( + BEND_SEQUENCE_KINDS, LOOP_FROM_START, MAX_SEQUENCE_ITEMS, NO_LOOP_POINT, 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]), repitched=False)[ + SequenceKind.ARPEGGIO + ] + + assert arpeggio.enabled is True + assert arpeggio.items == (0,) + assert arpeggio.loop_point == LOOP_FROM_START + + def test_an_arpeggio_shorter_than_a_bend_ending_with_no_offset_reaches_its_length(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7], pitch=[1, 2, 3, 0]), repitched=False)[ + SequenceKind.ARPEGGIO + ] + + assert arpeggio.items == (4, 7, 7, 7) + assert arpeggio.loop_point == NO_LOOP_POINT + + def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: sequences = features_to_instrument_sequences( - volume=np.array([15, 0]), - arpeggio=np.array([0]), - pitch=None, + build([15, 0], [0, 5], pitch=[1, 2, 3, 4], loop_point=LOOP_FROM_START), + repitched=False, + ) + + assert sequences[SequenceKind.ARPEGGIO].items == (0, 5) + assert sequences[SequenceKind.ARPEGGIO].loop_point == LOOP_FROM_START + + def test_an_arpeggio_already_covering_the_bend_is_left_as_it_stands(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9], pitch=[1, 0]), repitched=False)[ + SequenceKind.ARPEGGIO + ] + + assert (arpeggio.items, arpeggio.loop_point) == ((4, 7, 9), NO_LOOP_POINT) + + def test_a_bend_ending_on_an_offset_holds_it_with_the_arpeggio_running_on(self) -> None: + """The voice holds a bend's last offset, which the tracker re-adds on every tick the arpeggio reloads.""" + sequences = features_to_instrument_sequences(build([15, 0], [], pitch=[0, 1, 2]), repitched=False) + arpeggio, pitch = sequences[SequenceKind.ARPEGGIO], sequences[SequenceKind.PITCH] + + assert (pitch.items, pitch.loop_point) == ((0, 1, 2), 2) + assert (arpeggio.items, arpeggio.loop_point) == ((0,), LOOP_FROM_START) + + def test_a_repeating_bend_keeps_an_arpeggio_playing_once_running(self) -> None: + """A repeating bend acts all through the note, and a halted arpeggio would let it pile up.""" + features = Features( + initial_pitch=REFERENCE_PITCH, + volume=envelope([15, 0]), + arpeggio=envelope([4, 7]), + pitch=envelope([1, -1], LOOP_FROM_START), hi_pitch=None, duty_cycle=None, - loop=False, ) + + arpeggio = features_to_instrument_sequences(features, repitched=False)[SequenceKind.ARPEGGIO] + + assert (arpeggio.items, arpeggio.loop_point) == ((4, 7), 1) + + def test_an_instrument_writing_no_bend_gains_no_arpeggio(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], []), repitched=False)[SequenceKind.ARPEGGIO] + + assert arpeggio.enabled is False + + +class TestFeaturesToInstrumentSequences: + def test_all_five_kinds_present(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [0]), repitched=False) assert set(sequences) == set(SequenceKind) def test_populated_dimension_is_enabled_with_items(self) -> None: - 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], []), repitched=False)[SequenceKind.VOLUME] assert volume.enabled is True assert volume.items == (15, 12, 0) def test_missing_dimension_is_disabled(self) -> None: - sequences = features_to_instrument_sequences( - 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], []), repitched=False) assert sequences[SequenceKind.PITCH].enabled is False assert sequences[SequenceKind.PITCH].items == () assert sequences[SequenceKind.ARPEGGIO].enabled is False def test_items_are_python_ints(self) -> None: - sequences = features_to_instrument_sequences( - 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]), repitched=False) assert all(isinstance(item, int) for item in sequences[SequenceKind.VOLUME].items) assert all(isinstance(item, int) for item in sequences[SequenceKind.ARPEGGIO].items) - 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), repitched=False) 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, repitched=False) + + assert sequences[SequenceKind.VOLUME].loop_point == 2 + assert sequences[SequenceKind.ARPEGGIO].loop_point == 0 + assert sequences[SequenceKind.DUTY].loop_point == NO_LOOP_POINT + + def test_a_dimension_the_instrument_leaves_out_states_no_point(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [], loop_point=0), repitched=False) 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]), repitched=False) 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]), repitched=False) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) assert sequences[SequenceKind.DUTY].items == (1,) - 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), + repitched=False, ) - 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], []), repitched=False) assert sequences[SequenceKind.ARPEGGIO].items == () assert sequences[SequenceKind.PITCH].items == () def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: """An empty dimension leaves its sequence disabled; a single zero is a value the instrument sets.""" - cleared = features_to_instrument_sequences( - 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], []), repitched=False) + zeroed = features_to_instrument_sequences(build([15, 0], [0]), repitched=False) + assert cleared[SequenceKind.ARPEGGIO].enabled is False assert zeroed[SequenceKind.ARPEGGIO].enabled is True assert zeroed[SequenceKind.ARPEGGIO].items == (0,) def test_all_dimensions_empty_stays_empty(self) -> None: - sequences = features_to_instrument_sequences( - 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), repitched=False) assert all(not sequence.enabled for sequence in sequences.values()) def test_an_over_long_envelope_builds_sequences_famitracker_accepts(self) -> None: length = MAX_SEQUENCE_ITEMS + 48 sequences = features_to_instrument_sequences( - volume=np.arange(length) % 16, - arpeggio=np.zeros(length, dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=None, - loop=False, + build([index % 16 for index in range(length)], [0] * length), repitched=False ) assert all(len(sequence.items) <= MAX_SEQUENCE_ITEMS for sequence in sequences.values()) + + +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), []), repitched=False) + volume = sequences[SequenceKind.VOLUME] + assert len(volume.items) == MAX_SEQUENCE_ITEMS + assert volume.items[-1] == RELEASE + + def test_a_released_dimension_past_the_limit_still_ends_at_its_release(self) -> None: + sequences = features_to_instrument_sequences(build(released(MAX_SEQUENCE_ITEMS + 1), []), repitched=False) + volume = sequences[SequenceKind.VOLUME] + assert len(volume.items) == MAX_SEQUENCE_ITEMS + assert volume.items[-1] == RELEASE + + def test_the_release_displaces_the_last_item_that_would_not_fit(self) -> None: + source = released(MAX_SEQUENCE_ITEMS + 1) + volume = features_to_instrument_sequences(build(source, []), repitched=False)[SequenceKind.VOLUME] + assert volume.items == tuple(source[: MAX_SEQUENCE_ITEMS - 1]) + (RELEASE,) + + def test_a_dimension_that_goes_on_sounding_keeps_its_opening_items(self) -> None: + source = sounding(MAX_SEQUENCE_ITEMS + 8) + volume = features_to_instrument_sequences(build(source, []), repitched=False)[SequenceKind.VOLUME] + assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS]) + + def test_a_circling_dimension_reads_its_final_silence_as_part_of_the_cycle(self) -> None: + """A dimension repeating from a point never halts, so its last item releases nothing.""" + source = released(MAX_SEQUENCE_ITEMS + 1) + volume = features_to_instrument_sequences(build(source, [], loop_point=0), repitched=False)[SequenceKind.VOLUME] + assert volume.items == tuple(source[:MAX_SEQUENCE_ITEMS]) + + def test_a_dimension_other_than_volume_keeps_its_opening_items(self) -> None: + """Only a volume dimension releases a note; the rest are read at whatever they last stated.""" + source = [index % 8 for index in range(MAX_SEQUENCE_ITEMS + 8)] + source[-1] = 0 + arpeggio = features_to_instrument_sequences(build(released(4), source), repitched=False)[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)), stored_length) is False + + def test_a_dimension_past_the_limit_is_shortened(self) -> None: + assert is_shortened(FeatureKey.VOLUME, envelope(released(MAX_SEQUENCE_ITEMS + 1)), stored_length) 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, stored_length) 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 + + +class TestTheSequencesANoteSlideReaches: + """A note slide moves the channel's note, and only an arpeggio still running reloads the period from + it every tick, so an instrument a slide reaches keeps its arpeggio running for as long as the note + sounds. The bend is added to the period the arpeggio reloads and a halted bend adds nothing, so the + bend keeps running too, each on the last item it plays. + """ + + def test_an_arpeggio_playing_once_circles_on_its_last_item(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9]), repitched=True)[SequenceKind.ARPEGGIO] + + assert arpeggio.items == (4, 7, 9) + assert arpeggio.loop_point == len(arpeggio.items) - 1 + + def test_an_arpeggio_that_repeats_is_left_as_it_stands(self) -> None: + arpeggio = features_to_instrument_sequences( + build([15, 0], [4, 7, 9], loop_point=LOOP_FROM_START), + repitched=True, + )[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == LOOP_FROM_START + + def test_an_instrument_writing_no_arpeggio_takes_one_running_at_its_own_note(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], []), repitched=True)[SequenceKind.ARPEGGIO] + + assert (arpeggio.items, arpeggio.loop_point) == ((0,), LOOP_FROM_START) + + def test_a_bend_playing_once_circles_on_the_offset_it_ends_on(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [0, 2], pitch=[1, 2, 3]), repitched=True) + + assert (sequences[SequenceKind.PITCH].items, sequences[SequenceKind.PITCH].loop_point) == ((1, 2, 3), 2) + + def test_a_bend_the_channel_governs_stays_disabled(self) -> None: + sequences = features_to_instrument_sequences(build([15, 0], [0, 2]), repitched=True) + + assert not any(sequences[kind].enabled for kind in BEND_SEQUENCE_KINDS) + + def test_an_instrument_no_slide_reaches_keeps_its_arpeggio_halting(self) -> None: + arpeggio = features_to_instrument_sequences(build([15, 0], [4, 7, 9]), repitched=False)[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == NO_LOOP_POINT diff --git a/tests/unit/sampletones_core/formats/famitracker/test_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..ff448df07 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -1,11 +1,19 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple + import numpy as np import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME, MIN_PITCH, MIN_PLAYED_PITCH +from sampletones_core.exporters.skipped import SkippedRow, SkipReason from sampletones_core.formats.famitracker.builder import ( build_instrument_table, + build_module, project_to_module, ) +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.model.pattern import RowCell from sampletones_core.formats.famitracker.specification.channels import ( CHANNEL_COUNT_2A03, ChannelId, @@ -19,24 +27,67 @@ Machine, ) from sampletones_core.formats.famitracker.specification.patterns import ( + EMPTY_EFFECT, + EMPTY_EFFECT_PARAM, EMPTY_INSTRUMENT, + EMPTY_VOLUME, 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.performance.modifiers import triangle_sounds_at +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.patterns.pattern import Pattern +from sampletones_core.project.patterns.pitch import Step +from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project - -from .conftest import RECONSTRUCTION_LENGTH, ProjectFixture, build_reconstruction +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.project.voices.voice import voice_reference +from sampletones_core.structures import IdentifiedCollection +from sampletones_core.utils.frequencies import transpose_pitch +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.famitracker import FAMITRACKER_OPENING_VOLUME, cell_pitch, column_volume +from tests.suite.performance import song_row_volumes + +from .conftest import ( + RECONSTRUCTION_LENGTH, + ProjectFixture, + build_reconstruction, + contour_sample, + pulse_sample, + triangle_sample, +) LEAD_PITCH = 60 OCTAVE = 12 CUSTOM_NES_FREQUENCY = 30 +ROWS_PER_PATTERN: Final[int] = 8 +BASS_PITCH: Final[int] = 48 +TRANSPOSE: Final[int] = 5 +QUIET_VOLUME: Final[int] = 4 +NOTE_OFF_VOLUME: Final[int] = 6 +CLOSING_VOLUME: Final[int] = 9 +FRAME_CLOSING_VOLUME: Final[int] = 2 +QUIET_TRIANGLE_VOLUME: Final[int] = 5 +LOUD_TRIANGLE_VOLUME: Final[int] = 12 +RESTING_TRIANGLE_VOLUME: Final[int] = 7 +NOTE_TRIANGLE_VOLUME: Final[int] = 3 +SILENT_COLUMN: Final[int] = 0 +PLAYED_PASSES: Final[int] = 2 +LOW_PITCH: Final[int] = 36 +LOW_TRANSPOSE: Final[int] = -10 +CONTOUR_PITCHES: Final[Tuple[int, ...]] = (40, 50) +SUBMERGED_TRANSPOSE: Final[int] = -20 + class TestBuildInstrumentTable: def test_one_instrument_per_generator_slice(self, project_fixture: ProjectFixture) -> None: @@ -46,12 +97,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,37 +115,35 @@ 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 = project_fixture.lead.reconstruction.with_channel_data( + ChannelName.PULSE1, arpeggiated, - np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32), LEAD_PITCH, (), + heard=project_fixture.lead.reconstruction.recorded_stem_ids, ) 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) @@ -171,6 +220,12 @@ def test_empty_rows_are_dropped(self, project_fixture: ProjectFixture) -> None: pattern = self._pattern(module.track.patterns, ChannelId.SQUARE1, 0) assert {row.row_number for row in pattern.rows} == {0, 2, 4} + def test_every_row_leaves_its_effect_columns_empty(self, project_fixture: ProjectFixture) -> None: + """A `Vxx` is what moves FamiTracker's default duty, so every note there starts on duty 0.""" + module = project_to_module(project_fixture.project) + effects = {effect for pattern in module.track.patterns for row in pattern.rows for effect in row.effects} + assert effects == {(EMPTY_EFFECT, EMPTY_EFFECT_PARAM)} + def test_noise_row_uses_period_note(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) pattern = self._pattern(module.track.patterns, ChannelId.NOISE, 0) @@ -180,6 +235,68 @@ def test_noise_row_uses_period_note(self, project_fixture: ProjectFixture) -> No assert first.octave == 0 +class TestARowWithNoInstrumentOnItsChannel: + """A voice sounds on the channels its instruments cover, so a row naming it elsewhere plays + nothing in the song. The export writes a note cut there and reports the row.""" + + DRUM_ROW = 3 + + def _with_drum_on_pulse2(self, project_fixture: ProjectFixture) -> None: + rows = [Row() for _ in range(8)] + rows[self.DRUM_ROW] = Row(command=NoteOn(voice_id=project_fixture.drum.id), volume=9) + song = project_fixture.project.song + song.channels[ChannelName.PULSE2] = Channel(name=ChannelName.PULSE2, patterns={0: Pattern(rows=rows)}) + song.order[0][ChannelName.PULSE2] = 0 + + def test_the_row_is_written_as_a_note_cut(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + + module = project_to_module(project_fixture.project) + + pattern = next( + pattern for pattern in module.track.patterns if pattern.channel == ChannelId.SQUARE2 and pattern.index == 0 + ) + cut = next(row for row in pattern.rows if row.row_number == self.DRUM_ROW) + assert cut.note == int(NoteValue.HALT) + assert cut.instrument == EMPTY_INSTRUMENT + assert cut.volume == 9 + + def test_the_row_is_reported_where_the_tracker_shows_it(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + + skipped = build_module(project_fixture.project).skipped_rows + + assert skipped == ( + SkippedRow( + voice_id=project_fixture.drum.id, + channel=ChannelName.PULSE2, + order_position=0, + row_index=self.DRUM_ROW, + reason=SkipReason.NO_INSTRUMENT, + ), + ) + + def test_a_pattern_the_order_plays_twice_reports_each_frame(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + project_fixture.project.song.order[1][ChannelName.PULSE2] = 0 + + skipped = build_module(project_fixture.project).skipped_rows + + assert [row.order_position for row in skipped] == [0, 1] + + def test_a_pattern_no_frame_plays_reports_nothing(self, project_fixture: ProjectFixture) -> None: + self._with_drum_on_pulse2(project_fixture) + project_fixture.project.song.order[0][ChannelName.PULSE2] = None + + assert build_module(project_fixture.project).skipped_rows == () + + def test_a_project_naming_only_voices_with_instruments_reports_nothing( + self, + project_fixture: ProjectFixture, + ) -> None: + assert build_module(project_fixture.project).skipped_rows == () + + class TestProjectToModuleOrder: def test_order_has_one_entry_per_channel(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) @@ -201,3 +318,249 @@ def test_none_slots_map_to_a_reserved_pattern_index(self, project_fixture: Proje def test_dpcm_channel_is_always_empty(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) assert all(frame[ChannelId.DPCM] == 0 for frame in module.track.order) + + +def rows_with(*cells: Tuple[int, Row]) -> List[Row]: + """A pattern's rows, blank apart from the ones given by their index.""" + rows = [Row() for _ in range(ROWS_PER_PATTERN)] + for row_index, row in cells: + rows[row_index] = row + + return rows + + +def arranged_project( + voices: Sequence[Sample], + patterns: Mapping[ChannelName, Mapping[int, List[Row]]], + order: List[Dict[ChannelName, Optional[int]]], +) -> Project: + """A project playing ``voices`` through the channel patterns ``order`` names.""" + pool: IdentifiedCollection[Sample] = IdentifiedCollection() + for voice in voices: + pool.append(voice) + + channels = { + channel_name: Channel( + name=channel_name, + patterns={index: Pattern(rows=rows) for index, rows in patterns.get(channel_name, {}).items()}, + ) + for channel_name in ChannelName.items() + } + project = Project.create(title="Rows", author="Tester", settings=ProjectSettings()) + project.voices = pool + project.song = Song(rows_per_pattern=ROWS_PER_PATTERN, order=order, channels=channels) + return project + + +def played_volumes(module: FamiTrackerModule, channel: ChannelId) -> List[int]: + """The level FamiTracker plays one channel at on each row, the order played through and round again. + + FamiTracker wraps from the last frame to the first, keeping every channel's level, so a second + pass shows what the level the song ends on does to the notes it opens with. + """ + cells = { + (pattern.channel, pattern.index): {row.row_number: row for row in pattern.rows} + for pattern in module.track.patterns + } + level = FAMITRACKER_OPENING_VOLUME + levels: List[int] = [] + for _ in range(PLAYED_PASSES): + for frame in module.track.order: + pattern = cells.get((channel, frame[int(channel)]), {}) + for row_number in range(module.track.rows_per_pattern): + cell = pattern.get(row_number) + if cell is not None: + level = column_volume(level, cell.volume) + + levels.append(level) + + return levels + + +def pattern_cell(module: FamiTrackerModule, channel: ChannelId, index: int, row_number: int) -> RowCell: + pattern = next( + pattern for pattern in module.track.patterns if pattern.channel == channel and pattern.index == index + ) + return next(row for row in pattern.rows if row.row_number == row_number) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return pulse_sample("lead", pitch=LEAD_PITCH) + + +@pytest.fixture(name="bass") +def bass_fixture() -> Sample: + return triangle_sample("bass", pitch=BASS_PITCH) + + +class TestTheLevelsAPlayedModuleCarries: + """The song starts a note stating no level at the full level and sounds the triangle only above + half volume, while FamiTracker carries the last level a cell wrote into every note and sounds the + triangle at any level above silence. The module therefore writes its volume column so that + FamiTracker, playing the order through and round again, plays each row the song sounds at the + song's own level, including in a pattern two frames reach at different levels. + """ + + @pytest.fixture(name="arranged") + def arranged_fixture(self, lead: Sample, bass: Sample) -> Project: + lead_note = Row(command=NoteOn(voice_id=lead.id)) + bass_note = Row(command=NoteOn(voice_id=bass.id)) + pulse = { + 0: rows_with( + (0, lead_note), + (1, Row(volume=QUIET_VOLUME)), + (2, Row(pitch=Step(value=TRANSPOSE))), + (3, lead_note), + (4, Row(command=NoteOff(), volume=NOTE_OFF_VOLUME)), + (5, lead_note), + (6, Row(volume=CLOSING_VOLUME)), + ), + 1: rows_with( + (0, lead_note), + (3, Row(volume=FRAME_CLOSING_VOLUME)), + ), + } + triangle = { + 0: rows_with( + (0, bass_note), + (2, Row(volume=QUIET_TRIANGLE_VOLUME)), + (4, Row(volume=LOUD_TRIANGLE_VOLUME)), + (6, bass_note), + ), + 1: rows_with( + (0, Row(volume=RESTING_TRIANGLE_VOLUME)), + (2, bass_note), + (5, Row(command=NoteOn(voice_id=bass.id), volume=NOTE_TRIANGLE_VOLUME)), + ), + } + order: List[Dict[ChannelName, Optional[int]]] = [ + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 1, ChannelName.TRIANGLE: 1}, + {ChannelName.PULSE1: None, ChannelName.TRIANGLE: 0}, + {ChannelName.PULSE1: 0, ChannelName.TRIANGLE: None}, + ] + return arranged_project( + (lead, bass), + {ChannelName.PULSE1: pulse, ChannelName.TRIANGLE: triangle}, + order, + ) + + def test_the_pulse_plays_every_sounding_row_at_the_song_level(self, arranged: Project) -> None: + played = played_volumes(project_to_module(arranged), ChannelId.SQUARE1) + song = song_row_volumes(arranged, ChannelName.PULSE1) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level for level, _ in sounding] == [volume for _, volume in sounding] + + def test_the_triangle_sounds_on_every_row_the_song_sounds_it(self, arranged: Project) -> None: + played = played_volumes(project_to_module(arranged), ChannelId.TRIANGLE) + song = song_row_volumes(arranged, ChannelName.TRIANGLE) * PLAYED_PASSES + + sounding = [(level, volume) for level, volume in zip(played, song) if volume is not None] + assert [level > 0 for level, _ in sounding] == [triangle_sounds_at(volume) for _, volume in sounding] + + def test_a_note_after_a_quieter_row_starts_at_the_full_level(self, arranged: Project) -> None: + cell = pattern_cell(project_to_module(arranged), ChannelId.SQUARE1, 0, 3) + assert cell.volume == MAX_VOLUME + + def test_a_note_the_channel_reaches_at_the_full_level_leaves_the_column_alone(self, lead: Sample) -> None: + lead_note = Row(command=NoteOn(voice_id=lead.id)) + project = arranged_project( + (lead,), + {ChannelName.PULSE1: {0: rows_with((0, lead_note), (4, lead_note))}}, + [{ChannelName.PULSE1: 0}, {ChannelName.PULSE1: 0}], + ) + + module = project_to_module(project) + + assert [pattern_cell(module, ChannelId.SQUARE1, 0, row).volume for row in (0, 4)] == [EMPTY_VOLUME] * 2 + + def test_a_pattern_one_frame_reaches_quieter_writes_the_full_level(self, arranged: Project) -> None: + """The module stores the pulse's first pattern once for the two frames that play it, and the + second of them reaches its opening note at the level the frame before it ends on. + """ + cell = pattern_cell(project_to_module(arranged), ChannelId.SQUARE1, 0, 0) + assert cell.volume == MAX_VOLUME + + +class TestTheTriangleSoundsAboveHalfVolume(BaseTestSuite): + """The song sounds the triangle while a row asks for more than half volume, while FamiTracker + sounds it at any column level above zero, so a quieter row writes the level that silences it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + volume: int + expected: int + + test_cases: Tuple["TestTheTriangleSoundsAboveHalfVolume.TestCase", ...] = ( + TestCase(label="a level the triangle rests at", volume=QUIET_TRIANGLE_VOLUME, expected=SILENT_COLUMN), + TestCase(label="a level the triangle sounds at", volume=LOUD_TRIANGLE_VOLUME, expected=LOUD_TRIANGLE_VOLUME), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_row_writes_the_triangle_gate( + self, + bass: Sample, + test_case: "TestTheTriangleSoundsAboveHalfVolume.TestCase", + ) -> None: + project = arranged_project( + (bass,), + {ChannelName.TRIANGLE: {0: rows_with((0, Row(command=NoteOn(voice_id=bass.id), volume=test_case.volume)))}}, + [{ChannelName.TRIANGLE: 0}], + ) + + assert pattern_cell(project_to_module(project), ChannelId.TRIANGLE, 0, 0).volume == test_case.expected + + +class TestALowTransposeKeepsTheSongsPitch: + """The song and FamiTracker both hold every tick's transposed pitch within C-0..B-7, and both play a + note below A-0 at the longest period. FamiTracker writes no note below C-0, so a transpose below it + writes C-0. + """ + + @staticmethod + def _transposed(sample: Sample, transpose: int) -> Tuple[FamiTrackerModule, RowCell]: + project = arranged_project( + (sample,), + { + ChannelName.PULSE1: { + 0: rows_with((0, Row(command=NoteOn(voice_id=sample.id), pitch=Step(value=transpose)))) + } + }, + [{ChannelName.PULSE1: 0}], + ) + module = project_to_module(project) + return ( + module, + pattern_cell(module, ChannelId.SQUARE1, 0, 0), + ) + + @staticmethod + def _arpeggio(module: FamiTrackerModule) -> Tuple[int, ...]: + return module.instruments[0].sequences[SequenceKind.ARPEGGIO].items + + def test_a_flat_voice_transposed_below_a0_writes_its_own_note(self) -> None: + _, cell = self._transposed(pulse_sample("low", pitch=LOW_PITCH), LOW_TRANSPOSE) + + assert cell_pitch(cell) == LOW_PITCH + LOW_TRANSPOSE < MIN_PITCH + + def test_a_flat_voice_transposed_below_c0_writes_c0(self) -> None: + _, cell = self._transposed(pulse_sample("low", pitch=LOW_PITCH), SUBMERGED_TRANSPOSE) + + assert cell_pitch(cell) == MIN_PLAYED_PITCH + + def test_a_contour_reaching_below_c0_plays_every_tick_where_the_song_does(self) -> None: + sample = contour_sample("contour", CONTOUR_PITCHES) + reference = voice_reference(sample, ChannelName.PULSE1) + transpose = MIN_PLAYED_PITCH + 1 - reference + module, cell = self._transposed(sample, transpose) + + steps = self._arpeggio(module) + tracker = [transpose_pitch(cell_pitch(cell), step) for step in steps] + song = [transpose_pitch(reference + step, transpose) for step in steps] + + assert cell_pitch(cell) == reference + transpose + assert min(song) == MIN_PLAYED_PITCH + assert tracker == song diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index ca7a69fc0..7039620b9 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -1,31 +1,24 @@ from dataclasses import dataclass from typing import Final, Optional, Sequence -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.formats.famitracker.builder import build_instrument +from sampletones_core.features.envelope import Envelope from sampletones_core.formats.famitracker.footprint import ( InstrumentFootprint, + envelope_footprint, features_footprint, - instrument_footprint, reconstruction_footprints, - sequence_footprint, - sequences_footprint, total_footprint, ) -from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.memory import ( INSTRUMENT_DEFINITION_BYTES, SEQUENCE_HEADER_BYTES, SEQUENCE_POINTER_BYTES, ) -from sampletones_core.formats.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, - SequenceKind, -) +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -39,15 +32,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 +54,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,9 +88,11 @@ class TestCase(BaseRegularTestCase): [0] * OVER_LONG_LENGTH, None, ), - loop=False, - expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), - label="capped_at_the_sequence_limit", + expected=InstrumentFootprint( + instrument_bytes=7, + sequence_bytes=2 * (SEQUENCE_HEADER_BYTES + OVER_LONG_LENGTH), + ), + label="past_the_sequence_limit", ), TestCase( features=build_features( @@ -105,9 +100,8 @@ 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", + label="at_the_sequence_limit", ), ) @@ -116,31 +110,22 @@ 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 - - @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) - assert instrument_footprint(instrument) == 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_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 -class TestSequenceFootprint: - def test_a_sequence_holds_its_header_and_one_byte_per_item(self) -> None: - sequence = InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12, 9)) - assert sequence_footprint(sequence) == SEQUENCE_HEADER_BYTES + 3 +class TestEnvelopeFootprint: + def test_an_envelope_holds_its_header_and_one_byte_per_item(self) -> None: + envelope = Envelope[int](items=(15, 12, 9)) + assert envelope_footprint(envelope) == SEQUENCE_HEADER_BYTES + 3 - def test_a_disabled_sequence_costs_nothing(self) -> None: - sequences = ( - InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12)), - InstrumentSequence(kind=SequenceKind.PITCH, items=()), - ) - footprint = sequences_footprint(sequences) + def test_an_empty_dimension_costs_nothing(self) -> None: + features = build_features([15, 12], [], None) + footprint = features_footprint(features) assert footprint.instrument_bytes == INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES assert footprint.sequence_bytes == SEQUENCE_HEADER_BYTES + 2 @@ -161,31 +146,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..0fa16090f 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,33 @@ 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), + ), + repitched=False, ) 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 +134,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..7b0294f51 --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_instrument_module.py @@ -0,0 +1,138 @@ +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.pitch import Step +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: + project.song[channel].ensure_pattern(0, ROWS_PER_PATTERN) + project.song[channel].set_row(0, 0, Row(command=NoteOn(voice_id=instrument.id), pitch=Step(value=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_slides.py b/tests/unit/sampletones_core/formats/famitracker/test_slides.py new file mode 100644 index 000000000..a6f6de8fb --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_slides.py @@ -0,0 +1,355 @@ +from dataclasses import dataclass +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PITCH, MIN_PLAYED_PITCH, NUM_PERIODS +from sampletones_core.exporters.skipped import BuiltDocument, SkippedRow, SkipReason +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.model.pattern import RowCell +from sampletones_core.formats.famitracker.slides import slide_effect +from sampletones_core.formats.famitracker.specification.patterns import ( + EMPTY_EFFECT, + EMPTY_INSTRUMENT, + EMPTY_NOTE, + FASTEST_SLIDE_SPEED, + MAX_SLIDE_SEMITONES, + SLIDE_SPEED_SHIFT, + EffectId, +) +from sampletones_core.formats.famitracker.specification.sequences import NO_LOOP_POINT, SequenceKind +from sampletones_core.project.patterns.pitch import Step +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.project.voices.sample import Sample +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.famitracker import module_cell, replayed_pitches +from tests.suite.transposes import ( + contour_sample, + flat_sample, + note, + one_channel_project, + rows_with, + settling_sample, + sounded_pitches, +) + +UNIFORM_SETTINGS: Final[ProjectSettings] = ProjectSettings() +GROOVE_SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=210) +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +FRAMES: Final[int] = 240 +FLAT_FRAMES: Final[int] = 8 +FLAT_PITCH: Final[int] = 40 +HIGH_PITCH: Final[int] = 115 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +NOTE_TRANSPOSE: Final[int] = 5 +RESTARTED_TRANSPOSE: Final[int] = 4 +AFTER_RESET_TRANSPOSE: Final[int] = 1 +RAISED_ROW: Final[int] = 3 +LOWERED_ROW: Final[int] = 7 +RESET_ROW: Final[int] = 10 +AFTER_RESET_ROW: Final[int] = 13 +LATE_NOTE_ROW: Final[int] = 12 +FAR_TRANSPOSE: Final[int] = MAX_SLIDE_SEMITONES + 5 +LOW_FLAT_PITCH: Final[int] = 36 +SUBMERGED_TRANSPOSE: Final[int] = -14 +SOARING_TRANSPOSE: Final[int] = 10 +NOISE_PERIOD: Final[int] = 3 +NOISE_TRANSPOSES: Final[Tuple[int, ...]] = (-5, 12, 40) +SHORT_ORDER: Final[Tuple[Optional[int], ...]] = (0, 1, 2) + + +def cell( + document: FamiTrackerModule, + pattern_index: int, + row_number: int, + channel: ChannelName = CHANNEL, +) -> Optional[RowCell]: + return module_cell(document, channel, pattern_index, row_number) + + +def slide_at( + document: FamiTrackerModule, + pattern_index: int, + row_number: int, + channel: ChannelName = CHANNEL, +) -> Optional[Tuple[int, int]]: + """The note slide a cell's first effect column writes, or ``None`` where it writes none.""" + row = cell(document, pattern_index, row_number, channel) + if row is None or row.effects[0][0] == EMPTY_EFFECT: + return None + + return row.effects[0] + + +def semitones_up(semitones: int) -> Tuple[int, int]: + return int(EffectId.SLIDE_UP), (FASTEST_SLIDE_SPEED << SLIDE_SPEED_SHIFT) | semitones + + +def semitones_down(semitones: int) -> Tuple[int, int]: + return int(EffectId.SLIDE_DOWN), (FASTEST_SLIDE_SPEED << SLIDE_SPEED_SHIFT) | semitones + + +def instrument_of(document: FamiTrackerModule, index: int) -> Instrument2A03: + return next(instrument for instrument in document.instruments if instrument.index == index) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return contour_sample(CHANNEL, FRAMES) + + +def slid(voice: Sample, *cells: Tuple[int, Row]) -> BuiltDocument[FamiTrackerModule]: + return build_module(one_channel_project((voice,), CHANNEL, {0: rows_with(*cells)}, [0], settings=UNIFORM_SETTINGS)) + + +class TestTheSlideATransposeRowWrites: + """``Qxy`` and ``Rxy`` move the channel's note by ``y`` semitones, so a transpose row writes the slide + from the note the channel holds to the note a note-on at the row's transpose would write, at the + highest speed. + """ + + def test_the_encoding_names_the_direction_the_speed_and_the_semitones(self) -> None: + """The effect number and its parameter are the file format's own, which is the contract here.""" + assert slide_effect(RAISED) == (int(EffectId.SLIDE_UP), 0xF0 | RAISED) + assert slide_effect(LOWERED) == (int(EffectId.SLIDE_DOWN), 0xF0 | -LOWERED) + + def test_a_rise_slides_the_note_up(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=RAISED)))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_up(RAISED) + + def test_the_row_leaves_the_note_and_the_instrument_alone(self, lead: Sample) -> None: + """A note or an instrument cell retriggers the instrument, so the row writes neither.""" + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=RAISED)))).document + row = cell(document, 0, RAISED_ROW) + + assert row is not None + assert (row.note, row.instrument) == (EMPTY_NOTE, EMPTY_INSTRUMENT) + + def test_a_second_row_slides_from_where_the_first_left_the_note(self, lead: Sample) -> None: + document = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (LOWERED_ROW, Row(pitch=Step(value=LOWERED))), + ).document + + assert slide_at(document, 0, LOWERED_ROW) == semitones_down(RAISED - LOWERED) + + def test_the_slide_counts_from_the_transpose_the_note_started_at(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead, NOTE_TRANSPOSE)), (RAISED_ROW, Row(pitch=Step(value=RAISED)))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_down(NOTE_TRANSPOSE - RAISED) + + def test_a_note_on_starts_the_slides_over(self, lead: Sample) -> None: + document = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (RESET_ROW, note(lead, RESTARTED_TRANSPOSE)), + (AFTER_RESET_ROW, Row(pitch=Step(value=AFTER_RESET_TRANSPOSE))), + ).document + + assert slide_at(document, 0, RESET_ROW) is None + assert slide_at(document, 0, AFTER_RESET_ROW) == semitones_down(RESTARTED_TRANSPOSE - AFTER_RESET_TRANSPOSE) + + def test_a_row_restating_the_transpose_in_force_writes_no_slide(self, lead: Sample) -> None: + document = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=RAISED))), + (LOWERED_ROW, Row(pitch=Step(value=RAISED))), + ).document + + assert slide_at(document, 0, LOWERED_ROW) is None + + def test_a_transpose_below_the_range_slides_to_the_lowest_pitch(self) -> None: + """A note-on at the new transpose writes its note by the low-pitch rule, so a flat instrument + slides to C-0, the lowest pitch the song plays. + """ + flat = flat_sample(CHANNEL, LOW_FLAT_PITCH, FLAT_FRAMES) + document = slid(flat, (0, note(flat)), (RAISED_ROW, Row(pitch=Step(value=SUBMERGED_TRANSPOSE)))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_down(LOW_FLAT_PITCH - MIN_PLAYED_PITCH) + + def test_a_transpose_above_the_range_slides_to_the_highest_pitch(self) -> None: + high = flat_sample(CHANNEL, HIGH_PITCH, FLAT_FRAMES) + document = slid(high, (0, note(high)), (RAISED_ROW, Row(pitch=Step(value=SOARING_TRANSPOSE)))).document + + assert slide_at(document, 0, RAISED_ROW) == semitones_up(MAX_PITCH - HIGH_PITCH) + + def test_a_slide_is_the_same_however_long_the_note_has_sounded(self, lead: Sample) -> None: + """The slide moves the note, which a running arpeggio reads on every tick, so neither the groove + nor the frames between the note and the row change what the row writes. + """ + project = one_channel_project( + (lead,), + CHANNEL, + {0: rows_with((LATE_NOTE_ROW, note(lead))), 1: rows_with((RAISED_ROW, Row(pitch=Step(value=RAISED))))}, + [0, None, 1], + settings=GROOVE_SETTINGS, + ) + + assert slide_at(build_module(project).document, 1, RAISED_ROW) == semitones_up(RAISED) + + +class TestTheSlidesAModuleCannotCarry: + """A slide reaches fifteen semitones, and a module stores a pattern once for every frame that plays + it, so a row moving the note further or a shared cell needing different slides is written without + one there and reported. The rows after it slide from the note the channel holds. + """ + + def test_a_row_moving_the_note_past_a_slide_is_reported(self, lead: Sample) -> None: + built = slid(lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=FAR_TRANSPOSE)))) + + assert slide_at(built.document, 0, RAISED_ROW) is None + assert built.skipped_rows == ( + SkippedRow( + voice_id=lead.id, + channel=CHANNEL, + order_position=0, + row_index=RAISED_ROW, + reason=SkipReason.UNREACHED_TRANSPOSE, + ), + ) + + def test_the_row_after_it_slides_from_the_note_the_channel_holds(self, lead: Sample) -> None: + built = slid( + lead, + (0, note(lead)), + (RAISED_ROW, Row(pitch=Step(value=FAR_TRANSPOSE))), + (LOWERED_ROW, Row(pitch=Step(value=RAISED))), + ) + + assert slide_at(built.document, 0, LOWERED_ROW) == semitones_up(RAISED) + + def test_a_shared_cell_needing_another_slide_in_a_later_frame_is_reported(self, lead: Sample) -> None: + """The pattern holding the transpose row plays after notes at two transposes, so the slide the + first frame decides moves the second frame's note elsewhere. + """ + project = one_channel_project( + (lead,), + CHANNEL, + { + 0: rows_with((0, note(lead))), + 1: rows_with((RAISED_ROW, Row(pitch=Step(value=RAISED)))), + 2: rows_with((0, note(lead, NOTE_TRANSPOSE))), + }, + [0, 1, 2, 1], + settings=UNIFORM_SETTINGS, + ) + built = build_module(project) + + assert slide_at(built.document, 1, RAISED_ROW) == semitones_up(RAISED) + assert built.skipped_rows == ( + SkippedRow( + voice_id=lead.id, + channel=CHANNEL, + order_position=3, + row_index=RAISED_ROW, + reason=SkipReason.UNREACHED_TRANSPOSE, + ), + ) + + @pytest.mark.parametrize("transpose", NOISE_TRANSPOSES) + def test_every_noise_slide_is_within_reach(self, transpose: int) -> None: + """The note on noise is the period, and both notes lie within the sixteen periods, so a slide + between them reaches every transpose and the channel lands on the period the transpose walks to. + """ + drum = flat_sample(ChannelName.NOISE, NOISE_PERIOD, FLAT_FRAMES) + project = one_channel_project( + (drum,), + ChannelName.NOISE, + {0: rows_with((0, note(drum)), (RAISED_ROW, Row(pitch=Step(value=transpose))))}, + [0], + settings=UNIFORM_SETTINGS, + ) + built = build_module(project) + effect = slide_at(built.document, 0, RAISED_ROW, ChannelName.NOISE) + + assert built.skipped_rows == () + assert effect is not None + direction = 1 if effect[0] == int(EffectId.SLIDE_UP) else -1 + moved = NOISE_PERIOD + direction * (effect[1] & MAX_SLIDE_SEMITONES) + assert moved == (NOISE_PERIOD + transpose) % NUM_PERIODS + + +class TestTheInstrumentsASlideReaches: + """Only an arpeggio still running reloads the period from the note a slide moved, so an instrument a + slide reaches keeps its arpeggio running, and a module without a slide keeps every instrument as it + was. + """ + + def test_the_arpeggio_of_an_instrument_a_slide_reaches_keeps_running(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=RAISED)))).document + arpeggio = instrument_of(document, 0).sequences[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == len(arpeggio.items) - 1 + + def test_an_instrument_no_slide_reaches_is_left_as_it_was(self, lead: Sample) -> None: + document = slid(lead, (0, note(lead)), (RAISED_ROW, Row(pitch=Step(value=0)))).document + arpeggio = instrument_of(document, 0).sequences[SequenceKind.ARPEGGIO] + + assert arpeggio.loop_point == NO_LOOP_POINT + + +class TestATransposedSongSoundsTheSongsPitch(BaseTestSuite): + """Played the way FamiTracker reads its rows, a song whose transpose rows move a sounding note, cross + frames under a groove, and meet a note-on starting over sounds the pitch the song's walk sounds on + every tick the song sounds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + settling: bool + + test_cases: Tuple["TestATransposedSongSoundsTheSongsPitch.TestCase", ...] = ( + TestCase(label="pulse", channel=ChannelName.PULSE1, settling=False), + TestCase(label="triangle", channel=ChannelName.TRIANGLE, settling=False), + TestCase(label="noise", channel=ChannelName.NOISE, settling=False), + TestCase(label="pulse_settling", channel=ChannelName.PULSE1, settling=True), + TestCase(label="triangle_settling", channel=ChannelName.TRIANGLE, settling=True), + TestCase(label="noise_settling", channel=ChannelName.NOISE, settling=True), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_sounding_tick_plays_the_songs_pitch( + self, + test_case: "TestATransposedSongSoundsTheSongsPitch.TestCase", + ) -> None: + """A settling voice's arpeggio has played its items before the first transpose row comes.""" + voice = ( + settling_sample(test_case.channel, FRAMES) + if test_case.settling + else contour_sample(test_case.channel, FRAMES) + ) + patterns = { + 0: rows_with((0, note(voice)), (RAISED_ROW, Row(pitch=Step(value=RAISED)))), + 1: rows_with( + (2, Row(pitch=Step(value=LOWERED))), + (RESET_ROW, note(voice)), + (AFTER_RESET_ROW, Row(pitch=Step(value=AFTER_RESET_TRANSPOSE))), + ), + 2: rows_with((RAISED_ROW, Row(pitch=Step(value=LOWERED))), (LOWERED_ROW, Row(pitch=Step(value=0)))), + } + project = one_channel_project( + (voice,), + test_case.channel, + patterns, + list(SHORT_ORDER), + settings=GROOVE_SETTINGS, + ) + + sounded = sounded_pitches(project, test_case.channel) + played = replayed_pitches(build_module(project).document, project, test_case.channel) + + assert len(played) == len(sounded) + assert [tick for tick, pitch in enumerate(sounded) if pitch is not None and played[tick] != pitch] == [] diff --git a/tests/unit/sampletones_core/formats/famitracker/test_starts.py b/tests/unit/sampletones_core/formats/famitracker/test_starts.py new file mode 100644 index 000000000..2c1a56a05 --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_starts.py @@ -0,0 +1,241 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import BuiltDocument, SkippedRow, SkipReason +from sampletones_core.formats.famitracker.builder import build_instrument_table, build_module +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.model.pattern import RowCell +from sampletones_core.project.patterns.pitch import Step +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_off import NoteOff +from sampletones_core.project.voices.sample import Sample +from sampletones_core.project.voices.voice import VoiceUnion, voice_reference +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.famitracker import cell_pitch, module_cell, replayed_pitches +from tests.suite.transposes import ( + contour_sample, + flat_instrument, + note, + one_channel_project, + rows_with, + sounded_pitches, +) + +SETTINGS: Final[ProjectSettings] = ProjectSettings() +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +FRAMES: Final[int] = 240 +ORGAN_PITCH: Final[int] = 72 +ORGAN_PERIOD: Final[int] = 6 +NOTE_TRANSPOSE: Final[int] = 5 +RAISED: Final[int] = 2 +LOWERED: Final[int] = -3 +BEND_ROW: Final[int] = 2 +NOTE_OFF_ROW: Final[int] = 3 +INSTRUMENT_ROW: Final[int] = 6 +LATER_BEND_ROW: Final[int] = 9 +LATER_NOTE_OFF_ROW: Final[int] = 11 +SILENT_INSTRUMENT_ROW: Final[int] = 13 +SHARED_PATTERN: Final[int] = 2 +DECIDING_ORDER: Final[Tuple[Optional[int], ...]] = (0, SHARED_PATTERN, 1, SHARED_PATTERN) +AGREEING_ORDER: Final[Tuple[Optional[int], ...]] = (0, SHARED_PATTERN, SHARED_PATTERN) +REPORTED_FRAME: Final[int] = 3 + + +def organ_for(channel_name: ChannelName) -> Instrument: + return flat_instrument(channel_name, ORGAN_PERIOD if channel_name == ChannelName.NOISE else ORGAN_PITCH) + + +@pytest.fixture(name="lead") +def lead_fixture() -> Sample: + return contour_sample(CHANNEL, FRAMES) + + +@pytest.fixture(name="organ") +def organ_fixture() -> Instrument: + return organ_for(CHANNEL) + + +def arranged( + voices: Sequence[VoiceUnion], + patterns: Mapping[int, List[Row]], + order: Sequence[Optional[int]], + channel_name: ChannelName = CHANNEL, +) -> Project: + return one_channel_project(voices, channel_name, patterns, order, settings=SETTINGS) + + +def built( + voices: Sequence[VoiceUnion], + patterns: Mapping[int, List[Row]], + order: Sequence[Optional[int]], +) -> BuiltDocument[FamiTrackerModule]: + return build_module(arranged(voices, patterns, order)) + + +def instrument_cell(document: FamiTrackerModule, pattern_index: int) -> RowCell: + cell = module_cell(document, CHANNEL, pattern_index, INSTRUMENT_ROW) + assert cell is not None + return cell + + +def reported(organ: Instrument, order_position: int) -> SkippedRow: + return SkippedRow( + voice_id=organ.id, + channel=CHANNEL, + order_position=order_position, + row_index=INSTRUMENT_ROW, + reason=SkipReason.CARRIED_PITCH, + ) + + +class TestAnInstrumentPlacedWithoutAPitch: + """The song starts such an instrument on the pitch the channel is sounding, whichever voice sounded + it, and leaves a silent channel silent, so the cell writes that note beside the instrument where + the song sounds one and stays empty where it sounds none. + """ + + def test_the_cell_writes_the_note_the_sample_was_sounding(self, lead: Sample, organ: Instrument) -> None: + project = arranged( + (lead, organ), + {0: rows_with((0, note(lead, NOTE_TRANSPOSE)), (INSTRUMENT_ROW, note(organ)))}, + [0], + ) + _, slots = build_instrument_table(project) + + cell = instrument_cell(build_module(project).document, 0) + + assert cell_pitch(cell) == voice_reference(lead, CHANNEL) + NOTE_TRANSPOSE + assert cell.instrument == slots[(organ.id, CHANNEL)].index + + def test_the_note_follows_a_bend(self, lead: Sample, organ: Instrument) -> None: + document = built( + (lead, organ), + {0: rows_with((0, note(lead)), (BEND_ROW, Row(pitch=Step(value=RAISED))), (INSTRUMENT_ROW, note(organ)))}, + [0], + ).document + + assert cell_pitch(instrument_cell(document, 0)) == voice_reference(lead, CHANNEL) + RAISED + + def test_a_silent_channel_leaves_the_cell_empty(self, organ: Instrument) -> None: + built_module = built((organ,), {0: rows_with((INSTRUMENT_ROW, note(organ)))}, [0]) + + assert module_cell(built_module.document, CHANNEL, 0, INSTRUMENT_ROW) is None + assert built_module.skipped_rows == () + + def test_a_note_off_before_it_leaves_the_cell_empty(self, lead: Sample, organ: Instrument) -> None: + document = built( + (lead, organ), + {0: rows_with((0, note(lead)), (NOTE_OFF_ROW, Row(command=NoteOff())), (INSTRUMENT_ROW, note(organ)))}, + [0], + ).document + + assert module_cell(document, CHANNEL, 0, INSTRUMENT_ROW) is None + + +class TestACellSeveralFramesReach: + """A module stores a pattern once for every frame that plays it, so the first frame reaching an + instrument's cell decides its note, and a later frame reaching it after another pitch, or silent, + is reported. + """ + + def test_the_first_frame_decides_the_note(self, lead: Sample, organ: Instrument) -> None: + document = built( + (lead, organ), + { + 0: rows_with((0, note(lead, RAISED))), + 1: rows_with((0, note(lead, LOWERED))), + SHARED_PATTERN: rows_with((INSTRUMENT_ROW, note(organ))), + }, + DECIDING_ORDER, + ).document + + assert cell_pitch(instrument_cell(document, SHARED_PATTERN)) == voice_reference(lead, CHANNEL) + RAISED + + def test_a_frame_reaching_the_cell_after_another_pitch_is_reported(self, lead: Sample, organ: Instrument) -> None: + built_module = built( + (lead, organ), + { + 0: rows_with((0, note(lead, RAISED))), + 1: rows_with((0, note(lead, LOWERED))), + SHARED_PATTERN: rows_with((INSTRUMENT_ROW, note(organ))), + }, + DECIDING_ORDER, + ) + + assert built_module.skipped_rows == (reported(organ, REPORTED_FRAME),) + + def test_a_frame_reaching_the_cell_silent_is_reported(self, lead: Sample, organ: Instrument) -> None: + built_module = built( + (lead, organ), + { + 0: rows_with((0, note(lead))), + 1: rows_with((0, Row(command=NoteOff()))), + SHARED_PATTERN: rows_with((INSTRUMENT_ROW, note(organ))), + }, + DECIDING_ORDER, + ) + + assert cell_pitch(instrument_cell(built_module.document, SHARED_PATTERN)) == voice_reference(lead, CHANNEL) + assert built_module.skipped_rows == (reported(organ, REPORTED_FRAME),) + + def test_frames_reaching_the_cell_at_one_pitch_report_nothing(self, lead: Sample, organ: Instrument) -> None: + """The instrument the first frame started goes on sounding into the second, so the second frame + starts it at the pitch it already sounds. + """ + built_module = built( + (lead, organ), + {0: rows_with((0, note(lead, RAISED))), SHARED_PATTERN: rows_with((INSTRUMENT_ROW, note(organ)))}, + AGREEING_ORDER, + ) + + assert built_module.skipped_rows == () + + +class TestAnInstrumentTakingTheChannelsPitchSoundsTheSongsPitch(BaseTestSuite): + """Played the way FamiTracker reads its rows, an instrument that takes the pitch a sample left, is + bent afterwards, and is placed again on a silent channel sounds the pitch the song's walk sounds on + every tick the song sounds. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + + test_cases: Tuple["TestAnInstrumentTakingTheChannelsPitchSoundsTheSongsPitch.TestCase", ...] = ( + TestCase(label="pulse", channel=ChannelName.PULSE1), + TestCase(label="triangle", channel=ChannelName.TRIANGLE), + TestCase(label="noise", channel=ChannelName.NOISE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_every_sounding_tick_plays_the_songs_pitch( + self, + test_case: "TestAnInstrumentTakingTheChannelsPitchSoundsTheSongsPitch.TestCase", + ) -> None: + lead = contour_sample(test_case.channel, FRAMES) + organ = organ_for(test_case.channel) + patterns: Dict[int, List[Row]] = { + 0: rows_with( + (0, note(lead, NOTE_TRANSPOSE)), + (BEND_ROW, Row(pitch=Step(value=RAISED))), + (INSTRUMENT_ROW, note(organ)), + (LATER_BEND_ROW, Row(pitch=Step(value=LOWERED))), + (LATER_NOTE_OFF_ROW, Row(command=NoteOff())), + (SILENT_INSTRUMENT_ROW, note(organ)), + ), + } + project = arranged((lead, organ), patterns, [0], test_case.channel) + + sounded = sounded_pitches(project, test_case.channel) + played = replayed_pitches(build_module(project).document, project, test_case.channel) + + assert len(played) == len(sounded) + assert [tick for tick, pitch in enumerate(sounded) if pitch is not None and played[tick] != pitch] == [] + assert played[-1] is None 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..a6751c31a --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_voice.py @@ -0,0 +1,256 @@ +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(), + repitched=False, + ) + 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..c07d0db3c --- /dev/null +++ b/tests/unit/sampletones_core/formats/test_binary.py @@ -0,0 +1,228 @@ +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="uint16", expected=0x8000), + TestCase(method="uint16", expected=65535), + 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, + "uint16": writer.write_uint16, + "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, + "uint16": reader.read_uint16, + "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_a_skip_moves_past_the_count_asked_for(self) -> None: + reader = BinaryReader(b"abcdef") + reader.skip(4) + assert reader.read_bytes(2) == b"ef" + + def test_read_bytes_takes_the_count_asked_for(self) -> None: + reader = BinaryReader(b"abcdef") + assert reader.read_bytes(3) == b"abc" + 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_a_skip_past_the_end_raises(self) -> None: + reader = BinaryReader(b"abc") + with pytest.raises(TruncatedDataError): + reader.skip(4) + + def test_more_bytes_than_held_raises(self) -> None: + reader = BinaryReader(b"abc") + with pytest.raises(TruncatedDataError): + 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..cada72f62 100644 --- a/tests/unit/sampletones_core/generators/implementation/test_pulse.py +++ b/tests/unit/sampletones_core/generators/implementation/test_pulse.py @@ -2,8 +2,14 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName -from sampletones_core.constants.general import DUTY_CYCLES, MAX_VOLUME, MIXER_PULSE +from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.constants.general import ( + DUTY_CYCLES, + MAX_PITCH, + MAX_VOLUME, + MIN_SOUNDING_PULSE_TIMER, + MIXER_PULSE, +) from sampletones_core.generators.implementation.pulse import PulseGenerator from sampletones_core.instructions import NoiseInstruction, PulseInstruction @@ -19,7 +25,7 @@ def config() -> Config: @pytest.fixture def generator(config: Config) -> PulseGenerator: - return PulseGenerator(config, GeneratorName.PULSE1) + return PulseGenerator(config, ChannelName.PULSE1) class TestPulseGeneratorCall: @@ -49,6 +55,31 @@ def test_on_instruction_result_is_float32(self, generator: PulseGenerator) -> No assert result.dtype == np.float32 +class TestTheSweepUnitMutesShortTimers: + """The sweep unit mutes a pulse whose timer stands below eight, and the waveform keeps stepping.""" + + def _bent_to(self, generator: PulseGenerator, timer: int) -> PulseInstruction: + detune = timer - generator.played_timer_table[MAX_PITCH] + return PulseInstruction(on=True, pitch=MAX_PITCH, volume=MAX_VOLUME, duty_cycle=2, detune=detune) + + def test_a_timer_below_the_bound_is_silent(self, generator: PulseGenerator) -> None: + result = generator(self._bent_to(generator, MIN_SOUNDING_PULSE_TIMER - 1)) + + assert np.all(result == 0.0) + + def test_a_timer_at_the_bound_sounds(self, generator: PulseGenerator) -> None: + result = generator(self._bent_to(generator, MIN_SOUNDING_PULSE_TIMER)) + + assert np.any(result != 0.0) + + def test_the_waveform_steps_on_through_a_muted_frame(self, generator: PulseGenerator) -> None: + phase = generator.timer.phase + + generator(self._bent_to(generator, MIN_SOUNDING_PULSE_TIMER - 1), save=True) + + assert generator.timer.phase != phase + + class TestPulseApply: def test_volume_zero_returns_all_zeros(self, generator: PulseGenerator) -> None: phase = np.array([0.1, 0.3, 0.6, 0.8], dtype=np.float32) diff --git a/tests/unit/sampletones_core/generators/implementation/test_triangle.py b/tests/unit/sampletones_core/generators/implementation/test_triangle.py index d0dc6419b..466bdefb2 100644 --- a/tests/unit/sampletones_core/generators/implementation/test_triangle.py +++ b/tests/unit/sampletones_core/generators/implementation/test_triangle.py @@ -2,8 +2,12 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorClassName, GeneratorName -from sampletones_core.constants.general import MIXER_TRIANGLE +from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.constants.general import ( + MAX_PITCH, + MIN_SOUNDING_TRIANGLE_TIMER, + MIXER_TRIANGLE, +) from sampletones_core.generators.implementation.triangle import TriangleGenerator from sampletones_core.instructions import PulseInstruction, TriangleInstruction @@ -19,7 +23,7 @@ def config() -> Config: @pytest.fixture def generator(config: Config) -> TriangleGenerator: - return TriangleGenerator(config, GeneratorName.TRIANGLE) + return TriangleGenerator(config, ChannelName.TRIANGLE) class TestTriangleGeneratorCall: @@ -49,6 +53,32 @@ def test_on_instruction_result_is_float32(self, generator: TriangleGenerator) -> assert result.dtype == np.float32 +class TestAnUltrasonicTriangleRests: + """Below divider 2 the triangle steps above hearing, and the console's output rests at the middle of + its wave while the sequencer keeps stepping.""" + + def _bent_to(self, generator: TriangleGenerator, timer: int) -> TriangleInstruction: + detune = timer - generator.played_timer_table[MAX_PITCH] + return TriangleInstruction(on=True, pitch=MAX_PITCH, detune=detune) + + def test_a_timer_below_the_bound_rests(self, generator: TriangleGenerator) -> None: + result = generator(self._bent_to(generator, MIN_SOUNDING_TRIANGLE_TIMER - 1)) + + assert np.all(result == 0.0) + + def test_a_timer_at_the_bound_sounds(self, generator: TriangleGenerator) -> None: + result = generator(self._bent_to(generator, MIN_SOUNDING_TRIANGLE_TIMER)) + + assert np.any(result != 0.0) + + def test_the_wave_steps_on_through_a_resting_frame(self, generator: TriangleGenerator) -> None: + phase = generator.timer.phase + + generator(self._bent_to(generator, MIN_SOUNDING_TRIANGLE_TIMER - 1), save=True) + + assert generator.timer.phase != phase + + class TestTriangleApply: def test_output_bounded_within_mixer_level(self, generator: TriangleGenerator) -> None: phase = np.linspace(0.0, 1.0, 100, endpoint=False, dtype=np.float32) 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_maps.py b/tests/unit/sampletones_core/generators/test_maps.py new file mode 100644 index 000000000..2fc3a0412 --- /dev/null +++ b/tests/unit/sampletones_core/generators/test_maps.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.generators import CHANNEL_CLASSES, FULL_SCALE_RMS_LEVELS +from sampletones_core.generators.render import render_instructions +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +RENDERED_FRAMES: Final[int] = 120 +LEVEL_TOLERANCE: Final[float] = 1e-2 + + +class TestFullScaleRmsLevels(BaseTestSuite): + """A channel at full volume renders the RMS level the working level is anchored to.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + instructions: Tuple[InstructionUnion, ...] + + test_cases = ( + TestCase( + label="pulse_at_every_duty_cycle", + channel_name=ChannelName.PULSE1, + instructions=tuple( + PulseInstruction(on=True, pitch=60, volume=MAX_VOLUME, duty_cycle=duty_cycle) for duty_cycle in range(4) + ), + ), + TestCase( + label="triangle_across_the_range", + channel_name=ChannelName.TRIANGLE, + instructions=tuple(TriangleInstruction(on=True, pitch=pitch) for pitch in (33, 57, 81)), + ), + TestCase( + label="noise_at_long_periods", + channel_name=ChannelName.NOISE, + instructions=tuple( + NoiseInstruction(on=True, period=period, volume=MAX_VOLUME, short=False) for period in (0, 3, 6) + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_full_volume_rendering_holds_the_level(self, test_case: TestCase) -> None: + config = Config() + generator_class_name = CHANNEL_CLASSES[test_case.channel_name].class_name() + + for instruction in test_case.instructions: + audio = render_instructions([instruction] * RENDERED_FRAMES, test_case.channel_name, config) + level = float(np.sqrt(np.mean(np.square(audio.astype(np.float64))))) + + assert level == pytest.approx(FULL_SCALE_RMS_LEVELS[generator_class_name], rel=LEVEL_TOLERANCE) 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..c880435fb 100644 --- a/tests/unit/sampletones_core/generators/test_utils.py +++ b/tests/unit/sampletones_core/generators/test_utils.py @@ -1,22 +1,13 @@ -from typing import Dict - 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, -) -from sampletones_core.instructions import ( - NoiseInstruction, - PulseInstruction, - TriangleInstruction, ) @@ -25,30 +16,21 @@ def config() -> Config: return 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), - } - - -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: @@ -63,31 +45,3 @@ def test_generators_are_correct_types(self, config: Config) -> None: assert isinstance(result[GeneratorClassName.PULSE_GENERATOR], PulseGenerator) assert isinstance(result[GeneratorClassName.TRIANGLE_GENERATOR], TriangleGenerator) assert isinstance(result[GeneratorClassName.NOISE_GENERATOR], NoiseGenerator) - - -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), - } - result = get_remaining_generator_classes(named) - assert GeneratorClassName.PULSE_GENERATOR in result - assert GeneratorClassName.NOISE_GENERATOR in result - - -class TestGetGeneratorByInstruction: - def test_pulse_instruction_returns_pulse_generator(self, all_generators: Dict) -> None: - instruction = PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0) - result = get_generator_by_instruction(instruction, all_generators) - assert isinstance(result, PulseGenerator) - - def test_noise_instruction_returns_noise_generator(self, all_generators: Dict) -> None: - instruction = NoiseInstruction(on=True, period=3, volume=15, short=False) - result = get_generator_by_instruction(instruction, all_generators) - assert isinstance(result, NoiseGenerator) - - def test_triangle_instruction_returns_triangle_generator(self, all_generators: Dict) -> None: - instruction = TriangleInstruction(on=True, pitch=60) - result = get_generator_by_instruction(instruction, all_generators) - assert isinstance(result, TriangleGenerator) diff --git a/tests/unit/sampletones_core/headless/conversion/stems.py b/tests/unit/sampletones_core/headless/conversion/stems.py new file mode 100644 index 000000000..895d652d7 --- /dev/null +++ b/tests/unit/sampletones_core/headless/conversion/stems.py @@ -0,0 +1,21 @@ +from sampletones_core.constants.enums import ChannelName +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.configs.settings import StemSettings + + +def two_stems() -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1, ChannelName.PULSE2], bends=[ChannelName.PULSE1]), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.NOISE], bends=[]), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]]), + ) diff --git a/tests/unit/sampletones_core/headless/conversion/test_pairing.py b/tests/unit/sampletones_core/headless/conversion/test_pairing.py new file mode 100644 index 000000000..acff9ed70 --- /dev/null +++ b/tests/unit/sampletones_core/headless/conversion/test_pairing.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import Final + +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName +from sampletones_core.headless.conversion.pairing import describe_stem, pairing_lines +from sampletones_core.headless.conversion.request import ConversionRequest, classic_setup +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.files import empty_file +from tests.unit.sampletones_core.headless.conversion.stems import two_stems + +LOUD_DRIVE: Final[float] = 2.0 + + +class TestDescribeStem: + def test_a_stem_names_its_channels_and_its_bends(self) -> None: + first, second = two_stems().entries + + assert describe_stem(first) == "stem 0 on pulse1, pulse2, bending pulse1" + assert describe_stem(second) == "stem 1 on noise" + + def test_a_stem_names_a_count_below_its_channels_and_a_drive_off_unit(self) -> None: + entry = StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1, ChannelName.PULSE2], + bends=[], + drives={ChannelName.PULSE1: LOUD_DRIVE}, + channel_cap=1, + ), + ) + + assert describe_stem(entry) == "stem 0 on pulse1, pulse2, 1 at once, driving pulse1 at 2.00" + + +class TestPairingLines: + def test_recordings_pair_with_the_entries_in_order(self, tmp_path: Path) -> None: + bass = empty_file(tmp_path, "bass.wav") + drums = empty_file(tmp_path, "drums.wav") + + request = ConversionRequest(sources=(bass, drums), stems=two_stems(), output_path=None) + + assert pairing_lines(request) == [ + "bass.wav: stem 0 on pulse1, pulse2, bending pulse1", + "drums.wav: stem 1 on noise", + ] + + def test_a_directory_names_the_one_stem_every_recording_plays_under(self, tmp_path: Path) -> None: + setup = classic_setup(DEFAULT_CHANNELS) + request = ConversionRequest(sources=(tmp_path,), stems=setup, output_path=None) + + assert pairing_lines(request) == [f"{tmp_path.name}/: every recording under {describe_stem(setup.entries[0])}"] diff --git a/tests/unit/sampletones_core/headless/conversion/test_request.py b/tests/unit/sampletones_core/headless/conversion/test_request.py new file mode 100644 index 000000000..014146545 --- /dev/null +++ b/tests/unit/sampletones_core/headless/conversion/test_request.py @@ -0,0 +1,127 @@ +import json +from pathlib import Path + +import pytest + +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName +from sampletones_core.headless.conversion.request import ( + ConversionRequest, + channels_named, + classic_setup, + load_stems, +) +from tests.suite.files import empty_file +from tests.unit.sampletones_core.headless.conversion.stems import two_stems + + +class TestChannelsNamed: + def test_nothing_named_is_the_usual_three(self) -> None: + assert channels_named(None) == list(DEFAULT_CHANNELS) + + def test_names_are_read_in_order_with_their_spaces_stripped(self) -> None: + assert channels_named("noise, pulse1") == [ChannelName.NOISE, ChannelName.PULSE1] + + def test_an_unknown_name_is_refused_with_the_known_ones(self) -> None: + with pytest.raises(ValueError, match="Unknown channel 'pulse3'; the channels are pulse1, pulse2"): + channels_named("pulse1,pulse3") + + +class TestClassicSetup: + def test_one_stem_holds_the_channels_in_order_and_bends_the_toned_ones(self) -> None: + setup = classic_setup([ChannelName.NOISE, ChannelName.PULSE1]) + + assert len(setup.entries) == 1 + assert setup.entries[0].settings.channels == (ChannelName.PULSE1, ChannelName.NOISE) + assert setup.entries[0].settings.bends == (ChannelName.PULSE1,) + + +class TestLoadStems: + def test_a_setup_written_as_json_reads_back(self, tmp_path: Path) -> None: + stems = two_stems() + path = tmp_path / "stems.json" + path.write_text(json.dumps(stems.model_dump(mode="json")), encoding="utf-8") + + assert load_stems(path) == stems + + def test_a_file_holding_no_mapping_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "stems.json" + path.write_text("[]", encoding="utf-8") + + with pytest.raises(ValueError, match="must hold a mapping"): + load_stems(path) + + def test_a_missing_file_is_refused_by_its_path(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No stems file at"): + load_stems(tmp_path / "absent.json") + + def test_a_mapping_that_is_no_setup_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "stems.json" + path.write_text(json.dumps({"entries": [{"id": 0, "settings": {"channels": ["pulse9"], "bends": []}}]})) + + with pytest.raises(ValueError): + load_stems(path) + + +class TestConversionRequest: + def test_recordings_name_no_directory(self, tmp_path: Path) -> None: + bass = empty_file(tmp_path, "bass.wav") + drums = empty_file(tmp_path, "drums.wav") + + request = ConversionRequest(sources=(bass, drums), stems=two_stems(), output_path=None) + + assert request.directory is None + assert request.sources == (bass, drums) + + def test_a_directory_is_converted_file_by_file_under_one_stem(self, tmp_path: Path) -> None: + request = ConversionRequest(sources=(tmp_path,), stems=classic_setup(DEFAULT_CHANNELS), output_path=None) + + assert request.directory == tmp_path + + def test_the_sources_are_classified_once_when_the_request_is_made(self, tmp_path: Path) -> None: + directory = tmp_path / "recordings" + directory.mkdir() + request = ConversionRequest(sources=(directory,), stems=classic_setup(DEFAULT_CHANNELS), output_path=None) + + directory.rmdir() + + assert request.directory == directory + + def test_a_count_mismatch_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="1 sources for 2 stems"): + ConversionRequest(sources=(empty_file(tmp_path, "bass.wav"),), stems=two_stems(), output_path=None) + + def test_a_directory_under_several_stems_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="under one stem; the setup holds 2"): + ConversionRequest(sources=(tmp_path,), stems=two_stems(), output_path=None) + + def test_a_directory_among_recordings_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="one directory alone"): + ConversionRequest(sources=(empty_file(tmp_path, "bass.wav"), tmp_path), stems=two_stems(), output_path=None) + + def test_an_output_path_for_a_directory_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="an output path names the one file"): + ConversionRequest( + sources=(tmp_path,), + stems=classic_setup(DEFAULT_CHANNELS), + output_path=tmp_path / "out.stn", + ) + + def test_a_missing_source_is_refused_by_its_path(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No file at"): + ConversionRequest( + sources=(tmp_path / "absent.wav",), + stems=classic_setup(DEFAULT_CHANNELS), + output_path=None, + ) + + def test_a_file_other_than_a_recording_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="is no recording"): + ConversionRequest( + sources=(empty_file(tmp_path, "song.stp"),), + stems=classic_setup(DEFAULT_CHANNELS), + output_path=None, + ) + + def test_no_source_is_refused(self) -> None: + with pytest.raises(ValueError): + ConversionRequest(sources=(), stems=classic_setup(DEFAULT_CHANNELS), output_path=None) diff --git a/tests/unit/sampletones_core/headless/conversion/test_runners.py b/tests/unit/sampletones_core/headless/conversion/test_runners.py new file mode 100644 index 000000000..9b1ebda31 --- /dev/null +++ b/tests/unit/sampletones_core/headless/conversion/test_runners.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import Callable, Final, List, Tuple + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.headless.conversion import runners +from sampletones_core.headless.conversion.request import ConversionRequest, classic_setup +from sampletones_core.headless.conversion.runners import reconstruct, reconstruct_directory, reconstruct_sources +from sampletones_core.reconstructions.converter import ConversionJob, DirectoryConversion +from sampletones_core.reconstructions.progress import ReconstructionReporter +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_shared.logger import LoggerProtocol +from tests.suite.files import empty_file + +PREPARED: Final[str] = "library prepared" +CONVERTED: Final[str] = "converted" + + +class RecordedConversion: + """A directory conversion that records its start in place of running.""" + + def __init__(self, steps: List[str]) -> None: + self.steps = steps + + def set_callbacks(self, **callbacks: Callable[..., None]) -> None: + del callbacks + + def start(self) -> None: + self.steps.append(CONVERTED) + + def wait(self) -> None: + pass + + +class TestReconstruct: + def test_recordings_are_mixed_into_one_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls: List[Tuple[Tuple[Path, ...], Path]] = [] + + def reconstruct_sources( + sources: Tuple[Path, ...], config: Config, stems: StemsConfig, output_path: Path + ) -> None: + del config, stems + calls.append((sources, output_path)) + + monkeypatch.setattr(runners, "reconstruct_sources", reconstruct_sources) + source = empty_file(tmp_path, "song.wav") + request = ConversionRequest( + sources=(source,), stems=classic_setup(DEFAULT_CHANNELS), output_path=tmp_path / "x.stn" + ) + + reconstruct(request, Config()) + + assert calls == [((source,), tmp_path / "x.stn")] + + def test_a_directory_is_walked(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + walked: List[Path] = [] + + def reconstruct_directory(directory: Path, config: Config, stems: StemsConfig) -> None: + del config, stems + walked.append(directory) + + monkeypatch.setattr(runners, "reconstruct_directory", reconstruct_directory) + request = ConversionRequest(sources=(tmp_path,), stems=classic_setup(DEFAULT_CHANNELS), output_path=None) + + reconstruct(request, Config()) + + assert walked == [tmp_path] + + +class TestTheLibraryAConversionSearches: + """A conversion prepares the library its configuration names before it reconstructs anything.""" + + def test_recordings_are_reconstructed_once_it_is_prepared( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + steps: List[str] = [] + + def ensure_library(config: Config) -> None: + del config + steps.append(PREPARED) + + def reconstruct_job(arguments: Tuple[None, ConversionJob, ReconstructionReporter]) -> None: + del arguments + steps.append(CONVERTED) + + monkeypatch.setattr(runners, "ensure_library", ensure_library) + monkeypatch.setattr(runners, "Reconstructor", lambda config, channels: None) + monkeypatch.setattr(runners, "reconstruct_job", reconstruct_job) + source = empty_file(tmp_path, "song.wav") + + reconstruct_sources((source,), Config(), classic_setup(DEFAULT_CHANNELS), tmp_path / "x.stn") + + assert steps == [PREPARED, CONVERTED] + + def test_a_directory_is_walked_once_it_is_prepared(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + steps: List[str] = [] + + def ensure_library(config: Config) -> None: + del config + steps.append(PREPARED) + + def converter(config: Config, conversion: DirectoryConversion, logger: LoggerProtocol) -> RecordedConversion: + del config, conversion, logger + return RecordedConversion(steps) + + monkeypatch.setattr(runners, "ensure_library", ensure_library) + monkeypatch.setattr(runners, "ReconstructionConverter", converter) + + reconstruct_directory(tmp_path, Config(), classic_setup(DEFAULT_CHANNELS)) + + assert steps == [PREPARED, CONVERTED] diff --git a/tests/unit/sampletones_core/headless/test_config.py b/tests/unit/sampletones_core/headless/test_config.py new file mode 100644 index 000000000..f37588181 --- /dev/null +++ b/tests/unit/sampletones_core/headless/test_config.py @@ -0,0 +1,15 @@ +from pathlib import Path + +from sampletones_core.configs import Config +from sampletones_core.headless.config import load_config + + +class TestLoadConfig: + def test_a_file_named_is_read(self, tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text("{}", encoding="utf-8") + + assert load_config(path) == Config() + + def test_nothing_named_is_the_saved_configuration(self) -> None: + assert load_config(None) == Config.default() diff --git a/tests/unit/sampletones_core/headless/test_library.py b/tests/unit/sampletones_core/headless/test_library.py new file mode 100644 index 000000000..0cef62526 --- /dev/null +++ b/tests/unit/sampletones_core/headless/test_library.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.fft import Window +from sampletones_core.headless import library as headless_library +from sampletones_core.headless.library import ensure_library +from sampletones_core.library import InstructionLibrary, InstructionLibraryData +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +EARLIER_LIBRARY_VERSION: Final[str] = "2.0" + + +def _config_in(directory: Path) -> Config: + config = Config() + general = config.general.model_copy(update={"library_directory": str(directory)}) + return config.model_copy(update={"general": general}) + + +def _library_path(config: Config) -> Path: + library = InstructionLibrary.from_config(config) + return library.get_path(library.create_key(config, Window.from_config(config))) + + +def _write_current(config: Config) -> None: + InstructionLibraryData.create(config, {}).save(_library_path(config)) + + +def _write_earlier(config: Config) -> None: + library = InstructionLibraryData.create(config, {}) + stated = library.metadata.model_copy(update={"library_data_version": EARLIER_LIBRARY_VERSION}) + library.model_copy(update={"metadata": stated}).save(_library_path(config)) + + +def _write_nothing(config: Config) -> None: + del config + + +class TestTheLibraryAHeadlessConversionPrepares(BaseTestSuite): + """A library this build reads is kept; any other is generated in its place.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + write: Callable[[Config], None] + expected: bool + + test_cases = ( + TestCase(label="a library this build wrote", write=_write_current, expected=False), + TestCase(label="a library another version built", write=_write_earlier, expected=True), + TestCase(label="no library", write=_write_nothing, expected=True), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_whether_it_is_generated( + self, + test_case: TestCase, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + generated: List[Config] = [] + monkeypatch.setattr(headless_library, "generate_library", generated.append) + config = _config_in(tmp_path) + test_case.write(config) + + ensure_library(config) + + assert generated == ([config] if test_case.expected else []) diff --git a/tests/unit/sampletones_core/instructions/test_stream.py b/tests/unit/sampletones_core/instructions/test_stream.py new file mode 100644 index 000000000..fc87cae7a --- /dev/null +++ b/tests/unit/sampletones_core/instructions/test_stream.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import pytest + +from sampletones_core.instructions import InstructionUnion, PulseInstruction, sounds +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +def _pulse() -> PulseInstruction: + return PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0) + + +def _rest() -> PulseInstruction: + return PulseInstruction.null_instruction() + + +class TestSounds(BaseTestSuite): + """A stream sounds where any of its frames does, which is what puts a channel in play.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + stream: List[InstructionUnion] + + test_cases: Tuple["TestSounds.TestCase", ...] = ( + TestCase(label="sounding in one frame", stream=[_rest(), _pulse(), _rest()], expected=True), + TestCase(label="sounding throughout", stream=[_pulse(), _pulse()], expected=True), + TestCase(label="resting throughout", stream=[_rest(), _rest()], expected=False), + TestCase(label="no frames", stream=[], expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_stream_sounds_where_a_frame_does(self, test_case: TestCase) -> None: + assert sounds(test_case.stream) is test_case.expected 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/library/test_library.py b/tests/unit/sampletones_core/library/test_library.py index 7311fb253..e32592547 100644 --- a/tests/unit/sampletones_core/library/test_library.py +++ b/tests/unit/sampletones_core/library/test_library.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Final import pytest @@ -7,6 +8,8 @@ from sampletones_core.library.data import InstructionLibraryData from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.library.library import InstructionLibrary +from sampletones_core.library.state import LibraryState +from tests.suite.files import requires_symlinks @pytest.fixture(scope="module") @@ -38,6 +41,10 @@ def empty_library_data(config: Config) -> InstructionLibraryData: return InstructionLibraryData.create(config, {}) +LINK_TARGET: Final[str] = "libraries" +LINK: Final[str] = "linked_libraries" + + class TestInstructionLibraryFromConfig: def test_from_config_creates_instance(self, config: Config) -> None: library = InstructionLibrary.from_config(config) @@ -95,87 +102,55 @@ def test_get_without_window_defaults_from_config( assert result is not None -class TestInstructionLibraryExists: - def test_exists_returns_false_before_save( +class TestTheStateOfALibraryInTheCatalog: + def test_a_library_never_saved_is_missing( self, library: InstructionLibrary, library_key: InstructionLibraryKey, ) -> None: - assert library.exists(library_key) is False - - def test_exists_returns_true_after_save( - self, - library: InstructionLibrary, - library_key: InstructionLibraryKey, - empty_library_data: InstructionLibraryData, - ) -> None: - library.save_data(library_key, empty_library_data) - assert library.exists(library_key) is True + assert library.state(library_key) is LibraryState.MISSING - def test_exists_with_config_resolves_key( + def test_a_library_this_build_saved_is_current( self, library: InstructionLibrary, library_key: InstructionLibraryKey, empty_library_data: InstructionLibraryData, - config: Config, ) -> None: library.save_data(library_key, empty_library_data) - assert library.exists(config) is True + assert library.state(library_key) is LibraryState.CURRENT -class TestInstructionLibraryPurge: - def test_purge_clears_in_memory_data( +class TestInstructionLibrarySaveLoad: + def test_save_and_load_round_trip( self, library: InstructionLibrary, library_key: InstructionLibraryKey, empty_library_data: InstructionLibraryData, ) -> None: library.save_data(library_key, empty_library_data) - assert len(library.data) > 0 - library.purge() - assert len(library.data) == 0 + reopened = InstructionLibrary(directory=library.directory) + reopened.load_data(library_key) + assert reopened.data[library_key].config == empty_library_data.config - -class TestInstructionLibraryViews: - def test_keys_values_items_after_save( + def test_get_path_includes_filename( self, library: InstructionLibrary, library_key: InstructionLibraryKey, - empty_library_data: InstructionLibraryData, ) -> None: - library.save_data(library_key, empty_library_data) - assert library_key in library.keys() - assert empty_library_data in library.values() - assert (library_key, empty_library_data) in library.items() - + path = library.get_path(library_key) + assert path.name == library_key.filename -class TestInstructionLibrarySaveLoad: - def test_save_and_load_round_trip( + @requires_symlinks + def test_a_library_written_through_a_link_to_a_folder_yet_to_exist_makes_that_folder( self, - library: InstructionLibrary, library_key: InstructionLibraryKey, empty_library_data: InstructionLibraryData, + tmp_path: Path, ) -> None: - library.save_data(library_key, empty_library_data) - library.purge() - assert library_key not in library.data - library.load_data(library_key) - assert library_key in library.data + target = tmp_path / LINK_TARGET + link = tmp_path / LINK + link.symlink_to(target, target_is_directory=True) - def test_getitem_after_load( - self, - library: InstructionLibrary, - library_key: InstructionLibraryKey, - empty_library_data: InstructionLibraryData, - ) -> None: - library.save_data(library_key, empty_library_data) - loaded = library[library_key] - assert isinstance(loaded, InstructionLibraryData) + InstructionLibrary(directory=str(link)).write_data(library_key, empty_library_data) - def test_get_path_includes_filename( - self, - library: InstructionLibrary, - library_key: InstructionLibraryKey, - ) -> None: - path = library.get_path(library_key) - assert path.name == library_key.filename + assert (target / library_key.filename).is_file() diff --git a/tests/unit/sampletones_core/library/test_state.py b/tests/unit/sampletones_core/library/test_state.py new file mode 100644 index 000000000..56d588c5e --- /dev/null +++ b/tests/unit/sampletones_core/library/test_state.py @@ -0,0 +1,145 @@ +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final + +import msgpack +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.data import Metadata +from sampletones_core.library.data import InstructionLibraryData +from sampletones_core.library.state import ( + CONFIG_FIELD, + METADATA_FIELD, + VERSION_FIELD, + LibraryHeader, + LibraryState, + library_state, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.compatibility import LIBRARY_VERSION, archived + +LIBRARY_NAME: Final[str] = "library.ins" +FOREIGN_APPLICATION: Final[str] = "Another application" +LATER_LIBRARY_VERSION: Final[str] = "99.0" +MALFORMED_VERSION: Final[str] = "not a version" +STORED_GAMMA: Final[int] = 50 +STORED_SAMPLE_RATE: Final[int] = 22050 + + +def _write_current(path: Path) -> None: + InstructionLibraryData.create(Config(), {}).save(path) + + +def _write_stating(**metadata: str) -> Callable[[Path], None]: + def write(path: Path) -> None: + library = InstructionLibraryData.create(Config(), {}) + stated = library.metadata.model_copy(update=metadata) + library.model_copy(update={"metadata": stated}).save(path) + + return write + + +def _write_stating_no_version(path: Path) -> None: + payload = msgpack.unpackb(InstructionLibraryData.create(Config(), {}).serialize(), raw=False) + del payload[METADATA_FIELD][VERSION_FIELD] + path.write_bytes(msgpack.packb(payload, use_bin_type=True)) + + +def _write_archived(path: Path) -> None: + shutil.copyfile(archived(ObjectKind.LIBRARY, LIBRARY_VERSION), path) + + +def _write_directory(path: Path) -> None: + path.mkdir() + + +def _write_bytes(content: bytes) -> Callable[[Path], None]: + def write(path: Path) -> None: + path.write_bytes(content) + + return write + + +class TestWhereALibraryFileStands(BaseTestSuite): + """A library reads current exactly where this build loads it; any other file is out of date.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + write: Callable[[Path], None] + expected: LibraryState + + test_cases = ( + TestCase(label="a library this build wrote", write=_write_current, expected=LibraryState.CURRENT), + TestCase(label="the library the last release wrote", write=_write_archived, expected=LibraryState.OUTDATED), + TestCase( + label="a library a later build wrote", + write=_write_stating(library_data_version=LATER_LIBRARY_VERSION), + expected=LibraryState.OUTDATED, + ), + TestCase( + label="a library stating a malformed version", + write=_write_stating(library_data_version=MALFORMED_VERSION), + expected=LibraryState.OUTDATED, + ), + TestCase( + label="a library another application wrote", + write=_write_stating(application_name=FOREIGN_APPLICATION), + expected=LibraryState.OUTDATED, + ), + TestCase(label="a library stating no version", write=_write_stating_no_version, expected=LibraryState.OUTDATED), + TestCase(label="an empty file", write=_write_bytes(b""), expected=LibraryState.OUTDATED), + TestCase(label="bytes of no format", write=_write_bytes(b"\xc1" * 32), expected=LibraryState.OUTDATED), + TestCase(label="a path the system reads no file from", write=_write_directory, expected=LibraryState.OUTDATED), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_state_it_reads_as(self, test_case: TestCase, tmp_path: Path) -> None: + path = tmp_path / LIBRARY_NAME + test_case.write(path) + + assert library_state(path) is test_case.expected + + def test_a_path_holding_no_file_reads_as_missing(self, tmp_path: Path) -> None: + assert library_state(tmp_path / LIBRARY_NAME) is LibraryState.MISSING + + +class TestTheSettingsALibraryStates: + def test_they_read_as_the_library_was_built_for(self, tmp_path: Path) -> None: + base = Config() + library_config = base.library.model_copy( + update={ + "spectrum_method": SpectrumMethod.FFT, + "transformation_gamma": STORED_GAMMA, + "sample_rate": STORED_SAMPLE_RATE, + } + ) + path = tmp_path / LIBRARY_NAME + InstructionLibraryData.create(base.model_copy(update={"library": library_config}), {}).save(path) + + assert LibraryHeader.read(path).config == library_config + + def test_settings_stated_in_no_form_this_build_reads_read_as_none(self, tmp_path: Path) -> None: + payload = msgpack.unpackb(InstructionLibraryData.create(Config(), {}).serialize(), raw=False) + payload[CONFIG_FIELD] = "no settings" + path = tmp_path / LIBRARY_NAME + path.write_bytes(msgpack.packb(payload, use_bin_type=True)) + + assert LibraryHeader.read(path).config is None + + def test_the_metadata_names_the_build_that_wrote_it(self, tmp_path: Path) -> None: + path = tmp_path / LIBRARY_NAME + _write_current(path) + + assert LibraryHeader.read(path).metadata == Metadata.default() + + +class TestWhatAHeaderReadRestsOn: + def test_a_library_stores_its_header_ahead_of_its_entries(self) -> None: + """The metadata and the settings lead the stored map, which keeps a header read to the + first bytes of a library however many entries follow.""" + assert list(InstructionLibraryData.model_fields)[:2] == [METADATA_FIELD, CONFIG_FIELD] 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..da50fd635 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_bend.py @@ -0,0 +1,132 @@ +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, + apply_row, + sound_tick, +) +from sampletones_core.project.patterns.pitch import Step +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.note_on import NoteOn + +REFERENCE: Final[int] = 60 +SEMITONE: Final[int] = 1 +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_voice_writing_no_bend_starts_unbent_after_one_that_bent(self) -> None: + """A bend ends with the note that wrote it, so the next note sounds on its own divider.""" + bent = _instrument( + pitch=Envelope(items=(BEND,)), + hi_pitch=Envelope(items=(COARSE_BEND,)), + ) + plain = _instrument() + bent_reading = VoiceReading.read(bent, ChannelName.PULSE1) + plain_reading = VoiceReading.read(plain, ChannelName.PULSE1) + assert bent_reading is not None and plain_reading is not None + + voices = {bent.id: bent, plain.id: plain} + performance = ChannelPerformance() + apply_row( + performance, Row(command=NoteOn(voice_id=bent.id), pitch=Step(value=0)), ChannelName.PULSE1, voices.get + ) + sound_tick(performance, bent_reading) + apply_row(performance, Row(command=NoteOn(voice_id=plain.id)), ChannelName.PULSE1, voices.get) + sounded = sound_tick(performance, plain_reading) + + assert isinstance(sounded, PulseInstruction) + assert (sounded.detune, sounded.coarse_detune) == (0, 0) + + def test_a_bend_a_voice_writes_becomes_what_the_channel_holds(self) -> None: + reading = VoiceReading.read(_instrument(pitch=Envelope(items=(BEND,))), ChannelName.PULSE1) + 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..6f42e9d51 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_instrument_walk.py @@ -0,0 +1,138 @@ +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): + """An instrument 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, + transpose=0, + ) + + 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, + transpose=0, + ) + + 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, transpose=0) + + 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, transpose=0) + + 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, transpose=0) + + 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_core/performance/test_modifiers.py b/tests/unit/sampletones_core/performance/test_modifiers.py new file mode 100644 index 000000000..1e196c8f3 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_modifiers.py @@ -0,0 +1,431 @@ +from dataclasses import dataclass +from itertools import product +from typing import Any, Final, Tuple + +import pytest + +from sampletones_core.constants.general import ( + MAX_PITCH, + MAX_VOLUME, + MIN_PITCH, + MIN_PLAYED_PITCH, + MIN_VOLUME, + SILENT_VOLUME, + VOLUME_RANGE, +) +from sampletones_core.instructions import ( + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.performance import apply_modifiers +from sampletones_core.performance.modifiers import triangle_sounds_at +from tests.suite.case import BaseRegularTestCase, BaseTestCase + +LEVEL_PAIRS: Final[Tuple[Tuple[int, int], ...]] = tuple(product(VOLUME_RANGE, VOLUME_RANGE)) + + +def _pulse(volume: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=60, volume=volume, duty_cycle=0) + + +def _noise(volume: int) -> NoiseInstruction: + return NoiseInstruction(on=True, period=3, volume=volume, short=False) + + +@dataclass(frozen=True, kw_only=True) +class VolumeScalingCase(BaseRegularTestCase): + instruction_volume: int + row_volume: int + expected: Any + + +VOLUME_SCALING_CASES = [ + VolumeScalingCase( + label="max×max", + instruction_volume=15, + row_volume=15, + expected=15, + ), + VolumeScalingCase( + label="half×half", + instruction_volume=8, + row_volume=8, + expected=4, + ), + VolumeScalingCase( + label="zero row", + instruction_volume=15, + row_volume=0, + expected=0, + ), + VolumeScalingCase( + label="zero instruction", + instruction_volume=0, + row_volume=15, + expected=0, + ), + VolumeScalingCase( + label="max×half", + instruction_volume=15, + row_volume=7, + expected=7, + ), + VolumeScalingCase( + label="one×one", + instruction_volume=1, + row_volume=1, + expected=0, + ), + VolumeScalingCase( + label="ten×ten", + instruction_volume=10, + row_volume=10, + expected=7, + ), + VolumeScalingCase( + label="max×mid", + instruction_volume=15, + row_volume=8, + expected=8, + ), +] + + +class TestPulseVolumeScaling: + @pytest.mark.parametrize("case", VOLUME_SCALING_CASES, ids=lambda c: c.label) + def test_volume_scaled_correctly( + self, + case: VolumeScalingCase, + ) -> None: + result = apply_modifiers( + _pulse(case.instruction_volume), + transpose=0, + row_volume=case.row_volume, + ) + assert isinstance(result, PulseInstruction) + assert result.volume == case.expected + + def test_every_pair_of_levels_rounds_to_the_nearest_step(self) -> None: + sounded = { + (volume, row_volume): apply_modifiers(_pulse(volume), transpose=0, row_volume=row_volume).volume + for volume, row_volume in LEVEL_PAIRS + } + + assert sounded == { + (volume, row_volume): round(volume * row_volume / MAX_VOLUME) for volume, row_volume in LEVEL_PAIRS + } + + +NOISE_VOLUME_CASES = [ + VolumeScalingCase(label="max×max", instruction_volume=15, row_volume=15, expected=15), + VolumeScalingCase(label="a fraction rounds down", instruction_volume=13, row_volume=8, expected=6), + VolumeScalingCase(label="ten×ten rounds down", instruction_volume=10, row_volume=10, expected=6), + VolumeScalingCase(label="one×one keeps the quietest level", instruction_volume=1, row_volume=1, expected=1), + VolumeScalingCase(label="a quiet row keeps a loud note audible", instruction_volume=15, row_volume=1, expected=1), + VolumeScalingCase(label="a quiet note under a loud row", instruction_volume=1, row_volume=14, expected=1), + VolumeScalingCase(label="the loudest product below one step", instruction_volume=7, row_volume=2, expected=1), + VolumeScalingCase(label="a silent row silences", instruction_volume=15, row_volume=0, expected=0), + VolumeScalingCase(label="a silent instruction stays silent", instruction_volume=0, row_volume=15, expected=0), +] + + +class TestNoiseVolumeScaling: + """The noise level follows the rule FamiTracker and Bitphase share: the product rounded down, and the + quietest level wherever that comes out silent while both levels sound.""" + + @pytest.mark.parametrize("case", NOISE_VOLUME_CASES, ids=lambda c: c.label) + def test_volume_scaled_correctly( + self, + case: VolumeScalingCase, + ) -> None: + result = apply_modifiers( + _noise(case.instruction_volume), + transpose=0, + row_volume=case.row_volume, + ) + assert isinstance(result, NoiseInstruction) + assert result.volume == case.expected + + def test_every_pair_of_levels_follows_the_trackers_rule(self) -> None: + sounded = { + (volume, row_volume): apply_modifiers(_noise(volume), transpose=0, row_volume=row_volume).volume + for volume, row_volume in LEVEL_PAIRS + } + + assert sounded == { + (volume, row_volume): ( + max(MIN_VOLUME, volume * row_volume // MAX_VOLUME) if volume * row_volume else SILENT_VOLUME + ) + for volume, row_volume in LEVEL_PAIRS + } + + +@dataclass(frozen=True, kw_only=True) +class PulseTransposeCase(BaseTestCase): + label: str + pitch: int + transpose: int + expected_pitch: int + + +PULSE_TRANSPOSE_CASES = [ + PulseTransposeCase( + label="shift +5", + pitch=60, + transpose=5, + expected_pitch=65, + ), + PulseTransposeCase( + label="shift -12", + pitch=60, + transpose=-12, + expected_pitch=48, + ), + PulseTransposeCase( + label="shift +1", + pitch=60, + transpose=1, + expected_pitch=61, + ), + PulseTransposeCase( + label="clamped at MAX_PITCH", + pitch=MAX_PITCH, + transpose=20, + expected_pitch=MAX_PITCH, + ), + PulseTransposeCase( + label="below A-0, the note kept", + pitch=MIN_PITCH, + transpose=-5, + expected_pitch=MIN_PITCH - 5, + ), + PulseTransposeCase( + label="clamped at C-0", + pitch=MIN_PITCH, + transpose=-20, + expected_pitch=MIN_PLAYED_PITCH, + ), + PulseTransposeCase( + label="no transpose", + pitch=60, + transpose=0, + expected_pitch=60, + ), +] + + +class TestPulseTranspose: + @pytest.mark.parametrize( + "case", + PULSE_TRANSPOSE_CASES, + ids=lambda c: c.label, + ) + def test_pitch_transposed_correctly( + self, + case: PulseTransposeCase, + ) -> None: + instruction = PulseInstruction( + on=True, + pitch=case.pitch, + volume=15, + duty_cycle=0, + ) + result = apply_modifiers( + instruction, + transpose=case.transpose, + row_volume=MAX_VOLUME, + ) + assert isinstance(result, PulseInstruction) + assert result.pitch == case.expected_pitch + + +@dataclass(frozen=True, kw_only=True) +class NoiseTransposeCase(BaseTestCase): + label: str + period: int + transpose: int + expected_period: int + + +NOISE_TRANSPOSE_CASES = [ + NoiseTransposeCase( + label="shift +5", + period=3, + transpose=5, + expected_period=8, + ), + NoiseTransposeCase( + label="wraps past 15", + period=14, + transpose=5, + expected_period=3, + ), + NoiseTransposeCase( + label="no transpose", + period=7, + transpose=0, + expected_period=7, + ), + NoiseTransposeCase( + label="negative wrap", + period=2, + transpose=-5, + expected_period=13, + ), + NoiseTransposeCase( + label="full wrap +16", + period=3, + transpose=16, + expected_period=3, + ), + NoiseTransposeCase( + label="shift to boundary", + period=0, + transpose=15, + expected_period=15, + ), +] + + +class TestNoiseTranspose: + @pytest.mark.parametrize( + "case", + NOISE_TRANSPOSE_CASES, + ids=lambda c: c.label, + ) + def test_period_transposed_correctly( + self, + case: NoiseTransposeCase, + ) -> None: + instruction = NoiseInstruction( + on=True, + period=case.period, + volume=15, + short=False, + ) + result = apply_modifiers( + instruction, + transpose=case.transpose, + row_volume=MAX_VOLUME, + ) + assert isinstance(result, NoiseInstruction) + assert result.period == case.expected_period + + +@dataclass(frozen=True, kw_only=True) +class TriangleModifiersCase(BaseTestCase): + label: str + pitch: int + transpose: int + row_volume: int + expected_pitch: int + expected_on: bool + + +TRIANGLE_MODIFIERS_CASES = [ + TriangleModifiersCase( + label="volume zero forces off", + pitch=60, + transpose=0, + row_volume=0, + expected_pitch=60, + expected_on=False, + ), + TriangleModifiersCase( + label="volume at half threshold forces off", + pitch=60, + transpose=0, + row_volume=MAX_VOLUME // 2, + expected_pitch=60, + expected_on=False, + ), + TriangleModifiersCase( + label="volume one above half threshold preserves on", + pitch=60, + transpose=0, + row_volume=MAX_VOLUME // 2 + 1, + expected_pitch=60, + expected_on=True, + ), + TriangleModifiersCase( + label="nonzero volume preserves on", + pitch=60, + transpose=0, + row_volume=8, + expected_pitch=60, + expected_on=True, + ), + TriangleModifiersCase( + label="pitch shifted by positive transpose", + pitch=60, + transpose=3, + row_volume=15, + expected_pitch=63, + expected_on=True, + ), + TriangleModifiersCase( + label="pitch shifted by negative transpose", + pitch=60, + transpose=-7, + row_volume=15, + expected_pitch=53, + expected_on=True, + ), + TriangleModifiersCase( + label="pitch clamped at MAX_PITCH", + pitch=MAX_PITCH, + transpose=20, + row_volume=15, + expected_pitch=MAX_PITCH, + expected_on=True, + ), + TriangleModifiersCase( + label="pitch clamped at C-0", + pitch=MIN_PITCH, + transpose=-20, + row_volume=15, + expected_pitch=MIN_PLAYED_PITCH, + expected_on=True, + ), + TriangleModifiersCase( + label="zero volume with pitch shift — pitch still applied", + pitch=60, + transpose=5, + row_volume=0, + expected_pitch=65, + expected_on=False, + ), +] + + +class TestTriangleModifiers: + @pytest.mark.parametrize( + "case", + TRIANGLE_MODIFIERS_CASES, + ids=lambda c: c.label, + ) + def test_modifiers_applied( + self, + case: TriangleModifiersCase, + ) -> None: + instruction = TriangleInstruction(on=True, pitch=case.pitch) + result = apply_modifiers( + instruction, + transpose=case.transpose, + row_volume=case.row_volume, + ) + assert isinstance(result, TriangleInstruction) + assert result.pitch == case.expected_pitch + assert result.on == case.expected_on + + +class TestTheTriangleGate: + def test_the_triangle_sounds_at_every_level_the_gate_opens_at(self) -> None: + """A tracker export writes the triangle's volume column by the gate, so the gate is the rule + the song sounds the triangle by at every level a row can state. + """ + instruction = TriangleInstruction(on=True, pitch=MIN_PITCH) + sounded = [apply_modifiers(instruction, transpose=0, row_volume=volume).on for volume in VOLUME_RANGE] + + assert sounded == [triangle_sounds_at(volume) for volume in VOLUME_RANGE] diff --git a/tests/unit/sampletones_core/performance/test_rows.py b/tests/unit/sampletones_core/performance/test_rows.py new file mode 100644 index 000000000..a09382a08 --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_rows.py @@ -0,0 +1,490 @@ +from copy import deepcopy +from dataclasses import dataclass +from typing import Dict, Final, Optional, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import MAX_VOLUME, NUM_PERIODS +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.features.envelope import Envelope +from sampletones_core.performance import ( + ChannelPerformance, + VoiceReading, + apply_row, + resolve_row, + sound_tick, + sounding_pitch, +) +from sampletones_core.project.patterns.pitch import Note, Step +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.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.sample import Sample +from sampletones_core.project.voices.voice import VoiceLookup, VoiceUnion, voice_reference +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import make_noise_reconstruction, make_pulse_reconstruction + +ROWS_PER_PATTERN: int = 4 +SOUNDING_ROW: int = 2 +SAMPLE_ID: str = "sample" +ANOTHER_SAMPLE_ID: str = "another" +GONE_ID: Final[str] = "gone" +QUIET_VOLUME: Final[int] = 3 +LOW_VOLUME: Final[int] = 8 +PLAYED_OUT: Final[int] = 999 +LEFT_BEHIND: Final[Dict[FeatureKey, int]] = { + FeatureKey.VOLUME: 0, + FeatureKey.ARPEGGIO: 7, + FeatureKey.PITCH: -3, + FeatureKey.HI_PITCH: 1, + FeatureKey.DUTY_CYCLE: 2, +} +SAMPLE_PITCH: Final[int] = 60 +DRUM_PERIOD: Final[int] = 3 +LEAD_PITCH: Final[int] = 60 +LEAD_PERIOD: Final[int] = 5 +TYPED_NOTE: Final[int] = 67 +TYPED_PERIOD: Final[int] = 9 +STEP_UP: Final[int] = 5 +STEP_DOWN: Final[int] = -2 +SOUNDING_STEP: Final[int] = 7 +PERIOD_STEP: Final[int] = 14 + +SAMPLE: Final[Sample] = Sample(name=SAMPLE_ID, reconstruction=make_pulse_reconstruction(pitch=SAMPLE_PITCH)) +ANOTHER: Final[Sample] = Sample(name=ANOTHER_SAMPLE_ID, reconstruction=make_pulse_reconstruction(pitch=SAMPLE_PITCH)) +DRUM: Final[Sample] = Sample(name="drum", reconstruction=make_noise_reconstruction(period=DRUM_PERIOD)) +LEAD: Final[Instrument] = Instrument( + name="lead", + envelopes=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + initial_pitch=LEAD_PITCH, + initial_period=LEAD_PERIOD, +) +VOICES: Final[Dict[str, VoiceUnion]] = {voice.id: voice for voice in (SAMPLE, ANOTHER, DRUM, LEAD)} +LOOKUP: Final[VoiceLookup] = VOICES.get + + +def _song() -> Song: + """A one-frame song whose pulse pattern names a sample on ``SOUNDING_ROW``.""" + song = Song.empty(ROWS_PER_PATTERN) + song.channels[ChannelName.PULSE1].set_row( + 0, + SOUNDING_ROW, + Row(command=NoteOn(voice_id=SAMPLE.id)), + ) + return song + + +def _silent(*, transpose: int = 0, volume: int = MAX_VOLUME) -> ChannelPerformance: + return ChannelPerformance(transpose=transpose, volume=volume) + + +def _sounding( + voice_id: str, + *, + transpose: int = SOUNDING_STEP, + volume: int = LOW_VOLUME, + tick_index: int = 6, +) -> ChannelPerformance: + return ChannelPerformance(voice_id=voice_id, tick_index=tick_index, transpose=transpose, volume=volume) + + +def _reference(voice: VoiceUnion, channel_name: ChannelName = ChannelName.PULSE1) -> int: + return voice_reference(voice, channel_name) + + +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. + + A note-on with a pitch starts the voice there, whichever face the pitch was written in. A + sample without one plays as recorded. An instrument without one goes on from the pitch the + channel is sounding, whatever voice sounded it, and starts nothing on a silent channel. A + pitch without a note-on bends the sounding note and leaves a silent channel as it is. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Tuple[bool, Optional[str], int, int] + channel: ChannelName = ChannelName.PULSE1 + before: ChannelPerformance + row: Row + + test_cases: Tuple["TestApplyRow.TestCase", ...] = ( + TestCase( + label="a sample stating no pitch starts as recorded at full volume", + before=_silent(), + row=Row(command=NoteOn(voice_id=SAMPLE.id)), + expected=(True, SAMPLE.id, 0, MAX_VOLUME), + ), + TestCase( + label="a sample stating a step starts at it, at the volume the row states", + before=_silent(), + row=Row(command=NoteOn(voice_id=SAMPLE.id), pitch=Step(value=STEP_UP), volume=LOW_VOLUME), + expected=(True, SAMPLE.id, STEP_UP, LOW_VOLUME), + ), + TestCase( + label="a sample stating a note starts at the step reaching it", + before=_silent(), + row=Row(command=NoteOn(voice_id=SAMPLE.id), pitch=Note(value=TYPED_NOTE)), + expected=(True, SAMPLE.id, TYPED_NOTE - _reference(SAMPLE), MAX_VOLUME), + ), + TestCase( + label="an instrument stating a note starts at the step reaching it", + before=_silent(), + row=Row(command=NoteOn(voice_id=LEAD.id), pitch=Note(value=TYPED_NOTE)), + expected=(True, LEAD.id, TYPED_NOTE - _reference(LEAD), MAX_VOLUME), + ), + TestCase( + label="an instrument stating a step starts at it", + before=_silent(), + row=Row(command=NoteOn(voice_id=LEAD.id), pitch=Step(value=STEP_DOWN)), + expected=(True, LEAD.id, STEP_DOWN, MAX_VOLUME), + ), + TestCase( + label="an instrument stating no pitch on a silent channel starts nothing", + before=_silent(transpose=SOUNDING_STEP, volume=LOW_VOLUME), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(False, None, SOUNDING_STEP, LOW_VOLUME), + ), + TestCase( + label="an instrument stating no pitch goes on from the pitch a sample was sounding", + before=_sounding(SAMPLE.id), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(True, LEAD.id, _reference(SAMPLE) + SOUNDING_STEP - _reference(LEAD), MAX_VOLUME), + ), + TestCase( + label="an instrument stating no pitch restarts the instrument already sounding", + before=_sounding(LEAD.id), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(True, LEAD.id, SOUNDING_STEP, MAX_VOLUME), + ), + TestCase( + label="a sample that has played out is still the channel's note", + before=_sounding(SAMPLE.id, tick_index=PLAYED_OUT), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(True, LEAD.id, _reference(SAMPLE) + SOUNDING_STEP - _reference(LEAD), MAX_VOLUME), + ), + TestCase( + label="a note-off leaves nothing for an instrument to go on from", + before=_silent(transpose=SOUNDING_STEP), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(False, None, SOUNDING_STEP, MAX_VOLUME), + ), + TestCase( + label="a voice silent on the channel leaves nothing to go on from", + before=_sounding(DRUM.id), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(False, DRUM.id, SOUNDING_STEP, LOW_VOLUME), + ), + TestCase( + label="a sample stating no pitch ignores what the channel was sounding", + before=_sounding(LEAD.id), + row=Row(command=NoteOn(voice_id=SAMPLE.id)), + expected=(True, SAMPLE.id, 0, MAX_VOLUME), + ), + TestCase( + label="noise: an instrument stating no pitch goes on from the period the channel sounds", + channel=ChannelName.NOISE, + before=_sounding(DRUM.id, transpose=1), + row=Row(command=NoteOn(voice_id=LEAD.id)), + expected=(True, LEAD.id, (DRUM_PERIOD + 1) % NUM_PERIODS - LEAD_PERIOD, MAX_VOLUME), + ), + TestCase( + label="noise: a note states a period", + channel=ChannelName.NOISE, + before=_silent(), + row=Row(command=NoteOn(voice_id=LEAD.id), pitch=Note(value=TYPED_PERIOD)), + expected=(True, LEAD.id, TYPED_PERIOD - LEAD_PERIOD, MAX_VOLUME), + ), + TestCase( + label="noise: a step is kept as written", + channel=ChannelName.NOISE, + before=_silent(), + row=Row(command=NoteOn(voice_id=DRUM.id), pitch=Step(value=PERIOD_STEP)), + expected=(True, DRUM.id, PERIOD_STEP, MAX_VOLUME), + ), + TestCase( + label="a note-off silences the channel", + before=_sounding(SAMPLE.id), + row=Row(command=NoteOff()), + expected=(True, None, SOUNDING_STEP, LOW_VOLUME), + ), + TestCase( + label="a step bends the sounding sample without starting it over", + before=_sounding(SAMPLE.id, transpose=0), + row=Row(pitch=Step(value=STEP_DOWN), volume=QUIET_VOLUME), + expected=(False, SAMPLE.id, STEP_DOWN, QUIET_VOLUME), + ), + TestCase( + label="a note bends the sounding instrument to the step reaching it", + before=_sounding(LEAD.id), + row=Row(pitch=Note(value=TYPED_NOTE)), + expected=(False, LEAD.id, TYPED_NOTE - _reference(LEAD), LOW_VOLUME), + ), + TestCase( + label="a bend on a silent channel changes nothing", + before=_silent(transpose=SOUNDING_STEP, volume=LOW_VOLUME), + row=Row(pitch=Step(value=STEP_UP)), + expected=(False, None, SOUNDING_STEP, LOW_VOLUME), + ), + TestCase( + label="a bend on a voice the project lacks changes nothing", + before=_sounding(GONE_ID), + row=Row(pitch=Step(value=STEP_UP)), + expected=(False, GONE_ID, SOUNDING_STEP, LOW_VOLUME), + ), + TestCase( + label="a note-on naming a voice the project lacks starts a silent note", + before=_sounding(SAMPLE.id), + row=Row(command=NoteOn(voice_id=GONE_ID), pitch=Note(value=TYPED_NOTE)), + expected=(True, GONE_ID, 0, MAX_VOLUME), + ), + TestCase( + label="a volume alone sets the level of the sounding note", + before=_sounding(SAMPLE.id), + row=Row(volume=QUIET_VOLUME), + expected=(False, SAMPLE.id, SOUNDING_STEP, QUIET_VOLUME), + ), + TestCase( + label="an empty row leaves everything as it stands", + before=_sounding(SAMPLE.id), + row=Row(), + expected=(False, SAMPLE.id, SOUNDING_STEP, LOW_VOLUME), + ), + ) + + @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 = deepcopy(test_case.before) + + retriggered = apply_row(performance, test_case.row, test_case.channel, LOOKUP) + + assert (retriggered, performance.voice_id, performance.transpose, performance.volume) == test_case.expected + + @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 = deepcopy(test_case.before) + performance.tick_index = 6 + + retriggered = apply_row(performance, test_case.row, test_case.channel, LOOKUP) + + assert (performance.tick_index == 0) is retriggered + + +class TestSoundingPitch(BaseTestSuite): + """The pitch a channel sounds is where its voice's reference and transpose put it, and none while silent.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Optional[int] + channel: ChannelName = ChannelName.PULSE1 + performance: ChannelPerformance + + test_cases: Tuple["TestSoundingPitch.TestCase", ...] = ( + TestCase( + label="a silent channel sounds nothing", + performance=_silent(transpose=SOUNDING_STEP), + expected=None, + ), + TestCase( + label="a sample sounds its reference moved by the transpose", + performance=_sounding(SAMPLE.id), + expected=_reference(SAMPLE) + SOUNDING_STEP, + ), + TestCase( + label="a voice the project lacks sounds nothing", + performance=_sounding(GONE_ID), + expected=None, + ), + TestCase( + label="a voice with no frames on the channel sounds nothing", + performance=_sounding(DRUM.id), + expected=None, + ), + TestCase( + label="noise walks the step around the sixteen periods", + channel=ChannelName.NOISE, + performance=_sounding(DRUM.id, transpose=PERIOD_STEP), + expected=(DRUM_PERIOD + PERIOD_STEP) % NUM_PERIODS, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_pitch_the_channel_sounds(self, test_case: TestCase) -> None: + assert sounding_pitch(test_case.performance, test_case.channel, LOOKUP) == test_case.expected + + +class TestASampleHandsItsPitchToAnInstrument: + """An instrument placed without a pitch restarts its envelopes on the pitch the sample before it reached.""" + + def test_the_instrument_starts_over_on_the_samples_pitch(self) -> None: + reading = VoiceReading.read(SAMPLE, ChannelName.PULSE1) + assert reading is not None + performance = ChannelPerformance(feature_values=LEFT_BEHIND.copy()) + apply_row( + performance, Row(command=NoteOn(voice_id=SAMPLE.id), pitch=Step(value=STEP_UP)), ChannelName.PULSE1, LOOKUP + ) + sound_tick(performance, reading) + + restarted = apply_row(performance, Row(command=NoteOn(voice_id=LEAD.id)), ChannelName.PULSE1, LOOKUP) + + assert restarted is True + assert performance.voice_id == LEAD.id + assert performance.transpose == _reference(SAMPLE) + STEP_UP - _reference(LEAD) + assert performance.tick_index == 0 + assert performance.feature_values == CHANNEL_FEATURE_DEFAULTS + + +class TestANoteStartsItsDimensionsOver(BaseTestSuite): + """A note starts every envelope dimension from where a song starts, whatever the one before it left. + + A row naming no note plays on inside the note already sounding, so it leaves the dimensions + where that note has taken them. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: Row + expected: Dict[FeatureKey, int] + + test_cases: Tuple["TestANoteStartsItsDimensionsOver.TestCase", ...] = ( + TestCase( + label="a note column", + row=Row(command=NoteOn(voice_id=SAMPLE.id)), + expected=CHANNEL_FEATURE_DEFAULTS, + ), + TestCase( + label="a note column with modifiers", + row=Row( + command=NoteOn(voice_id=SAMPLE.id), + pitch=Step(value=STEP_UP), + volume=LOW_VOLUME, + ), + expected=CHANNEL_FEATURE_DEFAULTS, + ), + TestCase( + label="an empty row", + row=Row(), + expected=LEFT_BEHIND, + ), + TestCase( + label="a modifier row", + row=Row(pitch=Step(value=STEP_DOWN), volume=QUIET_VOLUME), + expected=LEFT_BEHIND, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_dimensions_the_channel_carries_after_the_row(self, test_case: TestCase) -> None: + performance = ChannelPerformance( + voice_id=ANOTHER.id, + tick_index=6, + feature_values=LEFT_BEHIND.copy(), + ) + + apply_row(performance, test_case.row, ChannelName.PULSE1, LOOKUP) + + assert performance.feature_values == test_case.expected + + def test_a_note_leaves_the_defaults_themselves_untouched(self) -> None: + """Each note takes a copy, so what a note writes reaches neither the next nor a song's start.""" + defaults = dict(CHANNEL_FEATURE_DEFAULTS) + performance = ChannelPerformance(voice_id=ANOTHER.id) + apply_row(performance, Row(command=NoteOn(voice_id=SAMPLE.id)), ChannelName.PULSE1, LOOKUP) + + performance.feature_values[FeatureKey.ARPEGGIO] += 1 + + assert CHANNEL_FEATURE_DEFAULTS == defaults + + def test_a_sample_holding_its_level_after_a_quieter_one_sounds_at_full_volume(self) -> None: + """A level one sample wrote ends with its note, so the next leaves its level where a song starts.""" + writes = Sample(name="writes", reconstruction=make_pulse_reconstruction(volume=QUIET_VOLUME)) + holds = Sample( + name="holds", + reconstruction=make_pulse_reconstruction( + volume=MAX_VOLUME, + held_features=(FeatureKey.VOLUME,), + ), + ) + lookup: VoiceLookup = {writes.id: writes, holds.id: holds}.get + writes_reading = VoiceReading.read(writes, ChannelName.PULSE1) + holds_reading = VoiceReading.read(holds, ChannelName.PULSE1) + assert writes_reading is not None and holds_reading is not None + + performance = ChannelPerformance() + apply_row(performance, Row(command=NoteOn(voice_id=writes.id)), ChannelName.PULSE1, lookup) + sound_tick(performance, writes_reading) + apply_row(performance, Row(command=NoteOn(voice_id=holds.id)), ChannelName.PULSE1, lookup) + sounded = sound_tick(performance, holds_reading) + + assert sounded is not None and sounded.on is True + assert performance.feature_values[FeatureKey.VOLUME] == MAX_VOLUME diff --git a/tests/unit/sampletones_core/performance/test_song.py b/tests/unit/sampletones_core/performance/test_song.py new file mode 100644 index 000000000..38bfcd53f --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_song.py @@ -0,0 +1,332 @@ +from dataclasses import dataclass +from typing import Final, List, Optional + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.exporters.maps import CHANNEL_TO_EXPORTER_MAP +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.performance import WalkProgress, song_instructions +from sampletones_core.project.patterns.pitch import Step +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.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming +from sampletones_shared.exceptions import OperationCanceled +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_instrument, + project_with_sample, +) + +ROWS_PER_PATTERN: Final[int] = 4 +ENVELOPE_TICKS: Final[int] = 2 +SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=150, speed=6, nes_frequency=60) +FOLLOWER_PITCH: Final[int] = 57 +FOLLOWER_PERIOD: Final[int] = 4 +ROW_VOLUME: Final[int] = 10 +LEADER_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes( + volume=Envelope(items=(6,)), + arpeggio=Envelope(items=(7,)), + pitch=Envelope(items=(-3,)), + hi_pitch=Envelope(items=(1,)), + duty_cycle=Envelope(items=(1,)), +) +RELEASED_ENVELOPES: Final[InstrumentEnvelopes] = InstrumentEnvelopes( + volume=Envelope(items=(MAX_VOLUME, 0)), + duty_cycle=Envelope(items=(1,)), +) + + +def _project() -> Project: + """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_timing_gives_every_row_it_plays(self) -> None: + project = _project() + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + + streams = song_instructions(project) + + assert len(streams[ChannelName.PULSE1]) == timing.frame_tick(project.song.order_length()) + + def test_a_row_starts_on_the_tick_the_timing_places_it_at(self) -> None: + """At tempo 125 a row lasts 7.2 ticks, so a pattern played again starts where its frame's bar line falls.""" + project = _project() + project.settings = SETTINGS.model_copy(update={"tempo": 125}) + project.song.append_frame() + project.song.set_order_entry(1, ChannelName.PULSE1, 0) + start = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).tick_at(1, 0) + + stream = song_instructions(project)[ChannelName.PULSE1] + + assert stream[start - 1] == _resting(ChannelName.PULSE1) + assert stream[start] == stream[0] + + def test_every_channel_answers_for_every_tick(self) -> None: + """One length across the four streams is what makes a tick index one moment of the song.""" + 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) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + + stream = song_instructions(project)[ChannelName.PULSE1] + + frame_ticks = timing.frame_tick(1) + assert stream[:frame_ticks] == stream[frame_ticks : timing.frame_tick(2)] + + +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 timing states the length before a row is played, so every report names the same.""" + project = _project() + heard: List[WalkProgress] = [] + song_instructions(project, lambda progress: heard.append(progress) is None) + expected = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) + assert [progress.total for progress in heard] == [expected] * len(heard) + + def test_the_walk_reaches_the_ticks_it_set_out_to_sound(self) -> None: + 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) + + +def _following( + leader: InstrumentEnvelopes, + follower: InstrumentEnvelopes, + channel_name: ChannelName, + *, + volume: Optional[int] = None, +) -> InstructionUnion: + """The first frame a voice sounds on the row after a whole row of another voice.""" + first = Instrument(name="leader", envelopes=leader) + second = Instrument( + name="follower", + envelopes=follower, + initial_pitch=FOLLOWER_PITCH, + initial_period=FOLLOWER_PERIOD, + ) + project = project_with_instrument(first, rows_per_pattern=ROWS_PER_PATTERN, settings=SETTINGS) + project.voices.append(second) + place_instrument(project, channel_name=channel_name, row_index=0, sample=first, transpose=0) + place_instrument(project, channel_name=channel_name, row_index=1, sample=second, transpose=0, volume=volume) + + stream = song_instructions(project)[channel_name] + return stream[SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).row_ticks(0, 0)] + + +class TestANoteStartsFromWhereASongStarts(BaseTestSuite): + """A voice leaving a dimension empty sounds it where a song starts it, whatever came before. + + The note before writes every dimension away from that start. The note after writes one + dimension, leaves the rest empty, and sounds full volume, no arpeggio offset, no bend, and the + first duty cycle or the long noise mode, which is how FamiTracker and Bitphase start a note. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + follower: InstrumentEnvelopes + expected: InstructionUnion + + test_cases = ( + TestCase( + label="pulse leaving its arpeggio, bend and duty cycle", + channel_name=ChannelName.PULSE1, + follower=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + expected=PulseInstruction( + on=True, + pitch=FOLLOWER_PITCH, + volume=MAX_VOLUME, + duty_cycle=0, + detune=0, + coarse_detune=0, + ), + ), + TestCase( + label="pulse leaving its volume", + channel_name=ChannelName.PULSE1, + follower=InstrumentEnvelopes(arpeggio=Envelope(items=(0,))), + expected=PulseInstruction( + on=True, + pitch=FOLLOWER_PITCH, + volume=MAX_VOLUME, + duty_cycle=0, + detune=0, + coarse_detune=0, + ), + ), + TestCase( + label="triangle leaving its arpeggio and bend", + channel_name=ChannelName.TRIANGLE, + follower=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + expected=TriangleInstruction( + on=True, + pitch=FOLLOWER_PITCH, + detune=0, + coarse_detune=0, + ), + ), + TestCase( + label="noise leaving its arpeggio and mode", + channel_name=ChannelName.NOISE, + follower=InstrumentEnvelopes(volume=Envelope(items=(MAX_VOLUME,))), + expected=NoiseInstruction( + on=True, + period=FOLLOWER_PERIOD, + volume=MAX_VOLUME, + short=False, + ), + ), + TestCase( + label="noise leaving its volume", + channel_name=ChannelName.NOISE, + follower=InstrumentEnvelopes(arpeggio=Envelope(items=(0,))), + expected=NoiseInstruction( + on=True, + period=FOLLOWER_PERIOD, + volume=MAX_VOLUME, + short=False, + ), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_note_after_sounds_the_empty_dimensions_at_their_start(self, test_case: TestCase) -> None: + sounded = _following(LEADER_ENVELOPES, test_case.follower, test_case.channel_name) + + assert sounded == test_case.expected + + @pytest.mark.parametrize( + "channel_name", + (ChannelName.PULSE1, ChannelName.NOISE), + ids=lambda channel_name: channel_name.value, + ) + def test_a_voice_leaving_its_volume_sounds_at_the_rows_after_one_that_released( + self, + channel_name: ChannelName, + ) -> None: + """A note that ended at silence leaves the next one its row's level, the way a tracker plays it.""" + sounded = _following( + RELEASED_ENVELOPES, + InstrumentEnvelopes(arpeggio=Envelope(items=(0, 12), loop_point=0)), + channel_name, + volume=ROW_VOLUME, + ) + + assert isinstance(sounded, (PulseInstruction, NoiseInstruction)) + assert sounded.on is True + assert sounded.volume == ROW_VOLUME + + +class TestAnInstrumentGoesOnFromTheSamplesPitch: + """An instrument placed without a pitch sounds on the pitch the sample before it reached, envelopes restarted.""" + + def test_the_instruments_first_tick_sounds_at_the_samples_pitch(self) -> None: + project, sample = project_with_sample( + make_pulse_reconstruction(pitch=FOLLOWER_PITCH, count=ENVELOPE_TICKS), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + lead = Instrument(name="lead", envelopes=LEADER_ENVELOPES, initial_pitch=FOLLOWER_PITCH + 12) + project.voices.append(lead) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, transpose=5) + project.song.channels[ChannelName.PULSE1].ensure_pattern(0, ROWS_PER_PATTERN) + project.song.channels[ChannelName.PULSE1].set_row(0, 2, Row(command=NoteOn(voice_id=lead.id))) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + + stream = song_instructions(project)[ChannelName.PULSE1] + + first = stream[timing.frame_tick(0) + sum(timing.row_ticks(0, row) for row in range(2))] + assert isinstance(first, PulseInstruction) + assert first.pitch == FOLLOWER_PITCH + 5 + LEADER_ENVELOPES.arpeggio.items[0] + + def test_an_instrument_placed_on_a_silent_channel_sounds_nothing(self) -> None: + project = project_with_instrument( + Instrument(name="lead", envelopes=LEADER_ENVELOPES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + lead = project.voices[0] + channel = project.song.channels[ChannelName.PULSE1] + channel.ensure_pattern(0, ROWS_PER_PATTERN) + channel.set_row(0, 0, Row(command=NoteOn(voice_id=lead.id))) + channel.set_row(0, 2, Row(command=NoteOn(voice_id=lead.id), pitch=Step(value=0))) + timing = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS) + + stream = song_instructions(project)[ChannelName.PULSE1] + + sounding_from = sum(timing.row_ticks(0, row) for row in range(2)) + assert all(instruction == _resting(ChannelName.PULSE1) for instruction in stream[:sounding_from]) + assert stream[sounding_from] != _resting(ChannelName.PULSE1) 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_core/performance/test_voice.py b/tests/unit/sampletones_core/performance/test_voice.py new file mode 100644 index 000000000..d1f740bcd --- /dev/null +++ b/tests/unit/sampletones_core/performance/test_voice.py @@ -0,0 +1,306 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Iterable, List, Sequence + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, +) +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + 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 +REFERENCE_PERIOD: Final[int] = 4 +SAMPLE_VOLUME: Final[int] = 9 +CHANNEL_VOLUME: Final[int] = 4 +CHANNEL_ARPEGGIO: Final[int] = 7 +CHANNEL_DUTY_CYCLE: Final[int] = 1 +CHANNEL_LONG_MODE: Final[int] = 0 +DUTY_CYCLE: Final[int] = 2 + + +def _reconstruction( + 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( + instructions={channel_name: instructions_list}, + config=Config(), + coefficient=1.0, + audio_filepath=(Path("/dev/null"),), + stems_data=single_entry_stems_data( + list(DEFAULT_CHANNELS), + {channel_name: instructions_list}, + ), + ) + reconstruction = reconstruction.with_channel_data( + channel_name, + list(instructions), + reconstruction.initial_pitches[channel_name], + held_features, + heard=reconstruction.recorded_stem_ids, + ) + return reconstruction + + +def _voice( + channel_name: ChannelName, + instructions: Sequence[InstructionUnion], + held_features: Iterable[FeatureKey], +) -> 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]: + return CHANNEL_FEATURE_DEFAULTS.copy() + + +class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite): + """An instrument writing every dimension sounds its frames exactly as it holds them.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + instructions: List[InstructionUnion] + + test_cases = ( + TestCase( + label="pulse", + channel_name=ChannelName.PULSE1, + instructions=[ + PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ) + ], + ), + TestCase( + label="triangle", + channel_name=ChannelName.TRIANGLE, + instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], + ), + TestCase( + label="noise", + channel_name=ChannelName.NOISE, + instructions=[ + NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ) + ], + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None: + voice = _voice(test_case.channel_name, test_case.instructions, ()) + + assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0] + + @pytest.mark.parametrize( + "test_case", + test_cases, + 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.channel_name, test_case.instructions, ()) + values = _channel_values() + + voice.sound(test_case.instructions[0], values) + + assert values[FeatureKey.ARPEGGIO] == 0 + assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME) + + +class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite): + """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds. + + Each channel offers its own dimensions and spells them in its own terms — an arpeggio is a + pitch on pulse and triangle and a period on noise, and a duty cycle is a waveform on pulse and + the noise mode on noise — so every dimension a channel offers is held here in the terms that + channel reads it in. + """ + + _INSTRUCTION = PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ) + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel_name: ChannelName + instruction: InstructionUnion + held_feature: FeatureKey + channel_value: int + expected: InstructionUnion + + test_cases = ( + TestCase( + label="pulse volume", + channel_name=ChannelName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.VOLUME, + channel_value=CHANNEL_VOLUME, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=CHANNEL_VOLUME, + duty_cycle=DUTY_CYCLE, + ), + ), + TestCase( + label="pulse arpeggio", + channel_name=ChannelName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ), + ), + TestCase( + label="pulse duty cycle", + channel_name=ChannelName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.DUTY_CYCLE, + channel_value=CHANNEL_DUTY_CYCLE, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=CHANNEL_DUTY_CYCLE, + ), + ), + TestCase( + label="triangle arpeggio", + channel_name=ChannelName.TRIANGLE, + instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH), + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=TriangleInstruction(on=True, pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO), + ), + TestCase( + label="noise period", + channel_name=ChannelName.NOISE, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ), + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD + CHANNEL_ARPEGGIO, + volume=SAMPLE_VOLUME, + short=True, + ), + ), + TestCase( + label="noise mode", + channel_name=ChannelName.NOISE, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ), + held_feature=FeatureKey.DUTY_CYCLE, + channel_value=CHANNEL_LONG_MODE, + expected=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=False, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + 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.channel_name, [test_case.instruction], (test_case.held_feature,)) + values = _channel_values() + values[test_case.held_feature] = test_case.channel_value + + assert voice.sound(test_case.instruction, values) == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + 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.channel_name, [test_case.instruction], (test_case.held_feature,)) + values = _channel_values() + values[test_case.held_feature] = test_case.channel_value + + voice.sound(test_case.instruction, values) + + assert values[test_case.held_feature] == test_case.channel_value + + 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(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(ChannelName.PULSE1, [self._INSTRUCTION, rest], ()) + values = _channel_values() + + assert voice.sound(rest, values).on is False + assert values[FeatureKey.VOLUME] == 0 + + def test_a_silent_frame_leaves_the_other_dimensions_where_the_channel_holds_them(self) -> None: + rest = PulseInstruction.null_instruction() + voice = _voice(ChannelName.PULSE1, [self._INSTRUCTION, rest], ()) + values = _channel_values() + values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE + + voice.sound(rest, values) + + assert values[FeatureKey.DUTY_CYCLE] == DUTY_CYCLE diff --git a/tests/unit/sampletones_core/project/instruments/test_sample.py b/tests/unit/sampletones_core/project/instruments/test_sample.py deleted file mode 100644 index 3d5cab3bb..000000000 --- a/tests/unit/sampletones_core/project/instruments/test_sample.py +++ /dev/null @@ -1,22 +0,0 @@ -from unittest.mock import Mock - -from sampletones_core.project.instruments.sample import Sample - - -class TestSampleClone: - 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_deep_copies_the_reconstruction(self) -> None: - reconstruction = Mock() - sample = Sample(name="lead", reconstruction=reconstruction) - clone = sample.clone() - reconstruction.model_copy.assert_called_once_with(deep=True) - assert clone.reconstruction is reconstruction.model_copy.return_value diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py index bf55412ce..5179e3bba 100644 --- a/tests/unit/sampletones_core/project/patterns/test_channel.py +++ b/tests/unit/sampletones_core/project/patterns/test_channel.py @@ -1,13 +1,14 @@ -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.pitch import Step 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: @@ -19,11 +20,15 @@ def test_add_pattern_appends_with_requested_length(self) -> None: 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), - volume=10, + channel.set_row( + 0, + 0, + Row( + instrument=NoteOn(voice_id="abc"), + volume=10, + ), ) + source = channel.patterns[0] clone_index = channel.clone_pattern(0) clone = channel.pattern(clone_index) @@ -55,7 +60,7 @@ def test_remove_pattern_drops_from_pool(self) -> None: def test_set_row_replaces_row(self) -> None: channel = _channel() pattern_index = 0 - row = Row(transpose=5, volume=12) + row = Row(pitch=Step(value=5), volume=12) channel.set_row(pattern_index, 3, row) diff --git a/tests/unit/sampletones_core/project/patterns/test_pattern.py b/tests/unit/sampletones_core/project/patterns/test_pattern.py index 6f2815f88..d629bcee3 100644 --- a/tests/unit/sampletones_core/project/patterns/test_pattern.py +++ b/tests/unit/sampletones_core/project/patterns/test_pattern.py @@ -1,9 +1,10 @@ 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.pitch import Step from sampletones_core.project.patterns.row import Row +from sampletones_core.project.voices.note_on import NoteOn _LENGTH = 4 @@ -13,7 +14,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,10 +22,10 @@ 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() + assert not Row(pitch=Step(value=0)).is_empty() def test_row_with_volume_is_not_empty(self) -> None: assert not Row(volume=15).is_empty() @@ -32,13 +33,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: @@ -46,14 +47,11 @@ def test_freshly_created_pattern_is_empty(self) -> None: assert _empty_pattern().is_empty() def test_pattern_with_one_filled_row_is_not_empty(self) -> None: - pattern = _empty_pattern() - pattern.rows[0] = _row_with_instrument() + pattern = _empty_pattern().with_row(0, _row_with_instrument()) assert not pattern.is_empty() def test_pattern_with_all_filled_rows_is_not_empty(self) -> None: - pattern = _empty_pattern() - for i in range(_LENGTH): - pattern.rows[i] = _row_with_instrument() + pattern = _empty_pattern().with_rows({index: _row_with_instrument() for index in range(_LENGTH)}) assert not pattern.is_empty() diff --git a/tests/unit/sampletones_core/project/patterns/test_pitch.py b/tests/unit/sampletones_core/project/patterns/test_pitch.py new file mode 100644 index 000000000..35cab4239 --- /dev/null +++ b/tests/unit/sampletones_core/project/patterns/test_pitch.py @@ -0,0 +1,250 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import ( + MAX_PERIOD, + MAX_PITCH, + MAX_TRANSPOSE, + MIN_PLAYED_PITCH, + MIN_TRANSPOSE, + NUM_PERIODS, +) +from sampletones_core.project.patterns.pitch import ( + Note, + RowPitch, + Step, + clamped_note, + clamped_step, + note_for_channel, + played_note, + shifted_pitch, + sounded_pitch, + step_of, +) +from sampletones_core.project.patterns.row import Row +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestStepOf(BaseTestSuite): + """A step states its offset outright, and a note is measured from the voice's reference.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + pitch: RowPitch + reference: int + + test_cases: Tuple["TestStepOf.TestCase", ...] = ( + TestCase(label="a step", pitch=Step(value=5), reference=60, expected=5), + TestCase(label="a note above the reference", pitch=Note(value=67), reference=60, expected=7), + TestCase(label="a note at the reference", pitch=Note(value=60), reference=60, expected=0), + TestCase(label="a period below the reference", pitch=Note(value=3), reference=9, expected=-6), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_step_a_pitch_asks_for(self, test_case: TestCase) -> None: + assert step_of(test_case.pitch, reference=test_case.reference) == test_case.expected + + +class TestSoundedPitch(BaseTestSuite): + """Where a voice sounds: held within the notes a tonal channel plays, walked around the periods on noise.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + channel: ChannelName + pitch: RowPitch + reference: int + + test_cases: Tuple["TestSoundedPitch.TestCase", ...] = ( + TestCase( + label="a note sounds where it says", + channel=ChannelName.PULSE1, + pitch=Note(value=67), + reference=60, + expected=67, + ), + TestCase( + label="a note below the lowest played note is held there", + channel=ChannelName.PULSE1, + pitch=Note(value=20), + reference=60, + expected=MIN_PLAYED_PITCH, + ), + TestCase( + label="a step past the highest note is held there", + channel=ChannelName.TRIANGLE, + pitch=Step(value=MAX_TRANSPOSE), + reference=MAX_PITCH, + expected=MAX_PITCH, + ), + TestCase( + label="a step on noise walks around the sixteen", + channel=ChannelName.NOISE, + pitch=Step(value=14), + reference=5, + expected=(5 + 14) % NUM_PERIODS, + ), + TestCase( + label="a period on noise sounds where it says", + channel=ChannelName.NOISE, + pitch=Note(value=3), + reference=9, + expected=3, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_where_the_voice_sounds(self, test_case: TestCase) -> None: + assert sounded_pitch(test_case.channel, test_case.pitch, reference=test_case.reference) == test_case.expected + + +class TestClampedPitches(BaseTestSuite): + """A note is held inside what the channel plays, and a step inside what a row accepts.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: RowPitch + channel: ChannelName + value: int + + test_cases: Tuple["TestClampedPitches.TestCase", ...] = ( + TestCase( + label="a low pitch rises to C-0", + channel=ChannelName.PULSE1, + value=20, + expected=Note(value=MIN_PLAYED_PITCH), + ), + TestCase( + label="a high pitch falls to B-7", channel=ChannelName.PULSE2, value=200, expected=Note(value=MAX_PITCH) + ), + TestCase( + label="a pitch on noise falls to the last period", + channel=ChannelName.NOISE, + value=20, + expected=Note(value=MAX_PERIOD), + ), + TestCase( + label="a negative period rises to the first", channel=ChannelName.NOISE, value=-1, expected=Note(value=0) + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_note_a_channel_keeps(self, test_case: TestCase) -> None: + assert clamped_note(test_case.channel, test_case.value) == test_case.expected + + @pytest.mark.parametrize(("value", "expected"), [(100, MAX_TRANSPOSE), (-100, MIN_TRANSPOSE), (5, 5)]) + def test_the_step_a_row_keeps(self, value: int, expected: int) -> None: + assert clamped_step(value) == Step(value=expected) + + +class TestNoteForChannel(BaseTestSuite): + """A piano key names a pitch; a tonal channel takes it as its note, and noise as the period it names.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: int + channel: ChannelName + pitch: int + + test_cases: Tuple["TestNoteForChannel.TestCase", ...] = ( + TestCase(label="a pitch within the range stands", channel=ChannelName.PULSE1, pitch=60, expected=60), + TestCase(label="a pitch past B-7 is held at it", channel=ChannelName.TRIANGLE, pitch=131, expected=MAX_PITCH), + TestCase(label="a C names the first period", channel=ChannelName.NOISE, pitch=48, expected=0), + TestCase(label="a D an octave up walks the periods round", channel=ChannelName.NOISE, pitch=62, expected=14), + TestCase( + label="a period lies where its pitch does", channel=ChannelName.NOISE, pitch=MAX_PERIOD, expected=MAX_PERIOD + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_note_the_channel_takes(self, test_case: TestCase) -> None: + assert note_for_channel(test_case.channel, test_case.pitch) == Note(value=test_case.expected) + + @pytest.mark.parametrize(("pitch", "expected"), [(131, MAX_PITCH), (20, MIN_PLAYED_PITCH), (60, 60)]) + def test_a_played_note_is_held_within_the_notes_a_channel_plays(self, pitch: int, expected: int) -> None: + assert played_note(pitch) == Note(value=expected) + + +class TestShiftedPitch(BaseTestSuite): + """A nudge moves a pitch on its own face and stops at the face's ends.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: RowPitch + pitch: RowPitch + delta: int + channel: ChannelName = ChannelName.PULSE1 + + test_cases: Tuple["TestShiftedPitch.TestCase", ...] = ( + TestCase(label="a step moves", pitch=Step(value=1), delta=12, expected=Step(value=13)), + TestCase( + label="a step stops at the top", + pitch=Step(value=MAX_TRANSPOSE - 1), + delta=5, + expected=Step(value=MAX_TRANSPOSE), + ), + TestCase(label="a note moves", pitch=Note(value=60), delta=12, expected=Note(value=72)), + TestCase(label="a note stops at B-7", pitch=Note(value=MAX_PITCH - 1), delta=5, expected=Note(value=MAX_PITCH)), + TestCase( + label="a note stops at C-0", + pitch=Note(value=MIN_PLAYED_PITCH), + delta=-1, + expected=Note(value=MIN_PLAYED_PITCH), + ), + TestCase( + label="a period stops at the last", + channel=ChannelName.NOISE, + pitch=Note(value=14), + delta=5, + expected=Note(value=MAX_PERIOD), + ), + TestCase( + label="a period stops at the first", + channel=ChannelName.NOISE, + pitch=Note(value=0), + delta=-1, + expected=Note(value=0), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_where_the_nudge_lands(self, test_case: TestCase) -> None: + assert shifted_pitch(test_case.pitch, test_case.delta, test_case.channel) == test_case.expected + + +class TestThePitchModel: + """A pitch stores its face beside its value, so a row reads back exactly what was written.""" + + @pytest.mark.parametrize("pitch", [Note(value=60), Step(value=-3), Step(value=0)]) + def test_a_row_round_trips_its_pitch(self, pitch: RowPitch) -> None: + row = Row(pitch=pitch) + + assert Row.model_validate(row.model_dump()) == row + assert Row.model_validate_json(row.model_dump_json()).pitch == pitch + + def test_a_step_reads_from_its_stored_shape(self) -> None: + assert Row.model_validate({"pitch": {"kind": "step", "value": 3}}).pitch == Step(value=3) + + def test_a_note_reads_from_its_stored_shape(self) -> None: + assert Row.model_validate({"pitch": {"kind": "note", "value": 61}}).pitch == Note(value=61) + + @pytest.mark.parametrize( + "stored", + [ + {"kind": "bend", "value": 3}, + {"kind": "note", "value": MAX_PITCH + 1}, + {"kind": "step", "value": MAX_TRANSPOSE + 1}, + ], + ) + def test_a_shape_the_union_lacks_is_refused(self, stored: object) -> None: + with pytest.raises(ValidationError): + Row.model_validate({"pitch": stored}) + + def test_the_two_faces_compare_apart(self) -> None: + assert Note(value=5) != Step(value=5) diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 22051afab..bf835a9f7 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -1,17 +1,24 @@ 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.instructions import PulseInstruction 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.pitch import Step 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_core.reconstructions import Reconstruction from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from sampletones_shared.constants.project import ( PROJECT_DOCUMENT_NAME, @@ -27,6 +34,16 @@ ) from tests.conftest import ReconstructionFactory from tests.suite.errors import DIRECTORY_READ_ERRORS +from tests.suite.stems import SHARED_CHANNEL, taking_turns_reconstruction + +Document = Dict[str, Any] +DocumentRewrite = Callable[[Document], Document] + +UNREACHED_VERSION: Final[str] = "9.0" +SHARING_SOURCES: Final[Tuple[Path, Path]] = (Path("a.wav"), Path("b.wav")) +ARCHIVE_NAME: Final[str] = "shared.stp" +EDITED: Final[PulseInstruction] = PulseInstruction(on=True, pitch=67, volume=11, duty_cycle=1) +EDITED_PITCH: Final[int] = 67 def _rewrite_format_version(source: Path, target: Path, *, format_version: str) -> None: @@ -42,6 +59,24 @@ 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 _another_builds_document(document: Document) -> Document: + """The document as a build no step reaches might write it: another version and a song it calls otherwise.""" + reshaped = {key: value for key, value in document.items() if key != "song"} + return {**reshaped, "format_version": UNREACHED_VERSION, "arrangement": document["song"]} + + def _populated_project( reconstruction_factory: ReconstructionFactory, shared: bool = False, @@ -52,26 +87,28 @@ 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] - pattern = channel.patterns[0] - pattern.name = "intro" - pattern.rows[0] = Row( - transpose=0, - command=Instrument( - sample_id=first.id, - generator_name=GeneratorName.PULSE1, - ), - volume=15, + channel = song[ChannelName.PULSE1] + channel.patterns[0] = ( + channel.patterns[0] + .model_copy(update={"name": "intro"}) + .with_row( + 0, + Row( + pitch=Step(value=0), + 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 +127,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.pitch == Step(value=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 +154,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].set_row(0, 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 +283,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 +294,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()) @@ -236,6 +348,17 @@ def test_malformed_document_raises_invalid_values( with pytest.raises(InvalidProjectDataValuesError): ProjectContainer.load(path) + def test_a_document_that_is_not_text_raises_invalid_values( + self, + tmp_path: Path, + ) -> None: + path = tmp_path / "binarydoc.stp" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(PROJECT_DOCUMENT_NAME, b"\xff\xfe\x00\xd8") + + with pytest.raises(InvalidProjectDataValuesError): + ProjectContainer.load(path) + def test_corrupt_reconstruction_raises_incorrect_data( self, tmp_path: Path, @@ -303,6 +426,61 @@ def test_incompatible_format_version_raises( assert exc_info.value.expected_version == SAMPLETONES_PROJECT_DATA_VERSION assert exc_info.value.actual_version == "9.0" + def test_a_version_no_step_reaches_is_refused_before_its_shape_is_read( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A document at another version keeps another build's shape, so the refusal names the version.""" + original = tmp_path / "demo.stp" + ProjectContainer.save(_populated_project(reconstruction_factory), original) + reshaped = tmp_path / "reshaped.stp" + _rewrite_document(original, reshaped, _another_builds_document) + + with pytest.raises(IncompatibleProjectVersionError) as exc_info: + ProjectContainer.load(reshaped) + + assert (exc_info.value.actual_version, exc_info.value.expected_version) == ( + UNREACHED_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + ) + + def test_the_same_shape_at_the_current_version_is_refused_for_its_shape( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + original = tmp_path / "demo.stp" + ProjectContainer.save(_populated_project(reconstruction_factory), original) + reshaped = tmp_path / "reshaped.stp" + _rewrite_document( + original, + reshaped, + lambda document: {**_another_builds_document(document), "format_version": SAMPLETONES_PROJECT_DATA_VERSION}, + ) + + with pytest.raises(InvalidProjectDataValuesError): + ProjectContainer.load(reshaped) + + def test_a_document_stating_no_version_reads_at_the_current_one( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + project = _populated_project(reconstruction_factory) + original = tmp_path / "demo.stp" + ProjectContainer.save(project, original) + unstated = tmp_path / "unstated.stp" + _rewrite_document( + original, + unstated, + lambda document: {key: value for key, value in document.items() if key != "format_version"}, + ) + + loaded = ProjectContainer.load(unstated) + + assert [voice.name for voice in loaded.voices] == [voice.name for voice in project.voices] + def test_incompatible_embedded_reconstruction_version_rejected( self, tmp_path: Path, @@ -310,7 +488,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" @@ -318,3 +496,56 @@ def test_incompatible_embedded_reconstruction_version_rejected( with pytest.raises(IncorrectReconstructionDataError): ProjectContainer.load(path) + + +class TestTheArchiveStoresEveryDocument: + """A project file stores every distinct document its samples hold, and one shared document once.""" + + @pytest.fixture + def document(self) -> Reconstruction: + """Two recordings taking turns on Pulse 1, the second holding Pulse 2 alone, their locations let go.""" + return taking_turns_reconstruction(SHARING_SOURCES).detached() + + @staticmethod + def _round_trip(project: Project, tmp_path: Path) -> Project: + path = tmp_path / ARCHIVE_NAME + ProjectContainer.save(project, path) + return ProjectContainer.load(path) + + def test_a_shared_document_is_stored_once_and_shared_again( + self, + document: Reconstruction, + tmp_path: Path, + ) -> None: + project = Project.create() + project.voices.append(Sample(name="Lead", reconstruction=document)) + project.voices.append(Sample(name="Twin", reconstruction=document)) + + reloaded = TestTheArchiveStoresEveryDocument._round_trip(project, tmp_path) + + first, second = list(reloaded.voices) + assert isinstance(first, Sample) and isinstance(second, Sample) + assert first.reconstruction is second.reconstruction + + def test_two_documents_of_one_id_both_survive( + self, + document: Reconstruction, + tmp_path: Path, + ) -> None: + edited = document.with_channel_data( + SHARED_CHANNEL, + [EDITED, EDITED], + EDITED_PITCH, + (), + heard=document.recorded_stem_ids, + ) + project = Project.create() + project.voices.append(Sample(name="Lead", reconstruction=document)) + project.voices.append(Sample(name="Copy", reconstruction=edited)) + + reloaded = TestTheArchiveStoresEveryDocument._round_trip(project, tmp_path) + + first, second = list(reloaded.voices) + assert isinstance(first, Sample) and isinstance(second, Sample) + assert first.reconstruction.instructions[SHARED_CHANNEL] == document.instructions[SHARED_CHANNEL] + assert second.reconstruction.instructions[SHARED_CHANNEL] == edited.instructions[SHARED_CHANNEL] diff --git a/tests/unit/sampletones_core/project/test_models.py b/tests/unit/sampletones_core/project/test_models.py index b48949bc4..134828959 100644 --- a/tests/unit/sampletones_core/project/test_models.py +++ b/tests/unit/sampletones_core/project/test_models.py @@ -3,55 +3,54 @@ 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.pitch import Step 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: def test_empty_row(self) -> None: row = Row() - assert row.transpose is None + assert row.pitch is None assert row.command is None assert row.volume is None def test_is_frozen(self) -> None: - row = Row(transpose=12) + row = Row(pitch=Step(value=12)) with pytest.raises(ValidationError): - row.transpose = 13 # type: ignore[misc] + row.pitch = Step(value=13) # type: ignore[misc] class TestRowSerialization(BaseTestSuite): @@ -61,12 +60,12 @@ class TestCase(BaseAutolabelTestCase): @property def label(self) -> str: - return f"transpose={self.expected.transpose}_command={self.expected.command is not None}" + return f"pitch={self.expected.pitch}_command={self.expected.command is not None}" test_cases = ( TestCase(expected=Row()), - TestCase(expected=Row(transpose=0, volume=15)), - TestCase(expected=Row(transpose=12, command=_instrument(), volume=8)), + TestCase(expected=Row(pitch=Step(value=0), volume=15)), + TestCase(expected=Row(pitch=Step(value=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..99d771ed6 100644 --- a/tests/unit/sampletones_core/project/test_serialization.py +++ b/tests/unit/sampletones_core/project/test_serialization.py @@ -1,12 +1,13 @@ 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.pitch import Step 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, @@ -14,16 +15,14 @@ def _pattern_with_instrument() -> Pattern: - pattern = Pattern.empty(4, name="intro") - pattern.rows[0] = Row( - transpose=0, - volume=15, - instrument=Instrument( - sample_id="abc123", - generator_name=GeneratorName.PULSE1, + return Pattern.empty(4, name="intro").with_row( + 0, + Row( + pitch=Step(value=0), + volume=15, + instrument=NoteOn(voice_id="abc123"), ), ) - return pattern class TestPatternSerialization: @@ -42,7 +41,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 +75,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_snapshot.py b/tests/unit/sampletones_core/project/test_snapshot.py new file mode 100644 index 000000000..0e8bd900b --- /dev/null +++ b/tests/unit/sampletones_core/project/test_snapshot.py @@ -0,0 +1,166 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.project import Project +from sampletones_core.project.patterns.pitch import Note +from sampletones_core.project.patterns.row import Row +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 tests.suite.history.audit import fresh_fingerprint +from tests.suite.history.projects import FIRST_PATTERN, LEAD, PAD, SECOND_PATTERN, every_part_project + +EDITED_ROW: Final[int] = 1 +EDITED_TEMPO: Final[int] = 99 +SHORTER: Final[int] = 4 +EDITED_VOLUME: Final[Envelope[int]] = Envelope[int](items=(3,)) + + +@pytest.fixture +def project() -> Project: + return every_part_project() + + +def _sample( + project: Project, + name: str, +) -> Sample: + return next(voice for voice in project.voices if isinstance(voice, Sample) and voice.name == name) + + +def _instrument( + project: Project, + name: str, +) -> Instrument: + return next(voice for voice in project.voices if isinstance(voice, Instrument) and voice.name == name) + + +class TestASnapshotKeepsItsOwnShells: + """A snapshot reads as the project it was taken from, and an edit of either leaves the other as it was.""" + + def test_it_reads_as_the_project(self, project: Project) -> None: + assert fresh_fingerprint(project.snapshot()) == fresh_fingerprint(project) + + def test_a_row_written_after_it_leaves_it_as_it_was(self, project: Project) -> None: + snapshot = project.snapshot() + before = fresh_fingerprint(snapshot) + lead = _sample(project, LEAD) + + project.song.channels[ChannelName.PULSE1].set_row( + FIRST_PATTERN, + EDITED_ROW, + Row(command=NoteOn(voice_id=lead.id), pitch=Note(value=50)), + ) + + assert fresh_fingerprint(snapshot) == before + assert fresh_fingerprint(project) != before + + def test_an_order_written_after_it_leaves_it_as_it_was(self, project: Project) -> None: + snapshot = project.snapshot() + + project.song.set_order_entry(0, ChannelName.PULSE1, SECOND_PATTERN) + + assert snapshot.song.order[0][ChannelName.PULSE1] == FIRST_PATTERN + + def test_a_setting_a_name_and_a_move_leave_it_as_it_was(self, project: Project) -> None: + snapshot = project.snapshot() + before = fresh_fingerprint(snapshot) + lead = _sample(project, LEAD) + + project.settings.tempo = EDITED_TEMPO + lead.name = f"{LEAD} again" + project.voices.move(lead.id, len(project.voices) - 1) + + assert fresh_fingerprint(snapshot) == before + assert _sample(snapshot, LEAD).name == LEAD + + def test_an_envelope_written_after_it_leaves_it_as_it_was(self, project: Project) -> None: + snapshot = project.snapshot() + pad = _instrument(project, PAD) + held = _instrument(snapshot, PAD).envelopes + + pad.envelopes = pad.envelopes.with_envelope(FeatureKey.VOLUME, EDITED_VOLUME) + pad.invalidate() + + assert _instrument(snapshot, PAD).envelopes is held + + +class TestASnapshotHoldsTheVeryValues: + """A snapshot copies the shells around the values and holds the values themselves.""" + + def test_documents_envelopes_and_patterns_are_shared(self, project: Project) -> None: + snapshot = project.snapshot() + + assert _sample(snapshot, LEAD).reconstruction is _sample(project, LEAD).reconstruction + assert _instrument(snapshot, PAD).envelopes is _instrument(project, PAD).envelopes + assert all( + snapshot.song.channels[name].patterns[index] is pattern + for name, channel in project.song.channels.items() + for index, pattern in channel.patterns.items() + ) + + def test_the_shells_are_its_own(self, project: Project) -> None: + snapshot = project.snapshot() + + assert snapshot.voices is not project.voices + assert _sample(snapshot, LEAD) is not _sample(project, LEAD) + assert _sample(snapshot, LEAD).id == _sample(project, LEAD).id + assert snapshot.song.order is not project.song.order + assert snapshot.song.order[0] is not project.song.order[0] + assert snapshot.song.channels[ChannelName.PULSE1] is not project.song.channels[ChannelName.PULSE1] + + +class TestAnEditOwnsWhatItReplaced: + """An edit replaces one value, and every value it leaves alone stays the very object the snapshot holds.""" + + def test_a_row_edit_owns_one_pattern(self, project: Project) -> None: + snapshot = project.snapshot() + channel = project.song.channels[ChannelName.PULSE1] + + channel.set_row(FIRST_PATTERN, EDITED_ROW, Row(volume=1)) + + held = snapshot.song.channels[ChannelName.PULSE1] + assert channel.patterns[FIRST_PATTERN] is not held.patterns[FIRST_PATTERN] + assert channel.patterns[SECOND_PATTERN] is held.patterns[SECOND_PATTERN] + unchanged = [index for index in range(len(held.patterns[FIRST_PATTERN].rows)) if index != EDITED_ROW] + assert all( + channel.patterns[FIRST_PATTERN].rows[index] is held.patterns[FIRST_PATTERN].rows[index] + for index in unchanged + ) + + def test_a_resize_shares_every_row_it_keeps(self, project: Project) -> None: + snapshot = project.snapshot() + + project.song.resize_patterns(SHORTER) + + before = snapshot.song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN] + after = project.song.channels[ChannelName.PULSE1].patterns[FIRST_PATTERN] + assert len(after.rows) == SHORTER + assert all(left is right for left, right in zip(after.rows, before.rows)) + + def test_a_removed_voice_rewrites_only_the_patterns_naming_it(self, project: Project) -> None: + snapshot = project.snapshot() + pad = _instrument(project, PAD) + + project.song.clear_voice_references(pad.id) + + before = snapshot.song.channels + after = project.song.channels + assert ( + after[ChannelName.TRIANGLE].patterns[FIRST_PATTERN] is before[ChannelName.TRIANGLE].patterns[FIRST_PATTERN] + ) + assert ( + after[ChannelName.PULSE1].patterns[FIRST_PATTERN] is not before[ChannelName.PULSE1].patterns[FIRST_PATTERN] + ) + + def test_an_envelope_edit_shares_every_other_envelope(self, project: Project) -> None: + pad = _instrument(project, PAD) + before = pad.envelopes + + after = before.with_envelope(FeatureKey.VOLUME, EDITED_VOLUME) + + assert after.arpeggio is before.arpeggio + assert after.volume is not before.volume diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index c6a38264b..acdc84ecd 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,9 @@ 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) - assert pattern is not None - pattern.rows[row_index] = Row(command=Instrument(sample_id=sample_id, generator_name=generator)) +def _place_voice(song: Song, channel: ChannelName, voice_id: str, row_index: int = 0) -> None: + assert song.pattern(channel, 0) is not None + song.channels[channel].set_row(0, row_index, Row(command=NoteOn(voice_id=voice_id))) class TestSongEmpty: @@ -32,13 +31,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 +47,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 +75,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 +90,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 +112,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 +125,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 +139,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 +201,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: + def test_duplicate_carries_an_unmaterialized_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 +290,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 +334,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..97b5bb8ec 100644 --- a/tests/unit/sampletones_core/project/test_structure.py +++ b/tests/unit/sampletones_core/project/test_structure.py @@ -2,15 +2,15 @@ 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 automation.scenario import BaseTestScenario, ScenarioStep +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 tests.suite.scenario import BaseTestScenario, ScenarioStep +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_core.project.voices.sample import Sample def _sample(name: str) -> Sample: @@ -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..626202d36 --- /dev/null +++ b/tests/unit/sampletones_core/project/test_tuning.py @@ -0,0 +1,66 @@ +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.exceptions import ProjectTuningError +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(ProjectTuningError, match=str(BAROQUE_PITCH)) as refused: + tuning_from_project(project) + + assert isinstance(refused.value, ValueError) diff --git a/tests/unit/sampletones_core/project/test_values.py b/tests/unit/sampletones_core/project/test_values.py new file mode 100644 index 000000000..d238215c5 --- /dev/null +++ b/tests/unit/sampletones_core/project/test_values.py @@ -0,0 +1,183 @@ +from collections.abc import Mapping +from datetime import datetime +from enum import Enum +from pathlib import PurePath +from types import NoneType, UnionType +from typing import ( + Annotated, + Any, + Dict, + Final, + FrozenSet, + List, + Literal, + Set, + Tuple, + Union, + get_args, + get_origin, + get_type_hints, +) + +import numpy as np +from pydantic import BaseModel, ConfigDict + +from sampletones_core.project import Project +from sampletones_core.project.info import ProjectInfo +from sampletones_core.project.patterns.channel import Channel +from sampletones_core.project.settings import ProjectSettings +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.structures import IdentifiedCollection + +SHELLS: Final[FrozenSet[type]] = frozenset( + {Project, ProjectInfo, ProjectSettings, IdentifiedCollection, Sample, Instrument, Song, Channel} +) +CONTAINER_SHELLS: Final[FrozenSet[type]] = frozenset({IdentifiedCollection}) +LEAVES: Final[Tuple[type, ...]] = (bool, int, float, str, Enum, PurePath, datetime, NoneType) +READ_ONLY_CONTAINERS: Final[Tuple[Any, ...]] = (tuple, frozenset, Mapping) +CHANGEABLE_CONTAINERS: Final[Tuple[Any, ...]] = (list, dict, set) +REACHED: Final[FrozenSet[str]] = frozenset( + { + "Reconstruction", + "InstructionsItem", + "PulseInstruction", + "StemsData", + "ChannelAssignment", + "StemSettings", + "Config", + "Pattern", + "Row", + "NoteOn", + "Step", + "InstrumentEnvelopes", + "Metadata", + } +) + + +class _ValueWalk: + """Reads every class a project reaches and holds each to being a declared shell or a value. + + A value is frozen and holds values alone: scalars, read-only containers of values, and other + values. A shell may hold anything, and whatever value it holds is held to the same rule. A + container shell holds what its type argument names, which the walk reads from the argument. + """ + + def __init__(self) -> None: + self.faults: List[str] = [] + self._visited: Set[type] = set() + + @property + def visited(self) -> FrozenSet[type]: + """Every class the walk has read.""" + return frozenset(self._visited) + + def visit_class(self, cls: type, path: str) -> None: + if cls in self._visited: + return + + self._visited.add(cls) + holder_is_value = cls not in SHELLS + if holder_is_value and not (issubclass(cls, BaseModel) and cls.model_config.get("frozen")): + self.faults.append(f"{path}: {cls.__name__} is a value that can change") + + if cls in CONTAINER_SHELLS: + return + + for name, annotation in self._fields(cls).items(): + self.visit(annotation, holder_is_value, f"{cls.__name__}.{name}") + + @staticmethod + def _fields(cls: type) -> Dict[str, Any]: + if issubclass(cls, BaseModel): + return {name: info.annotation for name, info in cls.model_fields.items()} + + hints = get_type_hints(cls.__init__) + hints.pop("return", None) + return hints + + def visit( + self, + annotation: Any, + holder_is_value: bool, + path: str, + ) -> None: + origin = get_origin(annotation) + arguments = [argument for argument in get_args(annotation) if argument is not Ellipsis] + if origin is Annotated: + self.visit(arguments[0], holder_is_value, path) + elif origin in (Union, UnionType) or origin in READ_ONLY_CONTAINERS: + for argument in arguments: + self.visit(argument, holder_is_value, path) + elif origin is Literal: + return + elif origin in CHANGEABLE_CONTAINERS: + if holder_is_value: + self.faults.append(f"{path}: a value holds a {origin.__name__}") + for argument in arguments: + self.visit(argument, holder_is_value, path) + elif origin is not None: + self.visit(origin, holder_is_value, path) + for argument in arguments: + self.visit(argument, holder_is_value, path) + elif annotation is np.ndarray: + if holder_is_value: + self.faults.append(f"{path}: a value holds an array") + elif isinstance(annotation, type) and issubclass(annotation, LEAVES): + return + elif isinstance(annotation, type): + if holder_is_value and annotation in SHELLS: + self.faults.append(f"{path}: a value holds the shell {annotation.__name__}") + self.visit_class(annotation, path) + else: + self.faults.append(f"{path}: the walk cannot read {annotation!r}") + + +class _ChangeableValue(BaseModel): + model_config = ConfigDict(frozen=True) + + rows: List[int] + + +class _UnfrozenValue(BaseModel): + count: int + + +class TestEveryValueIsImmutable: + """Every class a project reaches is a declared shell or a value that never changes once made. + + A snapshot shares the values and copies the shells, so a value that could change in place would + change every history entry holding it. The walk reads the types a project declares, and a + changeable container or an unfrozen model anywhere under a value is reported. + """ + + def test_the_project_holds_shells_over_values(self) -> None: + walk = _ValueWalk() + + walk.visit_class(Project, Project.__name__) + + assert walk.faults == [] + + def test_a_list_inside_a_value_is_reported(self) -> None: + walk = _ValueWalk() + + walk.visit_class(_ChangeableValue, _ChangeableValue.__name__) + + assert walk.faults == ["_ChangeableValue.rows: a value holds a list"] + + def test_an_unfrozen_model_is_reported(self) -> None: + walk = _ValueWalk() + + walk.visit_class(_UnfrozenValue, _UnfrozenValue.__name__) + + assert walk.faults == ["_UnfrozenValue: _UnfrozenValue is a value that can change"] + + def test_the_walk_reaches_the_values_a_project_holds(self) -> None: + walk = _ValueWalk() + + walk.visit_class(Project, Project.__name__) + + reached = {cls.__name__ for cls in walk.visited} + assert REACHED <= reached 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..3febb52fd --- /dev/null +++ b/tests/unit/sampletones_core/project/voices/test_instrument.py @@ -0,0 +1,236 @@ +from dataclasses import dataclass +from typing import List, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.general import MAX_VOLUME, NUM_PERIODS +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 _noise_frames(instrument: Instrument) -> List[NoiseInstruction]: + frames = instrument.instructions(ChannelName.NOISE) + assert all(isinstance(frame, NoiseInstruction) for frame in frames) + return [frame for frame in frames if isinstance(frame, NoiseInstruction)] + + +def _instrument(**overrides: object) -> Instrument: + fields: dict = { + "name": "lead", + "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_long_mode_of_an_even_duty(self) -> None: + """FamiTracker and Bitphase read the duty cycle's lowest bit as the noise mode, so duty 2 plays long.""" + first = _instrument().instructions(ChannelName.NOISE)[0] + + assert first == NoiseInstruction( + on=True, + period=RESTING_REFERENCE_PERIOD, + volume=VOLUME[0], + short=False, + ) + + def test_an_odd_duty_plays_the_short_noise_mode(self) -> None: + instrument = _instrument( + envelopes=InstrumentEnvelopes(volume=Envelope(items=VOLUME), duty_cycle=Envelope(items=(1, 3, 2))) + ) + + assert [frame.short for frame in _noise_frames(instrument)] == [True, True, False, False] + + def test_a_bend_moves_the_noise_period_one_step_per_unit(self) -> None: + bend = (0, 1, -2, 5) + instrument = _instrument( + envelopes=InstrumentEnvelopes(volume=Envelope(items=VOLUME), pitch=Envelope(items=bend)) + ) + + assert [frame.period for frame in _noise_frames(instrument)] == [ + (RESTING_REFERENCE_PERIOD + step) % NUM_PERIODS for step in bend + ] + + def test_a_bend_adds_to_the_arpeggio_on_noise(self) -> None: + instrument = _instrument( + envelopes=InstrumentEnvelopes( + volume=Envelope(items=VOLUME), + arpeggio=Envelope(items=(0, 4)), + pitch=Envelope(items=(3, 3)), + ) + ) + + assert [frame.period for frame in _noise_frames(instrument)][:2] == [ + RESTING_REFERENCE_PERIOD + 3, + RESTING_REFERENCE_PERIOD + 7, + ] + + def test_hi_pitch_moves_the_noise_period_a_whole_turn_which_leaves_it_where_it_was(self) -> None: + instrument = _instrument( + envelopes=InstrumentEnvelopes(volume=Envelope(items=VOLUME), hi_pitch=Envelope(items=(1, 2))) + ) + + assert {frame.period for frame in _noise_frames(instrument)} == {RESTING_REFERENCE_PERIOD} + + def test_an_instrument_writing_nothing_sounds_on_no_channel(self) -> None: + instrument = Instrument(name="empty") + + 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_bend_on_noise_decides_the_arpeggio_it_rides_on(self) -> None: + """A noise frame carries the arpeggio and the bend as one period, so the bend writes the arpeggio there.""" + instrument = Instrument(name="sweep", envelopes=InstrumentEnvelopes(pitch=Envelope(items=(1, 2, 3)))) + + assert FeatureKey.ARPEGGIO not in instrument.held_features(ChannelName.NOISE) + assert FeatureKey.ARPEGGIO in instrument.held_features(ChannelName.PULSE1) + + def test_a_channel_is_told_of_the_dimensions_it_offers_alone(self) -> None: + instrument = Instrument(name="lead", envelopes=InstrumentEnvelopes(arpeggio=Envelope(items=ARPEGGIO))) + + 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..93a169162 --- /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 an instrument + ``"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/voices/test_sample.py b/tests/unit/sampletones_core/project/voices/test_sample.py new file mode 100644 index 000000000..b0680c299 --- /dev/null +++ b/tests/unit/sampletones_core/project/voices/test_sample.py @@ -0,0 +1,21 @@ +from unittest.mock import Mock + +from sampletones_core.project.voices.sample import Sample + + +class TestSampleClone: + 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_the_name(self) -> None: + sample = Sample(name="lead", reconstruction=Mock()) + + assert sample.clone().name == "lead" + + def test_clone_shares_the_reconstruction(self) -> None: + reconstruction = Mock() + sample = Sample(name="lead", reconstruction=reconstruction) + clone = sample.clone() + assert clone.reconstruction is reconstruction + assert clone.id != sample.id 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..f043962c3 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py @@ -1,3 +1,5 @@ +from typing import FrozenSet + import pytest from sampletones_core.configs import Config @@ -8,10 +10,15 @@ format_spectrum_method, format_transformation_gamma, ) -from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + abbreviate_channel_names, +) from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields HASH = "6edf7c948606917a78b45d153c7ca7e0" +CHANNELS = frozenset(DEFAULT_CHANNELS) @pytest.fixture(scope="module") @@ -19,49 +26,63 @@ def config() -> Config: return Config() -class TestGenerateConfigDirectoryName: +def _directory_name(config: Config, channels: FrozenSet[ChannelName]) -> str: + return ConfigDirectoryFields.from_config(config, channels).directory_name + + +class TestTheDirectoryNameOfAConfiguration: def test_result_contains_sample_rate(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = _directory_name(config, CHANNELS) assert str(config.library.sample_rate) in name def test_result_contains_nes_frequency(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = _directory_name(config, CHANNELS) assert str(config.library.nes_frequency) in name def test_same_config_produces_same_name(self, config: Config) -> None: - assert ConfigDirectoryFields.generate_config_directory_name( - config - ) == ConfigDirectoryFields.generate_config_directory_name(config) + assert _directory_name(config, CHANNELS) == _directory_name(config, CHANNELS) + + def test_different_channel_sets_produce_different_names(self, config: Config) -> None: + """The directory names what a run hands out, so two sets of channels never share one.""" + assert _directory_name(config, CHANNELS) != _directory_name(config, frozenset({ChannelName.PULSE1})) + + def test_the_name_reads_the_same_however_the_set_was_gathered(self, config: Config) -> None: + """A set has no order of its own, so the name states the channels in the app's own order.""" + reversed_set = frozenset(reversed(list(DEFAULT_CHANNELS))) + + assert _directory_name(config, CHANNELS) == _directory_name(config, reversed_set) - 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]})} - ) + def test_a_hash_taken_once_names_the_same_directory(self, config: Config) -> None: + """A caller naming several channel sets hashes the settings once, and each name reads as the + configuration's own.""" + settings_hash = ConfigDirectoryFields.settings_hash(config) - assert ConfigDirectoryFields.generate_config_directory_name( - config - ) != ConfigDirectoryFields.generate_config_directory_name(single_generator_config) + assert ConfigDirectoryFields.from_hashed_config( + config, + CHANNELS, + settings_hash=settings_hash, + ) == ConfigDirectoryFields.from_config(config, CHANNELS) class TestConfigDirectoryFields: - def test_round_trips_with_generate_config_directory_name(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + def test_round_trips_with_the_directory_name(self, config: Config) -> None: + name = _directory_name(config, CHANNELS) fields = ConfigDirectoryFields.from_directory_name(name) assert fields is not None assert fields.directory_name == name def test_parses_components(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = _directory_name(config, CHANNELS) fields = ConfigDirectoryFields.from_directory_name(name) assert fields is not None assert fields.sr == config.library.sample_rate 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(DEFAULT_CHANNELS) def test_directory_name_embeds_field_keys(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = _directory_name(config, CHANNELS) segments = name.split("_") assert {"sr", "nf", "sm", "tg", "gn", "ch"}.issubset(segments) @@ -83,12 +104,12 @@ def test_malformed_names_return_none(self, name: str) -> None: assert ConfigDirectoryFields.from_directory_name(name) is None def test_display_name_combines_formatted_parts(self, config: Config) -> None: - fields = ConfigDirectoryFields.from_config(config) + fields = ConfigDirectoryFields.from_config(config, CHANNELS) display = fields.display_name assert format_sample_rate(config.library.sample_rate) in display 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(DEFAULT_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..54d083477 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -1,16 +1,26 @@ +import errno from pathlib import Path from typing import List import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName from sampletones_core.reconstructions.converter.paths import ( + ConfigDirectoryFields, filter_files, get_audio_files, get_output_path, get_relative_path, + group_output_path, + reconstructions_directory, + walk_entries, ) +from sampletones_core.reconstructions.converter.paths.utils import ConfigDirectories from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.files import LOCKED_FOLDER, NAMES_ONLY_FOLDER, held_at, requires_folder_permissions + +CHANNELS = frozenset(DEFAULT_CHANNELS) @pytest.fixture(scope="module") @@ -41,6 +51,20 @@ def test_result_is_absolute(self) -> None: assert result.is_absolute() +class TestConfigDirectories: + """A configuration names one directory per channel set under its reconstructions directory, as + its own fields name it.""" + + def test_each_channel_set_names_its_own_directory(self, config: Config) -> None: + directories = ConfigDirectories(config) + channel_sets = (CHANNELS, frozenset({ChannelName.PULSE1}), CHANNELS) + + assert [directories.directory(channels) for channels in channel_sets] == [ + reconstructions_directory(config) / ConfigDirectoryFields.from_config(config, channels).directory_name + for channels in channel_sets + ] + + class TestGetOutputPath: def test_file_input_returns_path_with_reconstruction_extension( self, @@ -49,7 +73,7 @@ def test_file_input_returns_path_with_reconstruction_extension( ) -> None: audio_file = tmp_path / "song.wav" audio_file.touch() - result = get_output_path(config, audio_file) + result = get_output_path(config, audio_file, CHANNELS) assert result.suffix == EXT_FILE_RECONSTRUCTION def test_directory_input_returns_path_ending_with_directory_name( @@ -57,7 +81,7 @@ def test_directory_input_returns_path_ending_with_directory_name( config: Config, tmp_path: Path, ) -> None: - result = get_output_path(config, tmp_path) + result = get_output_path(config, tmp_path, CHANNELS) assert result.name == tmp_path.name def test_non_existent_input_raises_file_not_found_error( @@ -67,7 +91,7 @@ def test_non_existent_input_raises_file_not_found_error( ) -> None: missing = tmp_path / "does_not_exist" with pytest.raises(FileNotFoundError): - get_output_path(config, missing) + get_output_path(config, missing, CHANNELS) class TestGetAudioFiles: @@ -100,6 +124,41 @@ def test_sorted_when_sort_is_true(self, tmp_path: Path) -> None: assert names == sorted(names) +@requires_folder_permissions +class TestWalkEntries: + """A walk reads every folder it may open and passes over the ones it may not.""" + + def test_a_locked_folder_is_passed_over_with_what_it_holds(self, tmp_path: Path) -> None: + (tmp_path / "open").mkdir() + (tmp_path / "open" / "kept.wav").touch() + locked = tmp_path / "locked" + (locked / "deeper").mkdir(parents=True) + (locked / "hidden.wav").touch() + + with held_at(locked, LOCKED_FOLDER): + names = sorted(path.name for path in walk_entries(tmp_path)) + + assert names == ["kept.wav", "locked", "open"] + + def test_a_folder_that_cannot_be_opened_raises(self, tmp_path: Path) -> None: + (tmp_path / "take.wav").touch() + + with held_at(tmp_path, LOCKED_FOLDER), pytest.raises(PermissionError): + walk_entries(tmp_path) + + def test_a_folder_listing_names_only_raises_naming_itself(self, tmp_path: Path) -> None: + """A folder may list its names and keep its recordings closed, which leaves the walk nothing + to read, as a folder that cannot be opened does.""" + root = tmp_path / "names_only" + root.mkdir() + (root / "take.wav").touch() + + with held_at(root, NAMES_ONLY_FOLDER), pytest.raises(PermissionError) as raised: + walk_entries(root) + + assert (raised.value.errno, raised.value.filename) == (errno.EACCES, str(root)) + + class TestFilterFiles: def test_includes_files_without_existing_output(self, tmp_path: Path) -> None: audio_files: List[Path] = [tmp_path / "song.wav"] 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..78c7e51c1 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -0,0 +1,599 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +import pytest +from pydantic import BaseModel + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) +from sampletones_core.reconstructions.converter.paths import fields as fields_module +from sampletones_core.reconstructions.converter.paths.utils import ( + config_directory_path, + get_output_path, + group_output_path, +) +from sampletones_core.reconstructions.converter.plan.batch import BatchConversion, BatchEntry +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.exceptions import NoFilesToProcessError +from sampletones_shared.utils.hashing import hash_models +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config() + + +CHANNELS = frozenset(DEFAULT_CHANNELS) +BATCH_RECORDINGS: Final[int] = 6 + + +@pytest.fixture(scope="module") +def stems(config: Config) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_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, CHANNELS) + + 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, CHANNELS) + + 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(StemSettings.covering([ChannelName.PULSE1]).with_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, CHANNELS) + + 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 _batch(stems: StemsConfig, *sources: Path, base_directory: Optional[Path] = None) -> BatchConversion: + entries = tuple(BatchEntry(source=source, stems=stems, base_directory=base_directory) for source in sources) + return BatchConversion(entries=entries) + + +class TestBatchConversion: + def test_every_recording_becomes_its_own_single_source_job( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + sources = _write_audio_files(tmp_path, ["a.wav", "b.wav"]) + + jobs = _batch(stems, *sources).jobs(config) + + assert [job.sources for job in jobs] == [(sources[0],), (sources[1],)] + + def test_a_recording_named_by_itself_is_written_where_a_single_conversion_writes_it( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["song.wav"])[0] + + jobs = _batch(stems, source).jobs(config) + + assert jobs[0].output_path == group_output_path(config, (source,), CHANNELS) + + def test_a_recording_gathered_from_a_folder_mirrors_that_folder( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["nested/deeper/b.wav"])[0] + + jobs = _batch(stems, source, base_directory=tmp_path).jobs(config) + + mirrored = config_directory_path(config, CHANNELS) / tmp_path.name + assert jobs[0].output_path == mirrored / "nested" / "deeper" / "b.stn" + + def test_each_recording_carries_the_setup_it_was_given( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + sources = _write_audio_files(tmp_path, ["a.wav", "b.wav"]) + targeted = StemsConfig.single_entry(StemSettings.covering([ChannelName.NOISE])) + + plan = BatchConversion( + entries=( + BatchEntry(source=sources[0], stems=stems, base_directory=None), + BatchEntry(source=sources[1], stems=targeted, base_directory=None), + ) + ) + + assert [job.stems for job in plan.jobs(config)] == [stems, targeted] + + def test_a_gathered_recording_already_written_is_left_as_it_stands( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + sources = _write_audio_files(tmp_path / "loops", ["a.wav", "b.wav"]) + plan = _batch(stems, *sources, base_directory=tmp_path / "loops") + written = plan.jobs(config)[0].output_path + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + assert [job.sources[0].name for job in plan.jobs(config)] == ["b.wav"] + + def test_a_recording_the_reader_named_is_written_again( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + plan = _batch(stems, source) + written = plan.jobs(config)[0].output_path + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + assert len(plan.jobs(config)) == 1 + + def test_a_batch_left_with_nothing_to_write_raises( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path / "loops", ["a.wav"])[0] + plan = _batch(stems, source, base_directory=tmp_path / "loops") + written = plan.jobs(config)[0].output_path + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + with pytest.raises(NoFilesToProcessError): + plan.jobs(config) + + def test_a_batch_holding_no_recording_raises(self, config: Config) -> None: + with pytest.raises(NoFilesToProcessError): + BatchConversion(entries=()).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_batch_names_the_target_standing_for_a_recording_the_reader_named( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + plan = _batch(stems, source) + target = plan.jobs(config)[0].output_path + target.parent.mkdir(parents=True) + target.touch() + + assert plan.existing_targets(config) == (target,) + + def test_a_batch_settles_a_gathered_recording_itself( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + """A folder is picked up where the last run stopped, so a written one puts nothing to the reader.""" + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path / "loops", ["a.wav"])[0] + plan = _batch(stems, source, base_directory=tmp_path / "loops") + target = plan.jobs(config)[0].output_path + target.parent.mkdir(parents=True) + target.touch() + + 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 TestOneFolderPerChannelSet: + """A batch writes each recording into the folder named after the channels that recording is handed.""" + + @pytest.fixture + def pulse(self) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])) + + @pytest.fixture + def pulse_and_triangle(self) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1, ChannelName.TRIANGLE])) + + def test_each_recording_lands_in_the_folder_of_its_own_channels( + self, + config: Config, + pulse: StemsConfig, + pulse_and_triangle: StemsConfig, + tmp_path: Path, + ) -> None: + lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=None), + BatchEntry(source=bass, stems=pulse, base_directory=None), + ) + ) + + assert [job.output_path.parent for job in plan.jobs(config)] == [ + config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})), + config_directory_path(config, frozenset({ChannelName.PULSE1})), + ] + + def test_a_folder_gathered_with_different_channels_mirrors_into_each_channels_folder( + self, + config: Config, + pulse: StemsConfig, + pulse_and_triangle: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + first, second = _write_audio_files(root, ["a.wav", "nested/b.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=first, stems=pulse_and_triangle, base_directory=root), + BatchEntry(source=second, stems=pulse, base_directory=root), + ) + ) + + both = config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})) / root.name + alone = config_directory_path(config, frozenset({ChannelName.PULSE1})) / root.name + assert [job.output_path for job in plan.jobs(config)] == [both / "a.stn", alone / "nested" / "b.stn"] + + def test_recordings_sharing_their_channels_name_their_one_folder( + self, + config: Config, + pulse: StemsConfig, + tmp_path: Path, + ) -> None: + lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse, base_directory=None), + BatchEntry(source=bass, stems=pulse, base_directory=None), + ) + ) + + assert plan.destination(config) == config_directory_path(config, frozenset({ChannelName.PULSE1})) + + def test_recordings_with_different_channels_name_the_folder_holding_theirs( + self, + pulse: StemsConfig, + pulse_and_triangle: StemsConfig, + tmp_path: Path, + ) -> None: + reconstructions = tmp_path / "out" + config = _config_writing_under(reconstructions) + lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=None), + BatchEntry(source=bass, stems=pulse, base_directory=None), + ) + ) + + assert plan.destination(config) == reconstructions + + def test_a_batch_of_one_names_the_file_it_writes( + self, + config: Config, + pulse: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["lead.wav"])[0] + plan = BatchConversion(entries=(BatchEntry(source=source, stems=pulse, base_directory=None),)) + + assert plan.destination(config) == plan.jobs(config)[0].output_path + assert plan.destination(config) == group_output_path(config, (source,), frozenset({ChannelName.PULSE1})) + + def test_a_rerun_writes_again_a_recording_whose_channels_changed( + self, + pulse: StemsConfig, + pulse_and_triangle: StemsConfig, + tmp_path: Path, + ) -> None: + """The first run wrote both with Pulse 1 and Triangle; the bass then lost Triangle.""" + config = _config_writing_under(tmp_path / "out") + root = tmp_path / "loops" + lead, bass = _write_audio_files(root, ["lead.wav", "bass.wav"]) + first_run = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=root), + BatchEntry(source=bass, stems=pulse_and_triangle, base_directory=root), + ) + ) + for job in first_run.jobs(config): + job.output_path.parent.mkdir(parents=True, exist_ok=True) + job.output_path.touch() + rerun = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=root), + BatchEntry(source=bass, stems=pulse, base_directory=root), + ) + ) + + jobs = rerun.jobs(config) + + assert [job.sources[0] for job in jobs] == [bass] + assert ( + jobs[0].output_path + == config_directory_path(config, frozenset({ChannelName.PULSE1})) / root.name / "bass.stn" + ) + + +class TestReadingABatchHashesItsSettingsOnce(BaseTestSuite): + """A batch names the folder of each channel set from one hash of its settings per reading, however + many recordings it holds.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + read: Callable[[BatchConversion, Config], object] + + test_cases = ( + TestCase(label="where it writes", read=BatchConversion.destination), + TestCase(label="its jobs", read=BatchConversion.jobs), + TestCase(label="the targets standing", read=BatchConversion.existing_targets), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_one_hash_names_every_folder( + self, + config: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + test_case: TestCase, + ) -> None: + hashed: List[Tuple[BaseModel, ...]] = [] + + def counted(*models: BaseModel) -> str: + hashed.append(models) + return hash_models(*models) + + monkeypatch.setattr(fields_module, "hash_models", counted) + sources = _write_audio_files(tmp_path, [f"{index}.wav" for index in range(BATCH_RECORDINGS)]) + channel_sets = ( + StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])), + StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1, ChannelName.TRIANGLE])), + ) + plan = BatchConversion( + entries=tuple( + BatchEntry(source=source, stems=channel_sets[index % len(channel_sets)], base_directory=None) + for index, source in enumerate(sources) + ) + ) + + test_case.read(plan, config) + + assert len(hashed) == 1 + + +class TestEveryJobWritesWhereItsPlanSays: + """A plan's destination is the file its one job writes, or the folder every job writes inside.""" + + def test_a_group_names_the_file_its_job_writes( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + sources = tuple(_write_audio_files(tmp_path, ["a.wav", "b.wav"])) + plan = GroupConversion(sources=sources, stems=stems) + + assert [job.output_path for job in plan.jobs(config)] == [plan.destination(config)] + + def test_a_directory_names_the_folder_mirroring_it( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + _write_audio_files(root, ["a.wav", "nested/b.wav"]) + plan = DirectoryConversion(directory=root, stems=stems) + + destination = plan.destination(config) + + assert destination == config_directory_path(config, CHANNELS) / root.name + assert all(destination in job.output_path.parents for job in plan.jobs(config)) + + def test_a_directory_that_is_gone_names_no_folder( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + with pytest.raises(FileNotFoundError): + DirectoryConversion(directory=tmp_path / "gone", stems=stems).destination(config) + + def test_a_batch_names_the_folder_every_job_writes_inside( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + named = _write_audio_files(tmp_path, ["song.wav"])[0] + gathered = _write_audio_files(root, ["a.wav"])[0] + plan = BatchConversion( + entries=( + BatchEntry(source=named, stems=stems, base_directory=None), + BatchEntry(source=gathered, stems=stems, base_directory=root), + ) + ) + + destination = plan.destination(config) + + assert all(destination in job.output_path.parents for job in plan.jobs(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,), CHANNELS) == get_output_path( + config, + source, + CHANNELS, + ) + + 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, CHANNELS).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..a2246b2f2 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py @@ -1,11 +1,23 @@ from pathlib import Path +from typing import Final, List from unittest.mock import MagicMock import pytest -from sampletones_core.reconstructions.converter.conversion import reconstruct_file +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import UnsupportedAudioFormatError +from sampletones_shared.utils.progress import silent_reporter + +CHANNELS: Final[List[ChannelName]] = list(DEFAULT_CHANNELS) @pytest.fixture @@ -13,36 +25,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(StemSettings.covering(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 +83,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..2932bf149 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -4,10 +4,17 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, +) 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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import NoFilesToProcessError _RECONSTRUCTOR_PATCH = "sampletones_core.reconstructions.converter.converter.Reconstructor" @@ -18,155 +25,203 @@ def config() -> Config: return Config() +@pytest.fixture(scope="module") +def stems(config: Config) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_CHANNELS))) + + +def _group(path: Path, stems: StemsConfig) -> GroupConversion: + return GroupConversion(sources=(path,), stems=stems) + + 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, - ) + 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_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_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 - 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 test_tracking_variables_initialized_to_zero( +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 TestReconstructionConverterStart: - def test_already_running_does_not_spawn_thread( +class TestTheJobUnderWay: + """A run names itself by the earliest job it has yet to count, and by the last once every job is + counted.""" + + def test_the_count_walks_the_jobs_and_rests_on_the_last( self, config: Config, + stems: StemsConfig, tmp_path: Path, ) -> None: - converter = ReconstructionConverter( - config, - tmp_path / "song.wav", - is_file=True, - ) - converter.running = True - converter.start() - assert converter.monitor_thread is None + for name in ("a.wav", "b.wav"): + (tmp_path / name).touch() + converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems)) + with patch(_RECONSTRUCTOR_PATCH): + converter._create_tasks() + first, second = converter.jobs + + assert [converter.job_under_way(completed) for completed in range(3)] == [first, second, second] + + def test_a_run_yet_to_read_its_plan_names_no_job( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) + + assert converter.job_under_way(0) 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/criterion/test_criterion.py b/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py index 5cf9168e1..b36c37bab 100644 --- a/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py +++ b/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Final +from dataclasses import dataclass +from typing import Final, Tuple from unittest.mock import MagicMock import numpy as np @@ -8,11 +9,28 @@ from sampletones_core.configs import Config, MetricConfig, WeightsConfig from sampletones_core.constants.enums import SpectralDistance, SpectrumMethod -from sampletones_core.fft import Window +from sampletones_core.fft import CyclicArray, Window from sampletones_core.reconstructions.criterion import Criterion +from sampletones_core.reconstructions.criterion.metric import SpectralMetric +from sampletones_core.reconstructions.criterion.spectral import spectral_floor from sampletones_shared.array import to_numpy +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase LONG_SIGNAL_LENGTH: Final[int] = 1 << 20 +SHIPPED_METRIC: Final[SpectralMetric] = SpectralMetric.from_config(Config().generation.metric) +SILENT_FRAME_FLOOR: Final[float] = SHIPPED_METRIC.silence_floor * 10.0 ** ( + -SHIPPED_METRIC.dynamic_range_decibels / 10.0 +) +HISS_LEVEL: Final[float] = SILENT_FRAME_FLOOR / 1e5 +QUIET_NOISE_LEVEL: Final[float] = 10.0 * HISS_LEVEL +TONE_POWER: Final[float] = 0.03 +ADDED_NOISE_POWER: Final[float] = 1e-5 +AUDIBLE_ADDITION_COST: Final[float] = 1e-3 +SECOND_ORDER_TOLERANCE: Final[float] = 1e-3 +CYCLE_SAMPLES: Final[int] = 97 +CANDIDATE_SEED: Final[int] = 7 +CONSTANT_LEVEL: Final[float] = 0.05 def _criterion_with_distance( @@ -31,6 +49,10 @@ def _criterion_with_distance( return Criterion(updated_config, window, LONG_SIGNAL_LENGTH) +def _hiss(bins: int) -> np.ndarray: + return np.linspace(0.5, 1.5, bins) * HISS_LEVEL + + @pytest.fixture(scope="module") def config() -> Config: return Config() @@ -46,6 +68,11 @@ def criterion(config: Config, window: Window) -> Criterion: return Criterion(config, window, LONG_SIGNAL_LENGTH) +@pytest.fixture(scope="module") +def bins(criterion: Criterion) -> int: + return int(criterion.weights.shape[-1]) + + class TestCriterionTemporalLoss: def test_2d_reference_raises_value_error( self, @@ -110,6 +137,50 @@ def test_near_silent_target_normalizes_at_the_level_floor( assert loss == pytest.approx(0.1 / criterion.temporal_level_floor, rel=1e-5) +class TestCriterionExpectedTemporalLoss: + """ + A candidate standing at every phase alike is measured by the loss its phases average to, which + its mean and variance state. + """ + + def test_the_expected_loss_is_the_mean_square_over_every_shift( + self, + criterion: Criterion, + config: Config, + ) -> None: + generator = np.random.default_rng(CANDIDATE_SEED) + sample = CyclicArray( + array=(0.3 * generator.standard_normal(CYCLE_SAMPLES) + 0.1).astype(np.float32), + sample_rate=config.library.sample_rate, + ) + target = generator.standard_normal(config.frame_length) + mean_squares = [ + np.mean((target - sample.get_fragment(shift, config.frame_length)) ** 2) for shift in range(sample.length) + ] + level = max(float(np.sqrt(np.mean(target**2))), criterion.temporal_level_floor) + + loss = float(to_numpy(criterion.expected_temporal_loss(target, sample.mean, sample.variance))[0]) + + assert loss == pytest.approx(float(np.sqrt(np.mean(mean_squares))) / level, rel=1e-5) + + def test_a_candidate_holding_one_level_expects_the_loss_it_scores( + self, + criterion: Criterion, + config: Config, + ) -> None: + target = np.random.default_rng(CANDIDATE_SEED).standard_normal(config.frame_length) + candidate = np.full((1, config.frame_length), CONSTANT_LEVEL) + + scored = float(to_numpy(criterion.temporal_loss(target, candidate))[0]) + expected = float(to_numpy(criterion.expected_temporal_loss(target, CONSTANT_LEVEL, 0.0))[0]) + + assert expected == pytest.approx(scored, rel=1e-6) + + def test_2d_reference_raises_value_error(self, criterion: Criterion, config: Config) -> None: + with pytest.raises(ValueError): + criterion.expected_temporal_loss(np.zeros((2, config.frame_length)), 0.0, 0.0) + + class TestCriterionGetLossWeights: def test_negative_weight_raises_value_error(self, window: Window) -> None: mock_config = MagicMock() @@ -140,10 +211,6 @@ def test_zero_total_weight_raises_value_error( class TestCriterionSpectralLoss: - @pytest.fixture - def bins(self, criterion: Criterion) -> int: - return int(criterion.weights.shape[-1]) - def test_beta_divergence_is_zero_for_identical_spectrum( self, config: Config, @@ -199,6 +266,103 @@ def test_closer_spectrum_scores_lower_than_distant_one( loss = criterion.spectral_loss(reference, np.stack([close, distant])) assert float(loss[0]) < float(loss[1]) + def test_a_hiss_below_the_floor_scores_silence_closer_than_quiet_noise( + self, + config: Config, + window: Window, + bins: int, + ) -> None: + """ + A hiss lying far below a silent frame's floor sits nearer silence than a louder noise, so + silence stays among the candidates a quiet frame keeps. + """ + criterion = _criterion_with_distance(config, window, SpectralDistance.BETA_DIVERGENCE, beta=1.0) + reference = _hiss(bins).astype(np.float32) + candidates = np.stack( + [ + np.zeros(bins, dtype=np.float32), + np.full(bins, QUIET_NOISE_LEVEL, dtype=np.float32), + ] + ) + silence, quiet_noise = to_numpy(criterion.spectral_loss(reference, candidates)) + assert silence < quiet_noise + + +class TestCriterionBetaDivergenceAtTheFloor(BaseTestSuite): + """ + Spectra lying far below a silent frame's floor score by the divergence their small difference + carries, which keeps the losses of near-silent frames apart in single-precision features. + + A relative difference `u` diverges by about `u² / 2`, so a candidate one hiss level away from a + hiss reference costs `(c - r)² / 2` over the floored spectrum raised to `β - 2`. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + beta: float + + test_cases = ( + TestCase(label="itakura_saito", beta=0.0), + TestCase(label="general", beta=0.5), + TestCase(label="kullback_leibler", beta=1.0), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_hiss_scores_its_second_order_divergence( + self, + config: Config, + window: Window, + bins: int, + test_case: TestCase, + ) -> None: + criterion = _criterion_with_distance(config, window, SpectralDistance.BETA_DIVERGENCE, beta=test_case.beta) + reference = np.full(bins, HISS_LEVEL, dtype=np.float32) + candidate = np.full((1, bins), 2 * HISS_LEVEL, dtype=np.float32) + weights = to_numpy(criterion.weights).reshape(-1).astype(np.float64) + floored = HISS_LEVEL + SILENT_FRAME_FLOOR + per_bin = HISS_LEVEL**2 / 2 * floored ** (test_case.beta - 2) + expected = np.sum(weights * per_bin) / (np.sum(weights * HISS_LEVEL) + SILENT_FRAME_FLOOR) + + loss = to_numpy(criterion.spectral_loss(reference, candidate)) + + np.testing.assert_allclose(loss, [expected], rtol=SECOND_ORDER_TOLERANCE) + + +class TestSpectralFloor: + def test_the_floor_follows_the_frame_s_loudest_bin(self, bins: int) -> None: + quiet = np.linspace(0.0, 1e-2, bins, dtype=np.float32) + loud = 100.0 * quiet + + assert float(to_numpy(spectral_floor(loud, metric=SHIPPED_METRIC))) == pytest.approx( + 100.0 * float(to_numpy(spectral_floor(quiet, metric=SHIPPED_METRIC))) + ) + + def test_a_silent_frame_keeps_a_positive_floor(self, bins: int) -> None: + assert float( + to_numpy(spectral_floor(np.zeros(bins, dtype=np.float32), metric=SHIPPED_METRIC)) + ) == pytest.approx(SILENT_FRAME_FLOOR) + + def test_quiet_noise_under_a_loud_tone_costs_what_it_adds( + self, + config: Config, + window: Window, + bins: int, + ) -> None: + """Noise far quieter than a tone but audible beside it makes a covering of the tone cost more.""" + criterion = _criterion_with_distance(config, window, SpectralDistance.BETA_DIVERGENCE, beta=1.0) + tone = np.zeros(bins, dtype=np.float32) + tone[bins // 4] = TONE_POWER + with_noise = tone + np.float32(ADDED_NOISE_POWER) + + alone, noisy = to_numpy(criterion.spectral_loss(tone, np.stack([tone, with_noise]))) + + assert alone == pytest.approx(0.0, abs=1e-9) + assert noisy > AUDIBLE_ADDITION_COST + class TestCriterionCqtAxis: def test_spectral_loss_runs_on_cqt_bins(self, config: Config) -> None: 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_approximations.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_approximations.py new file mode 100644 index 000000000..3059916ea --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_approximations.py @@ -0,0 +1,237 @@ +from typing import Dict, Final, FrozenSet, Mapping, Sequence + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import RESTING_STEM_ID, UNIT_DRIVE +from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.generators.render import render_channels, render_instructions +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_length, rendered_mix +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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.sequencer import sample_reconstruction +from tests.suite.stems import RECORDED_SCALE + +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +LOUD_DRIVE: Final[float] = 2.0 +STORED_AUDIO_KEYS: Final[FrozenSet[str]] = frozenset({"approximation", "approximations_data"}) + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _silence() -> PulseInstruction: + return PulseInstruction.null_instruction() + + +def _config() -> Config: + return Config() + + +def _stems_config(*, loud_channel_drive: float) -> StemsConfig: + """Two recordings over the first pulse, the first pushing it harder than the second.""" + channels = [ChannelName.PULSE1] + return StemsConfig( + entries=[ + StemEntry( + id=STEM_A, + settings=StemSettings( + channels=channels, + bends=bending_channels(channels), + drives={ChannelName.PULSE1: loud_channel_drive}, + channel_cap=1, + ), + ), + StemEntry( + id=STEM_B, + settings=StemSettings( + channels=channels, + bends=bending_channels(channels), + drives={ChannelName.PULSE1: UNIT_DRIVE}, + channel_cap=1, + ), + ), + ], + hierarchy=StemsHierarchy(levels=[[STEM_A], [STEM_B]]), + ) + + +def _reconstruction( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + owners: Mapping[ChannelName, Sequence[int]], + *, + loud_channel_drive: float = LOUD_DRIVE, +) -> Reconstruction: + return Reconstruction.create( + instructions=instructions, + config=_config(), + coefficient=1.0, + audio_filepath=(), + stems_data=StemsData( + config=_stems_config(loud_channel_drive=loud_channel_drive), + assignments=[ + ChannelAssignment(channel_name=channel_name, stem_ids=list(stem_ids)) + for channel_name, stem_ids in owners.items() + ], + scale=RECORDED_SCALE, + ), + ) + + +def _frame(audio: np.ndarray, index: int) -> np.ndarray: + frame_length = _config().library.frame_length + return audio[index * frame_length : (index + 1) * frame_length] + + +@pytest.fixture +def reconstruction() -> Reconstruction: + """Three pulse frames: the first recording holds the first, the second the next, and the last rests.""" + return _reconstruction( + {ChannelName.PULSE1: [_pulse(60), _pulse(62), _silence()]}, + {ChannelName.PULSE1: [STEM_A, STEM_B, RESTING_STEM_ID]}, + ) + + +class TestTheAudioAReconstructionAnswersWith: + """A channel sounds what its generator renders from the stream it carries, drive or none.""" + + def test_a_channel_sounds_exactly_what_its_instructions_render( + self, + reconstruction: Reconstruction, + ) -> None: + """The document behind this stands at a drive off unit, and sounds its instructions all the same.""" + rendered = render_channels(reconstruction.instructions, reconstruction.config) + + np.testing.assert_array_equal( + rendered_channels(reconstruction)[ChannelName.PULSE1], rendered[ChannelName.PULSE1] + ) + + def test_a_resting_frame_sounds_nothing(self, reconstruction: Reconstruction) -> None: + np.testing.assert_array_equal( + _frame(rendered_channels(reconstruction)[ChannelName.PULSE1], 2), + np.zeros(reconstruction.config.library.frame_length, dtype=np.float32), + ) + + def test_a_channel_standing_by_sounds_nothing_at_all(self) -> None: + reconstruction = _reconstruction( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [STEM_A]}, + ) + + assert ChannelName.TRIANGLE not in rendered_channels(reconstruction) + + +class TestWhatTheFileCarries: + def test_the_written_form_names_the_instructions_and_leaves_the_audio_out( + self, + reconstruction: Reconstruction, + ) -> None: + written = reconstruction.serialize_inner() + + assert "instructions_data" in written + assert STORED_AUDIO_KEYS.isdisjoint(written) + + def test_a_reconstruction_sounds_the_same_after_a_round_trip(self, reconstruction: Reconstruction) -> None: + restored = Reconstruction.deserialize(reconstruction.serialize()) + + np.testing.assert_array_equal( + rendered_channels(restored)[ChannelName.PULSE1], + rendered_channels(reconstruction)[ChannelName.PULSE1], + ) + + +class TestAFilteredReading: + """Filtering masks a whole channel's render, which is what keeps the oscillator where it stood.""" + + @staticmethod + def _kept(reconstruction: Reconstruction, stem_ids: Sequence[int]) -> Dict[ChannelName, np.ndarray]: + selection = StemSelection(channels={ChannelName.PULSE1: frozenset(stem_ids)}) + return filter_approximations( + reconstruction.stems_data, + rendered_channels(reconstruction), + selection, + reconstruction.config.library.frame_length, + ) + + def test_a_kept_frame_carries_the_samples_the_whole_render_gave_it( + self, + reconstruction: Reconstruction, + ) -> None: + filtered = self._kept(reconstruction, [STEM_B]) + + np.testing.assert_array_equal( + _frame(filtered[ChannelName.PULSE1], 1), + _frame(rendered_channels(reconstruction)[ChannelName.PULSE1], 1), + ) + + def test_a_frame_left_out_falls_silent(self, reconstruction: Reconstruction) -> None: + filtered = self._kept(reconstruction, [STEM_B]) + + np.testing.assert_array_equal( + _frame(filtered[ChannelName.PULSE1], 0), + np.zeros(reconstruction.config.library.frame_length, dtype=np.float32), + ) + + def test_a_held_note_kept_from_its_second_frame_stands_where_the_whole_render_left_it(self) -> None: + """Rendering the kept frames alone would restart the oscillator, so filtering renders the whole stream.""" + held = _reconstruction( + {ChannelName.PULSE1: [_pulse(60), _pulse(60)]}, + {ChannelName.PULSE1: [STEM_A, STEM_B]}, + loud_channel_drive=UNIT_DRIVE, + ) + alone = render_instructions([_pulse(60)], ChannelName.PULSE1, held.config) + + filtered = self._kept(held, [STEM_B]) + + np.testing.assert_array_equal( + _frame(filtered[ChannelName.PULSE1], 1), + _frame(rendered_channels(held)[ChannelName.PULSE1], 1), + ) + assert not np.array_equal(_frame(filtered[ChannelName.PULSE1], 1), alone) + + def test_every_channel_the_record_names_comes_back(self, reconstruction: Reconstruction) -> None: + filtered = self._kept(reconstruction, [STEM_A, STEM_B]) + + assert list(filtered) == [ChannelName.PULSE1] + + +class TestTheChannelsAReconstructionPlays: + def test_a_channel_whose_stream_describes_a_frame_plays(self) -> None: + reconstruction = _reconstruction( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [STEM_A]}, + ) + + assert reconstruction.playing_channels == (ChannelName.PULSE1,) + + def test_the_mixed_audio_sums_the_channels_that_play(self) -> None: + reconstruction = _reconstruction( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [STEM_A]}, + ) + + np.testing.assert_array_equal( + rendered_mix(reconstruction), + rendered_channels(reconstruction)[ChannelName.PULSE1], + ) + + +class TestTheRenderedLength: + """The length a render spans is read from the frames, so a reader learns it with no render made.""" + + @pytest.mark.parametrize("channels", [(ChannelName.PULSE1,), (ChannelName.PULSE1, ChannelName.NOISE)], ids=str) + def test_the_length_is_the_mix_length(self, channels: Sequence[ChannelName]) -> None: + reconstruction = sample_reconstruction(list(channels)) + + assert rendered_length(reconstruction) == len(rendered_mix(reconstruction)) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_heard_instructions.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_heard_instructions.py new file mode 100644 index 000000000..318eaf45f --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_heard_instructions.py @@ -0,0 +1,187 @@ +from typing import Dict, Final, List, Sequence, Tuple + +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.instructions import InstructionUnion, PulseInstruction +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 heard_instructions +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 sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.stems import RECORDED_SCALE + +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _silence() -> PulseInstruction: + return PulseInstruction.null_instruction() + + +def _heard(*stem_ids: int) -> StemSelection: + return StemSelection.everywhere(frozenset(stem_ids), EVERY_CHANNEL) + + +def _stems_data(stem_ids: List[int]) -> StemsData: + entries = [ + StemEntry( + id=stem_id, + settings=StemSettings(channels=[CHANNEL], bends=bending_channels([CHANNEL])), + ) + for stem_id in (STEM_A, STEM_B) + ] + return StemsData( + config=StemsConfig( + entries=entries, + hierarchy=StemsHierarchy(levels=[[entry.id] for entry in entries]), + ), + assignments=[ChannelAssignment(channel_name=CHANNEL, stem_ids=stem_ids)], + scale=RECORDED_SCALE, + ) + + +def _reading( + stream: Sequence[InstructionUnion], + stem_ids: List[int], + selection: StemSelection, +) -> List[InstructionUnion]: + instructions: Dict[ChannelName, Sequence[InstructionUnion]] = {CHANNEL: stream} + return heard_instructions(_stems_data(stem_ids), instructions, selection)[CHANNEL] + + +class TestWhatTheReadingLeavesOut: + """A frame of a recording the reader left out states silence where it stands.""" + + def test_a_frame_of_an_unheard_recording_reads_silent(self) -> None: + reading = _reading( + [_pulse(60), _pulse(62), _pulse(64)], + [STEM_A, STEM_B, STEM_A], + _heard(STEM_A), + ) + + assert reading == [_pulse(60), _silence(), _pulse(64)] + + def test_a_frame_of_a_heard_recording_reads_as_it_plays(self) -> None: + reading = _reading([_pulse(60), _pulse(62)], [STEM_A, STEM_B], _heard(STEM_A, STEM_B)) + + assert reading == [_pulse(60), _pulse(62)] + + def test_a_resting_frame_reads_through_whatever_is_heard(self) -> None: + reading = _reading([_silence(), _pulse(62)], [RESTING_STEM_ID, STEM_B], _heard(STEM_B)) + + assert reading == [_silence(), _pulse(62)] + + def test_a_frame_the_reader_wrote_reads_through_while_its_row_is_heard(self) -> None: + reading = _reading([_pulse(60), _pulse(62)], [AUTHORED_STEM_ID, STEM_B], _heard(AUTHORED_STEM_ID)) + + assert reading == [_pulse(60)] + + def test_a_frame_the_reader_wrote_states_silence_once_its_row_is_quiet(self) -> None: + """The frames a reader wrote answer to a row of their own, so that row's box reaches them.""" + reading = _reading([_pulse(60), _pulse(62)], [AUTHORED_STEM_ID, STEM_B], _heard(STEM_B)) + + assert reading == [_silence(), _pulse(62)] + + +class TestWhereTheReadingKeepsItsFrames: + """The reading states a frame at the index it stands on, so it lines up with the record.""" + + def test_a_run_of_frames_left_out_keeps_every_place(self) -> None: + reading = _reading( + [_pulse(60), _pulse(62), _pulse(64), _pulse(66)], + [STEM_A, STEM_B, STEM_B, STEM_A], + _heard(STEM_A), + ) + + assert reading == [_pulse(60), _silence(), _silence(), _pulse(66)] + + def test_a_leading_rest_keeps_its_place(self) -> None: + reading = _reading( + [_silence(), _pulse(62)], + [RESTING_STEM_ID, STEM_A], + _heard(STEM_A), + ) + + assert reading == [_silence(), _pulse(62)] + + def test_the_whole_selection_reads_the_stream_itself(self) -> None: + stream = [_pulse(60), _silence(), _pulse(64)] + + reading = _reading(stream, [STEM_A, RESTING_STEM_ID, STEM_B], _heard(STEM_A, STEM_B)) + + assert reading == stream + + +class TestWhereTheReadingEnds: + """The reading runs to the last frame the reader hears, so what is drawn is what is written. + + A rest belongs to no recording and is always read, so the reading lets go of the stretch a + left-out recording held and keeps every frame before it. A reading sounding in no frame + stands by, as the document stands by a channel resting throughout. + """ + + def test_a_trailing_frame_left_out_leaves_the_reading(self) -> None: + reading = _reading( + [_pulse(60), _pulse(62), _pulse(64)], + [STEM_A, STEM_B, STEM_B], + _heard(STEM_A), + ) + + assert reading == [_pulse(60)] + + def test_a_trailing_rest_stands_in_the_reading(self) -> None: + """A rest is read whoever is listening, so the frames drawn are the frames written.""" + reading = _reading( + [_pulse(60), _silence()], + [STEM_A, RESTING_STEM_ID], + _heard(STEM_A), + ) + + assert reading == [_pulse(60), _silence()] + + def test_a_channel_with_nothing_heard_reads_as_standing_by(self) -> None: + reading = _reading([_pulse(60), _pulse(62)], [STEM_A, STEM_B], _heard()) + + assert reading == [] + + def test_a_channel_whose_sound_is_left_out_lets_go_of_its_rests_too(self) -> None: + """A rest is no evidence the reader hears the channel, so the channel reads as standing by.""" + reading = _reading([_pulse(60), _silence()], [STEM_A, RESTING_STEM_ID], _heard()) + + assert reading == [] + + def test_a_channel_that_only_rests_reads_as_standing_by(self) -> None: + """A channel resting through every frame plays nothing, whoever the reader hears.""" + reading = _reading([_silence(), _silence()], [RESTING_STEM_ID, RESTING_STEM_ID], _heard(STEM_A, STEM_B)) + + assert reading == [] + + def test_a_channel_standing_by_reads_as_it_stands(self) -> None: + instructions: Dict[ChannelName, Sequence[InstructionUnion]] = {CHANNEL: []} + + reading = heard_instructions(_stems_data([]), instructions, _heard(STEM_A)) + + assert reading[CHANNEL] == [] + + +class TestAStreamTheRecordDoesNotReachTheEndOf: + """A frame standing past the record answers to no recording, so the reader hears it.""" + + def test_a_frame_past_the_record_reads_through(self) -> None: + reading = _reading([_pulse(60), _pulse(62)], [STEM_B], _heard(STEM_A)) + + assert reading == [_silence(), _pulse(62)] + + def test_a_record_running_past_the_stream_reads_the_stream(self) -> None: + reading = _reading([_pulse(60)], [STEM_A, STEM_B, STEM_B], _heard(STEM_A)) + + assert reading == [_pulse(60)] diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_instructions.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_instructions.py new file mode 100644 index 000000000..45fbd8f82 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_instructions.py @@ -0,0 +1,64 @@ +from typing import Final, List, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.instructions import InstructionData, InstructionUnion, PulseInstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem + +CHANNEL: Final[ChannelName] = ChannelName.PULSE1 +REFERENCE: Final[int] = 52 +HELD: Final[Tuple[FeatureKey, ...]] = (FeatureKey.ARPEGGIO,) + + +def _pulse() -> PulseInstruction: + return PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0) + + +def _rest() -> PulseInstruction: + return PulseInstruction.null_instruction() + + +def _created(stream: List[InstructionUnion]) -> InstructionsItem: + return InstructionsItem.create( + channel_name=CHANNEL, + instructions=stream, + initial_pitch=REFERENCE, + held_features=HELD, + ) + + +class TestAStreamRestingThroughEveryFrame: + """A channel resting throughout stands by, so its stream states no frame.""" + + def test_it_stores_no_frame(self) -> None: + item = _created([_rest(), _rest()]) + + assert item.instructions == () + assert not item.sounds + + def test_it_keeps_the_reference_it_was_given(self) -> None: + item = _created([_rest(), _rest()]) + + assert item.initial_pitch == REFERENCE + assert tuple(item.held_features) == HELD + + def test_a_stored_one_is_refused(self) -> None: + with pytest.raises(ValidationError): + InstructionsItem( + channel_name=CHANNEL, + instructions=[InstructionData.create(_rest())], + initial_pitch=REFERENCE, + held_features=list(HELD), + ) + + +class TestAStreamSoundingSomewhere: + def test_it_keeps_every_frame_it_describes(self) -> None: + stream = [_rest(), _pulse(), _rest()] + + item = _created(stream) + + assert [data.instruction for data in item.instructions] == stream + assert item.sounds diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_ownership.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_ownership.py new file mode 100644 index 000000000..47225d820 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_ownership.py @@ -0,0 +1,312 @@ +from dataclasses import dataclass +from typing import AbstractSet, Final, FrozenSet, List, Sequence + +import pytest + +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.reconstructions.reconstruction.stems.ownership import ( + OwnerRun, + carried_edit, + owner_runs, + writes_reach, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +EVERY_STEM: Final[FrozenSet[int]] = frozenset({STEM_A, STEM_B}) +EVERYTHING_HEARD: Final[FrozenSet[int]] = EVERY_STEM | {AUTHORED_STEM_ID} +NOTHING_HEARD: Final[FrozenSet[int]] = frozenset() + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _silence() -> PulseInstruction: + return PulseInstruction.null_instruction() + + +class TestTheOwnerAFrameLeavesAnEditWith(BaseTestSuite): + """A frame's owner follows from the frame it was and the frame it becomes. + + Rest and silence name the same frames, so the three outcomes below cover every frame: + one that sounds through the edit keeps its owner, one the edit quiets rests, and one the + edit brings into play is the reader's own. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + owner: int + previous: InstructionUnion + proposed: InstructionUnion + expected: int + + test_cases = ( + TestCase( + label="a recording's frame edited to another note keeps its recording", + owner=STEM_A, + previous=_pulse(60), + proposed=_pulse(64), + expected=STEM_A, + ), + TestCase( + label="a recording's frame edited silent rests", + owner=STEM_A, + previous=_pulse(60), + proposed=_silence(), + expected=RESTING_STEM_ID, + ), + TestCase( + label="a resting frame edited into play is the reader's own", + owner=RESTING_STEM_ID, + previous=_silence(), + proposed=_pulse(60), + expected=AUTHORED_STEM_ID, + ), + TestCase( + label="a resting frame edited silent rests on", + owner=RESTING_STEM_ID, + previous=_silence(), + proposed=_silence(), + expected=RESTING_STEM_ID, + ), + TestCase( + label="a frame the reader wrote keeps the reader through another edit", + owner=AUTHORED_STEM_ID, + previous=_pulse(60), + proposed=_pulse(64), + expected=AUTHORED_STEM_ID, + ), + TestCase( + label="a frame the reader wrote rests once the reader quiets it", + owner=AUTHORED_STEM_ID, + previous=_pulse(60), + proposed=_silence(), + expected=RESTING_STEM_ID, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_owner_the_frame_carries(self, test_case: TestCase) -> None: + carried = carried_edit( + [test_case.previous], + [test_case.owner], + [test_case.proposed], + heard=EVERYTHING_HEARD, + ) + + assert carried.stem_ids == [test_case.expected] + + +class TestAnEditThatChangesTheFrameCount: + def test_frames_written_past_the_end_are_the_readers_own(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [_pulse(60), _pulse(62)], heard=EVERY_STEM) + + assert carried.stem_ids == [STEM_A, AUTHORED_STEM_ID] + + def test_a_silent_frame_written_past_the_end_rests(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [_pulse(60), _silence()], heard=EVERY_STEM) + + assert carried.stem_ids == [STEM_A, RESTING_STEM_ID] + + def test_frames_the_edit_drops_take_their_ownership_along(self) -> None: + carried = carried_edit( + [_pulse(60), _pulse(62)], + [STEM_A, STEM_B], + [_pulse(60)], + heard=EVERY_STEM, + ) + + assert carried.stem_ids == [STEM_A] + assert carried.instructions == [_pulse(60)] + + def test_a_frame_dropped_outside_the_scope_keeps_playing(self) -> None: + carried = carried_edit( + [_pulse(60), _pulse(62), _pulse(64)], + [STEM_A, STEM_B, STEM_B], + [_pulse(70)], + heard=frozenset({STEM_A}), + ) + + assert carried.instructions == [_pulse(70), _pulse(62), _pulse(64)] + assert carried.stem_ids == [STEM_A, STEM_B, STEM_B] + + def test_a_frame_the_scope_reached_rests_where_the_stream_runs_on_without_it(self) -> None: + carried = carried_edit( + [_pulse(60), _pulse(62), _pulse(64)], + [STEM_A, STEM_A, STEM_B], + [], + heard=frozenset({STEM_A}), + ) + + assert carried.instructions == [_silence(), _silence(), _pulse(64)] + assert carried.stem_ids == [RESTING_STEM_ID, RESTING_STEM_ID, STEM_B] + + def test_a_channel_shortened_within_its_scope_runs_exactly_as_far_as_the_edit_states(self) -> None: + carried = carried_edit( + [_pulse(60), _pulse(62), _pulse(64)], + [STEM_A, STEM_B, STEM_A], + [_pulse(70)], + heard=EVERY_STEM, + ) + + assert carried.instructions == [_pulse(70)] + assert carried.stem_ids == [STEM_A] + + def test_a_resting_frame_past_the_edit_goes_whatever_is_heard(self) -> None: + carried = carried_edit( + [_pulse(60), _silence()], + [STEM_A, RESTING_STEM_ID], + [_pulse(70)], + heard=NOTHING_HEARD, + ) + + assert carried.instructions == [_pulse(60)] + assert carried.stem_ids == [STEM_A] + + def test_a_channel_edited_down_to_no_frame_carries_no_ownership(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [], heard=EVERY_STEM) + + assert carried.instructions == [] + assert carried.stem_ids == [] + + def test_a_channel_edited_down_to_no_frame_outside_the_scope_stands_whole(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [], heard=NOTHING_HEARD) + + assert carried.instructions == [_pulse(60)] + assert carried.stem_ids == [STEM_A] + + def test_a_channel_written_back_into_play_comes_back_the_readers_own(self) -> None: + carried = carried_edit([], [], [_pulse(60), _silence()], heard=EVERY_STEM) + + assert carried.stem_ids == [AUTHORED_STEM_ID, RESTING_STEM_ID] + + +class TestTheScopeAnEditWritesIn: + """A frame accepts a gesture where its owner is heard, where it rests, and where it is the reader's. + + Every other frame reads as it stands, which is what lets one recording's part be shaped + while the recordings beside it carry on. + """ + + def test_a_frame_of_a_recording_the_reader_hears_takes_the_edit(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [_pulse(64)], heard=frozenset({STEM_A})) + + assert carried.instructions == [_pulse(64)] + assert carried.stem_ids == [STEM_A] + + def test_a_frame_of_a_recording_the_reader_left_out_stands_as_it_is(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [_pulse(64)], heard=frozenset({STEM_B})) + + assert carried.instructions == [_pulse(60)] + assert carried.stem_ids == [STEM_A] + + def test_a_resting_frame_takes_the_edit_whatever_is_heard(self) -> None: + carried = carried_edit([_silence()], [RESTING_STEM_ID], [_pulse(60)], heard=NOTHING_HEARD) + + assert carried.instructions == [_pulse(60)] + assert carried.stem_ids == [AUTHORED_STEM_ID] + + def test_a_frame_the_reader_wrote_takes_the_edit_while_its_row_is_heard(self) -> None: + carried = carried_edit( + [_pulse(60)], + [AUTHORED_STEM_ID], + [_pulse(64)], + heard=frozenset({AUTHORED_STEM_ID}), + ) + + assert carried.instructions == [_pulse(64)] + assert carried.stem_ids == [AUTHORED_STEM_ID] + + def test_a_frame_the_reader_wrote_stands_once_its_row_is_quiet(self) -> None: + """A row the reader quiets is out of an edit's reach, so the frames it holds stand.""" + carried = carried_edit([_pulse(60)], [AUTHORED_STEM_ID], [_pulse(64)], heard=NOTHING_HEARD) + + assert carried.instructions == [_pulse(60)] + assert carried.stem_ids == [AUTHORED_STEM_ID] + + def test_a_frame_written_past_the_end_takes_the_edit_whatever_is_heard(self) -> None: + carried = carried_edit([_pulse(60)], [STEM_A], [_pulse(60), _pulse(62)], heard=NOTHING_HEARD) + + assert carried.instructions == [_pulse(60), _pulse(62)] + assert carried.stem_ids == [STEM_A, AUTHORED_STEM_ID] + + def test_an_edit_the_scope_refuses_throughout_leaves_the_channel_as_it_stood(self) -> None: + previous = [_pulse(60), _pulse(62)] + stem_ids = [STEM_A, STEM_B] + + carried = carried_edit(previous, stem_ids, [_silence(), _silence()], heard=NOTHING_HEARD) + + assert carried.instructions == previous + assert carried.stem_ids == stem_ids + + def test_an_edit_reaches_one_recordings_frames_and_leaves_the_others(self) -> None: + previous = [_pulse(60), _pulse(62), _silence()] + stem_ids = [STEM_A, STEM_B, RESTING_STEM_ID] + + carried = carried_edit( + previous, + stem_ids, + [_pulse(70), _pulse(70), _pulse(70)], + heard=frozenset({STEM_A}), + ) + + assert carried.instructions == [_pulse(70), _pulse(62), _pulse(70)] + assert carried.stem_ids == [STEM_A, STEM_B, AUTHORED_STEM_ID] + + +class TestTheFramesAnEditMayWrite: + @staticmethod + def _reach(stem_ids: Sequence[int], heard: AbstractSet[int]) -> List[bool]: + return writes_reach(stem_ids, heard) + + def test_every_frame_is_reachable_while_every_recording_is_heard(self) -> None: + assert self._reach([STEM_A, STEM_B, RESTING_STEM_ID], EVERY_STEM) == [True, True, True] + + def test_a_recording_left_out_holds_its_frames_back(self) -> None: + assert self._reach([STEM_A, STEM_B], frozenset({STEM_B})) == [False, True] + + def test_a_resting_frame_stays_reachable(self) -> None: + """A gesture writes a note into silence whatever the reader is listening to.""" + assert self._reach([RESTING_STEM_ID], NOTHING_HEARD) == [True] + + def test_the_frames_the_reader_wrote_follow_their_own_row(self) -> None: + assert self._reach([AUTHORED_STEM_ID], frozenset({AUTHORED_STEM_ID})) == [True] + assert self._reach([AUTHORED_STEM_ID], NOTHING_HEARD) == [False] + + +class TestTheStretchesAChannelDividesInto: + """A reader follows a recording by the stretches it holds, so the record reads as runs.""" + + def test_a_run_gathers_the_frames_one_recording_holds_in_a_row(self) -> None: + runs = owner_runs([STEM_A, STEM_A, STEM_B]) + + assert runs == [OwnerRun(start=0, end=2, stem_id=STEM_A), OwnerRun(start=2, end=3, stem_id=STEM_B)] + + def test_a_recording_coming_back_takes_a_run_of_its_own(self) -> None: + runs = owner_runs([STEM_A, STEM_B, STEM_A]) + + assert [run.stem_id for run in runs] == [STEM_A, STEM_B, STEM_A] + + def test_resting_and_authored_frames_take_runs_like_any_other(self) -> None: + runs = owner_runs([RESTING_STEM_ID, RESTING_STEM_ID, AUTHORED_STEM_ID]) + + assert runs == [ + OwnerRun(start=0, end=2, stem_id=RESTING_STEM_ID), + OwnerRun(start=2, end=3, stem_id=AUTHORED_STEM_ID), + ] + + def test_the_runs_cover_the_channel_end_to_end_without_overlapping(self) -> None: + stem_ids = [STEM_A, STEM_A, RESTING_STEM_ID, STEM_B, STEM_B, STEM_B] + + runs = owner_runs(stem_ids) + + assert runs[0].start == 0 + assert runs[-1].end == len(stem_ids) + assert all(before.end == after.start for before, after in zip(runs, runs[1:])) + + def test_a_channel_standing_by_divides_into_nothing(self) -> None: + assert owner_runs([]) == [] diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index fe7f269e6..eaa2aadc0 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -1,18 +1,36 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, List +from typing import Any, Callable, Dict, 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 ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, + HierarchyMode, + bending_channels, +) from sampletones_core.data import Metadata -from sampletones_core.features import resting_reference +from sampletones_core.data.document import DOCUMENT_MAGIC +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.renders import rendered_channels, rendered_mix +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) @@ -30,6 +48,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 RECORDED_SCALE, single_entry_stems_data _RETUNED_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY // 2 _FASTER_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY * 2 @@ -47,12 +66,14 @@ 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}, + 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(DEFAULT_CHANNELS), + {ChannelName.PULSE1: instructions}, + ), ) @@ -63,13 +84,159 @@ def _saved_playing_channels_only(path: Path) -> Path: on the way back. """ reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - reconstruction.instructions_data = [ - item for item in reconstruction.instructions_data if item.generator_name == GeneratorName.PULSE1 - ] - reconstruction.save(path) + playing = tuple(item for item in reconstruction.instructions_data if item.channel_name == ChannelName.PULSE1) + reconstruction.model_copy(update={"instructions_data": playing}).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, + settings=StemSettings( + channels=[ChannelName.PULSE1], + bends=bending_channels([ChannelName.PULSE1]), + drives={ChannelName.PULSE1: 1.5}, + channel_cap=1, + ), + ) + ], + hierarchy=StemsHierarchy(levels=[[0]], mode=HierarchyMode.STRICT), + ) + stems_data = StemsData( + config=stems_config, + assignments=[ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[0, 0], + ) + ], + scale=RECORDED_SCALE, + ) + reconstruction = Reconstruction.create( + 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 == reconstruction.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, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=1, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + ), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0, 1])], + scale=RECORDED_SCALE, + ) + reconstruction = Reconstruction.create( + instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH), _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( + StemSettings(channels=[ChannelName.PULSE1], bends=[ChannelName.PULSE1], channel_cap=1), + [ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0])], + RECORDED_SCALE, + ) + + with pytest.raises(ValueError, match="where the setup holds"): + Reconstruction.create( + 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, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=1, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + ), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0, 1])], + scale=RECORDED_SCALE, + ) + reconstruction = Reconstruction.create( + instructions={ChannelName.PULSE1: [_pulse(_BASE_PITCH), _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 = reconstruction.detached() + + assert reconstruction.audio_filepath == () + + class TestRoundTrip: def test_save_load_round_trip( self, @@ -85,21 +252,45 @@ def test_save_load_round_trip( assert loaded.id == reconstruction.id assert loaded.coefficient == reconstruction.coefficient assert loaded.audio_filepath == reconstruction.audio_filepath - assert_array_equal(loaded.approximation, reconstruction.approximation) + assert_array_equal(rendered_mix(loaded), rendered_mix(reconstruction)) - def test_detached_source_round_trips_as_none( + def test_detached_source_round_trips_as_empty( self, tmp_path: Path, reconstruction_factory: ReconstructionFactory, ) -> None: reconstruction = reconstruction_factory() - reconstruction.detach_source() + reconstruction = reconstruction.detached() path = tmp_path / "detached.stn" reconstruction.save(path) loaded = Reconstruction.load(path) - assert loaded.audio_filepath is None + assert loaded.audio_filepath == () + + def test_the_stored_file_carries_the_framing( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + path = tmp_path / "framed.stn" + + reconstruction.save(path) + + assert path.read_bytes().startswith(DOCUMENT_MAGIC) + assert path.stat().st_size < len(reconstruction.serialize()) + + def test_a_file_written_before_the_framing_still_loads( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + path = tmp_path / "plain.stn" + path.write_bytes(reconstruction.serialize()) + + assert Reconstruction.load(path).id == reconstruction.id class TestDetachSource: @@ -108,11 +299,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() + reconstruction = reconstruction.detached() - assert reconstruction.audio_filepath is None + assert reconstruction.audio_filepath == () class TestLoadRejectsForeignFiles: @@ -130,6 +321,18 @@ def test_corrupt_binary_raises_invalid_values(self) -> None: source="corrupt.stn", ) + def test_a_damaged_framing_raises_a_load_error( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + path = tmp_path / "damaged.stn" + reconstruction_factory().save(path) + path.write_bytes(path.read_bytes()[:32] + b"\xff" * 256) + + with pytest.raises(LoadReconstructionError): + Reconstruction.load(path) + class TestLoadFileAccess(BaseTestSuite): @dataclass(frozen=True, kw_only=True) @@ -243,7 +446,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 +461,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 = reconstruction.with_channel_data( + ChannelName.PULSE1, arpeggiated, - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (), + heard=reconstruction.recorded_stem_ids, ) - 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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [_pulse(_RESET_PITCH)], - np.ones(_AUDIO_LENGTH, dtype=np.float32), _RESET_PITCH, (), + heard=reconstruction.recorded_stem_ids, ) - 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 +498,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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (FeatureKey.ARPEGGIO,), + heard=reconstruction.recorded_stem_ids, ) - 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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (FeatureKey.ARPEGGIO,), + heard=reconstruction.recorded_stem_ids, ) - 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,54 +557,47 @@ 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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (FeatureKey.ARPEGGIO,), + heard=reconstruction.recorded_stem_ids, ) 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 = reconstruction.with_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), + heard=reconstruction.recorded_stem_ids, ) - 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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [_pulse(_BASE_PITCH)] * 3, - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + heard=reconstruction.recorded_stem_ids, ) path = tmp_path / "held.stn" @@ -411,72 +617,72 @@ 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 rendered_channels(reconstruction) 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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [], - np.zeros(0, dtype=np.float32), _BASE_PITCH, (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + heard=reconstruction.recorded_stem_ids, ) - 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 = reconstruction.with_channel_data( + ChannelName.PULSE2, [_pulse(_BASE_PITCH)] * 2, - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (), + heard=reconstruction.recorded_stem_ids, ) - 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 rendered_channels(reconstruction) def test_a_reconstruction_of_channels_standing_by_stays_valid(self) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) - reconstruction.update_generator_data( - GeneratorName.PULSE1, + reconstruction = reconstruction.with_channel_data( + ChannelName.PULSE1, [], - np.zeros(0, dtype=np.float32), _BASE_PITCH, (), + heard=reconstruction.recorded_stem_ids, ) - assert reconstruction.approximations == {} - assert reconstruction.approximation.size == 0 + assert rendered_channels(reconstruction) == {} + assert rendered_mix(reconstruction).size == 0 def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) @@ -485,30 +691,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 = loaded.with_channel_data( + ChannelName.PULSE2, [_pulse(_BASE_PITCH)], - np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, (), + heard=loaded.recorded_stem_ids, ) - 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,10 +732,10 @@ 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] - assert len(retuned.approximation) == len(generator_approximation) - assert len(retuned.approximation) == retuned.config.frame_length - assert len(faster.approximation) < len(retuned.approximation) + generator_approximation = rendered_channels(retuned)[ChannelName.PULSE1] + assert len(rendered_mix(retuned)) == len(generator_approximation) + assert len(rendered_mix(retuned)) == retuned.config.frame_length + assert len(rendered_mix(faster)) < len(rendered_mix(retuned)) def test_preserves_instructions(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() @@ -542,12 +748,12 @@ def test_preserves_instructions(self, reconstruction_factory: ReconstructionFact def test_leaves_original_untouched(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() original_frequency = reconstruction.config.nes_frequency - original_length = len(reconstruction.approximation) + original_length = len(rendered_mix(reconstruction)) reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) assert reconstruction.config.nes_frequency == original_frequency - assert len(reconstruction.approximation) == original_length + assert len(rendered_mix(reconstruction)) == original_length def test_a_channel_standing_by_stays_standing_by( self, @@ -558,27 +764,27 @@ 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(rendered_channels(retuned)) == {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 = reconstruction.with_channel_data( + ChannelName.PULSE1, [], - np.zeros(0, dtype=np.float32), _BASE_PITCH, (), + heard=reconstruction.recorded_stem_ids, ) retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) assert retuned.config.nes_frequency == _RETUNED_FREQUENCY - assert retuned.approximations == {} - assert retuned.approximation.size == 0 - assert retuned.playing_generators == () + assert rendered_channels(retuned) == {} + assert rendered_mix(retuned).size == 0 + 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_recordings.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_recordings.py new file mode 100644 index 000000000..364b6c73b --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_recordings.py @@ -0,0 +1,99 @@ +from pathlib import Path +from typing import Final, Optional, Tuple + +import numpy as np + +from sampletones_core.audio import load_audio, mix_scale, read_stems, scale_stems, write_wave +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.recordings import load_recordings +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +SAMPLES: Final[int] = 64 +LEVELS: Final[Tuple[float, ...]] = (0.6, 0.2) +MEASURED_SCALE: Final[float] = 0.4 + + +def _document(paths: Tuple[Path, ...], scale: Optional[float]) -> Reconstruction: + """A document over one recording per path, each holding one frame of the first pulse.""" + channels = [ChannelName.PULSE1] + stem_ids = list(range(len(paths))) + return Reconstruction.create( + instructions={ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] * len(paths)}, + config=Config(), + coefficient=1.0, + audio_filepath=paths, + stems_data=StemsData( + config=StemsConfig( + entries=[StemEntry(id=stem_id, settings=StemSettings.covering(channels)) for stem_id in stem_ids], + hierarchy=StemsHierarchy(levels=[stem_ids]), + ), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=stem_ids)], + scale=scale, + ), + ) + + +def _written(directory: Path, levels: Tuple[float, ...]) -> Tuple[Path, ...]: + sample_rate = Config().library.sample_rate + paths = [] + for index, level in enumerate(levels): + path = directory / f"stem_{index}.wav" + write_wave(path, sample_rate, np.full(SAMPLES, level, dtype=np.float32)) + paths.append(path) + + return tuple(paths) + + +class TestLoadingADocumentsRecordings: + """A document reads its recordings at the level its conversion read them at.""" + + def test_the_recordings_load_at_the_recorded_scale(self, tmp_path: Path) -> None: + paths = _written(tmp_path, LEVELS) + general = Config().general + + read = read_stems(paths, target_sample_rate=Config().library.sample_rate) + assert mix_scale(read, normalize=general.normalize) != MEASURED_SCALE + + recordings = load_recordings(_document(paths, MEASURED_SCALE)) + + expected = scale_stems( + read, + scale=MEASURED_SCALE, + quantize=general.quantize, + quantization_levels=general.quantization_levels, + ) + assert len(recordings) == len(paths) + for loaded, reference in zip(recordings, expected): + np.testing.assert_array_equal(loaded, reference) + + def test_an_unmeasured_record_reads_its_recording_at_its_own_peak(self, tmp_path: Path) -> None: + """A document written before the scale was recorded reads its one recording as it was converted.""" + (path,) = _written(tmp_path, LEVELS[:1]) + config = Config() + + (recording,) = load_recordings(_document((path,), None)) + + np.testing.assert_allclose( + recording, + load_audio( + path, + target_sample_rate=config.library.sample_rate, + normalize=config.general.normalize, + quantize=config.general.quantize, + quantization_levels=config.general.quantization_levels, + ), + ) + + def test_a_detached_document_loads_no_recording(self, tmp_path: Path) -> None: + document = _document(_written(tmp_path, LEVELS), MEASURED_SCALE) + document = document.detached() + + assert load_recordings(document) == () diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_sharing.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_sharing.py new file mode 100644 index 000000000..922ba8cfa --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_sharing.py @@ -0,0 +1,104 @@ +from pathlib import Path +from typing import Dict, Final, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.project.voices.sample import Sample +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment +from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem +from tests.suite.stems import SHARED_CHANNEL, SOLE_CHANNEL, STEM_A_ID, taking_turns_reconstruction + +SOURCES: Final[Tuple[Path, Path]] = (Path("a.wav"), Path("b.wav")) +EDITED: Final[PulseInstruction] = PulseInstruction(on=True, pitch=67, volume=11, duty_cycle=1) +EDITED_PITCH: Final[int] = 67 +RETUNED_RATE: Final[int] = 50 + + +@pytest.fixture +def document() -> Reconstruction: + """Two recordings taking turns on Pulse 1, the second holding Pulse 2 alone, its locations let go.""" + return taking_turns_reconstruction(SOURCES).detached() + + +def _streams(reconstruction: Reconstruction) -> Dict[ChannelName, InstructionsItem]: + return {item.channel_name: item for item in reconstruction.instructions_data} + + +def _owners(reconstruction: Reconstruction) -> Dict[ChannelName, ChannelAssignment]: + return {item.channel_name: item for item in reconstruction.stems_data.assignments} + + +class TestAnEditOwnsWhatItChanged: + """An edit makes a new document that holds the very parts of the old one it left alone.""" + + def test_a_channel_edit_shares_every_other_channel(self, document: Reconstruction) -> None: + edited = document.with_channel_data( + SHARED_CHANNEL, + [EDITED, EDITED], + EDITED_PITCH, + (), + heard=document.recorded_stem_ids, + ) + + assert edited is not document + assert _streams(edited)[SOLE_CHANNEL] is _streams(document)[SOLE_CHANNEL] + assert _owners(edited)[SOLE_CHANNEL] is _owners(document)[SOLE_CHANNEL] + assert _streams(edited)[SHARED_CHANNEL] is not _streams(document)[SHARED_CHANNEL] + + def test_a_channel_edit_leaves_the_old_document_as_it_was(self, document: Reconstruction) -> None: + before = list(document.instructions[SHARED_CHANNEL]) + + document.with_channel_data(SHARED_CHANNEL, [EDITED, EDITED], EDITED_PITCH, (), heard=document.recorded_stem_ids) + + assert document.instructions[SHARED_CHANNEL] == before + + def test_a_removal_shares_the_channels_it_releases_nothing_from(self, document: Reconstruction) -> None: + removed = without_stem(document, STEM_A_ID) + + assert _streams(removed)[SOLE_CHANNEL] is _streams(document)[SOLE_CHANNEL] + assert _owners(removed)[SOLE_CHANNEL] is _owners(document)[SOLE_CHANNEL] + assert _streams(removed)[SHARED_CHANNEL] is not _streams(document)[SHARED_CHANNEL] + + def test_a_retune_shares_every_stream_and_the_record(self, document: Reconstruction) -> None: + retuned = document.with_nes_frequency(RETUNED_RATE) + + assert retuned.config.nes_frequency == RETUNED_RATE + assert all( + left is right for left, right in zip(retuned.instructions_data, document.instructions_data, strict=True) + ) + assert retuned.stems_data is document.stems_data + + +class TestDetaching: + """A document holding a location is detached into a new one sharing its streams; one holding none is kept.""" + + def test_a_document_with_no_location_is_kept(self, document: Reconstruction) -> None: + assert document.detached() is document + + def test_a_document_with_locations_detaches_into_a_new_one(self) -> None: + located = taking_turns_reconstruction(SOURCES) + + detached = located.detached() + + assert detached is not located + assert detached.audio_filepath == () + assert located.audio_filepath == SOURCES + assert all( + left is right for left, right in zip(detached.instructions_data, located.instructions_data, strict=True) + ) + + +class TestACopiedSample: + """A duplicated sample shares its document until one of the two is edited.""" + + def test_the_copy_holds_the_same_document(self, document: Reconstruction) -> None: + original = Sample(name="Lead", reconstruction=document) + + copy = original.clone() + + assert copy.id != original.id + assert copy.reconstruction is original.reconstruction diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py new file mode 100644 index 000000000..3357e127f --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_data.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, FrozenSet, List, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, bending_channels +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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.stems import RECORDED_SCALE + +LEAD: Final[int] = 0 +BASS: Final[int] = 1 +RECORDINGS: Final[List[Path]] = [Path("/music/Lead.wav"), Path("/music/Bass.wav")] +EVERY_CHANNEL: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) +MEASURED_SCALE: Final[float] = 0.4 + + +def _setup(stem_ids: Tuple[int, ...]) -> StemsConfig: + channels = [ChannelName.PULSE1] + return StemsConfig( + entries=[ + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) + for stem_id in stem_ids + ], + hierarchy=StemsHierarchy(levels=[[stem_id] for stem_id in stem_ids]), + ) + + +def _record(stem_ids: List[int], *, scale: float = RECORDED_SCALE) -> StemsData: + """Two recordings over the first pulse, whose frames ``stem_ids`` hands out.""" + return StemsData( + config=_setup((LEAD, BASS)), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=stem_ids)], + scale=scale, + ).with_sources(RECORDINGS) + + +class TestTheRecordingsHoldingAFrame: + def test_every_recording_named_in_a_frame_holds_one(self) -> None: + assert _record([LEAD, BASS]).holding_stem_ids == frozenset({LEAD, BASS}) + + def test_a_rest_and_the_readers_own_frames_hold_for_no_recording(self) -> None: + assert _record([LEAD, RESTING_STEM_ID, AUTHORED_STEM_ID]).holding_stem_ids == frozenset({LEAD}) + + +class TestWhatSettlingLetsGoOf: + """A settled record names the recordings behind its frames, unless nothing tells them apart.""" + + def test_a_recording_holding_no_frame_leaves_with_its_source(self) -> None: + settled = _record([LEAD, RESTING_STEM_ID]).settled(EVERY_CHANNEL) + + assert [entry.id for entry in settled.config.entries] == [LEAD] + assert [source.stem_id for source in settled.sources] == [LEAD] + assert settled.config.hierarchy.levels == ((LEAD,),) + assert settled.paths == (RECORDINGS[0],) + + def test_a_record_where_no_recording_holds_a_frame_keeps_them_all(self) -> None: + settled = _record([RESTING_STEM_ID, RESTING_STEM_ID]).settled(EVERY_CHANNEL) + + assert [entry.id for entry in settled.config.entries] == [LEAD, BASS] + assert settled.paths == tuple(RECORDINGS) + + def test_authored_frames_alone_keep_every_recording(self) -> None: + settled = _record([AUTHORED_STEM_ID, RESTING_STEM_ID]).settled(EVERY_CHANNEL) + + assert [entry.id for entry in settled.config.entries] == [LEAD, BASS] + + def test_a_record_whose_channels_all_stand_by_keeps_every_recording(self) -> None: + settled = _record([LEAD, BASS]).settled(frozenset()) + + assert settled.assignments == () + assert [entry.id for entry in settled.config.entries] == [LEAD, BASS] + + def test_a_record_every_recording_holds_a_frame_in_stands_whole(self) -> None: + record = _record([BASS, LEAD]) + + assert record.settled(EVERY_CHANNEL) == record + + +class TestTheRecordedScale: + """The factor a conversion read its recordings at belongs to the document, whatever it holds.""" + + def test_a_record_stating_no_scale_holds_one_recording(self) -> None: + record = StemsData( + config=_setup((LEAD,)), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[LEAD])], + scale=None, + ) + + assert record.scale is None + + def test_a_record_of_several_recordings_stating_no_scale_is_refused(self) -> None: + with pytest.raises(ValidationError): + StemsData( + config=_setup((LEAD, BASS)), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[LEAD, BASS])], + scale=None, + ) + + +class TestTheScaleThroughEveryRewrite(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + rewrite: Callable[[StemsData], StemsData] + + test_cases: Tuple["TestTheScaleThroughEveryRewrite.TestCase", ...] = ( + TestCase(label="naming its sources", rewrite=lambda record: record.with_sources(RECORDINGS)), + TestCase(label="detached", rewrite=lambda record: record.detached()), + TestCase( + label="given new owners", + rewrite=lambda record: record.with_assignments( + [ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[BASS, LEAD])] + ), + ), + TestCase(label="letting an entry go", rewrite=lambda record: record.without_entries(frozenset({BASS}))), + TestCase(label="settled", rewrite=lambda record: record.settled(EVERY_CHANNEL)), + TestCase(label="settled with every channel standing by", rewrite=lambda record: record.settled(frozenset())), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_scale_survives(self, test_case: TestCase) -> None: + assert test_case.rewrite(_record([LEAD, BASS], scale=MEASURED_SCALE)).scale == MEASURED_SCALE 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..4345a966a --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -0,0 +1,147 @@ +from typing import Final, List, Tuple + +import numpy as np + +from sampletones_core.constants.enums import ChannelName, bending_channels +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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.stems import RECORDED_SCALE + +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, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_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], + scale=RECORDED_SCALE, + ) + + +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_invariants.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_invariants.py new file mode 100644 index 000000000..011ed1354 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_invariants.py @@ -0,0 +1,412 @@ +from dataclasses import dataclass +from typing import Callable, Final, FrozenSet, List, Mapping, Sequence + +import pytest +from pydantic import ValidationError + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import AUTHORED_STEM_ID, RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.features import resting_reference +from sampletones_core.instructions import InstructionData, InstructionUnion, NoiseInstruction, PulseInstruction +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.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 +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.stems import RECORDED_SCALE + +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 +EVERY_STEM: Final[FrozenSet[int]] = frozenset({STEM_A, STEM_B}) +UNHELD_STEM_IDS: Final[FrozenSet[int]] = frozenset({RESTING_STEM_ID, AUTHORED_STEM_ID}) +STORED_ID: Final[str] = "stored" + + +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: + """Stem A over the first pulse and the noise, stem B over the first pulse alone.""" + return StemsConfig( + entries=[ + StemEntry( + id=STEM_A, + settings=StemSettings( + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=bending_channels([ChannelName.PULSE1, ChannelName.NOISE]), + channel_cap=1, + ), + ), + StemEntry( + id=STEM_B, + settings=StemSettings( + channels=[ChannelName.PULSE1], + bends=bending_channels([ChannelName.PULSE1]), + channel_cap=1, + ), + ), + ], + hierarchy=StemsHierarchy(levels=[[STEM_A], [STEM_B]]), + ) + + +def _reconstruction( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + owners: Mapping[ChannelName, Sequence[int]], +) -> Reconstruction: + return Reconstruction.create( + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=(), + 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() + ], + scale=RECORDED_SCALE, + ), + ) + + +def _stored( + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], + owners: Mapping[ChannelName, Sequence[int]], +) -> Reconstruction: + """A document built from its stored parts as they stand, the way a file states them. + + Creating a document settles it, so a record naming a channel the streams leave silent is + one a stored file carries and creation never writes. + """ + return Reconstruction( + id=STORED_ID, + config=Config(), + instructions_data=[ + InstructionsItem( + channel_name=channel_name, + instructions=[ + InstructionData.create(instruction) for instruction in instructions.get(channel_name, ()) + ], + initial_pitch=resting_reference(channel_name), + held_features=[], + ) + for channel_name in ChannelName.items() + ], + 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() + ], + scale=RECORDED_SCALE, + ), + coefficient=1.0, + ) + + +def _edited(reconstruction: Reconstruction, instructions: List[InstructionUnion]) -> Reconstruction: + edited = reconstruction.model_copy(deep=True) + edited = edited.with_channel_data( + ChannelName.PULSE1, + instructions, + initial_pitch=reconstruction.initial_pitches[ChannelName.PULSE1], + held_features=reconstruction.held_features[ChannelName.PULSE1], + heard=EVERY_STEM, + ) + return edited + + +@pytest.fixture +def reconstruction() -> Reconstruction: + """Two recordings over the pulse, stem A alone on the noise, one frame resting on each.""" + return _reconstruction( + { + ChannelName.PULSE1: [_pulse(60), _pulse(62), PulseInstruction.null_instruction()], + ChannelName.NOISE: [_noise(), _noise(), NoiseInstruction.null_instruction()], + }, + { + ChannelName.PULSE1: [STEM_A, STEM_B, RESTING_STEM_ID], + ChannelName.NOISE: [STEM_A, STEM_A, RESTING_STEM_ID], + }, + ) + + +def _assert_record_holds(reconstruction: Reconstruction) -> None: + """Every rule the per-frame record keeps, asserted together. + + The record answers for the channels in play and for those alone; each frame names an + owner the setup knows, rest and silence name the same frames, and a recording holds only + the channels its own settings occupy. + """ + stems_data = reconstruction.stems_data + recorded = frozenset(stems_data.config.entries_by_id) + owned = stems_data.assignments_by_channel + + assert frozenset(owned) == frozenset(reconstruction.playing_channels) + + for channel_name in reconstruction.playing_channels: + instructions = reconstruction.instructions[channel_name] + stem_ids = owned[channel_name] + assert len(stem_ids) == len(instructions) + + for instruction, stem_id in zip(instructions, stem_ids): + assert stem_id in recorded | UNHELD_STEM_IDS + assert (stem_id == RESTING_STEM_ID) == (not instruction.on) + if stem_id in recorded: + assert channel_name in stems_data.config.entries_by_id[stem_id].settings.channel_set + + +class TestTheRecordAfterEveryGesture(BaseTestSuite): + """Whatever a reader does to a document, the per-frame record still answers for it.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + gesture: Callable[[Reconstruction], Reconstruction] + + test_cases = ( + TestCase( + label="a document straight out of a conversion", + gesture=lambda reconstruction: reconstruction, + ), + TestCase( + label="an edit changing a frame a recording holds", + gesture=lambda reconstruction: _edited( + reconstruction, + [_pulse(70), _pulse(62), PulseInstruction.null_instruction()], + ), + ), + TestCase( + label="an edit quieting a frame a recording holds", + gesture=lambda reconstruction: _edited( + reconstruction, + [PulseInstruction.null_instruction(), _pulse(62), PulseInstruction.null_instruction()], + ), + ), + TestCase( + label="an edit writing into a resting frame", + gesture=lambda reconstruction: _edited(reconstruction, [_pulse(60), _pulse(62), _pulse(64)]), + ), + TestCase( + label="an edit lengthening the stream", + gesture=lambda reconstruction: _edited( + reconstruction, + [_pulse(60), _pulse(62), _pulse(64), _pulse(66)], + ), + ), + TestCase( + label="an edit shortening the stream", + gesture=lambda reconstruction: _edited(reconstruction, [_pulse(60)]), + ), + TestCase( + label="an edit emptying the channel", + gesture=lambda reconstruction: _edited(reconstruction, []), + ), + TestCase( + label="an edit writing every frame silent", + gesture=lambda reconstruction: _edited(reconstruction, [PulseInstruction.null_instruction()] * 3), + ), + TestCase( + label="an edit writing an emptied channel back into play", + gesture=lambda reconstruction: _edited(_edited(reconstruction, []), [_pulse(60), _pulse(62)]), + ), + TestCase( + label="a recording taken out", + gesture=lambda reconstruction: without_stem(reconstruction, STEM_B), + ), + TestCase( + label="a removal leaving a channel only rests", + gesture=lambda reconstruction: without_stem( + _edited( + reconstruction, + [PulseInstruction.null_instruction(), _pulse(62), PulseInstruction.null_instruction()], + ), + STEM_B, + ), + ), + TestCase( + label="a recording taken out after an edit", + gesture=lambda reconstruction: without_stem( + _edited(reconstruction, [_pulse(70), _pulse(72), _pulse(74)]), + STEM_B, + ), + ), + TestCase( + label="a document detached from its recordings", + gesture=lambda reconstruction: _detached(reconstruction), + ), + TestCase( + label="a document through a round trip", + gesture=lambda reconstruction: Reconstruction.deserialize(reconstruction.serialize()), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_record_answers_for_the_document( + self, + reconstruction: Reconstruction, + test_case: TestCase, + ) -> None: + _assert_record_holds(test_case.gesture(reconstruction)) + + +def _detached(reconstruction: Reconstruction) -> Reconstruction: + detached = reconstruction.model_copy(deep=True) + detached = detached.detached() + return detached + + +class TestARecordTheDocumentRefuses: + def test_a_channel_in_play_naming_no_owner_per_frame_is_refused(self) -> None: + with pytest.raises(ValidationError): + _stored( + {ChannelName.PULSE1: [_pulse(60), _pulse(62)]}, + {ChannelName.PULSE1: [STEM_A]}, + ) + + def test_a_frame_naming_a_recording_the_setup_leaves_out_is_refused(self) -> None: + with pytest.raises(ValidationError): + _stored( + {ChannelName.NOISE: [_noise()]}, + {ChannelName.NOISE: [STEM_B]}, + ) + + def test_a_sounding_frame_recorded_as_resting_is_refused(self) -> None: + with pytest.raises(ValidationError): + _stored( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [RESTING_STEM_ID]}, + ) + + def test_a_silent_frame_recorded_as_held_is_refused(self) -> None: + with pytest.raises(ValidationError): + _stored( + {ChannelName.PULSE1: [_pulse(60), PulseInstruction.null_instruction()]}, + {ChannelName.PULSE1: [STEM_A, STEM_A]}, + ) + + def test_a_channel_standing_by_carrying_a_record_is_refused(self) -> None: + with pytest.raises(ValidationError): + _stored( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [STEM_A], ChannelName.TRIANGLE: [RESTING_STEM_ID]}, + ) + + def test_a_channel_resting_through_every_frame_is_refused(self) -> None: + """A channel resting throughout stands by, so a stream stating those frames is refused.""" + with pytest.raises(ValidationError): + _stored( + { + ChannelName.PULSE1: [_pulse(60)], + ChannelName.NOISE: [NoiseInstruction.null_instruction()] * 2, + }, + { + ChannelName.PULSE1: [STEM_A], + ChannelName.NOISE: [RESTING_STEM_ID] * 2, + }, + ) + + +class TestWhatCreatingSettles: + """A conversion hands every channel it covered a stream, and a silent one stands by.""" + + @pytest.fixture + def created(self) -> Reconstruction: + """Stem A sounding the pulse while the noise it also covers rests through every frame.""" + return _reconstruction( + { + ChannelName.PULSE1: [_pulse(60), _pulse(62)], + ChannelName.NOISE: [NoiseInstruction.null_instruction()] * 2, + }, + { + ChannelName.PULSE1: [STEM_A, STEM_A], + ChannelName.NOISE: [RESTING_STEM_ID] * 2, + }, + ) + + def test_a_channel_resting_through_every_frame_stands_by(self, created: Reconstruction) -> None: + assert created.playing_channels == (ChannelName.PULSE1,) + assert created.streams[ChannelName.NOISE] == InstructionsItem.resting(ChannelName.NOISE) + + def test_the_record_names_it_no_more(self, created: Reconstruction) -> None: + assert ChannelName.NOISE not in created.stems_data.assignments_by_channel + + def test_a_conversion_where_nothing_sounds_stands_every_channel_by(self) -> None: + created = _reconstruction( + {ChannelName.PULSE1: [PulseInstruction.null_instruction()]}, + {ChannelName.PULSE1: [RESTING_STEM_ID]}, + ) + + assert created.playing_channels == () + assert created.stems_data.assignments == () + + +class TestARecordingHoldingNoFrame: + """A recording holding no frame leaves the document, unless no recording holds one at all.""" + + def test_a_recording_the_picker_never_chose_leaves(self) -> None: + reconstruction = _reconstruction( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [STEM_A]}, + ) + + assert [entry.id for entry in reconstruction.stems_data.config.entries] == [STEM_A] + assert reconstruction.stems_data.config.hierarchy.levels == ((STEM_A,),) + + def test_a_recording_an_edit_emptied_leaves(self, reconstruction: Reconstruction) -> None: + edited = _edited( + reconstruction, + [_pulse(60), PulseInstruction.null_instruction(), PulseInstruction.null_instruction()], + ) + + assert [entry.id for entry in edited.stems_data.config.entries] == [STEM_A] + assert STEM_B not in edited.stems_data.assignments_by_channel[ChannelName.PULSE1] + + def test_a_record_where_nothing_sounds_keeps_every_recording(self) -> None: + reconstruction = _reconstruction( + {ChannelName.PULSE1: [PulseInstruction.null_instruction()]}, + {ChannelName.PULSE1: [RESTING_STEM_ID]}, + ) + + assert [entry.id for entry in reconstruction.stems_data.config.entries] == [STEM_A, STEM_B] + + def test_an_edit_silencing_every_recording_keeps_them_all(self) -> None: + reconstruction = _reconstruction( + {ChannelName.PULSE1: [_pulse(60), _pulse(62)]}, + {ChannelName.PULSE1: [STEM_A, STEM_B]}, + ) + + edited = _edited(reconstruction, []) + + assert [entry.id for entry in edited.stems_data.config.entries] == [STEM_A, STEM_B] + + def test_a_record_holding_only_the_readers_frames_keeps_every_recording(self) -> None: + """Frames the reader wrote answer to no recording, so they tell none of them apart.""" + reconstruction = _reconstruction( + {ChannelName.PULSE1: [_pulse(60), _pulse(62)]}, + {ChannelName.PULSE1: [STEM_A, STEM_B]}, + ) + + authored = _edited(_edited(reconstruction, []), [_pulse(60)]) + + assert authored.stems_data.assignments_by_channel == {ChannelName.PULSE1: (AUTHORED_STEM_ID,)} + assert [entry.id for entry in authored.stems_data.config.entries] == [STEM_A, STEM_B] + + def test_a_stored_record_naming_an_idle_recording_is_refused(self) -> None: + with pytest.raises(ValidationError): + _stored( + {ChannelName.PULSE1: [_pulse(60)]}, + {ChannelName.PULSE1: [STEM_A]}, + ) 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..20cf7f132 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py @@ -0,0 +1,324 @@ +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, bending_channels +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem +from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels, rendered_mix +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 can_remove_stem, without_stem +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.stems import RECORDED_SCALE + +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, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]), channel_cap=1 + ), + ), + StemEntry( + id=STEM_B, + settings=StemSettings( + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=bending_channels([ChannelName.PULSE1, ChannelName.NOISE]), + channel_cap=1, + ), + ), + StemEntry( + id=STEM_C, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]), channel_cap=1 + ), + ), + ], + hierarchy=StemsHierarchy( + levels=[[STEM_A], [STEM_B, STEM_C]], + mode=HierarchyMode.STRICT, + ), + ) + + +def _reconstruction( + owners: Mapping[ChannelName, Sequence[int]], + *, + instructions: Mapping[ChannelName, Sequence[InstructionUnion]], +) -> Reconstruction: + """A three-recording reconstruction whose frames are owned as ``owners`` states.""" + return Reconstruction.create( + 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() + ], + scale=RECORDED_SCALE, + ), + ) + + +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), PulseInstruction.null_instruction()], + ChannelName.NOISE: [_noise() for _ in range(FRAME_COUNT)], + }, + ) + + +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_each_stems_count_carry_over(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_C) + + assert remaining.stems_data.config.hierarchy.mode == HierarchyMode.STRICT + assert all(entry.settings.channel_cap == 1 for entry in remaining.stems_data.config.entries) + + 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 = reconstruction.detached() + + 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, False] + + def test_the_frames_it_held_lose_their_samples(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + audio = rendered_channels(remaining)[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) + + for index in (0, 2, 3): + assert remaining.instructions[ChannelName.PULSE1][index] == ( + reconstruction.instructions[ChannelName.PULSE1][index] + ) + + for index in (0, 2): + assert _frame(rendered_channels(remaining)[ChannelName.PULSE1], index).any() + + def test_the_channel_keeps_its_length(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + assert len(rendered_channels(remaining)[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( + rendered_channels(remaining)[ChannelName.NOISE], + rendered_channels(reconstruction)[ChannelName.NOISE], + ) + + +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) + + assert ChannelName.NOISE not in rendered_channels(remaining) + + def test_a_channel_left_entirely_released_leaves_the_record( + self, + reconstruction: Reconstruction, + ) -> None: + """A channel standing by describes no frame, so the record names it no more.""" + remaining = without_stem(reconstruction, STEM_B) + + assert ChannelName.NOISE not in remaining.stems_data.assignments_by_channel + + def test_an_emptied_channel_keeps_its_reference(self, reconstruction: Reconstruction) -> None: + """A channel the removal silences keeps the base it was shaped from, as an edit keeps it.""" + stream = reconstruction.streams[ChannelName.NOISE] + assert stream != InstructionsItem.resting(ChannelName.NOISE) + + remaining = without_stem(reconstruction, STEM_B) + + assert remaining.streams[ChannelName.NOISE].initial_pitch == stream.initial_pitch + assert remaining.streams[ChannelName.NOISE].held_features == stream.held_features + + +class TestTheMixedApproximation: + def test_the_mix_is_summed_afresh_from_what_stays(self, reconstruction: Reconstruction) -> None: + remaining = without_stem(reconstruction, STEM_B) + + np.testing.assert_array_equal( + rendered_mix(remaining), + rendered_channels(remaining)[ChannelName.PULSE1], + ) + + +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: + before = rendered_channels(reconstruction)[ChannelName.NOISE].copy() + + 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(rendered_channels(reconstruction)[ChannelName.NOISE], before) + + +@pytest.fixture +def single_recording() -> Reconstruction: + """A reconstruction built from one recording alone.""" + return Reconstruction.create( + instructions={ChannelName.PULSE1: [_pulse(60)] * FRAME_COUNT}, + config=Config(), + coefficient=1.0, + audio_filepath=(RECORDINGS[STEM_A],), + stems_data=StemsData.single_entry( + StemSettings(channels=[ChannelName.PULSE1], bends=[ChannelName.PULSE1]), + [ + ChannelAssignment( + channel_name=ChannelName.PULSE1, + stem_ids=[STEM_A] * FRAME_COUNT, + ) + ], + RECORDED_SCALE, + ), + ) + + +class TestARefusedRemoval: + def test_removing_an_unrecorded_stem_is_refused(self, reconstruction: Reconstruction) -> None: + with pytest.raises(ValueError, match="names no entry"): + without_stem(reconstruction, 7) + + def test_removing_the_last_recording_is_refused(self, single_recording: Reconstruction) -> None: + with pytest.raises(ValueError, match="at least one stem"): + without_stem(single_recording, STEM_A) + + +class TestWhetherARemovalApplies: + """A removal asked about first answers as :func:`without_stem` would, without raising.""" + + def test_a_recording_standing_beside_others_can_leave(self, reconstruction: Reconstruction) -> None: + assert can_remove_stem(reconstruction, STEM_B) + + def test_an_unrecorded_stem_cannot(self, reconstruction: Reconstruction) -> None: + assert not can_remove_stem(reconstruction, 7) + + def test_the_last_recording_cannot(self, single_recording: Reconstruction) -> None: + assert not can_remove_stem(single_recording, STEM_A) + + def test_a_recording_already_taken_out_cannot(self, reconstruction: Reconstruction) -> None: + assert not can_remove_stem(without_stem(reconstruction, STEM_B), STEM_B) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py new file mode 100644 index 000000000..96ad3eef1 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_sources.py @@ -0,0 +1,182 @@ +from pathlib import Path +from typing import Final, List, Sequence + +import pytest +from pydantic import ValidationError + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.instructions import 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.reconstruction.stems.source import StemSource +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.configs.settings import StemSettings +from sampletones_shared.exceptions import SampleToNESError +from tests.suite.stems import RECORDED_SCALE + +LEAD: Final[int] = 0 +BASS: Final[int] = 1 +RECORDINGS: Final[List[Path]] = [Path("/music/Lead Vocals.wav"), Path("/music/Bass.wav")] + + +def _pulse(pitch: int) -> PulseInstruction: + return PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0) + + +def _stems_config() -> StemsConfig: + channels = [ChannelName.PULSE1] + return StemsConfig( + entries=[ + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) + for stem_id in (LEAD, BASS) + ], + hierarchy=StemsHierarchy(levels=[[LEAD], [BASS]]), + ) + + +def _reconstruction(paths: Sequence[Path] = tuple(RECORDINGS)) -> Reconstruction: + """Two recordings sharing the first pulse, one frame each, read from ``paths``.""" + return Reconstruction.create( + instructions={ChannelName.PULSE1: [_pulse(60), _pulse(62)]}, + config=Config(), + coefficient=1.0, + audio_filepath=tuple(paths), + stems_data=StemsData( + config=_stems_config(), + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[LEAD, BASS])], + scale=RECORDED_SCALE, + ), + ) + + +class TestWhatTheRecordRemembersAboutARecording: + def test_a_recording_is_named_after_the_file_it_was_read_from(self) -> None: + reconstruction = _reconstruction() + + assert reconstruction.stems_data.named(LEAD) == "Lead Vocals" + assert reconstruction.stems_data.named(BASS) == "Bass" + + def test_the_locations_read_back_in_entry_order(self) -> None: + reconstruction = _reconstruction() + + assert reconstruction.audio_filepath == tuple(RECORDINGS) + + def test_the_sources_number_one_per_entry(self) -> None: + with pytest.raises(ValueError, match="where the setup holds"): + _reconstruction(paths=(RECORDINGS[0],)) + + def test_a_record_naming_an_entry_the_setup_leaves_out_is_refused(self) -> None: + with pytest.raises(ValidationError, match="where the setup holds"): + StemsData( + config=_stems_config(), + sources=[StemSource.of(LEAD, RECORDINGS[0]), StemSource.of(7, RECORDINGS[1])], + assignments=[ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[LEAD, BASS])], + scale=RECORDED_SCALE, + ) + + +class TestADetachedDocument: + def test_it_keeps_the_names_of_the_recordings_behind_it(self) -> None: + reconstruction = _reconstruction() + + reconstruction = reconstruction.detached() + + assert reconstruction.stems_data.named(LEAD) == "Lead Vocals" + assert reconstruction.stems_data.named(BASS) == "Bass" + + def test_it_states_no_location_at_all(self) -> None: + reconstruction = _reconstruction() + + reconstruction = reconstruction.detached() + + assert reconstruction.audio_filepath == () + assert all(source.path is None for source in reconstruction.stems_data.sources) + + def test_it_keeps_the_setup_and_the_per_frame_record(self) -> None: + reconstruction = _reconstruction() + before = dict(reconstruction.stems_data.assignments_by_channel) + + reconstruction = reconstruction.detached() + + assert reconstruction.stems_data.config == _stems_config() + assert reconstruction.stems_data.assignments_by_channel == before + + def test_it_carries_its_names_through_a_round_trip(self, tmp_path: Path) -> None: + reconstruction = _reconstruction() + reconstruction = reconstruction.detached() + path = tmp_path / "detached.stn" + + reconstruction.save(path) + restored = Reconstruction.load(path) + + assert restored.stems_data.named(LEAD) == "Lead Vocals" + assert restored.audio_filepath == () + + +class TestReadingTheRecordingsOnTheirOwn: + def test_the_record_reads_as_the_whole_document_states_it(self, tmp_path: Path) -> None: + path = tmp_path / "stems.stn" + _reconstruction().save(path) + + assert Reconstruction.read_stems_data(path) == Reconstruction.load(path).stems_data + + def test_a_document_stating_nothing_readable_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "broken.stn" + path.write_bytes(b"not a document") + + with pytest.raises(SampleToNESError): + Reconstruction.read_stems_data(path) + + +class TestALocationOneRecordingHasLost: + def test_the_whole_set_reads_as_unlocated(self) -> None: + """One unreadable recording costs the whole original, so a partial set offers none.""" + reconstruction = _reconstruction() + stems_data = reconstruction.stems_data + partial = StemsData( + config=stems_data.config, + sources=[stems_data.sources_by_id[LEAD], stems_data.sources_by_id[BASS].detached()], + assignments=stems_data.assignments, + scale=RECORDED_SCALE, + ) + + assert partial.paths == () + assert partial.named(BASS) == "Bass" + + +class TestARecordingTakenOut: + def test_its_source_leaves_the_record_with_it(self) -> None: + reconstruction = _reconstruction() + + remaining = without_stem(reconstruction, LEAD) + + assert [source.stem_id for source in remaining.stems_data.sources] == [BASS] + assert remaining.audio_filepath == (RECORDINGS[1],) + + def test_the_recordings_that_stay_keep_their_names(self) -> None: + reconstruction = _reconstruction() + + remaining = without_stem(reconstruction, LEAD) + + assert remaining.stems_data.named(BASS) == "Bass" + + +class TestAnEditedDocument: + def test_it_keeps_the_recordings_it_was_built_from(self) -> None: + reconstruction = _reconstruction() + + reconstruction = reconstruction.with_channel_data( + ChannelName.PULSE1, + [_pulse(70), _pulse(72)], + reconstruction.initial_pitches[ChannelName.PULSE1], + reconstruction.held_features[ChannelName.PULSE1], + heard=reconstruction.recorded_stem_ids, + ) + + assert reconstruction.audio_filepath == tuple(RECORDINGS) + assert reconstruction.stems_data.named(LEAD) == "Lead Vocals" diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py index 5f58d61a6..8c3f2731a 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py @@ -1,14 +1,18 @@ -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 ( + DEFAULT_CHANNELS, + 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 +37,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, DEFAULT_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 +59,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 +86,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..6f567f2e6 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/decoder/conftest.py @@ -0,0 +1,43 @@ +from typing import Any, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +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, contribution=Contribution.silence(1, 1)) + + +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_refiner.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py new file mode 100644 index 000000000..dc11e3c05 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py @@ -0,0 +1,212 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Optional, Tuple + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.configs.generation import GenerationConfig +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.fft.instantaneous import FundamentalReading +from sampletones_core.generators import PulseGenerator, get_generators_by_channels +from sampletones_core.instructions import PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate +from sampletones_core.reconstructions.reconstructor.refinement import refiner +from sampletones_core.reconstructions.reconstructor.refinement.refiner import PitchRefiner +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.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TONES: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] +PITCH: Final[int] = 60 +NEXT_PITCH: Final[int] = 62 +SILENT_FRAMES: Final[int] = 2 +VOLUME: Final[int] = 12 +FRAMES: Final[int] = 4 +BEND: Final[int] = 3 +SECONDS: Final[float] = 0.2 +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config() + + +def _stems(*entries: StemEntry) -> StemsConfig: + """A setup naming ``entries`` on one precedence level.""" + return StemsConfig( + entries=list(entries), + hierarchy=StemsHierarchy(levels=[[entry.id for entry in entries]]), + ) + + +def _recording(config: Config, channel_name: ChannelName) -> np.ndarray: + """A steady tone standing a little off the note ``PITCH`` names, so a reading has room to move.""" + generator = get_generators_by_channels(config, TONES)[channel_name] + sample_rate = config.library.sample_rate + frequency = generator.sounds_at(PITCH, BEND) + + count = int(sample_rate * SECONDS) + phase = (np.arange(count) * frequency / sample_rate) % 1.0 + return np.where(phase < 0.5, 0.4, -0.4).astype(np.float64) + + +def _stream(channel_name: ChannelName) -> List[ScoredCandidate]: + """One channel's frames, each sounding the note the matching chose and no bend.""" + instruction = ( + PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=2) + if channel_name is ChannelName.PULSE1 + else TriangleInstruction(on=True, pitch=PITCH) + ) + return [ + ScoredCandidate(instruction=instruction, cost=0.0, contribution=Contribution.silence(1, 1)) + for _ in range(FRAMES) + ] + + +def _bends(streams: Dict[ChannelName, List[ScoredCandidate]], channel_name: ChannelName) -> List[int]: + return [candidate.instruction.timer_offset for candidate in streams[channel_name]] + + +def _refine( + config: Config, + stems: StemsConfig, + stem_ids: Dict[ChannelName, List[int]], +) -> Dict[ChannelName, List[ScoredCandidate]]: + refined = PitchRefiner( + config=config, + channels=get_generators_by_channels(config, TONES), + stems=stems, + ) + return refined.refine( + {channel_name: _stream(channel_name) for channel_name in TONES}, + stem_ids, + {STEM_A: _recording(config, ChannelName.PULSE1)}, + ) + + +class TestWhichChannelsAStemCarries(BaseTestSuite): + """A stem states the channels it bends, and the refinement acts on those alone. + + The same recording reaches both channels of one stem here, so what separates them is only + what the entry names — which is the whole of the switch. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + bends: Tuple[ChannelName, ...] + bent: Tuple[ChannelName, ...] + + test_cases: Tuple["TestWhichChannelsAStemCarries.TestCase", ...] = ( + TestCase(label="both channels", bends=tuple(TONES), bent=tuple(TONES)), + TestCase(label="the pulse alone", bends=(ChannelName.PULSE1,), bent=(ChannelName.PULSE1,)), + TestCase(label="the triangle alone", bends=(ChannelName.TRIANGLE,), bent=(ChannelName.TRIANGLE,)), + TestCase(label="neither channel", bends=(), bent=()), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_only_the_channels_the_stem_names_are_carried( + self, + config: Config, + test_case: "TestWhichChannelsAStemCarries.TestCase", + ) -> None: + stems = _stems(StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=list(test_case.bends)))) + streams = _refine(config, stems, {channel_name: [STEM_A] * FRAMES for channel_name in TONES}) + + for channel_name in TONES: + bends = _bends(streams, channel_name) + if channel_name in test_case.bent: + assert any(bends), f"{channel_name} was named and stayed at its note" + else: + assert not any(bends), f"{channel_name} was left out and moved anyway" + + +class TestEachNoteHoldsItsOwnBend: + """A bend counts divider steps from its own note, so a note's frames are settled on their own.""" + + @staticmethod + def _two_notes(config: Config, monkeypatch: pytest.MonkeyPatch) -> List[int]: + """The bends of a note that reads nothing followed by one that reads ``BEND``.""" + sounding = PulseGenerator(config).sounds_at(NEXT_PITCH, BEND) + + class SilentThenBent: + def __init__(self, recording: np.ndarray, sample_rate: int, hop_length: int) -> None: + del recording, sample_rate, hop_length + + def at(self, frame: int, reference: float) -> Optional[FundamentalReading]: + del reference + return FundamentalReading(frequency=sounding, confidence=1.0) if frame >= SILENT_FRAMES else None + + monkeypatch.setattr(refiner, "InstantaneousPitch", SilentThenBent) + pitches = [PITCH] * SILENT_FRAMES + [NEXT_PITCH] * FRAMES + stream = [ + ScoredCandidate( + instruction=PulseInstruction(on=True, pitch=pitch, volume=VOLUME, duty_cycle=2), + cost=0.0, + contribution=Contribution.silence(1, 1), + ) + for pitch in pitches + ] + stems = _stems(StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=list(TONES)))) + refined = PitchRefiner(config=config, channels=get_generators_by_channels(config, TONES), stems=stems) + streams = refined.refine( + {ChannelName.PULSE1: stream}, + {ChannelName.PULSE1: [STEM_A] * len(pitches)}, + {STEM_A: _recording(config, ChannelName.PULSE1)}, + ) + return _bends(streams, ChannelName.PULSE1) + + def test_a_note_that_reads_nothing_stays_at_its_note(self, config: Config, monkeypatch: pytest.MonkeyPatch) -> None: + bends = self._two_notes(config, monkeypatch) + + assert bends[:SILENT_FRAMES] == [0] * SILENT_FRAMES + + def test_the_next_note_takes_the_bend_it_reads(self, config: Config, monkeypatch: pytest.MonkeyPatch) -> None: + bends = self._two_notes(config, monkeypatch) + + assert bends[SILENT_FRAMES:] == [BEND] * FRAMES + + +class TestWhatTheRefinementReadsPerStem: + """A reading is taken for the recording a bend is read from, and for no other.""" + + @staticmethod + def _counted(monkeypatch: pytest.MonkeyPatch) -> List[int]: + readings: List[int] = [] + + def counting(recording: np.ndarray, sample_rate: int, hop_length: int) -> refiner.InstantaneousPitch: + readings.append(len(recording)) + return refiner.InstantaneousPitch(recording, sample_rate, hop_length) + + monkeypatch.setattr(refiner, "InstantaneousPitch", counting) + return readings + + def test_a_stem_carrying_nothing_is_never_read( + self, + config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readings = self._counted(monkeypatch) + stems = _stems( + StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=[])), + StemEntry(id=STEM_B, settings=StemSettings(channels=TONES, bends=list(TONES))), + ) + _refine(config, stems, {channel_name: [STEM_A] * FRAMES for channel_name in TONES}) + + assert not readings + + def test_a_frame_no_stem_took_is_left_at_its_note(self, config: Config) -> None: + """A resting frame names no recording, so there is nothing to read a bend out of.""" + stems = _stems(StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=list(TONES)))) + resting = {channel_name: [RESTING_STEM_ID] * FRAMES for channel_name in TONES} + streams = _refine(config, stems, resting) + + assert not any(_bends(streams, ChannelName.PULSE1)) + assert not any(_bends(streams, ChannelName.TRIANGLE)) 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..31f8bcb26 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/conftest.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Mapping, Optional, Sequence + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH, UNIT_DRIVE +from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.fft import Fragment +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import GeneratorUnion, get_generators_by_channels +from sampletones_core.generators.render import render_instructions +from sampletones_core.instructions import InstructionUnion +from sampletones_core.library import InstructionLibraryData +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +from sampletones_core.reconstructions.reconstructor.matching import FrameMatcher, ScoredCandidate +from sampletones_core.reconstructions.reconstructor.mix import FrameMix +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker + +RENDERED_FRAMES: Final[int] = 5 + + +@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 rendered_fragment( + config: Config, + extractor: FeatureExtractor, + instructions: Mapping[ChannelName, InstructionUnion], +) -> Fragment: + """A frame of the channels playing ``instructions`` together, taken from the middle of a held note.""" + audio = sum( + ( + render_instructions([instruction] * RENDERED_FRAMES, channel_name, config) + for channel_name, instruction in instructions.items() + ), + start=np.zeros(RENDERED_FRAMES * config.library.frame_length, dtype=np.float32), + ) + return extractor.extract(audio)[RENDERED_FRAMES // 2] + + +def audible_instruction_of(library_data: InstructionLibraryData, generator: GeneratorUnion) -> InstructionUnion: + """The first sounding instruction the library holds for ``generator``'s class.""" + return next(instruction for instruction in library_data.filter((generator.class_name(),)).keys() if instruction.on) + + +@dataclass(frozen=True) +class _Pick: + """A channel standing for the next pick: what it would play, and the cost it is ranked on.""" + + channel_name: ChannelName + head: ScoredCandidate + unit_cost: float + + +def frame_objective_baseline( + fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + drive: float = UNIT_DRIVE, +) -> Dict[ChannelName, ScoredCandidate]: + """The frame objective restated one candidate at a time, as the assignment's reference. + + Every candidate of every free channel's kind is scored alone at ``drive`` by the frame's cost + with the picks so far sounding beside it, the lowest free channel of a kind standing for the + kind. The channel taken is the one reaching the lowest cost at unit drive while that cost + lowers the frame, and it sounds the head its column at ``drive`` carries; the channels no pick + reaches hold their silence; every channel is then scored once more with the others' heads + sounding. A one-stem setup at full count driving every channel alike runs exactly this, which + is what makes the two comparable frame by frame. + """ + heads: Dict[ChannelName, ScoredCandidate] = {} + frame_cost = matcher.mix_cost(fragment, FrameMix.empty(fragment)) + while True: + best: Optional[_Pick] = None + for channel_name in _representatives(channels, heads): + context = [head.contribution for head in heads.values()] + generator = channels[channel_name] + column = _column(fragment, generator, context, matcher, drive) + unit_column = column if drive == UNIT_DRIVE else _column(fragment, generator, context, matcher, UNIT_DRIVE) + unit_cost = unit_column[0].cost + if column[0].instruction.on and unit_cost < frame_cost and (best is None or unit_cost < best.unit_cost): + best = _Pick(channel_name=channel_name, head=column[0], unit_cost=unit_cost) + + if best is None: + break + + heads[best.channel_name] = best.head + frame_cost = best.unit_cost + + for channel_name in channels: + if channel_name not in heads: + sounding = [head.contribution for head in heads.values() if head.instruction.on] + heads[channel_name] = _column(fragment, channels[channel_name], sounding, matcher, drive)[0] + + for channel_name in list(heads): + others = [head.contribution for name, head in heads.items() if name != channel_name and head.instruction.on] + heads[channel_name] = _column(fragment, channels[channel_name], others, matcher, drive)[0] + + return heads + + +def _representatives( + channels: Dict[ChannelName, GeneratorUnion], + taken: Mapping[ChannelName, ScoredCandidate], +) -> List[ChannelName]: + representatives: Dict[GeneratorClassName, ChannelName] = {} + for channel_name, generator in channels.items(): + if channel_name not in taken: + representatives.setdefault(generator.class_name(), channel_name) + + return list(representatives.values()) + + +def _column( + fragment: Fragment, + generator: GeneratorUnion, + context: Sequence[Contribution], + matcher: FrameMatcher, + drive: float, +) -> List[ScoredCandidate]: + library_data = matcher.candidate_provider.library_data + scored = [ + _scored_alone(fragment, context, instruction, matcher, drive) + for instruction in library_data.filter((generator.class_name(),)).keys() + ] + return sorted(scored, key=lambda candidate: (candidate.cost, candidate.instruction.on)) + + +def _scored_alone( + fragment: Fragment, + context: Sequence[Contribution], + instruction: InstructionUnion, + matcher: FrameMatcher, + drive: float, +) -> ScoredCandidate: + provider = matcher.candidate_provider + mix = FrameMix.of(fragment, context) + power = provider.power_of(instruction) / drive**2 + contribution = matcher.contribution(instruction, mix.residual_waveform(fragment), power, drive=drive) + spectral = matcher.scorer.spectral_costs(fragment, provider.features_of((mix.power + power)[None, :])) + cost = matcher.scorer.frame_costs( + fragment, + spectral, + (mix.expectation + contribution.expectation)[None, :], + np.array([mix.variance + contribution.variance]), + )[0] + return ScoredCandidate(instruction=instruction, cost=float(cost), contribution=contribution) + + +@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_columns.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_columns.py new file mode 100644 index 000000000..9cf18f103 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_columns.py @@ -0,0 +1,79 @@ +from typing import Dict, Final, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators import GeneratorUnion, get_generators_by_channels +from sampletones_core.reconstructions.reconstructor.stems.assignment.columns import column_groups + +LOUD_DRIVE: Final[float] = 2.0 + + +@pytest.fixture(name="generators", scope="module") +def generators_fixture(config: Config) -> Dict[ChannelName, GeneratorUnion]: + return get_generators_by_channels(config, ChannelName.items()) + + +def _remaining( + generators: Dict[ChannelName, GeneratorUnion], + channel_names: List[ChannelName], +) -> Dict[ChannelName, GeneratorUnion]: + return {channel_name: generators[channel_name] for channel_name in channel_names} + + +class TestColumnGroups: + """One column answers the channels of a kind a stem drives alike, and each drive stands apart.""" + + def test_channels_of_one_kind_driven_alike_form_one_group( + self, + generators: Dict[ChannelName, GeneratorUnion], + ) -> None: + remaining = _remaining(generators, [ChannelName.PULSE1, ChannelName.PULSE2]) + drives = {ChannelName.PULSE1: UNIT_DRIVE, ChannelName.PULSE2: UNIT_DRIVE} + + groups = column_groups(remaining, drives) + + assert [(group.generator.name, group.drive) for group in groups] == [(ChannelName.PULSE1, UNIT_DRIVE)] + + def test_the_lowest_channel_left_stands_for_its_group( + self, + generators: Dict[ChannelName, GeneratorUnion], + ) -> None: + remaining = _remaining(generators, [ChannelName.PULSE2]) + + groups = column_groups(remaining, {ChannelName.PULSE2: UNIT_DRIVE}) + + assert [group.generator.name for group in groups] == [ChannelName.PULSE2] + + def test_channels_of_one_kind_driven_apart_form_a_group_each( + self, + generators: Dict[ChannelName, GeneratorUnion], + ) -> None: + remaining = _remaining(generators, [ChannelName.PULSE1, ChannelName.PULSE2]) + drives = {ChannelName.PULSE1: UNIT_DRIVE, ChannelName.PULSE2: LOUD_DRIVE} + + groups = column_groups(remaining, drives) + + assert [(group.generator.name, group.drive) for group in groups] == [ + (ChannelName.PULSE1, UNIT_DRIVE), + (ChannelName.PULSE2, LOUD_DRIVE), + ] + + def test_the_groups_stand_in_channel_order( + self, + generators: Dict[ChannelName, GeneratorUnion], + ) -> None: + remaining = _remaining(generators, [ChannelName.NOISE, ChannelName.PULSE1, ChannelName.TRIANGLE]) + + groups = column_groups(remaining, dict.fromkeys(remaining, UNIT_DRIVE)) + + assert [group.generator.name for group in groups] == [ + ChannelName.PULSE1, + ChannelName.TRIANGLE, + ChannelName.NOISE, + ] + + def test_no_channel_left_offers_no_group(self) -> None: + assert column_groups({}, {}) == [] 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..98f9d1379 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py @@ -0,0 +1,174 @@ +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels +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.configs.settings import StemSettings + + +def _stems_config() -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry( + id=0, settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])) + ), + StemEntry( + id=1, settings=StemSettings(channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])) + ), + ], + hierarchy=StemsHierarchy(levels=[[0], [1]], mode=HierarchyMode.STRICT), + ) + + +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 + + def test_duplicate_entry_ids_raise(self) -> None: + with pytest.raises(ValidationError): + StemsConfig( + entries=[ + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE]) + ), + ), + ], + hierarchy=StemsHierarchy(levels=[[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 + + +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, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_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, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=1, + settings=StemSettings( + channels=[ChannelName.NOISE], bends=bending_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, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_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].settings.channels == (ChannelName.NOISE,) + + def test_covered_channels_gather_every_entry(self) -> None: + stems = _stems_config() + assert stems.covered_channels == frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + +class TestWithoutEntries: + """The entries a setup lets go of leave with their places in the hierarchy.""" + + @staticmethod + def _shared_level() -> StemsConfig: + """Stems 0 and 1 picking together on the first level, stem 2 alone on the second.""" + return StemsConfig( + entries=[ + StemEntry( + id=stem_id, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ) + for stem_id in (0, 1, 2) + ], + hierarchy=StemsHierarchy(levels=[[0, 1], [2]], mode=HierarchyMode.STRICT), + ) + + def test_the_entries_named_leave(self) -> None: + remaining = self._shared_level().without_entries(frozenset({1})) + + assert [entry.id for entry in remaining.entries] == [0, 2] + assert remaining.hierarchy.levels == ((0,), (2,)) + + def test_a_level_they_empty_collapses(self) -> None: + remaining = self._shared_level().without_entries(frozenset({2})) + + assert remaining.hierarchy.levels == ((0, 1),) + + def test_the_picking_mode_stays(self) -> None: + remaining = self._shared_level().without_entries(frozenset({0, 1})) + + assert remaining.hierarchy.mode == HierarchyMode.STRICT + assert remaining.hierarchy.levels == ((2,),) + + def test_naming_nothing_leaves_the_setup_as_it_stands(self) -> None: + setup = self._shared_level() + + assert setup.without_entries(frozenset()) == setup + + +class TestSingleEntry: + def test_names_one_stem_over_the_settings_it_is_given(self) -> None: + settings = StemSettings.covering([ChannelName.PULSE1, ChannelName.TRIANGLE]) + stems = StemsConfig.single_entry(settings) + + assert [entry.settings for entry in stems.entries] == [settings] + assert stems.hierarchy.levels == ((0,),) + assert stems.covered_channels == settings.channel_set + + def test_carries_the_count_the_settings_state(self) -> None: + settings = StemSettings.covering([ChannelName.PULSE1, ChannelName.TRIANGLE]).with_channel_cap(1) + stems = StemsConfig.single_entry(settings) + assert stems.entries[0].settings.channel_cap == 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..3d6d8c143 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -0,0 +1,338 @@ +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 ( + MAX_DRIVE, + MIN_DRIVE, + SINGLE_STATE_LATTICE_WIDTH, + UNIT_DRIVE, +) +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels +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.contribution import Contribution +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.configs.settings import StemSettings +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment + +from .conftest import audible_instruction_of, frame_objective_baseline, rendered_fragment, shared_frames + +RANDOM_SEEDS: Final[Tuple[int, ...]] = (11, 23, 47, 89, 131, 197) +COST_TOLERANCE: Final[float] = 1e-5 +LOUD_DRIVE: Final[float] = 2.0 + + +def _config( + entries: Dict[int, Iterable[ChannelName]], + levels: List[List[int]], + mode: HierarchyMode, + channel_cap: int, + drive: float = UNIT_DRIVE, +) -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry( + id=stem_id, + settings=StemSettings( + channels=list(channels), + bends=bending_channels(list(channels)), + drives={channel_name: drive for channel_name in channels}, + channel_cap=channel_cap, + ), + ) + for stem_id, channels in entries.items() + ], + hierarchy=StemsHierarchy(levels=levels, mode=mode), + ) + + +class TestSingleStemEquivalence: + def test_matches_the_frame_objective_baseline( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels)) + + assignment = assign_frame( + shared_frames(synthetic_fragment, stems_config), + stems_config, + channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + baseline = frame_objective_baseline(synthetic_fragment, channels, matcher) + + assert [choice.channel_name for choice in assignment.choices] == list(baseline) + _assert_same_heads(assignment, baseline) + + def test_matches_the_baseline_with_all_four_channels( + self, + synthetic_fragment: Fragment, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> 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, + SINGLE_STATE_LATTICE_WIDTH, + ) + baseline = frame_objective_baseline(synthetic_fragment, all_channels, matcher) + + assert [choice.channel_name for choice in assignment.choices] == list(baseline) + _assert_same_heads(assignment, baseline) + + def test_matches_the_baseline_at_a_drive_off_unit( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + """A stem driving its channels harder picks what the frame objective at that drive picks.""" + stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels), LOUD_DRIVE) + + assignment = assign_frame( + shared_frames(synthetic_fragment, stems_config), + stems_config, + channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + baseline = frame_objective_baseline(synthetic_fragment, channels, matcher, LOUD_DRIVE) + + assert [choice.channel_name for choice in assignment.choices] == list(baseline) + _assert_same_heads(assignment, baseline) + + def test_matches_the_baseline_on_a_frame_two_channels_sound( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + """A frame a pulse and the triangle sound together takes several picks and a second pass.""" + fragment = rendered_fragment( + config, + extractor, + { + channel_name: audible_instruction_of(library_data, channels[channel_name]) + for channel_name in (ChannelName.PULSE1, ChannelName.TRIANGLE) + }, + ) + stems_config = _config({0: channels}, [[0]], HierarchyMode.STRICT, len(channels)) + + assignment = assign_frame( + shared_frames(fragment, stems_config), + stems_config, + channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + baseline = frame_objective_baseline(fragment, channels, matcher) + + assert sum(choice.sounding for choice in assignment.choices) >= 2 + assert [choice.channel_name for choice in assignment.choices] == list(baseline) + _assert_same_heads(assignment, baseline) + + +class TestLatticeWidthLeavesOwnership: + """A wider lattice grows what the decoder may choose from, and the heads stay put.""" + + def test_ownership_and_heads_hold_across_widths( + self, + synthetic_fragment: Fragment, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> 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, + SINGLE_STATE_LATTICE_WIDTH, + ) + wide = assign_frame( + shared_frames(synthetic_fragment, stems_config), + stems_config, + all_channels, + matcher, + 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_contribution(narrow_choice.contribution, wide_choice.contribution) + 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, + ) -> None: + """Two stems sounding differently each answer their own frame over their own channels. + + A stem's choices are the frame objective 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(frame_objective_baseline(first_fragment, subset_pulse_triangle, matcher)) + expected.update(frame_objective_baseline(second_fragment, subset_noise, matcher)) + + 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, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert set(assignment.by_channel) == set(expected) + _assert_same_heads(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, + SINGLE_STATE_LATTICE_WIDTH, + ) + repeat = assign_frame( + shared_frames(fragment, stems_config), + stems_config, + all_channels, + matcher, + 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].settings.channel_set + for stem_id, count in counts.items(): + assert count <= stems_config.entries_by_id[stem_id].settings.channel_cap + + +def _assert_same_heads( + assignment: StemFrameAssignment, + baseline: Dict[ChannelName, ScoredCandidate], +) -> None: + for channel_name, candidate in baseline.items(): + choice = assignment.by_channel[channel_name] + assert choice.instruction == candidate.instruction + assert choice.cost == pytest.approx(candidate.cost, abs=COST_TOLERANCE) + _assert_same_contribution(choice.contribution, candidate.contribution) + + +def _assert_same_contribution(left: Contribution, right: Contribution) -> None: + """Two contributions add the same sound: one power, one expected waveform, one variance.""" + np.testing.assert_allclose(left.power, right.power, rtol=1e-6, atol=1e-12) + np.testing.assert_allclose(left.expectation, right.expectation, rtol=1e-6, atol=1e-9) + assert left.variance == pytest.approx(right.variance) + + +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)), + float(rng.uniform(MIN_DRIVE, MAX_DRIVE)), + ) + + +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..f11b53699 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -0,0 +1,799 @@ +from dataclasses import dataclass, field +from typing import Dict, Final, List, Sequence, Set, Tuple + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import ( + ALL_STEMS_CHANNEL_CAP, + MAX_DRIVE, + SINGLE_STATE_LATTICE_WIDTH, + UNIT_DRIVE, +) +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + GeneratorClassName, + HierarchyMode, + bending_channels, +) +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 Column, FrameMatcher, column_of +from sampletones_core.reconstructions.reconstructor.mix import FrameMix +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.configs.settings import StemSettings +from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice +from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment +from sampletones_core.structures.histogram import Histogram +from tests.suite.fragments import amplified + +from .conftest import audible_instruction_of, rendered_fragment, shared_frames + +DEFAULT_CHANNELS: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] +LOUDER_STEM_SCALE: Final[float] = 4.0 +LOUD_DRIVE: Final[float] = 2.0 +SWEPT_DRIVES: Final[Tuple[float, ...]] = (UNIT_DRIVE, LOUD_DRIVE, MAX_DRIVE) + + +def _config( + entries: Dict[int, List[ChannelName]], + levels: List[List[int]], + mode: HierarchyMode, + channel_cap: int, +) -> StemsConfig: + return StemsConfig( + entries=[ + StemEntry( + id=stem_id, + settings=StemSettings(channels=channels, bends=bending_channels(channels), channel_cap=channel_cap), + ) + for stem_id, channels in entries.items() + ], + hierarchy=StemsHierarchy(levels=levels, mode=mode), + ) + + +def _assign( + fragment: Fragment, + stems_config: StemsConfig, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + lattice_width: int = SINGLE_STATE_LATTICE_WIDTH, +) -> StemFrameAssignment: + return assign_frame( + shared_frames(fragment, stems_config), + stems_config, + channels, + matcher, + lattice_width, + ) + + +class TestAssignFrameValidation: + def test_channel_outside_enabled_channels_raises( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + stems_config = _config({0: [ChannelName.PULSE2]}, [[0]], HierarchyMode.STRICT, 1) + with pytest.raises(ValueError, match="the run was not built for"): + _assign(synthetic_fragment, stems_config, channels, matcher) + + +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, + ) -> None: + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + + 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, + ) -> None: + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + + 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, + ) -> None: + stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + + 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, + ) -> 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: _silent(synthetic_fragment), 1: synthetic_fragment}, + stems_config, + channels, + matcher, + 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, + ) -> 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 = _silent(synthetic_fragment) + + assignment = assign_frame( + {0: silent}, + stems_config, + channels, + matcher, + 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 TestImprovementsWithinALevel: + """Two stems sharing a level compete for a channel by how much of their sound it covers.""" + + 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 where both are covered alike. + + A cost is a fraction of its own recording's energy, so the two stems lower their costs by + the same fraction; weighting that lowering 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 = amplified(extractor, synthetic_fragment, LOUDER_STEM_SCALE) + + 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, + 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. + + Improvements 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: amplified(extractor, synthetic_fragment, LOUDER_STEM_SCALE)}, + stems_config, + channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert [choice.stem_id for choice in assignment.choices] == [0] + + +class TestDrivesLeaveTheCompetitionStanding: + """A drive settles what a channel plays and leaves the stems competing where they stood. + + This is the contract ``docs/concepts/stems.md`` states of a drive: raising one lifts the part of the mix its own channel carries, while the stems beside it hold + the channels they held. + """ + + @staticmethod + def _stems_config(driven_stem_id: int, drive: float) -> StemsConfig: + """Two stems on one level reaching for the first pulse, one of them pushed to ``drive``.""" + held = [ChannelName.PULSE1] + return StemsConfig( + entries=[ + StemEntry( + id=stem_id, + settings=StemSettings( + channels=held, + bends=bending_channels(held), + drives={ChannelName.PULSE1: drive if stem_id == driven_stem_id else UNIT_DRIVE}, + channel_cap=1, + ), + ) + for stem_id in (0, 1) + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + ) + + def _winner( + self, + fragments: Dict[int, Fragment], + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + driven_stem_id: int, + drive: float, + ) -> int: + """The stem the first pulse reaches with ``driven_stem_id`` pushed to ``drive``.""" + assignment = assign_frame( + fragments, + self._stems_config(driven_stem_id, drive), + channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + return assignment.by_channel[ChannelName.PULSE1].stem_id + + @pytest.mark.parametrize("driven_stem_id", (0, 1)) + def test_the_channel_holds_its_stem_at_every_drive( + self, + driven_stem_id: int, + audible_fragments: List[Fragment], + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + """Two recordings reaching for one channel keep it where it stood, whichever is pushed. + + The stems are ranked on the channel read at unit drive, so the winner follows the + recordings alone and a reader raising a drive hears that stem louder where it stood. The + sweep stands at and above the level the library is calibrated to, the range this + library's rows answer. + """ + assert len(audible_fragments) >= 2 + fragments = {0: audible_fragments[0], 1: audible_fragments[-1]} + standing = self._winner(fragments, channels, matcher, driven_stem_id, UNIT_DRIVE) + + winners = {drive: self._winner(fragments, channels, matcher, driven_stem_id, drive) for drive in SWEPT_DRIVES} + + assert winners == {drive: standing for drive in SWEPT_DRIVES} + + +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, + ) -> None: + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + + 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, + ) -> 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, 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, + ) -> None: + stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, 1) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher, matcher.top_k) + + column = assignment.by_channel[ChannelName.PULSE1].column + expected = matcher.score_column( + synthetic_fragment, + channels[ChannelName.PULSE1], + FrameMix.empty(synthetic_fragment), + drive=UNIT_DRIVE, + ) + assert [candidate.instruction for candidate in column] == [ + candidate.instruction for candidate in column_of(expected, matcher.top_k) + ] + + def test_a_resting_channel_holds_its_null_instruction_alone( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> 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, 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(candidate.contribution.expectation) + + +class TestChannelCap: + def test_strict_mode_respects_the_cap( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + for cap, expected_count in ((1, 1), (2, 2), (ALL_STEMS_CHANNEL_CAP, 3)): + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, cap) + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + 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_no_more_channels_sound_than_the_cap_allows( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + """A frame every channel could answer sounds as many channels as the cap allows at most.""" + fragment = _every_kind(config, extractor, library_data, channels) + for cap in (1, 2): + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, cap) + assignment = _assign(fragment, stems_config, channels, matcher, matcher.top_k) + assert sum(choice.sounding for choice in assignment.choices) <= cap + + def test_round_robin_mode_respects_the_cap( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.ROUND_ROBIN, 2) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + + assert len(assignment.choices) == 2 + assert len(assignment.resting) == 1 + + +class TestPerStemCount: + """Each stem holds at most the count its own settings allow, whatever the stems beside it allow.""" + + def test_each_stem_holds_to_its_own_count( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + fragment = _every_kind(config, extractor, library_data, all_channels) + stems_config = StemsConfig( + entries=[ + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1, ChannelName.TRIANGLE], bends=[], channel_cap=1), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE2, ChannelName.NOISE], bends=[], channel_cap=2), + ), + ], + hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), + ) + + assignment = assign_frame( + {0: fragment, 1: fragment}, + stems_config, + all_channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + + held = {stem_id: [choice for choice in assignment.choices if choice.stem_id == stem_id] for stem_id in (0, 1)} + assert len(held[0]) == 1 + assert len(held[1]) == 2 + assert set(assignment.by_channel) | set(assignment.resting) == stems_config.covered_channels + + def test_a_count_above_the_channels_held_sounds_the_channels_held( + self, + synthetic_fragment: Fragment, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + stems_config = _config({0: [ChannelName.PULSE1]}, [[0]], HierarchyMode.STRICT, ALL_STEMS_CHANNEL_CAP) + + assignment = _assign(synthetic_fragment, stems_config, channels, matcher) + + assert [choice.channel_name for choice in assignment.choices] == [ChannelName.PULSE1] + assert assignment.resting == () + + +@dataclass(frozen=True) +class _CountingMatcher(FrameMatcher): + """A matcher recording every column it was asked for, so a case reads what was scored apart.""" + + requests: List[Tuple[GeneratorClassName, float]] = field(default_factory=list) + + def score_column( + self, + target: Fragment, + generator: GeneratorUnion, + mix: FrameMix, + *, + drive: float, + ) -> Column: + self.requests.append((generator.class_name(), drive)) + return super().score_column(target, generator, mix, drive=drive) + + +class TestDrivenColumns: + """A stem's channels are scored at the drives its settings give them.""" + + def test_channels_of_one_kind_driven_alike_are_scored_at_that_drive( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + counting = _counting(matcher) + + _assign_pulses( + _every_kind(config, extractor, library_data, all_channels), + {ChannelName.PULSE1: UNIT_DRIVE, ChannelName.PULSE2: UNIT_DRIVE}, + all_channels, + counting, + ) + + assert _pulse_drives(counting) == {UNIT_DRIVE} + + def test_channels_of_one_kind_driven_apart_are_scored_apart( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + counting = _counting(matcher) + + _assign_pulses( + _every_kind(config, extractor, library_data, all_channels), + {ChannelName.PULSE1: UNIT_DRIVE, ChannelName.PULSE2: LOUD_DRIVE}, + all_channels, + counting, + ) + + assert _pulse_drives(counting) == {UNIT_DRIVE, LOUD_DRIVE} + + +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, + ) -> None: + entries = {0: [ChannelName.PULSE1], 1: [ChannelName.PULSE1]} + + first = _assign( + synthetic_fragment, + _config(entries, [[0, 1]], HierarchyMode.STRICT, 1), + channels, + matcher, + ) + 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, + ) + 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, + ) -> 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) + second = _assign(synthetic_fragment, stems_config, channels, matcher) + + assert _choice_keys(first.choices) == _choice_keys(second.choices) + assert first.resting == second.resting + + +class TestHierarchyOrdering: + """Levels take their channels in the hierarchy's mode, each stem answering its own sound.""" + + def _frames( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], + ) -> Dict[int, Fragment]: + pulse_and_triangle = { + channel_name: audible_instruction_of(library_data, channels[channel_name]) + for channel_name in (ChannelName.PULSE1, ChannelName.TRIANGLE) + } + pulse = {ChannelName.PULSE1: audible_instruction_of(library_data, channels[ChannelName.PULSE1])} + return { + 0: rendered_fragment(config, extractor, pulse_and_triangle), + 1: rendered_fragment(config, extractor, pulse), + } + + def _stems(self, mode: HierarchyMode) -> StemsConfig: + return _config( + {0: [ChannelName.PULSE1, ChannelName.TRIANGLE], 1: [ChannelName.PULSE2]}, + [[0], [1]], + mode, + 2, + ) + + def test_strict_mode_exhausts_the_first_level_before_the_next( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + assignment = assign_frame( + self._frames(config, extractor, library_data, all_channels), + self._stems(HierarchyMode.STRICT), + all_channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + + 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.PULSE2 + assert all(choice.sounding for choice in assignment.choices) + + def test_round_robin_mode_alternates_levels_each_round( + self, + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + all_channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + assignment = assign_frame( + self._frames(config, extractor, library_data, all_channels), + self._stems(HierarchyMode.ROUND_ROBIN), + all_channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + + assert [choice.stem_id for choice in assignment.choices] == [0, 1, 0] + assert assignment.choices[1].channel_name == ChannelName.PULSE2 + assert {assignment.choices[0].channel_name, assignment.choices[2].channel_name} == { + ChannelName.PULSE1, + ChannelName.TRIANGLE, + } + + +class TestPicksThatStop: + """A channel sounds where it lowers the frame's cost, and holds its silence elsewhere.""" + + def test_a_frame_one_channel_renders_whole_sounds_that_channel_alone( + self, + config: Config, + window: Window, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + """The triangle's own frame costs nothing with the triangle sounding, so no other channel lowers it.""" + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + + assignment = _assign( + self._triangle_frame(config, window, library_data, channels), stems_config, channels, matcher, matcher.top_k + ) + + assert [choice.channel_name for choice in assignment.choices if choice.sounding] == [ChannelName.TRIANGLE] + assert assignment.resting == () + + def test_a_declined_channel_keeps_its_alternatives_headed_by_silence( + self, + config: Config, + window: Window, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, + ) -> None: + stems_config = _config({0: DEFAULT_CHANNELS}, [[0]], HierarchyMode.STRICT, len(DEFAULT_CHANNELS)) + + assignment = _assign( + self._triangle_frame(config, window, library_data, channels), stems_config, channels, matcher, matcher.top_k + ) + + declined = [choice for choice in assignment.choices if not choice.sounding] + assert declined + for choice in declined: + assert len(choice.column) > 1 + assert any(candidate.instruction.on for candidate in choice.column) + + @staticmethod + def _triangle_frame( + config: Config, + window: Window, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], + ) -> Fragment: + triangle = audible_instruction_of(library_data, channels[ChannelName.TRIANGLE]) + return library_data[triangle].get_fragment(0, config, window) + + +def _counting(matcher: FrameMatcher) -> _CountingMatcher: + """The same matching machinery, keeping a record of the columns it scores.""" + return _CountingMatcher( + config=matcher.config, + candidate_provider=matcher.candidate_provider, + scorer=matcher.scorer, + phase_aligner=matcher.phase_aligner, + ) + + +def _assign_pulses( + fragment: Fragment, + drives: Dict[ChannelName, float], + channels: Dict[ChannelName, GeneratorUnion], + matcher: FrameMatcher, +) -> StemFrameAssignment: + """One frame of a stem holding both pulses at the drives it gives them.""" + stems_config = StemsConfig( + entries=[ + StemEntry( + id=0, + settings=StemSettings( + channels=list(drives), + bends=[], + drives=drives, + channel_cap=len(drives), + ), + ) + ], + hierarchy=StemsHierarchy(levels=[[0]], mode=HierarchyMode.STRICT), + ) + return assign_frame( + shared_frames(fragment, stems_config), + stems_config, + channels, + matcher, + SINGLE_STATE_LATTICE_WIDTH, + ) + + +def _pulse_drives(matcher: _CountingMatcher) -> Set[float]: + """The drives the pulse class was scored at over the whole frame.""" + return {drive for class_name, drive in matcher.requests if class_name == GeneratorClassName.PULSE_GENERATOR} + + +def _silent(fragment: Fragment) -> Fragment: + """The same frame holding no sound.""" + return Fragment( + audio=np.zeros_like(np.asarray(fragment.audio)), + feature=Histogram(edges=fragment.feature.edges, values=np.zeros_like(np.asarray(fragment.feature.values))), + config=fragment.config, + ) + + +def _every_kind( + config: Config, + extractor: FeatureExtractor, + library_data: InstructionLibraryData, + channels: Dict[ChannelName, GeneratorUnion], +) -> Fragment: + return rendered_fragment( + config, + extractor, + {channel_name: audible_instruction_of(library_data, generator) for channel_name, generator in channels.items()}, + ) + + +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..dbd9a8c0e --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_models.py @@ -0,0 +1,157 @@ +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.contribution import Contribution +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)), + config=config, + ) + + +def _silence(fragment: Fragment) -> Contribution: + return Contribution.silence(len(fragment.feature.values), fragment.audio.shape[0]) + + +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, + column=( + ScoredCandidate( + instruction=instruction, + cost=FRAME_COST, + contribution=_silence(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, + contribution=_silence(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_before_it_sounds_records_both_frames(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.stem_ids[ChannelName.NOISE] == [RESTING_STEM_ID, 0] + + def test_a_frame_decoded_silent_is_released_to_the_resting_stem(self) -> None: + config = Config() + first, _ = _choices(config) + silent_pick = _choice(0, ChannelName.PULSE1, PulseInstruction.null_instruction(), _fragment(config)) + + track = TrackAssignment([ChannelName.PULSE1]) + for choice in (first, silent_pick, first): + track.add(StemFrameAssignment(choices=(choice,), rests=())) + track.release_silent({ChannelName.PULSE1: [column[0] for column in track.lattices[ChannelName.PULSE1]]}) + + assert track.stem_ids[ChannelName.PULSE1] == [first.stem_id, RESTING_STEM_ID, first.stem_id] + + def test_a_channel_decoded_silent_throughout_rests_in_every_frame(self) -> None: + config = Config() + silent_pick = _choice(0, ChannelName.PULSE1, PulseInstruction.null_instruction(), _fragment(config)) + + track = TrackAssignment([ChannelName.PULSE1]) + for _ in range(3): + track.add(StemFrameAssignment(choices=(silent_pick,), rests=())) + track.release_silent({ChannelName.PULSE1: [column[0] for column in track.lattices[ChannelName.PULSE1]]}) + + assert track.stem_ids[ChannelName.PULSE1] == [RESTING_STEM_ID] * 3 + + +class TestHierarchyMode: + def test_values(self) -> None: + assert tuple(HierarchyMode) == (HierarchyMode.ROUND_ROBIN, HierarchyMode.STRICT) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py new file mode 100644 index 000000000..0b0e0ec9a --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py @@ -0,0 +1,238 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional, Type + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.algorithm import ( + ALL_STEMS_CHANNEL_CAP, + MAX_DRIVE, + MIN_DRIVE, + MIN_STEMS_CHANNEL_CAP, + UNIT_DRIVE, +) +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName, bending_channels +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +PUSHED: float = 2.5 +TWO_CHANNELS: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE] + + +class TestTheBendsASettingsHolds(BaseTestSuite): + """A bend names a channel the recording occupies and whose hardware loads a divider.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Optional[Type[Exception]] + channels: List[ChannelName] + bends: List[ChannelName] + + test_cases = ( + TestCase( + label="bending_nothing", + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=[], + expected=None, + ), + TestCase( + label="bending_a_channel_it_occupies", + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE], + bends=[ChannelName.TRIANGLE], + expected=None, + ), + TestCase( + label="bending_every_tone_channel_it_occupies", + channels=list(sorted(TONE_CHANNELS)), + bends=list(sorted(TONE_CHANNELS)), + expected=None, + ), + TestCase( + label="bending_a_channel_it_leaves_out", + channels=[ChannelName.PULSE1], + bends=[ChannelName.TRIANGLE], + expected=ValidationError, + ), + TestCase( + label="bending_the_noise_channel", + channels=[ChannelName.NOISE], + bends=[ChannelName.NOISE], + expected=ValidationError, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_settings_are_held_to_their_bends(self, test_case: TestCase) -> None: + if test_case.expected is None: + settings = StemSettings(channels=test_case.channels, bends=test_case.bends) + assert settings.bends == tuple(test_case.bends) + return + + with pytest.raises(test_case.expected): + StemSettings(channels=test_case.channels, bends=test_case.bends) + + +class TestTheSetsSettingsAnswerWith: + """The membership tests an assignment and the refinement each read once per frame.""" + + def test_the_channel_set_holds_every_channel_occupied(self) -> None: + settings = StemSettings( + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=[ChannelName.PULSE1], + ) + assert settings.channel_set == frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + def test_the_bend_set_holds_every_channel_bent(self) -> None: + settings = StemSettings( + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE], + bends=[ChannelName.TRIANGLE], + ) + assert settings.bend_set == frozenset({ChannelName.TRIANGLE}) + + +class TestTheDrivesASettingsHolds(BaseTestSuite): + """A drive stands on every channel the recording occupies, at unit where none was stated.""" + + def test_channels_stated_without_a_drive_play_at_unit(self) -> None: + settings = StemSettings(channels=TWO_CHANNELS, bends=[]) + assert settings.drives == {channel_name: UNIT_DRIVE for channel_name in TWO_CHANNELS} + + def test_a_drive_stated_stands_beside_the_unit_ones(self) -> None: + settings = StemSettings(channels=TWO_CHANNELS, bends=[], drives={ChannelName.PULSE1: PUSHED}) + assert settings.drives == {ChannelName.PULSE1: PUSHED, ChannelName.TRIANGLE: UNIT_DRIVE} + + def test_a_drive_read_from_a_written_form_keys_by_channel(self) -> None: + settings = StemSettings.model_validate( + {"channels": ["pulse1", "triangle"], "bends": [], "drives": {"pulse1": PUSHED}} + ) + assert settings.drives == {ChannelName.PULSE1: PUSHED, ChannelName.TRIANGLE: UNIT_DRIVE} + + def test_a_drive_on_a_channel_left_out_is_refused(self) -> None: + with pytest.raises(ValidationError, match="occupies"): + StemSettings(channels=[ChannelName.PULSE1], bends=[], drives={ChannelName.NOISE: PUSHED}) + + @pytest.mark.parametrize("drive", (MIN_DRIVE / 2, MAX_DRIVE * 2), ids=("below_the_floor", "above_the_ceiling")) + def test_a_drive_outside_the_bounds_is_refused(self, drive: float) -> None: + with pytest.raises(ValidationError, match="outside"): + StemSettings(channels=[ChannelName.PULSE1], bends=[], drives={ChannelName.PULSE1: drive}) + + def test_the_bounds_themselves_are_drives(self) -> None: + settings = StemSettings( + channels=TWO_CHANNELS, + bends=[], + drives={ChannelName.PULSE1: MIN_DRIVE, ChannelName.TRIANGLE: MAX_DRIVE}, + ) + assert settings.drives == {ChannelName.PULSE1: MIN_DRIVE, ChannelName.TRIANGLE: MAX_DRIVE} + + +class TestTheCountASettingsHolds(BaseTestSuite): + """The count is the reader's choice within the channels the hardware has, whatever channels are held.""" + + def test_a_recording_sounds_every_channel_at_once_until_told_otherwise(self) -> None: + assert StemSettings(channels=[ChannelName.PULSE1], bends=[]).channel_cap == ALL_STEMS_CHANNEL_CAP + + @pytest.mark.parametrize( + "channel_cap", + (MIN_STEMS_CHANNEL_CAP - 1, ALL_STEMS_CHANNEL_CAP + 1), + ids=("below_one", "beyond_the_channels_there_are"), + ) + def test_a_count_outside_the_channels_there_are_is_refused(self, channel_cap: int) -> None: + with pytest.raises(ValidationError): + StemSettings(channels=[ChannelName.PULSE1], bends=[], channel_cap=channel_cap) + + def test_a_count_above_the_channels_held_stands(self) -> None: + settings = StemSettings(channels=[ChannelName.PULSE1], bends=[], channel_cap=ALL_STEMS_CHANNEL_CAP) + assert settings.channel_cap == ALL_STEMS_CHANNEL_CAP + + +class TestTheUsualSettings(BaseTestSuite): + """What a recording is converted with until a reader says otherwise.""" + + def test_covering_bends_every_tone_channel_at_unit_drive_all_at_once(self) -> None: + channels = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] + + settings = StemSettings.covering(channels) + + assert settings.channels == tuple(channels) + assert settings.bends == tuple(bending_channels(channels)) + assert settings.drives == {channel_name: UNIT_DRIVE for channel_name in channels} + assert settings.channel_cap == ALL_STEMS_CHANNEL_CAP + + +class TestRewritingTheChannels(BaseTestSuite): + """Narrowing the channels narrows what belongs to them; a channel newly held plays at unit.""" + + def test_a_channel_kept_keeps_its_drive_and_bend(self) -> None: + settings = StemSettings.covering(TWO_CHANNELS).with_drive(ChannelName.PULSE1, PUSHED) + + rewritten = settings.with_channels({ChannelName.PULSE1}) + + assert rewritten.channels == (ChannelName.PULSE1,) + assert rewritten.bends == (ChannelName.PULSE1,) + assert rewritten.drives == {ChannelName.PULSE1: PUSHED} + + def test_a_channel_newly_held_plays_at_unit_and_bends_nothing(self) -> None: + settings = StemSettings.covering([ChannelName.PULSE1]).with_drive(ChannelName.PULSE1, PUSHED) + + rewritten = settings.with_channels({ChannelName.PULSE1, ChannelName.TRIANGLE}) + + assert rewritten.channels == tuple(TWO_CHANNELS) + assert rewritten.bends == (ChannelName.PULSE1,) + assert rewritten.drives == {ChannelName.PULSE1: PUSHED, ChannelName.TRIANGLE: UNIT_DRIVE} + + def test_the_count_stays_the_readers_choice(self) -> None: + settings = StemSettings.covering(TWO_CHANNELS).with_channel_cap(MIN_STEMS_CHANNEL_CAP) + + assert settings.with_channels({ChannelName.NOISE}).channel_cap == MIN_STEMS_CHANNEL_CAP + + def test_the_channels_stand_in_the_order_the_application_names_them(self) -> None: + rewritten = StemSettings.covering([ChannelName.NOISE]).with_channels({ChannelName.NOISE, ChannelName.PULSE1}) + + assert rewritten.channels == (ChannelName.PULSE1, ChannelName.NOISE) + + +class TestRewritingTheOtherChoices(BaseTestSuite): + def test_a_bend_reaches_only_an_occupied_tone_channel(self) -> None: + settings = StemSettings.covering([ChannelName.PULSE1, ChannelName.NOISE]).with_drive(ChannelName.NOISE, PUSHED) + + rewritten = settings.with_bends({ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE}) + + assert rewritten.bends == (ChannelName.PULSE1,) + assert rewritten.drives == settings.drives + assert rewritten.channel_cap == settings.channel_cap + + def test_a_drive_lands_on_the_channel_named(self) -> None: + settings = StemSettings.covering(TWO_CHANNELS).with_drive(ChannelName.TRIANGLE, PUSHED) + + assert settings.drives == {ChannelName.PULSE1: UNIT_DRIVE, ChannelName.TRIANGLE: PUSHED} + + def test_a_drive_on_a_channel_left_out_is_refused(self) -> None: + with pytest.raises(ValueError, match="unoccupied"): + StemSettings.covering([ChannelName.PULSE1]).with_drive(ChannelName.NOISE, PUSHED) + + def test_a_drive_outside_the_bounds_is_refused(self) -> None: + with pytest.raises(ValidationError, match="outside"): + StemSettings.covering([ChannelName.PULSE1]).with_drive(ChannelName.PULSE1, MAX_DRIVE * 2) + + def test_a_count_lands_as_stated(self) -> None: + assert StemSettings.covering(TWO_CHANNELS).with_channel_cap(2).channel_cap == 2 + + def test_a_count_outside_the_channels_there_are_is_refused(self) -> None: + with pytest.raises(ValidationError): + StemSettings.covering(TWO_CHANNELS).with_channel_cap(ALL_STEMS_CHANNEL_CAP + 1) + + +class TestTheWrittenForm(BaseTestSuite): + """The settings come back from their written form as they went in, drives and count included.""" + + def test_settings_survive_a_round_trip(self) -> None: + drives: Dict[ChannelName, float] = {ChannelName.PULSE1: PUSHED, ChannelName.TRIANGLE: MIN_DRIVE} + settings = StemSettings( + channels=TWO_CHANNELS, + bends=[ChannelName.TRIANGLE], + drives=drives, + channel_cap=MIN_STEMS_CHANNEL_CAP, + ) + + assert StemSettings.deserialize(settings.serialize()) == settings diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_candidates.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_candidates.py new file mode 100644 index 000000000..19fd062f7 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_candidates.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Dict + +import numpy as np + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators import GeneratorUnion +from sampletones_core.instructions import InstructionUnion +from sampletones_core.library import InstructionLibraryData +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker +from sampletones_shared.array import to_numpy + + +class TestCandidatePowers: + def test_a_power_reads_back_as_the_feature_it_came_from( + self, + worker: ReconstructorWorker, + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + ) -> None: + provider = worker.candidate_provider + + feature = to_numpy(provider.features_of(provider.power_of(audible_instruction)[None, :]))[0] + + np.testing.assert_allclose(feature, library_data[audible_instruction].feature.values, rtol=1e-4, atol=1e-9) + + def test_every_candidate_of_a_class_holds_a_row_of_power( + self, + worker: ReconstructorWorker, + channels: Dict[ChannelName, GeneratorUnion], + ) -> None: + pulse = channels[ChannelName.PULSE1] + + candidates = worker.candidate_provider.candidates({pulse.class_name(): pulse}) + + powers = to_numpy(candidates.powers) + assert powers.shape == (len(candidates.instructions), worker.candidate_provider.widths.shape[0]) + for instruction, row in zip(candidates.instructions, powers): + np.testing.assert_allclose(row, worker.candidate_provider.power_of(instruction)) 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..0a5101330 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_matching.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Final, List + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.algorithm import MAX_DRIVE, UNIT_DRIVE +from sampletones_core.constants.enums import ChannelName, GeneratorClassName +from sampletones_core.fft import Fragment, Window +from sampletones_core.fft.features import FeatureExtractor +from sampletones_core.generators import GeneratorUnion +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction +from sampletones_core.library import InstructionLibraryData, InstructionLibraryFragment +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate, column_of +from sampletones_core.reconstructions.reconstructor.mix import FrameMix +from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker +from sampletones_shared.array import to_numpy, xp +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.fragments import amplified + +WORKER_SIGNAL_LENGTH: Final[int] = 1 << 20 +DRIVE: Final[float] = 2.0 +AVERAGED_SHIFTS: Final[int] = 400 +AVERAGE_TOLERANCE: Final[float] = 0.05 +SOUNDING_COST: Final[float] = 0.2 +SILENT_COST: Final[float] = 0.5 +LADDER_PITCH: Final[int] = 60 +LADDER_DUTY_CYCLE: Final[int] = 0 +LADDER_STEP: Final[int] = 2 +QUIET_RUNG: Final[int] = 3 +MIDDLE_RUNG: Final[int] = 7 + + +def _long_noise(library_data: InstructionLibraryData) -> NoiseInstruction: + return next( + instruction + for instruction in library_data.keys() + if isinstance(instruction, NoiseInstruction) and instruction.on and not instruction.short + ) + + +def _searching( + config: Config, + window: Window, + channels: Dict[ChannelName, GeneratorUnion], + library_data: InstructionLibraryData, + find_best_phase: bool, +) -> ReconstructorWorker: + calculation = config.generation.calculation.model_copy(update={"find_best_phase": find_best_phase}) + searching = config.model_copy( + update={"generation": config.generation.model_copy(update={"calculation": calculation})} + ) + return ReconstructorWorker( + config=searching, + window=window, + channels=channels, + library_data=library_data, + signal_length=WORKER_SIGNAL_LENGTH, + ) + + +def _kinds(worker: ReconstructorWorker) -> List[GeneratorUnion]: + """One generator per kind the run hands out, the lowest channel of each standing for it.""" + kinds: Dict[GeneratorClassName, GeneratorUnion] = {} + for generator in worker.channels.values(): + kinds.setdefault(generator.class_name(), generator) + + return list(kinds.values()) + + +def _generator_of(worker: ReconstructorWorker, instruction: InstructionUnion) -> GeneratorUnion: + """The generator of the kind the instruction belongs to.""" + return next(generator for generator in _kinds(worker) if generator.get_instruction_type() is type(instruction)) + + +def _temporal_loss(worker: ReconstructorWorker, target: Fragment, contribution: Contribution) -> float: + loss = worker.scorer.criterion.expected_temporal_loss( + xp.asarray(target.audio, dtype=xp.float64), + xp.asarray(contribution.expectation), + contribution.variance, + ) + return float(to_numpy(loss)[0]) + + +class TestScoreColumn: + def test_a_column_is_ranked_by_frame_cost_with_the_channel_s_silence_among_it( + self, + worker: ReconstructorWorker, + synthetic_fragment: Fragment, + ) -> None: + for generator in _kinds(worker): + column = worker.matcher.score_column( + synthetic_fragment, + generator, + FrameMix.empty(synthetic_fragment), + drive=UNIT_DRIVE, + ) + + costs = [candidate.cost for candidate in column] + assert costs == sorted(costs) + assert len(column) <= worker.matcher.top_k + 1 + assert any(not candidate.instruction.on for candidate in column) + + def test_a_phase_shifted_rendering_wins_its_class_at_near_zero_cost( + self, + worker: ReconstructorWorker, + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + config: Config, + window: Window, + ) -> None: + """ + The spectral shortlist is phase-independent and the candidate's waveform is aligned to the + target, so the phase accident carries no penalty. + """ + library_fragment = library_data[audible_instruction] + shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window) + + column = worker.matcher.score_column( + shifted_target, + _generator_of(worker, audible_instruction), + FrameMix.empty(shifted_target), + drive=UNIT_DRIVE, + ) + + assert column[0].instruction == audible_instruction + assert column[0].cost == pytest.approx(0.0, abs=1e-3) + + def test_a_driven_candidate_answers_a_target_it_sounds_louder_than( + self, + worker: ReconstructorWorker, + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + extractor: FeatureExtractor, + config: Config, + window: Window, + ) -> None: + """A candidate scored at a drive answers the target its library sample sounds that loud over.""" + library_fragment = library_data[audible_instruction].get_fragment(0, config, window) + target = amplified(extractor, library_fragment, UNIT_DRIVE / DRIVE) + + column = worker.matcher.score_column( + target, + _generator_of(worker, audible_instruction), + FrameMix.empty(target), + drive=DRIVE, + ) + + assert column[0].instruction == audible_instruction + assert column[0].cost == pytest.approx(0.0, abs=1e-3) + + def test_silence_costs_what_the_mix_alone_costs( + self, + worker: ReconstructorWorker, + synthetic_fragment: Fragment, + ) -> None: + mix = FrameMix.empty(synthetic_fragment) + generator = _kinds(worker)[0] + + column = worker.matcher.score_column(synthetic_fragment, generator, mix, drive=UNIT_DRIVE) + + silence = next(candidate for candidate in column if not candidate.instruction.on) + assert silence.cost == pytest.approx(worker.matcher.mix_cost(synthetic_fragment, mix), rel=1e-6) + + def test_a_sound_the_mix_already_holds_is_left_silent( + self, + worker: ReconstructorWorker, + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + config: Config, + window: Window, + ) -> None: + """Adding what the frame already sounds costs more than silence, so the channel's head is its silence.""" + target = library_data[audible_instruction].get_fragment(0, config, window) + generator = _generator_of(worker, audible_instruction) + covering = worker.matcher.score_column(target, generator, FrameMix.empty(target), drive=UNIT_DRIVE)[0] + + column = worker.matcher.score_column( + target, + generator, + FrameMix.of(target, [covering.contribution]), + drive=UNIT_DRIVE, + ) + + assert not column[0].instruction.on + + def test_a_library_without_the_class_s_silence_is_refused( + self, + config: Config, + window: Window, + channels: Dict[ChannelName, GeneratorUnion], + extractor: FeatureExtractor, + synthetic_fragment: Fragment, + ) -> None: + pulse = channels[ChannelName.PULSE1] + data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = { + instruction: InstructionLibraryFragment.create(pulse, instruction, extractor) + for instruction in list(pulse.get_possible_instructions())[1:3] + } + worker = ReconstructorWorker( + config=config, + window=window, + channels={ChannelName.PULSE1: pulse}, + library_data=InstructionLibraryData.create(config, data), + signal_length=WORKER_SIGNAL_LENGTH, + ) + + with pytest.raises(ValueError, match="lacks the silent instruction"): + worker.matcher.score_column( + synthetic_fragment, + pulse, + FrameMix.empty(synthetic_fragment), + drive=UNIT_DRIVE, + ) + + +class TestHowACandidateIsMeasured(BaseTestSuite): + """ + A candidate playback lines up with the target is measured at its best phase, and a candidate whose + frames show different stretches of a sequence by what it averages to over the phases it starts on, + whether or not the matching searches for the best phase. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + find_best_phase: bool + + test_cases = ( + TestCase(label="searching_the_phase", find_best_phase=True), + TestCase(label="keeping_the_carried_phase", find_best_phase=False), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_long_noise_costs_what_it_averages_to_over_its_shifts( + self, + test_case: TestCase, + config: Config, + window: Window, + channels: Dict[ChannelName, GeneratorUnion], + library_data: InstructionLibraryData, + synthetic_fragment: Fragment, + ) -> None: + worker = _searching(config, window, channels, library_data, test_case.find_best_phase) + noise = _long_noise(library_data) + library_fragment = library_data[noise] + criterion = worker.scorer.criterion + shifts = range(0, library_fragment.length, library_fragment.length // AVERAGED_SHIFTS) + averaged = np.mean( + [ + float( + to_numpy( + criterion.temporal_loss( + xp.asarray(synthetic_fragment.audio), + xp.asarray(library_fragment.get_fragment(shift, config, window).audio / DRIVE), + ) + )[0] + ) + for shift in shifts + ] + ) + + contribution = worker.matcher.contribution( + noise, + np.asarray(synthetic_fragment.audio, dtype=np.float64), + worker.candidate_provider.power_of(noise) / DRIVE**2, + drive=DRIVE, + ) + + assert _temporal_loss(worker, synthetic_fragment, contribution) == pytest.approx( + averaged, rel=AVERAGE_TOLERANCE + ) + + def test_a_note_searched_for_costs_no_more_than_at_its_library_phase( + self, + config: Config, + window: Window, + channels: Dict[ChannelName, GeneratorUnion], + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + synthetic_fragment: Fragment, + ) -> None: + searching = _searching(config, window, channels, library_data, True) + keeping = _searching(config, window, channels, library_data, False) + residual = np.asarray(synthetic_fragment.audio, dtype=np.float64) + power = searching.candidate_provider.power_of(audible_instruction) + + searched = searching.matcher.contribution(audible_instruction, residual, power, drive=UNIT_DRIVE) + kept = keeping.matcher.contribution(audible_instruction, residual, power, drive=UNIT_DRIVE) + + assert _temporal_loss(searching, synthetic_fragment, searched) <= _temporal_loss( + keeping, synthetic_fragment, kept + ) + + +def _ladder_instructions(generator: GeneratorUnion) -> List[InstructionUnion]: + """One pulse pitch at every other volume it holds, with the channel's silence beside them. + + Stepping over the volumes keeps the whole ladder inside the spectral shortlist, so the + loudest rung stands before the full cost whatever drive the column is scored at. + """ + rungs = sorted( + ( + instruction + for instruction in generator.get_possible_instructions() + if instruction.on and instruction.pitch == LADDER_PITCH and instruction.duty_cycle == LADDER_DUTY_CYCLE + ), + key=lambda instruction: instruction.volume, + ) + return [*rungs[::LADDER_STEP], generator.get_instruction_type().null_instruction()] + + +def _ladder_target( + worker: ReconstructorWorker, + config: Config, + window: Window, + volume: int, +) -> Fragment: + """The frame the rung at ``volume`` sounds, which a drive is measured against.""" + rung = next( + instruction for instruction in worker.library_data.keys() if instruction.on and instruction.volume == volume + ) + return worker.library_data[rung].get_fragment(0, config, window) + + +def _winner(worker: ReconstructorWorker, target: Fragment, drive: float) -> InstructionUnion: + """The row a channel plays in ``target`` at ``drive``.""" + generator = worker.channels[ChannelName.PULSE1] + column = worker.matcher.score_column(target, generator, FrameMix.empty(target), drive=drive) + return column[0].instruction + + +def _loudest(worker: ReconstructorWorker) -> InstructionUnion: + """The loudest row the ladder holds, which is the level the channel saturates at.""" + return max( + (instruction for instruction in worker.library_data.keys() if instruction.on), + key=lambda instruction: instruction.volume, + ) + + +@pytest.fixture(scope="module") +def ladder_worker( + config: Config, + window: Window, + extractor: FeatureExtractor, + channels: Dict[ChannelName, GeneratorUnion], +) -> ReconstructorWorker: + """A run over one pulse pitch at a ladder of volumes, which is what a drive climbs.""" + generator = channels[ChannelName.PULSE1] + data: Dict[InstructionUnion, InstructionLibraryFragment[Any]] = { + instruction: InstructionLibraryFragment.create(generator, instruction, extractor) + for instruction in _ladder_instructions(generator) + } + return ReconstructorWorker( + config=config, + window=window, + channels={ChannelName.PULSE1: generator}, + library_data=InstructionLibraryData.create(config, data), + signal_length=WORKER_SIGNAL_LENGTH, + ) + + +class TestTheRowADriveReachesFor: + """A drive lifts what a channel reaches for, up to the loudest row the channel holds.""" + + def test_a_driven_channel_reaches_for_a_louder_row( + self, + ladder_worker: ReconstructorWorker, + config: Config, + window: Window, + ) -> None: + target = _ladder_target(ladder_worker, config, window, QUIET_RUNG) + + standing = _winner(ladder_worker, target, UNIT_DRIVE) + driven = _winner(ladder_worker, target, DRIVE) + + assert standing.volume < driven.volume < _loudest(ladder_worker).volume + + def test_a_drive_the_channel_cannot_reach_settles_on_its_loudest_row( + self, + ladder_worker: ReconstructorWorker, + config: Config, + window: Window, + ) -> None: + target = _ladder_target(ladder_worker, config, window, MIDDLE_RUNG) + + assert _winner(ladder_worker, target, MAX_DRIVE) == _loudest(ladder_worker) + + +class TestColumnOf: + def _column(self) -> tuple[ScoredCandidate, ...]: + silence = Contribution.silence(1, 1) + return ( + ScoredCandidate(PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=1), SOUNDING_COST, silence), + ScoredCandidate(PulseInstruction(on=True, pitch=61, volume=8, duty_cycle=1), SOUNDING_COST, silence), + ScoredCandidate(PulseInstruction.null_instruction(), SILENT_COST, silence), + ) + + def test_a_single_state_decoder_reads_the_head_alone(self) -> None: + column = self._column() + assert column_of(column, 1) == column[:1] + + def test_a_wider_column_keeps_the_channel_s_silence(self) -> None: + column = self._column() + assert column_of(column, 2) == (column[0], column[2]) + + def test_a_column_already_holding_its_silence_keeps_its_order(self) -> None: + column = self._column() + assert column_of(column, 3) == column diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_mix.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_mix.py new file mode 100644 index 000000000..c238ec9c8 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_mix.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Final + +import numpy as np +import pytest + +from sampletones_core.fft import Fragment +from sampletones_core.reconstructions.reconstructor.contribution import Contribution +from sampletones_core.reconstructions.reconstructor.mix import FrameMix + +VARIANCE: Final[float] = 0.25 + + +def _contribution(fragment: Fragment, scale: float) -> Contribution: + bins = len(fragment.feature.values) + frame_length = fragment.audio.shape[0] + return Contribution( + power=np.full(bins, scale), + expectation=np.full(frame_length, scale), + variance=scale * VARIANCE, + ) + + +class TestFrameMix: + def test_an_empty_mix_sounds_nothing(self, synthetic_fragment: Fragment) -> None: + mix = FrameMix.empty(synthetic_fragment) + + assert mix.power.shape == (len(synthetic_fragment.feature.values),) + assert mix.expectation.shape == synthetic_fragment.audio.shape + assert not np.any(mix.power) and not np.any(mix.expectation) + assert mix.variance == 0.0 + + def test_contributions_add_their_power_waveform_and_variance(self, synthetic_fragment: Fragment) -> None: + first = _contribution(synthetic_fragment, 1.0) + second = _contribution(synthetic_fragment, 2.0) + + mix = FrameMix.of(synthetic_fragment, [first, second]) + + np.testing.assert_allclose(mix.power, first.power + second.power) + np.testing.assert_allclose(mix.expectation, first.expectation + second.expectation) + assert mix.variance == pytest.approx(3.0 * VARIANCE) + + def test_the_residual_waveform_is_what_the_mix_leaves_of_the_frame(self, synthetic_fragment: Fragment) -> None: + contribution = _contribution(synthetic_fragment, 0.5) + + residual = FrameMix.empty(synthetic_fragment).added(contribution).residual_waveform(synthetic_fragment) + + np.testing.assert_allclose(residual, np.asarray(synthetic_fragment.audio, dtype=np.float64) - 0.5) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_phase.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_phase.py index 18b1b9a64..1d03c5e5d 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_phase.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_phase.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import Type +from typing import Final, Type import numpy as np import pytest from sampletones_core.configs import Config -from sampletones_core.fft import Fragment, Window +from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.fft import Window from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibraryData from sampletones_core.reconstructions.reconstructor.phase import ( @@ -15,9 +16,11 @@ SlidingRmsePhaseAligner, ) +DRIVE: Final[float] = 2.0 -def _rmse(target: Fragment, aligned: Fragment) -> float: - difference = np.asarray(target.audio) - np.asarray(aligned.audio) + +def _rmse(target: np.ndarray, aligned: np.ndarray) -> float: + difference = np.asarray(target, dtype=np.float64) - np.asarray(aligned, dtype=np.float64) return float(np.sqrt(np.mean(difference**2))) @@ -35,9 +38,9 @@ def test_cross_correlation_reaches_the_sliding_rmse_optimum( assert active_instructions for instruction in active_instructions: - target = library_data[instruction].get_fragment(0, config, window) - sliding_rmse = _rmse(target, sliding.align(target, instruction)) - cross_correlation_rmse = _rmse(target, cross_correlation.align(target, instruction)) + target = library_data[instruction].get_fragment(0, config, window).audio + sliding_rmse = _rmse(target, sliding.align(target, instruction, UNIT_DRIVE)) + cross_correlation_rmse = _rmse(target, cross_correlation.align(target, instruction, UNIT_DRIVE)) assert cross_correlation_rmse == pytest.approx(sliding_rmse, abs=1e-6) assert cross_correlation_rmse == pytest.approx(0.0, abs=1e-4) @@ -45,7 +48,7 @@ def test_cross_correlation_reaches_the_sliding_rmse_optimum( class TestPhaseAlignerDrive: @pytest.mark.parametrize("aligner_class", [SlidingRmsePhaseAligner, CrossCorrelationPhaseAligner]) - def test_aligned_candidate_matches_a_drive_scaled_target( + def test_an_aligned_candidate_matches_the_target_it_sounds_louder_than( self, aligner_class: Type[PhaseAligner], config: Config, @@ -55,15 +58,29 @@ def test_aligned_candidate_matches_a_drive_scaled_target( ) -> None: """ The aligner searches and returns the candidate at the amplitude it competes - at, so a target that is a drive-scaled, phase-shifted rendering of the - candidate is reproduced exactly. + at, so a target the candidate sounds ``drive`` times as loud as, phase-shifted, + is reproduced exactly. """ - drive = 2.0 - driven_config = config.model_copy(update={"generation": config.generation.model_copy(update={"drive": drive})}) - aligner = aligner_class(driven_config, window, library_data) + aligner = aligner_class(config, window, library_data) library_fragment = library_data[audible_instruction] - target = library_fragment.get_fragment(library_fragment.length // 4, config, window) * drive - aligned = aligner.align(target, audible_instruction) + target = library_fragment.get_fragment(library_fragment.length // 4, config, window).audio / DRIVE + aligned = aligner.align(target, audible_instruction, DRIVE) assert _rmse(target, aligned) == pytest.approx(0.0, abs=1e-4) + + def test_the_sliding_energy_of_a_candidate_is_read_once( + self, + config: Config, + window: Window, + library_data: InstructionLibraryData, + audible_instruction: InstructionUnion, + ) -> None: + aligner = CrossCorrelationPhaseAligner(config, window, library_data) + target = library_data[audible_instruction].get_fragment(0, config, window).audio + + first = aligner.align(target, audible_instruction, UNIT_DRIVE) + second = aligner.align(0.5 * target, audible_instruction, UNIT_DRIVE) + + assert len(aligner._energies) == 1 # pylint: disable=protected-access + np.testing.assert_allclose(first, second) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index 50ec2fd16..c72edd0f9 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -1,21 +1,35 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path +from typing import Final, List from unittest.mock import MagicMock import numpy as np import pytest from sampletones_core.configs import Config -from sampletones_core.fft import Fragment, FragmentedAudio, 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.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + GeneratorClassName, ) +from sampletones_core.fft import Fragment, Window +from sampletones_core.generators import FULL_SCALE_RMS_LEVELS +from sampletones_core.generators.render import render_channels +from sampletones_core.library import InstructionLibraryData +from sampletones_core.reconstructions.reconstruction.renders import rendered_channels 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_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import NoLibraryDataError +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TRIANGLE_LEVEL: Final[float] = FULL_SCALE_RMS_LEVELS[GeneratorClassName.TRIANGLE_GENERATOR] +SINE_FRAMES: Final[int] = 30 +SINE_AMPLITUDE: Final[float] = 0.8 +SINE_CYCLES_PER_FRAME: Final[int] = 4 def _make_reconstructor(config: Config, library_data: InstructionLibraryData) -> Reconstructor: @@ -23,7 +37,7 @@ def _make_reconstructor(config: Config, library_data: InstructionLibraryData) -> mock_library.get.return_value = library_data mock_library.create_key.return_value = MagicMock() mock_library.get_path.return_value = "test/path" - return Reconstructor(config, library=mock_library) + return Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=mock_library) class TestReconstructorInit: @@ -33,8 +47,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(DEFAULT_CHANNELS) + assert set(reconstructor.channels.keys()) == expected_names def test_window_created_from_config( self, @@ -54,7 +68,7 @@ def test_none_library_data_raises_no_library_data_error(self, config: Config) -> mock_library.create_key.return_value = MagicMock() mock_library.get_path.return_value = "test/path" with pytest.raises(NoLibraryDataError): - Reconstructor(config, library=mock_library) + Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=mock_library) class TestReconstructorGetCoefficient: @@ -65,9 +79,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 / TRIANGLE_LEVEL) def test_coefficient_is_robust_to_a_lone_transient( self, @@ -76,12 +89,11 @@ 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()) audio = np.full(frame_length * 24, 0.05, dtype=np.float32) audio[:frame_length] = 1.0 - coefficient = reconstructor.get_coefficient(audio) - assert coefficient == pytest.approx(0.05 / total_mixer, rel=1e-3) - assert coefficient < 1.0 / total_mixer + coefficient = reconstructor.get_coefficient(audio, _full_setup(config)) + assert coefficient == pytest.approx(0.05 / TRIANGLE_LEVEL, rel=1e-3) + assert coefficient < 1.0 / TRIANGLE_LEVEL def test_louder_audio_produces_larger_coefficient( self, @@ -89,9 +101,75 @@ 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_steady_sine_lands_at_the_level_a_full_volume_triangle_plays( + self, + config: Config, + library_data: InstructionLibraryData, + ) -> None: + reconstructor = _make_reconstructor(config, library_data) + frame_length = config.library.frame_length + phase = np.arange(frame_length * SINE_FRAMES) * SINE_CYCLES_PER_FRAME / frame_length + sine = (SINE_AMPLITUDE * np.sin(2.0 * np.pi * phase)).astype(np.float32) + + scaled = sine / reconstructor.get_coefficient(sine, _full_setup(config)) + + level = float(np.sqrt(np.mean(np.square(scaled.astype(np.float64))))) + assert level == pytest.approx(TRIANGLE_LEVEL, rel=1e-2) + + +class TestReconstructorWorkingLevel(BaseTestSuite): + """The working level is the full-scale RMS level of the quietest tone channel a setup covers.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channels: List[ChannelName] + anchor: GeneratorClassName + + test_cases = ( + TestCase( + label="triangle_among_the_tone_channels", + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE], + anchor=GeneratorClassName.TRIANGLE_GENERATOR, + ), + TestCase( + label="pulse_without_the_triangle", + channels=[ChannelName.PULSE1, ChannelName.NOISE], + anchor=GeneratorClassName.PULSE_GENERATOR, + ), + TestCase( + label="noise_without_a_tone_channel", + channels=[ChannelName.NOISE], + anchor=GeneratorClassName.NOISE_GENERATOR, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_level_follows_the_covered_channels( + self, + config: Config, + library_data: InstructionLibraryData, + test_case: TestCase, + ) -> None: + reconstructor = _make_reconstructor(config, library_data) + setup = StemsConfig.single_entry(StemSettings.covering(test_case.channels)) + audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5 + + assert reconstructor.get_coefficient(audio, setup) == pytest.approx( + 0.5 / FULL_SCALE_RMS_LEVELS[test_case.anchor] + ) + + +def _full_setup(config: Config) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering(list(DEFAULT_CHANNELS))) class TestReconstructorGetFragments: @@ -119,7 +197,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 +215,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 +223,90 @@ 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 TestReconstructorReconstruct: - def test_state_is_populated_for_each_fragment( - 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( - 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) - for generator_name in reconstructor.generators: - for approximation in reconstructor.state.approximations[generator_name]: - assert len(approximation) == config.library.frame_length +class TestTheAudioAReconstructionRecords: + """A frame records its instruction rendered afresh, which is the audio the channel plays.""" + def _tone_path(self, tmp_path: Path, config: Config, synthetic_fragment: Fragment) -> Path: + from sampletones_core.audio import write_wave -class TestReconstructorCall: - def test_non_path_argument_raises_type_error( + 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 + + def test_every_channel_spans_the_frames_its_stream_describes( self, config: Config, library_data: InstructionLibraryData, + synthetic_fragment: Fragment, + tmp_path: Path, ) -> None: reconstructor = _make_reconstructor(config, library_data) - with pytest.raises(TypeError): - reconstructor(42) # type: ignore[arg-type] - def test_returns_reconstruction_for_valid_audio_path( + reconstruction = reconstructor(self._tone_path(tmp_path, config, synthetic_fragment)) + + assert reconstruction is not None + for channel_name in reconstruction.playing_channels: + frames = len(reconstruction.instructions[channel_name]) + assert len(rendered_channels(reconstruction)[channel_name]) == frames * config.library.frame_length + + @pytest.mark.parametrize("reset_phase", [False, True], ids=["carried_phase", "reset_phase"]) + def test_the_recorded_audio_is_what_the_channels_render( self, config: Config, library_data: InstructionLibraryData, synthetic_fragment: Fragment, tmp_path: Path, + reset_phase: bool, ) -> None: - from sampletones_core.audio import write_wave - from sampletones_core.reconstructions.reconstruction.reconstruction import ( - Reconstruction, + """The phase a new note starts on follows the configuration in both, so an export plays what + the reconstruction shows.""" + resetting = config.model_copy( + update={"generation": config.generation.model_copy(update={"reset_phase": reset_phase})} ) - - 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) + reconstructor = _make_reconstructor(resetting, library_data) + + reconstruction = reconstructor(self._tone_path(tmp_path, resetting, synthetic_fragment)) + + assert reconstruction is not None + rendered = render_channels(reconstruction.instructions, resetting) + for channel_name in reconstruction.playing_channels: + np.testing.assert_allclose( + rendered_channels(reconstruction)[channel_name], + rendered[channel_name], + atol=1e-6, + ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py index ea33f4797..7c2fe3809 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_scorer.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Tuple - import numpy as np import pytest @@ -9,30 +7,32 @@ from sampletones_core.fft import Fragment, Window from sampletones_core.instructions import InstructionUnion from sampletones_core.library import InstructionLibraryData +from sampletones_core.reconstructions.reconstructor.candidates import ClassCandidates from sampletones_core.reconstructions.reconstructor.scorer import Scorer from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker -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) - return worker.candidate_provider.candidates(remaining_generator_classes) +def _candidates(worker: ReconstructorWorker) -> ClassCandidates: + """Every candidate of every kind the run hands out, stacked as one column would read them.""" + return worker.candidate_provider.candidates( + {generator.class_name(): generator for generator in worker.channels.values()} + ) class TestSpectralCosts: - def test_library_waveform_scores_its_source_instruction_at_zero( + def test_library_feature_scores_its_source_instruction_at_zero( self, worker: ReconstructorWorker, audible_instruction: InstructionUnion, synthetic_fragment: Fragment, ) -> None: - instructions, candidates = _candidate_approximations(worker) - costs = worker.scorer.spectral_costs(synthetic_fragment, candidates) - source_index = instructions.index(audible_instruction) + candidates = _candidates(worker) + costs = worker.scorer.spectral_costs( + synthetic_fragment, worker.candidate_provider.features_of(candidates.powers) + ) + source_index = candidates.instructions.index(audible_instruction) - assert costs.shape == (len(instructions),) + assert costs.shape == (len(candidates.instructions),) assert float(costs[source_index]) == pytest.approx(0.0, abs=1e-5) def test_costs_are_independent_of_the_target_phase( @@ -47,60 +47,52 @@ def test_costs_are_independent_of_the_target_phase( Spectral costs compare phase-averaged features, so the same waveform rendered at two phases receives identical costs. """ - instruction = audible_instruction - library_fragment = library_data[instruction] + library_fragment = library_data[audible_instruction] target = library_fragment.get_fragment(0, config, window) shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window) + features = worker.candidate_provider.features_of(_candidates(worker).powers) - _, candidates = _candidate_approximations(worker) - costs = worker.scorer.spectral_costs(target, candidates) - shifted_costs = worker.scorer.spectral_costs(shifted_target, candidates) + costs = worker.scorer.spectral_costs(target, features) + shifted_costs = worker.scorer.spectral_costs(shifted_target, features) np.testing.assert_allclose(shifted_costs, costs, rtol=1e-6) -class TestAlignedCost: - def test_alignment_forgives_the_target_phase( +class TestFrameCosts: + def test_the_target_s_own_waveform_costs_nothing_over_time( self, worker: ReconstructorWorker, - library_data: InstructionLibraryData, - audible_instruction: InstructionUnion, - config: Config, - window: Window, + synthetic_fragment: Fragment, ) -> None: - """ - The aligned cost evaluates the temporal term at the candidate's best phase - against the target, so a phase-shifted rendering of the candidate itself - scores at zero while the unaligned rendering carries the phase accident. - """ - instruction = audible_instruction - library_fragment = library_data[instruction] - shifted_target = library_fragment.get_fragment(library_fragment.length // 4, config, window) - - aligned = worker.phase_aligner.align(shifted_target, instruction) - unaligned = library_fragment.get_fragment(0, config, window) + audio = np.asarray(synthetic_fragment.audio, dtype=np.float64) - aligned_cost = worker.scorer.aligned_cost(shifted_target, 0.0, aligned) - unaligned_cost = worker.scorer.aligned_cost(shifted_target, 0.0, unaligned) + costs = worker.scorer.frame_costs(synthetic_fragment, np.zeros(1), audio[None, :], np.zeros(1)) - assert aligned_cost == pytest.approx(0.0, abs=1e-4) - assert aligned_cost < unaligned_cost + assert float(costs[0]) == pytest.approx(0.0, abs=1e-6) def test_blends_spectral_and_temporal_terms_with_the_configured_weights( self, worker: ReconstructorWorker, - library_data: InstructionLibraryData, - audible_instruction: InstructionUnion, - config: Config, - window: Window, + synthetic_fragment: Fragment, ) -> None: - instruction = audible_instruction - library_fragment = library_data[instruction] - target = library_fragment.get_fragment(0, config, window) - aligned = worker.phase_aligner.align(target, instruction) - + audio = np.asarray(synthetic_fragment.audio, dtype=np.float64) spectral_cost = 0.5 - cost = worker.scorer.aligned_cost(target, spectral_cost, aligned) - assert cost == pytest.approx(worker.scorer.criterion.alpha * spectral_cost, abs=1e-5) + + cost = worker.scorer.frame_costs(synthetic_fragment, np.array([spectral_cost]), audio[None, :], np.zeros(1)) + + assert float(cost[0]) == pytest.approx(worker.scorer.criterion.alpha * spectral_cost, abs=1e-5) + + def test_a_spread_costs_what_its_variance_adds( + self, + worker: ReconstructorWorker, + synthetic_fragment: Fragment, + ) -> None: + """A mix known up to a spread costs more over time than the same mix rendered exactly.""" + audio = np.asarray(synthetic_fragment.audio, dtype=np.float64) + expectations = np.stack([audio, audio]) + + exact, spread = worker.scorer.frame_costs(synthetic_fragment, np.zeros(2), expectations, np.array([0.0, 1e-3])) + + assert exact < spread class TestTopK: @@ -109,8 +101,8 @@ def test_top_k_is_ascending( worker: ReconstructorWorker, synthetic_fragment: Fragment, ) -> None: - _, candidates = _candidate_approximations(worker) - costs = worker.scorer.spectral_costs(synthetic_fragment, candidates) + features = worker.candidate_provider.features_of(_candidates(worker).powers) + costs = worker.scorer.spectral_costs(synthetic_fragment, features) indices = Scorer.top_k(costs, 3) assert int(indices[0]) == int(np.argmin(costs)) assert bool(np.all(np.diff(costs[indices]) >= 0.0)) @@ -120,7 +112,7 @@ def test_top_k_clamps_to_candidate_count( worker: ReconstructorWorker, synthetic_fragment: Fragment, ) -> None: - _, candidates = _candidate_approximations(worker) - costs = worker.scorer.spectral_costs(synthetic_fragment, candidates) + features = worker.candidate_provider.features_of(_candidates(worker).powers) + costs = worker.scorer.spectral_costs(synthetic_fragment, features) indices = Scorer.top_k(costs, costs.shape[0] + 10) assert indices.shape[0] == costs.shape[0] 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..313ad1d33 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py @@ -2,49 +2,33 @@ 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_audio(value: float = 1.0) -> np.ndarray: - return np.full(_FRAME_LENGTH, value, dtype=np.float32) +def _make_instruction() -> PulseInstruction: + return PulseInstruction(on=True, pitch=60, volume=10, duty_cycle=0) @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, ], ), ] @@ -55,21 +39,16 @@ def test_empty_list_creates_empty_instruction_dicts(self) -> None: state = ReconstructionState.create([]) assert state.instructions == {} - def test_empty_list_creates_empty_approximation_dicts(self) -> None: - state = ReconstructionState.create([]) - assert state.approximations == {} - @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: state = ReconstructionState.create(case.names) for name in case.names: assert state.instructions[name] == [] - assert state.approximations[name] == [] class TestReconstructionStateAppend: @@ -86,30 +65,21 @@ 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] - - 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) + instruction = _make_instruction() + state.append(ChannelName.PULSE1, instruction) + assert state.instructions[ChannelName.PULSE1] == [instruction] @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 _ in range(case.count): + state.append(ChannelName.PULSE1, _make_instruction()) + assert len(state.instructions[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()) + state.append(ChannelName.TRIANGLE, _make_instruction()) + assert len(state.instructions[ChannelName.PULSE1]) == 1 + assert len(state.instructions[ChannelName.TRIANGLE]) == 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..8ff24c6b3 --- /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.GATHERING, 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.GATHERING, 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_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py index 993da11ef..01a4606c0 100644 --- a/tests/unit/sampletones_core/structures/tree/test_factory.py +++ b/tests/unit/sampletones_core/structures/tree/test_factory.py @@ -1,12 +1,13 @@ from pathlib import Path from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.factory import create_directory_node from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode from sampletones_core.structures.tree.type import NodeType -CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) RECONSTRUCTIONS_DIRECTORY = Path("/reconstructions") diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py index 15482d80f..50070104d 100644 --- a/tests/unit/sampletones_core/structures/tree/test_node.py +++ b/tests/unit/sampletones_core/structures/tree/test_node.py @@ -1,7 +1,8 @@ from pathlib import Path from sampletones_core.configs import Config -from sampletones_core.constants.enums import LibraryGeneratorName +from sampletones_core.constants.enums import DEFAULT_CHANNELS, GeneratorName +from sampletones_core.fft import Window from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( @@ -14,8 +15,8 @@ ) from sampletones_core.structures.tree.type import NodeType -LIBRARY_KEY = InstructionLibraryKey.from_config(Config()) -CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) +LIBRARY_KEY = InstructionLibraryKey.create(Config().library, Window.from_config(Config())) +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) class TestTreeNode: @@ -116,29 +117,34 @@ def test_a_copy_is_a_configuration_heading_of_its_own(self) -> None: class TestLibraryNode: def test_library_key_is_stored(self) -> None: - node = LibraryNode("lib", library_key=LIBRARY_KEY) + node = LibraryNode("lib", library_key=LIBRARY_KEY, outdated=False) assert node.library_key == LIBRARY_KEY def test_copy_preserves_library_key(self) -> None: - node = LibraryNode("lib", library_key=LIBRARY_KEY) + node = LibraryNode("lib", library_key=LIBRARY_KEY, outdated=False) copied = node.copy() assert copied.library_key == LIBRARY_KEY def test_default_node_type_is_library(self) -> None: - node = LibraryNode("lib", library_key=LIBRARY_KEY) + node = LibraryNode("lib", library_key=LIBRARY_KEY, outdated=False) assert node.node_type == NodeType.LIBRARY + def test_a_copy_keeps_the_mark_of_another_version(self) -> None: + node = LibraryNode("lib", library_key=LIBRARY_KEY, outdated=True) + copied = node.copy() + assert (copied.name, copied.outdated) == ("lib", True) + 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_lfsr.py b/tests/unit/sampletones_core/timers/implementation/test_lfsr.py index 58f4b8d17..48ac3d300 100644 --- a/tests/unit/sampletones_core/timers/implementation/test_lfsr.py +++ b/tests/unit/sampletones_core/timers/implementation/test_lfsr.py @@ -41,14 +41,14 @@ def label(self) -> str: @pytest.fixture def long_timer() -> LFSRTimer: - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = False return timer @pytest.fixture def short_timer() -> LFSRTimer: - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = True return timer @@ -253,7 +253,7 @@ def test_fastest_short_mode_tone_matches_hardware_reference(self, short_timer: L @pytest.mark.parametrize("case", NOISE_RENDER_CASES, ids=lambda c: c.label) def test_real_frequency_is_the_sequence_repetition_rate(self, case: NoiseRenderCase) -> None: - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = case.short timer.period = case.period_index assert timer.real_frequency == pytest.approx(tone_frequency(case.period_index, case.short)) @@ -262,7 +262,7 @@ def test_real_frequency_is_the_sequence_repetition_rate(self, case: NoiseRenderC class TestLFSRAgainstReference: @pytest.mark.parametrize("case", NOISE_RENDER_CASES, ids=lambda c: c.label) def test_consecutive_frames_match_the_hardware_reference(self, case: NoiseRenderCase) -> None: - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = case.short timer.period = case.period_index timer.set((1, 0.0)) @@ -280,7 +280,7 @@ def test_consecutive_frames_match_the_hardware_reference(self, case: NoiseRender @pytest.mark.parametrize("case", NOISE_RENDER_CASES, ids=lambda c: c.label) def test_render_resumes_the_reference_state(self, case: NoiseRenderCase) -> None: - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = case.short timer.period = case.period_index timer.set((1, 0.0)) @@ -300,7 +300,7 @@ def test_render_resumes_the_reference_state(self, case: NoiseRenderCase) -> None class TestLFSRBitDensity: @pytest.mark.parametrize("short", [False, True], ids=["long", "short"]) def test_mean_level_matches_the_shift_register_bit_density(self, short: bool) -> None: - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = short timer.period = FASTEST_PERIOD timer.set((1, 0.0)) @@ -316,7 +316,7 @@ def test_render_repeats_with_the_shift_register_cycle(self, case: NoiseRenderCas here sit a fraction of a shift-register step apart. Slow periods hold that fraction far below one step, where the repetition shows as near-perfect correlation. """ - timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY) + timer = LFSRTimer(sample_rate=SAMPLE_RATE, nes_frequency=NES_FREQUENCY, reset_phase=False) timer.short = case.short timer.period = case.period_index timer.set((1, 0.0)) diff --git a/tests/unit/sampletones_core/timers/implementation/test_phase.py b/tests/unit/sampletones_core/timers/implementation/test_phase.py index b0512259c..5eb71c88e 100644 --- a/tests/unit/sampletones_core/timers/implementation/test_phase.py +++ b/tests/unit/sampletones_core/timers/implementation/test_phase.py @@ -12,49 +12,7 @@ @pytest.fixture 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 + return PhaseTimer(sample_rate=44100, nes_frequency=60, reset_phase=False) class TestPhaseTimerValidate: 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..2bd2a1c9f --- /dev/null +++ b/tests/unit/sampletones_core/timers/test_arithmetic.py @@ -0,0 +1,136 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.general import MAX_TIMER, MIN_TIMER, TIMER_CYCLE_DIVIDER +from sampletones_core.timers.arithmetic import ( + bent_timer, + 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 TestBentTimer(BaseTestSuite): + """A bend moves a divider by its steps, and the register's range holds wherever it lands.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: int + timer: int + offset: int + + @property + def label(self) -> str: + return f"timer_{self.timer}_bent_{self.offset:+d}" + + test_cases = ( + TestCase(timer=253, offset=0, expected=253), + TestCase(timer=253, offset=7, expected=260), + TestCase(timer=253, offset=-7, expected=246), + TestCase(timer=MAX_TIMER - 3, offset=10, expected=MAX_TIMER), + TestCase(timer=MIN_TIMER + 2, offset=-10, expected=MIN_TIMER), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_divider_matches(self, test_case: TestCase) -> None: + assert bent_timer(test_case.timer, test_case.offset) == 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/timers/test_nearest.py b/tests/unit/sampletones_core/timers/test_nearest.py new file mode 100644 index 000000000..1a7d7d28a --- /dev/null +++ b/tests/unit/sampletones_core/timers/test_nearest.py @@ -0,0 +1,84 @@ +from typing import Dict, Final, Tuple + +import pytest + +from sampletones_core.constants.general import MAX_TIMER +from sampletones_core.timers.nearest import NearestPitch, nearest_pitch, nearest_pitches +from sampletones_core.timers.utils import get_timer_table +from sampletones_shared.music import Tuning + +LOW_PITCH: Final[int] = 40 +HIGH_PITCH: Final[int] = 41 +SHARING_PITCH: Final[int] = 42 +LOW_TIMER: Final[int] = 100 +HIGH_TIMER: Final[int] = 90 +HALFWAY: Final[int] = (LOW_TIMER + HIGH_TIMER) // 2 + + +@pytest.fixture(scope="module") +def table() -> Dict[int, int]: + return get_timer_table(Tuning()) + + +@pytest.fixture(scope="module") +def nearest(table: Dict[int, int]) -> Tuple[NearestPitch, ...]: + return nearest_pitches(table) + + +class TestNamingADividerByItsNearestPitch: + def test_every_divider_the_register_holds_is_named(self, nearest: Tuple[NearestPitch, ...]) -> None: + assert len(nearest) == MAX_TIMER + 1 + + def test_a_pitch_s_own_divider_names_the_lowest_pitch_sounding_it( + self, + table: Dict[int, int], + nearest: Tuple[NearestPitch, ...], + ) -> None: + for timer in table.values(): + named = nearest[timer] + assert named.offset == 0 + assert named.pitch == min(other for other, sounded in table.items() if sounded == timer) + + def test_no_pitch_lies_nearer_than_the_one_named( + self, + table: Dict[int, int], + nearest: Tuple[NearestPitch, ...], + ) -> None: + for divider, named in enumerate(nearest): + assert table[named.pitch] + named.offset == divider + assert all(abs(named.offset) <= abs(divider - timer) for timer in table.values()) + + +class TestADividerBetweenTwoPitches: + """A hand-built table, so the halfway point and a shared divider are known exactly.""" + + @pytest.fixture + def nearest(self) -> Tuple[NearestPitch, ...]: + return nearest_pitches({LOW_PITCH: LOW_TIMER, HIGH_PITCH: HIGH_TIMER, SHARING_PITCH: HIGH_TIMER}) + + def test_a_shared_divider_names_the_lower_of_its_pitches(self, nearest: Tuple[NearestPitch, ...]) -> None: + assert nearest[HIGH_TIMER] == NearestPitch(pitch=HIGH_PITCH, offset=0) + + def test_the_halfway_divider_goes_to_the_higher_pitch(self, nearest: Tuple[NearestPitch, ...]) -> None: + assert nearest[HALFWAY] == NearestPitch(pitch=HIGH_PITCH, offset=HALFWAY - HIGH_TIMER) + + def test_a_divider_past_halfway_goes_to_the_lower_pitch(self, nearest: Tuple[NearestPitch, ...]) -> None: + assert nearest[HALFWAY + 1] == NearestPitch(pitch=LOW_PITCH, offset=HALFWAY + 1 - LOW_TIMER) + + def test_dividers_beyond_the_table_take_the_pitch_at_its_edge(self, nearest: Tuple[NearestPitch, ...]) -> None: + assert nearest[0].pitch == HIGH_PITCH + assert nearest[MAX_TIMER].pitch == LOW_PITCH + + def test_an_empty_table_is_refused(self) -> None: + with pytest.raises(ValueError): + nearest_pitches({}) + + +class TestNamingOneDivider: + def test_one_divider_is_named_as_the_whole_table_names_it( + self, + table: Dict[int, int], + nearest: Tuple[NearestPitch, ...], + ) -> None: + for divider in (0, HALFWAY, table[max(table)], MAX_TIMER): + assert nearest_pitch(table, divider) == nearest[divider] diff --git a/tests/unit/sampletones_core/timing/test_distribution.py b/tests/unit/sampletones_core/timing/test_distribution.py index f659f4b85..2948e2dfe 100644 --- a/tests/unit/sampletones_core/timing/test_distribution.py +++ b/tests/unit/sampletones_core/timing/test_distribution.py @@ -1,163 +1,107 @@ from dataclasses import dataclass -from typing import Tuple +from fractions import Fraction +from math import ceil, floor +from typing import Final, Tuple import pytest -from sampletones_core.timing.distribution import distribute_by_halving, distribute_proportionally +from sampletones_core.timing.distribution import nearest, split_by_halving from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase +from tests.suite.groove import is_proportional, surplus_rows +COMMON_BEAT: Final[int] = 4 +BEATS_IN_A_LONG_BAR: Final[int] = 8 -class TestDistributeProportionally(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseAutolabelTestCase): - expected: Tuple[int, ...] - total: int - lengths: Tuple[int, ...] - - @property - def label(self) -> str: - spans = "_".join(str(length) for length in self.lengths) - return f"total_{self.total}_over_{spans}" - - test_cases = ( - TestCase( - total=69, - lengths=(4, 4, 4, 4), - expected=(18, 17, 17, 17), - ), - TestCase( - total=69, - lengths=(8, 8), - expected=(35, 34), - ), - TestCase( - total=274, - lengths=(16, 16, 16, 16), - expected=(69, 68, 69, 68), - ), - TestCase( - total=17, - lengths=(2, 2), - expected=(9, 8), - ), - TestCase( - total=18, - lengths=(2, 2), - expected=(9, 9), - ), - TestCase( - total=100, - lengths=(1,), - expected=(100,), - ), - TestCase( - total=0, - lengths=(4, 4), - expected=(0, 0), - ), - TestCase( - total=69, - lengths=(12, 4), - expected=(52, 17), - ), - TestCase( - total=10, - lengths=(1, 1, 1), - expected=(4, 3, 3), - ), - TestCase( - total=11, - lengths=(1, 1, 1), - expected=(4, 4, 3), - ), - ) +class TestNearest(BaseTestSuite): @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, + ("value", "expected"), + ( + (Fraction(7, 2), 4), + (Fraction(36, 5), 7), + (Fraction(38, 5), 8), + (Fraction(-1, 2), 0), + (Fraction(6), 6), + ), ) - def test_shares_match(self, test_case: TestCase) -> None: - shares = distribute_proportionally(test_case.total, test_case.lengths) - assert shares == test_case.expected - assert sum(shares) == test_case.total + def test_a_half_rounds_up(self, value: Fraction, expected: int) -> None: + assert nearest(value) == expected - def test_no_span_is_rejected(self) -> None: - with pytest.raises(ValueError, match="At least one span"): - distribute_proportionally(10, ()) - def test_empty_span_is_rejected(self) -> None: - with pytest.raises(ValueError, match="at least 1 row"): - distribute_proportionally(10, (4, 0)) - - -class TestDistributeByHalving(BaseTestSuite): +class TestSplitByHalving(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseAutolabelTestCase): expected: Tuple[int, ...] total: int - rows: int + spans: Tuple[int, ...] + row_ticks: Fraction @property def label(self) -> str: - return f"total_{self.total}_over_{self.rows}_rows" + spans = "_".join(str(span) for span in self.spans) + return f"total_{self.total}_over_{spans}" test_cases = ( TestCase( - total=22, - rows=4, - expected=(6, 5, 6, 5), + total=115, + spans=(4, 4, 4, 4), + row_ticks=Fraction(36, 5), + expected=(8, 7, 7, 7, 8, 7, 7, 7, 8, 7, 7, 7, 7, 7, 7, 7), ), TestCase( total=69, - rows=16, + spans=(4, 4, 4, 4), + row_ticks=Fraction(30, 7), expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4), ), TestCase( - total=18, - rows=4, - expected=(5, 4, 5, 4), - ), - TestCase( - total=17, - rows=4, - expected=(5, 4, 4, 4), + total=24, + spans=(4,), + row_ticks=Fraction(6), + expected=(6, 6, 6, 6), ), TestCase( - total=9, - rows=2, - expected=(5, 4), + total=29, + spans=(4,), + row_ticks=Fraction(29, 4), + expected=(8, 7, 7, 7), ), TestCase( - total=100, - rows=1, - expected=(100,), + total=30, + spans=(4,), + row_ticks=Fraction(30, 4), + expected=(8, 7, 8, 7), ), TestCase( - total=0, - rows=5, - expected=(0, 0, 0, 0, 0), + total=31, + spans=(4,), + row_ticks=Fraction(31, 4), + expected=(8, 8, 8, 7), ), TestCase( - total=13, - rows=3, - expected=(5, 4, 4), + total=22, + spans=(3,), + row_ticks=Fraction(22, 3), + expected=(8, 7, 7), ), TestCase( - total=22, - rows=5, - expected=(5, 5, 4, 4, 4), + total=23, + spans=(3,), + row_ticks=Fraction(23, 3), + expected=(8, 7, 8), ), TestCase( - total=30, - rows=7, - expected=(5, 4, 5, 4, 4, 4, 4), + total=100, + spans=(1,), + row_ticks=Fraction(100), + expected=(100,), ), TestCase( - total=52, - rows=12, - expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4), + total=50, + spans=(3, 3, 1), + row_ticks=Fraction(36, 5), + expected=(8, 7, 7, 7, 7, 7, 7), ), ) @@ -167,15 +111,72 @@ def label(self) -> str: ids=lambda test_case: test_case.label, ) def test_ticks_match(self, test_case: TestCase) -> None: - ticks = distribute_by_halving(test_case.total, test_case.rows) + ticks = split_by_halving(test_case.total, test_case.spans, row_ticks=test_case.row_ticks) + assert ticks == test_case.expected assert sum(ticks) == test_case.total - def test_absent_rows_are_rejected(self) -> None: - with pytest.raises(ValueError, match="rows must be at least 1"): - distribute_by_halving(10, 0) + @pytest.mark.parametrize( + ("rows", "strongest"), + ( + (2, (0, 1)), + (3, (0, 2, 1)), + (4, (0, 2, 1, 3)), + (6, (0, 3, 2, 5, 1, 4)), + (8, (0, 4, 2, 6, 1, 5, 3, 7)), + ), + ) + def test_a_beat_gives_its_surplus_to_its_strongest_rows(self, rows: int, strongest: Tuple[int, ...]) -> None: + """The first row takes the first surplus tick, the middle row the next, the quarters after them.""" + for surplus in range(1, rows): + row_ticks = 7 + Fraction(surplus, rows) + ticks = split_by_halving(7 * rows + surplus, (rows,), row_ticks=row_ticks) + + assert surplus_rows(ticks, row_ticks) == tuple(sorted(strongest[:surplus])) + + @pytest.mark.parametrize("surplus", range(1, BEATS_IN_A_LONG_BAR)) + def test_a_bar_gives_its_surplus_to_its_strongest_beats(self, surplus: int) -> None: + """Eight beats share their surplus ticks as the first half of them, then the halves of those.""" + strongest = (0, 4, 2, 6, 1, 5, 3, 7) + rows = COMMON_BEAT * BEATS_IN_A_LONG_BAR + row_ticks = 7 + Fraction(surplus, rows) + ticks = split_by_halving( + 7 * rows + surplus, + (COMMON_BEAT,) * BEATS_IN_A_LONG_BAR, + row_ticks=row_ticks, + ) + longer_beats = tuple( + beat + for beat in range(BEATS_IN_A_LONG_BAR) + if sum(ticks[beat * COMMON_BEAT : (beat + 1) * COMMON_BEAT]) > 7 * COMMON_BEAT + ) + + assert longer_beats == tuple(sorted(strongest[:surplus])) + + @pytest.mark.parametrize("spans", ((4, 4, 4, 4), (3, 3, 1), (5, 5, 5), (2,), (7,), (6, 6, 4), (1, 1, 1))) + @pytest.mark.parametrize("row_ticks", (Fraction(36, 5), Fraction(30, 7), Fraction(11, 4), Fraction(13, 10))) + def test_every_total_the_rows_carry_keeps_each_row_proportional( + self, + spans: Tuple[int, ...], + row_ticks: Fraction, + ) -> None: + rows = sum(spans) + for total in range(rows * floor(row_ticks), rows * ceil(row_ticks) + 1): + ticks = split_by_halving(total, spans, row_ticks=row_ticks) + + assert sum(ticks) == total + assert len(ticks) == rows + assert is_proportional(ticks, row_ticks) + + def test_no_span_is_rejected(self) -> None: + with pytest.raises(ValueError, match="At least one span"): + split_by_halving(10, (), row_ticks=Fraction(5)) + + def test_an_empty_span_is_rejected(self) -> None: + with pytest.raises(ValueError, match="at least 1 row"): + split_by_halving(10, (4, 0), row_ticks=Fraction(5, 2)) - @pytest.mark.parametrize("rows", (1, 2, 3, 4, 5, 7, 8, 12, 16, 31, 64)) - def test_earlier_rows_run_at_least_as_long(self, rows: int) -> None: - ticks = distribute_by_halving(rows * 4 + 1, rows) - assert ticks[0] == max(ticks) + @pytest.mark.parametrize("total", (27, 33)) + def test_a_total_the_rows_cannot_carry_is_rejected(self, total: int) -> None: + with pytest.raises(ValueError, match="carry no total"): + split_by_halving(total, (4,), row_ticks=Fraction(29, 4)) diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py index f728dd2b3..379349183 100644 --- a/tests/unit/sampletones_core/timing/test_groove.py +++ b/tests/unit/sampletones_core/timing/test_groove.py @@ -6,24 +6,35 @@ import pytest -from sampletones_core.timing.groove import Groove, calculate_groove -from sampletones_core.timing.metre import Metre +from sampletones_core.timing.bounds import TickBounds +from sampletones_core.timing.groove import Groove +from sampletones_core.timing.meter import Meter from sampletones_core.timing.rate import RowRate +from sampletones_core.timing.song import SongTiming from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase +from tests.suite.groove import bar_line_drift, bar_rows, is_proportional MINIMUM_TICKS: Final[int] = 1 MAXIMUM_TICKS: Final[int] = 255 +ENGINE_BOUNDS: Final[TickBounds] = TickBounds(minimum=MINIMUM_TICKS, maximum=MAXIMUM_TICKS) +FIRST_FRAME: Final[int] = 0 COMMON_TIME_BEAT: Final[int] = 4 COMMON_TIME_BAR: Final[int] = 16 REFERENCE_SPEED: Final[int] = 6 +HALF_TICK: Final[Fraction] = Fraction(1, 2) + + +def first_frame_groove(rate: RowRate, meter: Meter) -> Groove: + """The groove the song's first frame plays, where every bar line is counted from the pattern's first row.""" + return SongTiming(rate=rate, meter=meter, bounds=ENGINE_BOUNDS).groove(FIRST_FRAME) class TestGroove(BaseTestSuite): - """One case table, read both for the ticks it produces and for the rules they obey.""" + """One case table of the song's first frame, read both for the ticks it produces and for the rules they obey.""" @dataclass(frozen=True, kw_only=True) class TestCase(BaseAutolabelTestCase): @@ -43,25 +54,31 @@ 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, ) @property - def groove(self) -> Groove: - return calculate_groove( - RowRate.from_parameters( - tempo=self.tempo, - speed=self.speed, - nes_frequency=self.nes_frequency, - ), - self.metre, + def rate(self) -> RowRate: + return RowRate.from_parameters( + tempo=self.tempo, + speed=self.speed, + nes_frequency=self.nes_frequency, + ) + + @property + def reachable_rate(self) -> Fraction: + return self.rate.bounded( minimum_ticks=MINIMUM_TICKS, maximum_ticks=MAXIMUM_TICKS, - ) + ).ticks_per_row + + @property + def groove(self) -> Groove: + return first_frame_groove(self.rate, self.meter) test_cases = ( TestCase( @@ -116,7 +133,7 @@ def groove(self) -> Groove: rows=12, first_highlight=3, second_highlight=12, - expected=(2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 1), + expected=(2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 1, 2), ), TestCase( tempo=210, @@ -197,7 +214,7 @@ def groove(self) -> Groove: rows=7, first_highlight=2, second_highlight=3, - expected=(5, 5, 4, 5, 5, 4, 4), + expected=(5, 4, 5, 5, 4, 5, 4), ), TestCase( tempo=251, @@ -206,7 +223,7 @@ def groove(self) -> Groove: rows=17, first_highlight=5, second_highlight=7, - expected=(26, 26, 26, 26, 26, 26, 25, 26, 26, 26, 26, 25, 26, 25, 26, 26, 25), + expected=(26, 25, 26, 26, 26, 26, 25, 26, 25, 26, 26, 26, 26, 26, 26, 25, 26), ), TestCase( tempo=97, @@ -215,7 +232,7 @@ def groove(self) -> Groove: rows=9, first_highlight=4, second_highlight=6, - expected=(4, 3, 4, 3, 3, 3, 3, 3, 3), + expected=(4, 3, 3, 3, 3, 3, 4, 3, 3), ), TestCase( tempo=128, @@ -224,7 +241,7 @@ def groove(self) -> Groove: rows=15, first_highlight=4, second_highlight=16, - expected=(10, 9, 10, 9, 10, 9, 10, 9, 10, 9, 9, 9, 10, 9, 9), + expected=(10, 9, 10, 9, 10, 9, 9, 9, 10, 9, 10, 9, 10, 9, 9), ), TestCase( tempo=100, @@ -233,7 +250,7 @@ def groove(self) -> Groove: rows=13, first_highlight=7, second_highlight=7, - expected=(8, 8, 8, 8, 8, 8, 7, 8, 8, 8, 8, 8, 7), + expected=(8, 8, 8, 7, 8, 8, 8, 8, 8, 8, 8, 7, 8), ), TestCase( tempo=43, @@ -242,7 +259,7 @@ def groove(self) -> Groove: rows=11, first_highlight=3, second_highlight=8, - expected=(53, 53, 52, 53, 52, 52, 52, 52, 52, 52, 52), + expected=(53, 52, 52, 53, 52, 52, 52, 52, 53, 52, 52), ), TestCase( tempo=199, @@ -296,7 +313,7 @@ def groove(self) -> Groove: rows=7, first_highlight=4, second_highlight=16, - expected=(5, 4, 5, 4, 4, 4, 4), + expected=(5, 4, 4, 4, 5, 4, 4), ), TestCase( tempo=210, @@ -323,7 +340,7 @@ def groove(self) -> Groove: rows=13, first_highlight=4, second_highlight=16, - expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 4), + expected=(5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4), ), TestCase( tempo=210, @@ -341,7 +358,7 @@ def groove(self) -> Groove: rows=23, first_highlight=4, second_highlight=16, - expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4, 4, 4), + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4), ), TestCase( tempo=210, @@ -378,7 +395,7 @@ def groove(self) -> Groove: 4, 5, 4, - 5, + 4, 4, 5, 4, @@ -394,7 +411,7 @@ def groove(self) -> Groove: 4, 5, 4, - 4, + 5, 4, 5, 4, @@ -503,7 +520,7 @@ def groove(self) -> Groove: rows=16, first_highlight=1, second_highlight=1, - expected=(9, 9, 8, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 8, 9, 9, 8), ), TestCase( tempo=105, @@ -512,7 +529,7 @@ def groove(self) -> Groove: rows=16, first_highlight=2, second_highlight=4, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -521,7 +538,7 @@ def groove(self) -> Groove: rows=16, first_highlight=3, second_highlight=4, - expected=(9, 9, 9, 8, 9, 9, 8, 8, 9, 9, 8, 8, 9, 9, 8, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -530,7 +547,7 @@ def groove(self) -> Groove: rows=16, first_highlight=5, second_highlight=3, - expected=(9, 9, 8, 9, 9, 8, 9, 9, 8, 9, 8, 8, 9, 9, 8, 8), + expected=(9, 8, 9, 9, 8, 8, 9, 8, 9, 9, 8, 9, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -539,7 +556,7 @@ def groove(self) -> Groove: rows=16, first_highlight=16, second_highlight=4, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -557,7 +574,7 @@ def groove(self) -> Groove: rows=12, first_highlight=4, second_highlight=6, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8), ), TestCase( tempo=105, @@ -575,7 +592,7 @@ def groove(self) -> Groove: rows=9, first_highlight=2, second_highlight=6, - expected=(9, 9, 9, 8, 9, 8, 9, 8, 8), + expected=(9, 8, 9, 8, 9, 8, 9, 8, 9), ), TestCase( tempo=105, @@ -584,7 +601,7 @@ def groove(self) -> Groove: rows=15, first_highlight=5, second_highlight=15, - expected=(9, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 9, 8, 9, 8), + expected=(9, 8, 9, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 9, 8), ), TestCase( tempo=150, @@ -712,29 +729,14 @@ def test_ticks_match(self, test_case: TestCase) -> None: def test_the_groove_fills_the_pattern(self, test_case: TestCase) -> None: assert len(test_case.groove.ticks) == test_case.rows - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_total_and_the_mean_describe_the_rows(self, test_case: TestCase) -> None: - groove = test_case.groove - assert groove.total_ticks == sum(groove.ticks) - assert groove.mean_ticks_per_row == Fraction(groove.total_ticks, test_case.rows) - @pytest.mark.parametrize( "test_case", test_cases, ids=lambda test_case: test_case.label, ) def test_the_rate_is_reached_as_closely_as_the_range_allows(self, test_case: TestCase) -> None: - rate = RowRate.from_parameters( - tempo=test_case.tempo, - speed=test_case.speed, - nes_frequency=test_case.nes_frequency, - ) - reachable = min(max(rate.ticks_per_row, MINIMUM_TICKS), MAXIMUM_TICKS) - assert abs(test_case.groove.mean_ticks_per_row - reachable) <= Fraction(1, 2 * test_case.rows) + exact = test_case.reachable_rate * test_case.rows + assert abs(sum(test_case.groove.ticks) - exact) <= HALF_TICK @pytest.mark.parametrize( "test_case", @@ -749,23 +751,21 @@ def test_every_row_lies_within_the_engine_range(self, test_case: TestCase) -> No test_cases, ids=lambda test_case: test_case.label, ) - def test_every_row_neighbours_the_average(self, test_case: TestCase) -> None: - groove = test_case.groove - shorter, remainder = divmod(groove.total_ticks, test_case.rows) - longer = shorter + 1 if remainder else shorter - assert set(groove.ticks) <= {shorter, longer} + def test_every_row_lasts_the_floor_or_the_ceiling_of_the_rate(self, test_case: TestCase) -> None: + assert is_proportional(test_case.groove.ticks, test_case.reachable_rate) @pytest.mark.parametrize( "test_case", test_cases, ids=lambda test_case: test_case.label, ) - def test_longer_rows_come_first(self, test_case: TestCase) -> None: - groove = test_case.groove - elapsed = 0 - for index, ticks in enumerate(groove.ticks, start=1): - elapsed += ticks - assert elapsed >= groove.total_ticks * index // test_case.rows + def test_every_bar_starts_on_the_tick_nearest_its_exact_start(self, test_case: TestCase) -> None: + drift = bar_line_drift( + test_case.groove.ticks, + bar_rows(test_case.meter, frames=1), + test_case.reachable_rate, + ) + assert drift <= HALF_TICK @pytest.mark.parametrize( "test_case", @@ -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) @@ -788,22 +788,19 @@ class TestReferenceCalibration(BaseTestSuite): @pytest.mark.parametrize("speed", tuple(range(1, 32))) @pytest.mark.parametrize("rows", (1, 2, 7, 16, 60, 64, 256)) def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None: - groove = calculate_groove( + groove = first_frame_groove( RowRate.from_parameters( tempo=REFERENCE_TEMPO, speed=speed, nes_frequency=REFERENCE_NES_FREQUENCY, ), - Metre( + Meter( rows=rows, first_highlight=COMMON_TIME_BEAT, second_highlight=COMMON_TIME_BAR, ), - minimum_ticks=MINIMUM_TICKS, - maximum_ticks=MAXIMUM_TICKS, ) assert groove.ticks == (speed,) * rows - assert groove.is_uniform class TestSecondHighlight(BaseTestSuite): @@ -811,19 +808,17 @@ class TestSecondHighlight(BaseTestSuite): @staticmethod def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) -> Groove: - return calculate_groove( + return first_frame_groove( RowRate.from_parameters( tempo=tempo, speed=REFERENCE_SPEED, nes_frequency=60, ), - Metre( + Meter( rows=rows, first_highlight=first_highlight, second_highlight=second_highlight, ), - minimum_ticks=MINIMUM_TICKS, - maximum_ticks=MAXIMUM_TICKS, ) @pytest.mark.parametrize("second_highlight", (1, 2, 3, 4, 7, 8, 12, 16, 20, 32, 64)) @@ -832,52 +827,35 @@ def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) def test_the_bar_leaves_the_tempo_alone(self, tempo: int, rows: int, second_highlight: int) -> None: grouped = self._groove(rows, COMMON_TIME_BEAT, second_highlight, tempo) pattern_wide = self._groove(rows, COMMON_TIME_BEAT, rows, tempo) - assert grouped.total_ticks == pattern_wide.total_ticks + assert sum(grouped.ticks) == sum(pattern_wide.ticks) def test_a_bar_cutting_across_the_beat_reorganizes_the_groove(self) -> None: - across = self._groove(4, 2, 3, 105) - pattern_wide = self._groove(4, 2, 4, 105) - assert across.ticks == (9, 9, 8, 8) - assert pattern_wide.ticks == (9, 8, 9, 8) - assert across.total_ticks == pattern_wide.total_ticks + across = self._groove(6, 2, 3, 105) + pattern_wide = self._groove(6, 2, 6, 105) + assert across.ticks == (9, 8, 9, 9, 8, 8) + assert pattern_wide.ticks == (9, 8, 9, 8, 9, 8) + assert sum(across.ticks) == sum(pattern_wide.ticks) def test_a_bar_shorter_than_the_beat_reorganizes_the_groove(self) -> None: across = self._groove(5, 5, 4, 105) aligned = self._groove(5, 5, 8, 105) - assert across.ticks == (9, 9, 9, 8, 8) - assert aligned.ticks == (9, 9, 8, 9, 8) - assert across.total_ticks == aligned.total_ticks + assert across.ticks == (9, 8, 9, 8, 9) + assert aligned.ticks == (9, 8, 9, 9, 8) + assert sum(across.ticks) == sum(aligned.ticks) def test_a_bar_of_whole_beats_reorganizes_the_groove_too(self) -> None: barred = self._groove(64, COMMON_TIME_BEAT, COMMON_TIME_BAR, 105) pattern_wide = self._groove(64, COMMON_TIME_BEAT, 64, 105) assert barred.ticks == ( - 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, ) # fmt: skip assert pattern_wide.ticks == ( - 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, - 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, - 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, ) # fmt: skip - assert barred.total_ticks == pattern_wide.total_ticks - - -class TestGrooveProperties(BaseTestSuite): - def test_total_ticks_sums_the_rows(self) -> None: - assert Groove(ticks=(5, 4, 4, 4)).total_ticks == 17 - - def test_mean_ticks_per_row_is_exact(self) -> None: - assert Groove(ticks=(5, 4, 4, 4)).mean_ticks_per_row == Fraction(17, 4) - - def test_a_varying_groove_is_not_uniform(self) -> None: - assert not Groove(ticks=(5, 4, 4, 4)).is_uniform - - def test_a_constant_groove_is_uniform(self) -> None: - assert Groove(ticks=(4, 4, 4, 4)).is_uniform - - def test_a_single_row_groove_is_uniform(self) -> None: - assert Groove(ticks=(4,)).is_uniform + assert sum(barred.ticks) == sum(pattern_wide.ticks) diff --git a/tests/unit/sampletones_core/timing/test_meter.py b/tests/unit/sampletones_core/timing/test_meter.py new file mode 100644 index 000000000..4b31d0eb4 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_meter.py @@ -0,0 +1,161 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing.meter import Meter +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestSpans(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[Tuple[int, ...], ...] + rows: int + first_highlight: int + second_highlight: int + + @property + def label(self) -> str: + return f"{self.rows}_rows_at_{self.first_highlight}_{self.second_highlight}" + + test_cases = ( + TestCase( + rows=16, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4),), + ), + TestCase( + rows=64, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4)), + ), + TestCase( + rows=60, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4)), + ), + TestCase( + rows=17, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4), (1,)), + ), + TestCase( + rows=12, + first_highlight=3, + second_highlight=12, + expected=((3, 3, 3, 3),), + ), + TestCase( + rows=16, + first_highlight=6, + second_highlight=12, + expected=((6, 6), (4,)), + ), + TestCase( + rows=1, + first_highlight=4, + second_highlight=16, + expected=((1,),), + ), + TestCase( + rows=8, + first_highlight=1, + second_highlight=1, + expected=((1,), (1,), (1,), (1,), (1,), (1,), (1,), (1,)), + ), + TestCase( + rows=8, + first_highlight=16, + second_highlight=4, + expected=((4,), (4,)), + ), + TestCase( + rows=8, + first_highlight=64, + second_highlight=64, + expected=((8,),), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_spans_match(self, test_case: TestCase) -> None: + meter = Meter( + rows=test_case.rows, + first_highlight=test_case.first_highlight, + second_highlight=test_case.second_highlight, + ) + assert meter.spans == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_spans_cover_the_pattern(self, test_case: TestCase) -> None: + 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 meter.spans) == test_case.rows + + +class TestBounds(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: str + field: str + + @property + def label(self) -> str: + return f"{self.field}_below_one" + + test_cases = ( + TestCase( + field="rows", + expected="rows must be at least 1", + ), + TestCase( + field="first_highlight", + expected="first_highlight must be at least 1", + ), + TestCase( + field="second_highlight", + expected="second_highlight must be at least 1", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + 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): + Meter(**fields) + + +class TestProjectSettings: + def test_settings_state_the_highlights(self) -> None: + settings = ProjectSettings(first_highlight=3, second_highlight=12) + 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: + meter = Meter.from_settings(ProjectSettings(), rows=16) + assert meter.spans == ((4, 4, 4, 4),) diff --git a/tests/unit/sampletones_core/timing/test_metre.py b/tests/unit/sampletones_core/timing/test_metre.py deleted file mode 100644 index a9d7711ee..000000000 --- a/tests/unit/sampletones_core/timing/test_metre.py +++ /dev/null @@ -1,161 +0,0 @@ -from dataclasses import dataclass -from typing import Tuple - -import pytest - -from sampletones_core.project.settings import ProjectSettings -from sampletones_core.timing.metre import Metre -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseAutolabelTestCase - - -class TestSpans(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseAutolabelTestCase): - expected: Tuple[Tuple[int, ...], ...] - rows: int - first_highlight: int - second_highlight: int - - @property - def label(self) -> str: - return f"{self.rows}_rows_at_{self.first_highlight}_{self.second_highlight}" - - test_cases = ( - TestCase( - rows=16, - first_highlight=4, - second_highlight=16, - expected=((4, 4, 4, 4),), - ), - TestCase( - rows=64, - first_highlight=4, - second_highlight=16, - expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4)), - ), - TestCase( - rows=60, - first_highlight=4, - second_highlight=16, - expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4)), - ), - TestCase( - rows=17, - first_highlight=4, - second_highlight=16, - expected=((4, 4, 4, 4), (1,)), - ), - TestCase( - rows=12, - first_highlight=3, - second_highlight=12, - expected=((3, 3, 3, 3),), - ), - TestCase( - rows=16, - first_highlight=6, - second_highlight=12, - expected=((6, 6), (4,)), - ), - TestCase( - rows=1, - first_highlight=4, - second_highlight=16, - expected=((1,),), - ), - TestCase( - rows=8, - first_highlight=1, - second_highlight=1, - expected=((1,), (1,), (1,), (1,), (1,), (1,), (1,), (1,)), - ), - TestCase( - rows=8, - first_highlight=16, - second_highlight=4, - expected=((4,), (4,)), - ), - TestCase( - rows=8, - first_highlight=64, - second_highlight=64, - expected=((8,),), - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_spans_match(self, test_case: TestCase) -> None: - metre = Metre( - rows=test_case.rows, - first_highlight=test_case.first_highlight, - second_highlight=test_case.second_highlight, - ) - assert metre.spans == test_case.expected - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_spans_cover_the_pattern(self, test_case: TestCase) -> None: - metre = Metre( - 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 - - -class TestBounds(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseAutolabelTestCase): - expected: str - field: str - - @property - def label(self) -> str: - return f"{self.field}_below_one" - - test_cases = ( - TestCase( - field="rows", - expected="rows must be at least 1", - ), - TestCase( - field="first_highlight", - expected="first_highlight must be at least 1", - ), - TestCase( - field="second_highlight", - expected="second_highlight must be at least 1", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - 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) - - -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( - 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),) diff --git a/tests/unit/sampletones_core/timing/test_rate.py b/tests/unit/sampletones_core/timing/test_rate.py index 2b2cc410f..04a70e697 100644 --- a/tests/unit/sampletones_core/timing/test_rate.py +++ b/tests/unit/sampletones_core/timing/test_rate.py @@ -124,3 +124,22 @@ def test_settings_and_parameters_agree(self) -> None: speed=settings.speed, nes_frequency=settings.nes_frequency, ) + + +class TestBoundedRate(BaseTestSuite): + """An engine plays a row for at least its shortest and at most its longest length.""" + + @pytest.mark.parametrize( + ("ticks_per_row", "expected"), + ( + (Fraction(1, 4), Fraction(1)), + (Fraction(1), Fraction(1)), + (Fraction(36, 5), Fraction(36, 5)), + (Fraction(255), Fraction(255)), + (Fraction(2907, 4), Fraction(255)), + ), + ) + def test_a_rate_is_held_within_the_engine_range(self, ticks_per_row: Fraction, expected: Fraction) -> None: + bounded = RowRate(ticks_per_row=ticks_per_row).bounded(minimum_ticks=1, maximum_ticks=255) + + assert bounded == RowRate(ticks_per_row=expected) diff --git a/tests/unit/sampletones_core/timing/test_song.py b/tests/unit/sampletones_core/timing/test_song.py new file mode 100644 index 000000000..0602cb703 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_song.py @@ -0,0 +1,196 @@ +from fractions import Fraction +from itertools import chain +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.performance import song_instructions +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SONG_TICK_BOUNDS, Meter, RowRate, SongTiming, TickBounds +from tests.suite.base import BaseTestSuite +from tests.suite.groove import bar_line_drift, bar_rows, is_proportional + +ROWS_PER_PATTERN: Final[int] = 4 +FRAMES: Final[int] = 3 +HALF_TICK: Final[Fraction] = Fraction(1, 2) +COMMON_TIME: Final[Meter] = Meter(rows=16, first_highlight=4, second_highlight=16) +TRACKER_BOUNDS: Final[TickBounds] = TickBounds(minimum=1, maximum=255) +FRAMES_WALKED: Final[int] = 40 + + +def _timing( + tempo: int, + speed: int, + nes_frequency: int, + meter: Meter, +) -> SongTiming: + return SongTiming( + rate=RowRate.from_parameters(tempo=tempo, speed=speed, nes_frequency=nes_frequency), + meter=meter, + bounds=SONG_TICK_BOUNDS, + ) + + +def _song_ticks(timing: SongTiming, frames: int) -> Tuple[int, ...]: + return tuple(chain.from_iterable(timing.groove(frame).ticks for frame in range(frames))) + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + project = Project.create( + rows_per_pattern=ROWS_PER_PATTERN, + settings=ProjectSettings(tempo=125, speed=5, nes_frequency=60), + ) + for _ in range(FRAMES - project.song.order_length()): + project.song.append_frame() + + return project + + +class TestFrameTick: + """A frame starts a bar, so it starts on the tick nearest its exact start.""" + + def test_the_first_frame_starts_the_song(self, project: Project) -> None: + assert SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(0) == 0 + + def test_each_frame_starts_on_the_tick_nearest_its_exact_start(self) -> None: + """At 30/7 ticks a row, a 16-row pattern lasts 68 4/7 ticks, so frames start 69 or 68 ticks apart.""" + timing = _timing(210, 6, 60, COMMON_TIME) + + assert [timing.frame_tick(frame) for frame in range(8)] == [0, 69, 137, 206, 274, 343, 411, 480] + + def test_the_frame_past_the_last_starts_where_the_walk_ends(self, project: Project) -> None: + """The song's length and the loop point a frame names are read from one rule.""" + walked = len(song_instructions(project)[ChannelName.PULSE1]) + + assert ( + SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) == walked + ) + + +class TestTheRemainderCarries: + """A frame lasts the ticks between its own start and the next frame's, so frames take turns at the surplus.""" + + def test_a_frame_after_a_longer_one_is_shorter(self) -> None: + timing = _timing(210, 6, 60, COMMON_TIME) + + assert timing.groove(0).ticks == (5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) + assert timing.groove(1).ticks == (5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) + + def test_the_grooves_repeat_once_the_exact_lengths_add_up_to_a_whole_tick(self) -> None: + """Seven patterns of 68 4/7 ticks come to 480, so the eighth frame plays the first one's groove.""" + timing = _timing(210, 6, 60, COMMON_TIME) + + assert [timing.groove(frame + 7) for frame in range(7)] == [timing.groove(frame) for frame in range(7)] + + def test_a_rate_of_whole_ticks_plays_every_frame_alike(self) -> None: + timing = _timing(150, 6, 60, COMMON_TIME) + + assert {timing.groove(frame) for frame in range(FRAMES_WALKED)} == {timing.groove(0)} + + +class TestRowLookups: + """Every answer about a row follows from its place in the song alone.""" + + @pytest.mark.parametrize("frame", (0, 1, 5, 13)) + def test_a_row_lasts_what_its_frame_groove_gives_it(self, frame: int) -> None: + timing = _timing(125, 6, 60, Meter(rows=12, first_highlight=3, second_highlight=6)) + groove = timing.groove(frame) + + assert [timing.row_ticks(frame, row) for row in range(12)] == list(groove.ticks) + + @pytest.mark.parametrize("frame", (0, 1, 5, 13)) + def test_a_row_starts_once_every_row_before_it_has_played(self, frame: int) -> None: + timing = _timing(125, 6, 60, Meter(rows=12, first_highlight=3, second_highlight=6)) + groove = timing.groove(frame) + + for row in range(12): + assert timing.tick_at(frame, row) == timing.frame_tick(frame) + sum(groove.ticks[:row]) + + +class TestTicksAcross: + """A span of rows reads each row at its own place, and the order comes round to its first frame.""" + + TIMING: Final[SongTiming] = _timing(210, 6, 60, COMMON_TIME) + + def test_a_span_within_a_frame_adds_up_its_rows(self) -> None: + assert self.TIMING.ticks_across(1, 2, 3, frames=4) == sum(self.TIMING.groove(1).ticks[2:5]) + + def test_a_span_into_the_next_frame_reads_that_frame(self) -> None: + expected = sum(self.TIMING.groove(0).ticks[14:]) + sum(self.TIMING.groove(1).ticks[:2]) + + assert self.TIMING.ticks_across(0, 14, 4, frames=4) == expected + + def test_a_span_past_the_song_goes_on_from_its_first_frame(self) -> None: + expected = sum(self.TIMING.groove(3).ticks[14:]) + sum(self.TIMING.groove(0).ticks[:2]) + + assert self.TIMING.ticks_across(3, 14, 4, frames=4) == expected + + def test_a_span_of_whole_passes_lasts_the_song_that_many_times(self) -> None: + assert self.TIMING.ticks_across(2, 5, 2 * 4 * 16, frames=4) == 2 * self.TIMING.frame_tick(4) + + def test_an_empty_span_lasts_no_tick(self) -> None: + assert self.TIMING.ticks_across(2, 5, 0, frames=4) == 0 + + +class TestTheBounds: + """A player holds every row within its own range, and the rate is held there first.""" + + def test_a_rate_below_one_tick_plays_every_row_for_one_tick(self) -> None: + """At tempo 255, speed 1 and 50 Hz a row asks for 25/51 ticks, so the song runs slower than set.""" + timing = _timing(255, 1, 50, COMMON_TIME) + + assert timing.groove(0).ticks == (1,) * 16 + assert timing.frame_tick(FRAMES) == FRAMES * 16 + + def test_a_rate_above_a_trackers_range_plays_every_row_at_its_longest(self) -> None: + timing = SongTiming( + rate=RowRate.from_parameters(tempo=32, speed=31, nes_frequency=300), + meter=COMMON_TIME, + bounds=TRACKER_BOUNDS, + ) + + assert timing.groove(1).ticks == (TRACKER_BOUNDS.maximum,) * 16 + + +SWEPT_METERS: Final[Tuple[Meter, ...]] = ( + COMMON_TIME, + Meter(rows=16, first_highlight=3, second_highlight=7), + Meter(rows=12, first_highlight=3, second_highlight=12), + Meter(rows=20, first_highlight=4, second_highlight=20), + Meter(rows=24, first_highlight=6, second_highlight=24), + Meter(rows=16, first_highlight=4, second_highlight=32), + Meter(rows=16, first_highlight=20, second_highlight=8), + Meter(rows=16, first_highlight=1, second_highlight=1), + Meter(rows=1, first_highlight=4, second_highlight=16), + Meter(rows=7, first_highlight=2, second_highlight=5), + Meter(rows=256, first_highlight=16, second_highlight=256), +) + + +def _meter_label(meter: Meter) -> str: + return f"{meter.rows}r_{meter.first_highlight}_{meter.second_highlight}" + + +class TestAWholeSongKeepsTheGroovesRules(BaseTestSuite): + """Over many frames, every bar line stays within half a tick of its exact start and every row in proportion.""" + + @pytest.mark.parametrize("meter", SWEPT_METERS, ids=_meter_label) + @pytest.mark.parametrize("tempo", (32, 97, 125, 133, 210, 251, 255)) + @pytest.mark.parametrize(("speed", "nes_frequency"), ((1, 60), (6, 60), (5, 50), (7, 41), (13, 15), (31, 300))) + def test_every_bar_line_and_every_row( + self, + meter: Meter, + tempo: int, + speed: int, + nes_frequency: int, + ) -> None: + timing = _timing(tempo, speed, nes_frequency, meter) + frames = max(1, FRAMES_WALKED * COMMON_TIME.rows // meter.rows) + ticks = _song_ticks(timing, frames) + + assert bar_line_drift(ticks, bar_rows(meter, frames), timing.exact_row_ticks) <= HALF_TICK + assert is_proportional(ticks, timing.exact_row_ticks) + assert sum(ticks) == timing.frame_tick(frames) diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py deleted file mode 100644 index 4c9aa41fd..000000000 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ /dev/null @@ -1,229 +0,0 @@ -import gzip -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, Final, List, Optional - -import numpy as np -import pytest - -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.project.project import Project -from sampletones_core.project.settings import ProjectSettings -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.implementation.bitphase import ( - BitphaseBackend, - BitphasePresetBackend, -) -from sampletones_core.trackers.request import ( - InstrumentExport, - ProjectExport, - SampleExport, -) -from sampletones_core.trackers.scope import ExportScope -from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON - -NES_FREQUENCY: Final[int] = 60 -REFERENCE_PITCH: Final[int] = 60 -ENVELOPE_FRAMES: Final[int] = 16 -LONG_ENVELOPE_FRAMES: Final[int] = 600 -PROJECT_TITLE: Final[str] = "Demo" - - -PRESET_SCOPES: List[ExportScope] = [ExportScope.INSTRUMENT, ExportScope.SAMPLE] - - -def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: - duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) - return Features( - initial_pitch=REFERENCE_PITCH, - volume=np.full(frames, 15, dtype=int), - arpeggio=np.zeros(frames, dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=duty_cycle, - ) - - -def build_instrument(name: str, frames: int) -> InstrumentExport: - return InstrumentExport( - name=name, - generator=GeneratorName.PULSE1, - features=build_features(frames), - loop=False, - nes_frequency=NES_FREQUENCY, - ) - - -def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: - return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) - - -def read_document(destination: Path) -> Dict[str, Any]: - document: Dict[str, Any] = json.loads(gzip.decompress(destination.read_bytes())) - return document - - -@pytest.fixture(name="backend") -def backend_fixture() -> BitphaseBackend: - return BitphaseBackend() - - -@pytest.fixture(name="preset_backend") -def preset_backend_fixture() -> BitphasePresetBackend: - return BitphasePresetBackend() - - -@pytest.fixture(name="project") -def project_fixture() -> Project: - return Project.create(title=PROJECT_TITLE, author="Tester", settings=ProjectSettings()) - - -class TestFormatDeclaration: - def test_the_backend_names_its_format(self, backend: BitphaseBackend) -> None: - assert backend.tracker_format == TrackerFormat.BITPHASE - - def test_every_scope_is_supported(self, backend: BitphaseBackend) -> None: - assert backend.supported_scopes == frozenset(ExportScope) - - @pytest.mark.parametrize("scope", list(ExportScope)) - def test_every_scope_carries_the_document_extension(self, backend: BitphaseBackend, scope: ExportScope) -> None: - assert backend.extension(scope) == EXT_FILE_BITPHASE - - -class TestWriteInstrument: - def test_the_file_is_written_and_reported(self, backend: BitphaseBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" - - artifact = backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) - - assert destination.exists() - assert artifact.paths == (destination,) - - def test_the_document_holds_the_slice(self, backend: BitphaseBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Short{EXT_FILE_BITPHASE}" - backend.write_instrument(destination, build_instrument("Short", ENVELOPE_FRAMES)) - - document = read_document(destination) - - assert [instrument["name"] for instrument in document["instruments"]] == ["Short"] - - def test_a_long_envelope_crosses_over_whole(self, backend: BitphaseBackend, tmp_path: Path) -> None: - """Bitphase stores instrument rows without a length limit, so a reconstruction - reaches the document at its full length. - """ - destination = tmp_path / f"Long{EXT_FILE_BITPHASE}" - artifact = backend.write_instrument(destination, build_instrument("Long", LONG_ENVELOPE_FRAMES)) - - document = read_document(destination) - - assert len(document["instruments"][0]["rows"]) == LONG_ENVELOPE_FRAMES - assert artifact.truncation is None - - -class TestWriteSample: - def test_every_slice_lands_in_one_document(self, backend: BitphaseBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" - request = build_sample( - "Kick", - build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), - build_instrument("Kick (noise)", ENVELOPE_FRAMES), - ) - - artifact = backend.write_sample(destination, request) - - assert artifact.paths == (destination,) - assert len(read_document(destination)["instruments"]) == 2 - - def test_the_document_is_named_after_the_reconstruction(self, backend: BitphaseBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Kick{EXT_FILE_BITPHASE}" - backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) - - assert read_document(destination)["name"] == "Kick" - - -class TestWriteProject: - def test_the_document_is_written_and_reported( - self, - backend: BitphaseBackend, - project: Project, - tmp_path: Path, - ) -> None: - destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" - - artifact = backend.write_project(destination, ProjectExport(project=project)) - - assert artifact.paths == (destination,) - assert destination.exists() - - def test_the_document_takes_the_project_title( - self, - backend: BitphaseBackend, - project: Project, - tmp_path: Path, - ) -> None: - destination = tmp_path / f"Demo{EXT_FILE_BITPHASE}" - backend.write_project(destination, ProjectExport(project=project)) - - assert read_document(destination)["name"] == PROJECT_TITLE - - -class TestThePresetBackend: - def test_the_backend_names_its_format(self, preset_backend: BitphasePresetBackend) -> None: - assert preset_backend.tracker_format == TrackerFormat.BITPHASE_PRESET - - def test_a_preset_holds_instruments_rather_than_a_song(self, preset_backend: BitphasePresetBackend) -> None: - assert preset_backend.supported_scopes == frozenset({ExportScope.INSTRUMENT, ExportScope.SAMPLE}) - - @pytest.mark.parametrize("scope", PRESET_SCOPES, ids=lambda scope: str(scope)) - def test_every_supported_scope_carries_the_preset_extension( - self, - preset_backend: BitphasePresetBackend, - scope: ExportScope, - ) -> None: - assert preset_backend.extension(scope) == EXT_FILE_JSON - - def test_one_slice_lands_in_a_file(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Lead{EXT_FILE_JSON}" - - artifact = preset_backend.write_instrument(destination, build_instrument("Lead", ENVELOPE_FRAMES)) - - assert artifact.paths == (destination,) - assert json.loads(destination.read_text(encoding="utf-8"))["name"] == "Lead" - - def test_each_slice_lands_beside_the_destination_named_after_its_instrument( - self, - preset_backend: BitphasePresetBackend, - tmp_path: Path, - ) -> None: - destination = tmp_path / f"Kick{EXT_FILE_JSON}" - request = build_sample( - "Kick", - build_instrument("Kick (pulse1)", ENVELOPE_FRAMES), - build_instrument("Kick (noise)", ENVELOPE_FRAMES), - ) - - artifact = preset_backend.write_sample(destination, request) - - assert artifact.paths == ( - tmp_path / f"Kick (pulse1){EXT_FILE_JSON}", - tmp_path / f"Kick (noise){EXT_FILE_JSON}", - ) - assert all(path.exists() for path in artifact.paths) - - def test_a_missing_directory_is_created(self, preset_backend: BitphasePresetBackend, tmp_path: Path) -> None: - destination = tmp_path / "nested" / f"Kick{EXT_FILE_JSON}" - - preset_backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", ENVELOPE_FRAMES))) - - assert destination.parent.is_dir() - - def test_a_project_is_refused( - self, - preset_backend: BitphasePresetBackend, - project: Project, - tmp_path: Path, - ) -> None: - with pytest.raises(ValueError, match="one instrument"): - preset_backend.write_project(tmp_path / f"Demo{EXT_FILE_JSON}", ProjectExport(project=project)) diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py deleted file mode 100644 index b9cc907d1..000000000 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ /dev/null @@ -1,116 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, Final, List, Optional - -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_shared.paths.extensions import ( - EXT_FILE_BITPHASE, - EXT_FILE_INSTRUMENT, - EXT_FILE_JSON, - EXT_FILE_MODULE, -) - -UNKNOWN_EXTENSION: Final[str] = ".xm" -NO_EXTENSION: Final[str] = "" - - -@dataclass(frozen=True) -class ExtensionCase: - scope: ExportScope - extension: str - expected: Optional[TrackerFormat] - - -EXTENSION_CASES: Final[List[ExtensionCase]] = [ - ExtensionCase( - scope=ExportScope.INSTRUMENT, - extension=EXT_FILE_INSTRUMENT, - expected=TrackerFormat.FAMITRACKER, - ), - ExtensionCase( - scope=ExportScope.INSTRUMENT, - extension=EXT_FILE_BITPHASE, - expected=TrackerFormat.BITPHASE, - ), - ExtensionCase( - scope=ExportScope.INSTRUMENT, - extension=EXT_FILE_JSON, - expected=TrackerFormat.BITPHASE_PRESET, - ), - ExtensionCase( - scope=ExportScope.SAMPLE, - extension=EXT_FILE_JSON, - expected=TrackerFormat.BITPHASE_PRESET, - ), - ExtensionCase( - scope=ExportScope.PROJECT, - extension=EXT_FILE_MODULE, - expected=TrackerFormat.FAMITRACKER, - ), - ExtensionCase( - scope=ExportScope.INSTRUMENT, - extension=UNKNOWN_EXTENSION, - expected=None, - ), - ExtensionCase( - scope=ExportScope.INSTRUMENT, - extension=NO_EXTENSION, - expected=None, - ), -] - - -@pytest.fixture(name="backends") -def backends_fixture() -> Dict[TrackerFormat, TrackerBackend]: - return build_tracker_backends() - - -class TestFormatForExtension: - @pytest.mark.parametrize( - "case", - EXTENSION_CASES, - ids=lambda case: f"{case.scope}{case.extension}", - ) - def test_the_extension_names_the_format_that_writes_it( - self, - backends: Dict[TrackerFormat, TrackerBackend], - case: ExtensionCase, - ) -> None: - assert format_for_extension(backends, case.scope, case.extension) == case.expected - - def test_an_extension_typed_in_capitals_reaches_the_same_format( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - assert ( - format_for_extension(backends, ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT.upper()) - == TrackerFormat.FAMITRACKER - ) - - def test_a_format_that_cannot_express_the_scope_stays_unmatched( - self, - backends: Dict[TrackerFormat, TrackerBackend], - ) -> None: - """A preset holds one instrument, so a project named with its extension resolves - to no format at all. - """ - assert format_for_extension(backends, ExportScope.PROJECT, EXT_FILE_JSON) is None - - @pytest.mark.parametrize("scope", list(ExportScope), ids=lambda scope: str(scope)) - def test_every_extension_a_backend_writes_resolves_back_to_it( - self, - backends: Dict[TrackerFormat, TrackerBackend], - scope: ExportScope, - ) -> None: - """A dialog offers the extension of each format it can reach, so a destination taking - one of them names the backend that put it in the selector. Each scope's extensions are - therefore distinct across formats, which is what the resolution reads them as. - """ - for tracker_format, backend in backends.items(): - if scope in backend.supported_scopes: - assert format_for_extension(backends, scope, backend.extension(scope)) == tracker_format diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py deleted file mode 100644 index 3f036b020..000000000 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ /dev/null @@ -1,163 +0,0 @@ -from pathlib import Path -from typing import Final, Optional - -import numpy as np -import pytest - -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.exporters import Features -from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.formats.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, -) -from sampletones_core.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.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE - -NES_FREQUENCY: Final[int] = 60 - - -def build_features(frames: int, *, duty_cycle_frames: Optional[int] = None) -> Features: - duty_cycle = None if duty_cycle_frames is None else np.zeros(duty_cycle_frames, dtype=int) - return Features( - initial_pitch=60, - volume=np.full(frames, 15, dtype=int), - arpeggio=np.zeros(frames, dtype=int), - pitch=None, - hi_pitch=None, - duty_cycle=duty_cycle, - ) - - -def build_instrument(name: str, frames: int) -> InstrumentExport: - return InstrumentExport( - name=name, - generator=GeneratorName.PULSE1, - features=build_features(frames), - loop=False, - nes_frequency=NES_FREQUENCY, - ) - - -def build_sample(name: str, *instruments: InstrumentExport) -> SampleExport: - return SampleExport(name=name, instruments=instruments, nes_frequency=NES_FREQUENCY) - - -@pytest.fixture(name="backend") -def backend_fixture() -> FamiTrackerBackend: - return FamiTrackerBackend() - - -class TestFormatDeclaration: - def test_the_backend_names_its_format(self, backend: FamiTrackerBackend) -> None: - assert backend.tracker_format == TrackerFormat.FAMITRACKER - - def test_every_scope_is_supported(self, backend: FamiTrackerBackend) -> None: - assert backend.supported_scopes == frozenset(ExportScope) - - @pytest.mark.parametrize( - ("scope", "expected"), - [ - (ExportScope.INSTRUMENT, EXT_FILE_INSTRUMENT), - (ExportScope.SAMPLE, EXT_FILE_INSTRUMENT), - (ExportScope.PROJECT, EXT_FILE_MODULE), - ], - ) - def test_instruments_carry_the_instrument_extension_and_a_project_the_module_one( - self, - backend: FamiTrackerBackend, - scope: ExportScope, - expected: str, - ) -> None: - assert backend.extension(scope) == expected - - -class TestWriteInstrument: - def test_the_file_is_written_and_reported(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Short{EXT_FILE_INSTRUMENT}" - - artifact = backend.write_instrument(destination, build_instrument("Short", 16)) - - assert destination.exists() - assert artifact.paths == (destination,) - - def test_an_envelope_within_the_limit_reports_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - artifact = backend.write_instrument( - tmp_path / f"Short{EXT_FILE_INSTRUMENT}", - build_instrument("Short", MAX_SEQUENCE_ITEMS), - ) - assert artifact.truncation is None - - def test_an_envelope_beyond_the_limit_reports_both_counts( - self, - backend: FamiTrackerBackend, - tmp_path: Path, - ) -> None: - artifact = backend.write_instrument( - tmp_path / f"Long{EXT_FILE_INSTRUMENT}", - build_instrument("Long", 300), - ) - assert artifact.truncation == EnvelopeTruncation( - frames=MAX_SEQUENCE_ITEMS, - source_frames=300, - instruments=1, - ) - - def test_a_shortened_export_still_writes_the_file(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - destination = tmp_path / f"Long{EXT_FILE_INSTRUMENT}" - backend.write_instrument(destination, build_instrument("Long", 300)) - assert destination.exists() - - -class TestWriteSample: - def test_each_slice_lands_beside_the_destination_named_after_its_instrument( - self, - backend: FamiTrackerBackend, - tmp_path: Path, - ) -> None: - destination = tmp_path / f"Kick{EXT_FILE_INSTRUMENT}" - request = build_sample( - "Kick", - build_instrument("Kick (pulse1)", 16), - build_instrument("Kick (noise)", 16), - ) - - artifact = backend.write_sample(destination, request) - - assert artifact.paths == ( - tmp_path / f"Kick (pulse1){EXT_FILE_INSTRUMENT}", - tmp_path / f"Kick (noise){EXT_FILE_INSTRUMENT}", - ) - assert all(path.exists() for path in artifact.paths) - - def test_a_missing_directory_is_created(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - destination = tmp_path / "nested" / f"Kick{EXT_FILE_INSTRUMENT}" - - backend.write_sample(destination, build_sample("Kick", build_instrument("Kick", 16))) - - assert destination.parent.is_dir() - - def test_the_report_spans_every_shortened_slice(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - request = build_sample( - "Kick", - build_instrument("Short", 16), - build_instrument("Long", 300), - build_instrument("Longer", 410), - ) - - artifact = backend.write_sample(tmp_path / "Kick", request) - - assert artifact.truncation == EnvelopeTruncation( - frames=MAX_SEQUENCE_ITEMS, - source_frames=410, - instruments=2, - ) - - def test_slices_that_all_fit_report_nothing(self, backend: FamiTrackerBackend, tmp_path: Path) -> None: - request = build_sample("Kick", build_instrument("Short", 16)) - - artifact = backend.write_sample(tmp_path / "Kick", request) - - assert artifact.truncation is None diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py index c342f7e13..fd7eb3a2b 100644 --- a/tests/unit/sampletones_core/utils/test_display.py +++ b/tests/unit/sampletones_core/utils/test_display.py @@ -3,27 +3,31 @@ import pytest -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PLAYED_PITCH 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.patterns.pitch import Note, RowPitch, Step +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_pitch, display_transpose, + display_voice, + display_voice_label, display_volume, ) +from sampletones_core.utils.frequencies import period_to_name, pitch_to_name 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 +35,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 +52,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 +69,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 +90,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 +112,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,24 +122,25 @@ 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 ) -_TRANSPOSE_CASES = [ +_TRANSPOSE_CASES: List[Tuple[int, str]] = [ + (0, "+00"), (5, "+05"), (-5, "-05"), - (26, "+1A"), - (-26, "-1A"), + (26, "+26"), + (-26, "-26"), ] class TestDisplayTranspose: @pytest.mark.parametrize(("value", "expected"), _TRANSPOSE_CASES) - def test_signed_offset_is_two_hexadecimal_digits(self, value: int, expected: str) -> None: + def test_signed_offset_is_two_decimal_digits(self, value: int, expected: str) -> None: assert display_transpose(value) == expected def test_explicit_zero_reads_as_a_zero_offset(self) -> None: @@ -164,3 +166,33 @@ def test_silent_volume_reads_as_zero(self) -> None: def test_absent_volume_is_placeholder(self) -> None: assert display_volume(None) == "." + + +_PITCH_CASES: List[Tuple[Optional[RowPitch], str]] = [ + (None, NOTE_BLANK), + (Step(value=0), display_transpose(0)), + (Step(value=-10), display_transpose(-10)), + (Note(value=60), pitch_to_name(60)), + (Note(value=61), pitch_to_name(61)), + (Note(value=MIN_PLAYED_PITCH), pitch_to_name(MIN_PLAYED_PITCH)), + (Note(value=MAX_PITCH), pitch_to_name(MAX_PITCH)), + (Note(value=0), period_to_name(0)), + (Note(value=MAX_PERIOD), period_to_name(MAX_PERIOD)), + (Note(value=MAX_PERIOD + 1), pitch_to_name(MIN_PLAYED_PITCH)), +] + + +class TestDisplayPitch: + """A pitch prints in the face it was written: a note by its name, a step as a signed offset.""" + + @pytest.mark.parametrize(("pitch", "expected"), _PITCH_CASES) + def test_the_face_a_pitch_prints(self, pitch: Optional[RowPitch], expected: str) -> None: + assert display_pitch(pitch) == expected + + @pytest.mark.parametrize(("pitch", "expected"), _PITCH_CASES) + def test_every_face_is_the_same_width(self, pitch: Optional[RowPitch], expected: str) -> None: + assert len(display_pitch(pitch)) == len(NOTE_BLANK) + + def test_a_period_and_a_pitch_name_apart(self) -> None: + """The two value ranges stand apart, so a note needs no channel to be named.""" + assert display_pitch(Note(value=MAX_PERIOD)) != display_pitch(Note(value=MIN_PLAYED_PITCH)) diff --git a/tests/unit/sampletones_core/utils/test_frequencies.py b/tests/unit/sampletones_core/utils/test_frequencies.py index 8f41a2cda..d9ea3f353 100644 --- a/tests/unit/sampletones_core/utils/test_frequencies.py +++ b/tests/unit/sampletones_core/utils/test_frequencies.py @@ -1,258 +1,41 @@ 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, MIN_PLAYED_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, + played_pitch, 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 TestPlayedPitch: + """A note is played within C-0..B-7, the notes FamiTracker and Bitphase write, and held there past either end.""" + def test_a_note_within_the_range_is_played_as_it_is(self) -> None: + for pitch in range(MIN_PLAYED_PITCH, MAX_PITCH + 1): + assert played_pitch(pitch) == pitch -class TestIsPitchValid: - def test_is_pitch_valid(self) -> None: - valid_pitches = range(MIN_PITCH, MAX_PITCH + 1) - for pitch in valid_pitches: - assert is_pitch_valid(pitch), pitch + def test_a_note_below_a0_keeps_its_own_pitch(self) -> None: + assert played_pitch(MIN_PITCH - 1) == MIN_PITCH - 1 - invalid_pitches = ( - 0, - LIMIT_MIN_PITCH, - MIN_PITCH - 1, - MAX_PITCH + 1, - LIMIT_MAX_PITCH, - 128, - ) - for pitch in invalid_pitches: - assert not is_pitch_valid(pitch), pitch + def test_a_note_past_either_end_is_held_there(self) -> None: + assert played_pitch(MIN_PLAYED_PITCH - 1) == MIN_PLAYED_PITCH + assert played_pitch(0) == MIN_PLAYED_PITCH + assert played_pitch(MAX_PITCH + 1) == MAX_PITCH + assert played_pitch(LIMIT_MAX_PITCH) == MAX_PITCH class TestValidatePeriod(BaseTestSuite): @@ -351,464 +134,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): @@ -1528,14 +853,24 @@ class TestCase(BaseRegularTestCase): ), TestCase( name="G--", - expected="--", - label="multiple_hyphens_g_removed", + expected="G--", + label="multiple_hyphens_kept", ), TestCase( name="C##4", expected="C##4", label="double_sharp", ), + TestCase( + name="G-3", + expected="G-3", + label="the_seventh_note_letter", + ), + TestCase( + name="H-3", + expected="-3", + label="a_letter_past_the_notes", + ), TestCase( name=None, expected=AttributeError, diff --git a/tests/unit/sampletones_core/utils/test_pitch_kind.py b/tests/unit/sampletones_core/utils/test_pitch_kind.py index 3b309b1b6..9281339d1 100644 --- a/tests/unit/sampletones_core/utils/test_pitch_kind.py +++ b/tests/unit/sampletones_core/utils/test_pitch_kind.py @@ -2,11 +2,14 @@ import pytest -from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH, MIN_PLAYED_PITCH from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, PITCH_VALUE_KIND, + PLAYED_PITCH_VALUE_KIND, PitchValueKind, + note_value_kind, ) @@ -21,6 +24,9 @@ class ClampCase: ClampCase(PITCH_VALUE_KIND, MIN_PITCH - 10, MIN_PITCH), ClampCase(PITCH_VALUE_KIND, MAX_PITCH + 10, MAX_PITCH), ClampCase(PITCH_VALUE_KIND, 60, 60), + ClampCase(PLAYED_PITCH_VALUE_KIND, MIN_PLAYED_PITCH - 10, MIN_PLAYED_PITCH), + ClampCase(PLAYED_PITCH_VALUE_KIND, MAX_PITCH + 10, MAX_PITCH), + ClampCase(PLAYED_PITCH_VALUE_KIND, MIN_PLAYED_PITCH, MIN_PLAYED_PITCH), ClampCase(PERIOD_VALUE_KIND, -5, 0), ClampCase(PERIOD_VALUE_KIND, MAX_PERIOD + 5, MAX_PERIOD), ClampCase(PERIOD_VALUE_KIND, 4, 4), @@ -58,3 +64,23 @@ def test_unparseable_text_falls_back(self) -> None: def test_round_trip_through_name(self) -> None: assert PERIOD_VALUE_KIND.from_text(PERIOD_VALUE_KIND.to_name(11), fallback=0) == 11 + + +class TestNoteValueKind: + """A row's note is held to the notes a tonal channel plays, from C-0 up, and to the periods on noise.""" + + @pytest.mark.parametrize("channel_name", [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE]) + def test_a_tonal_channel_plays_from_the_lowest_note(self, channel_name: ChannelName) -> None: + assert note_value_kind(channel_name) is PLAYED_PITCH_VALUE_KIND + + def test_the_noise_channel_names_its_periods(self) -> None: + assert note_value_kind(ChannelName.NOISE) is PERIOD_VALUE_KIND + + def test_the_lowest_note_has_a_name_the_played_kind_reads(self) -> None: + lowest = PLAYED_PITCH_VALUE_KIND.to_name(MIN_PLAYED_PITCH) + + assert ( + PLAYED_PITCH_VALUE_KIND.sanitized_name_to_value[PLAYED_PITCH_VALUE_KIND.sanitize(lowest)] + == MIN_PLAYED_PITCH + ) + assert PLAYED_PITCH_VALUE_KIND.sanitize(lowest) not in PITCH_VALUE_KIND.sanitized_name_to_value 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..bb859f203 --- /dev/null +++ b/tests/unit/sampletones_player/clock/test_schedule.py @@ -0,0 +1,277 @@ +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..6c64ba496 --- /dev/null +++ b/tests/unit/sampletones_player/clock/test_step.py @@ -0,0 +1,63 @@ +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..a18fc934b --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/test_phrase.py @@ -0,0 +1,39 @@ +import pytest +from pydantic import ValidationError + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.specification.compression import ( + MAX_PHRASE_LENGTH, + PHRASE_DEFAULT_SIZE, + 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_its_count_and_its_body( + self, + ) -> None: + assert phrase(1, 2, 3).size == PHRASE_TABLE_ENTRY_SIZE + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_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..0c2a3709c --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/test_prune.py @@ -0,0 +1,26 @@ +from collections import defaultdict +from typing import Dict, Final + +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 + +NO_COUNTS: Final[Dict[int, int]] = defaultdict(int) + + +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}, NO_COUNTS) + 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}, NO_COUNTS).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}, NO_COUNTS) + 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..73c85e8d8 --- /dev/null +++ b/tests/unit/sampletones_player/compression/dictionary/test_table.py @@ -0,0 +1,38 @@ +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..9e041ce26 --- /dev/null +++ b/tests/unit/sampletones_player/compression/matches/test_matcher.py @@ -0,0 +1,67 @@ +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.matcher import ( + PhraseMatch, + 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..13ad6b0ad --- /dev/null +++ b/tests/unit/sampletones_player/compression/parse/test_boundaries.py @@ -0,0 +1,31 @@ +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..1f8490a63 --- /dev/null +++ b/tests/unit/sampletones_player/compression/parse/test_plane.py @@ -0,0 +1,134 @@ +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, default=False),) + + 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, default=False),) + + 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_flags.py b/tests/unit/sampletones_player/compression/planes/test_flags.py new file mode 100644 index 000000000..842b6b064 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_flags.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_player.compression.planes.flags import ( + flagged_ticks, + flagged_value, + is_flagged, + note_flags, + pitch_index, +) +from sampletones_player.specification.compression import PITCH_INDEX_MASK +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +INDEX: Final[int] = 33 +NEIGHBOR: Final[int] = 34 + + +class TestAValueByte: + def test_a_flagged_value_names_its_index_and_its_flag(self) -> None: + value = flagged_value(INDEX, True) + assert (pitch_index(value), is_flagged(value)) == (INDEX, True) + + def test_an_unflagged_value_is_its_index(self) -> None: + assert flagged_value(INDEX, False) == INDEX + assert not is_flagged(INDEX) + + def test_an_index_reaching_the_flag_is_refused(self) -> None: + with pytest.raises(ValueError): + flagged_value(PITCH_INDEX_MASK + 1, False) + + def test_the_flagged_ticks_are_counted(self) -> None: + value = bytes((flagged_value(INDEX, True), INDEX, flagged_value(INDEX, True))) + assert flagged_ticks(value) == 2 + + +class TestNoteFlags(BaseTestSuite): + """A note is flagged from its first bent tick to its last, and a note that never bends is not.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + indices: Tuple[int, ...] + offsets: Tuple[int, ...] + expected: Tuple[bool, ...] + + test_cases = ( + TestCase( + label="an unbent note carries no flag", + indices=(INDEX,) * 3, + offsets=(0, 0, 0), + expected=(False, False, False), + ), + TestCase( + label="a bend passing through zero keeps its note flagged", + indices=(INDEX,) * 5, + offsets=(0, 3, 0, -3, 0), + expected=(False, True, True, True, False), + ), + TestCase( + label="each note is flagged on its own", + indices=(INDEX, INDEX, NEIGHBOR, NEIGHBOR), + offsets=(0, 2, 0, 0), + expected=(False, True, False, False), + ), + TestCase( + label="a note returning after another is a note of its own", + indices=(INDEX, NEIGHBOR, INDEX), + offsets=(4, 0, 4), + expected=(True, False, True), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_flags_match(self, test_case: TestCase) -> None: + assert note_flags(test_case.indices, test_case.offsets) == test_case.expected + + def test_indices_and_offsets_covering_different_ticks_are_refused( + self, + ) -> None: + with pytest.raises(ValueError): + note_flags((INDEX,), ()) 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..3df08237d --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_order.py @@ -0,0 +1,25 @@ +import pytest + +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.planes 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..4f5ddf563 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_rebuild.py @@ -0,0 +1,43 @@ +from sampletones_core.constants.general import MAX_TIMER, MIN_TIMER +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 +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + noise_tick, + player_streams, + pulse_tick, + triangle_tick, +) +from tests.unit.sampletones_player.compression.planes.conftest import ( + NOISE_PERIOD, +) + + +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) + + def test_every_divider_the_register_holds_rebuilds(self, pitches: PitchTable) -> None: + """A bent tick may sound any divider, and the index and bend it splits into sum back to it.""" + dividers = range(MIN_TIMER, MAX_TIMER + 1) + streams = player_streams( + pulse1=[pulse_tick(PLAYER_FULL_VOLUME, 0, divider) for divider in dividers], + pulse2=[pulse_tick(PLAYER_FULL_VOLUME, 1, divider) for divider in reversed(dividers)], + triangle=[triangle_tick(True, divider) for divider in dividers], + noise=[noise_tick(PLAYER_FULL_VOLUME, 0, NOISE_PERIOD)], + ) + rebuilt = streams_from_planes(planes_from_streams(streams, pitches), pitches) + assert [rebuilt.at(tick) for tick in range(streams.ticks)] == [ + streams.at(tick) for tick in range(streams.ticks) + ] 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..09e83fc99 --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_separate.py @@ -0,0 +1,110 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.flags import flagged_value +from sampletones_player.compression.planes.separate import ( + channel_planes, + planes_from_streams, +) +from sampletones_player.registers.pulse import PulseRegisters +from sampletones_player.registers.streams import ChannelStreams +from sampletones_player.specification.binary import ( + SIGNED_BYTE_LIMIT, + unsigned_byte, +) +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) +from tests.suite.player import PLAYER_FULL_VOLUME, resting_streams +from tests.unit.sampletones_player.compression.planes.conftest import ( + HIGH_INDEX, + LOW_INDEX, + NOISE_PERIOD, + SOUNDING_TICKS, +) + +PAST_HALFWAY: Final[int] = -40 + + +def anchored_tick(pitches: PitchTable, index: int, bend: int) -> PulseRegisters: + """A sounding pulse tick counted from the pitch at ``index``, bent by ``bend`` steps.""" + divider = pitches.timers[index] + bend + return PulseRegisters( + control=PLAYER_FULL_VOLUME, + timer_low=divider & MAX_REGISTER_VALUE, + timer_high=divider >> TIMER_HIGH_SHIFT, + anchor=pitches.pitch(index), + ) + + +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.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.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.planes.pulse2_control == bytes((planes.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.of(ChannelName.PULSE1) + + +class TestABentTickSplitsIntoANoteAndABend: + """A tick's divider reaches the planes as the pitch it is counted from and the steps from there.""" + + def test_an_unbent_tick_holds_a_bend_of_nothing( + self, + sounding_streams: ChannelStreams, + pitches: PitchTable, + ) -> None: + planes = planes_from_streams(sounding_streams, pitches) + assert planes.planes.pulse1_bend == b"" + + def test_a_bent_tick_names_its_anchor_and_the_steps_from_it(self, pitches: PitchTable) -> None: + bends = (2, -3, PAST_HALFWAY) + streams = resting_streams([anchored_tick(pitches, LOW_INDEX, bend) for bend in bends]) + planes = planes_from_streams(streams, pitches) + assert planes.planes.pulse1_value == bytes(flagged_value(LOW_INDEX, True) for _ in bends) + assert planes.planes.pulse1_bend == bytes(unsigned_byte(bend) for bend in bends) + + def test_a_note_is_flagged_from_its_first_bend_to_its_last(self, pitches: PitchTable) -> None: + bends = (0, 3, 0, -3, 0) + streams = resting_streams([anchored_tick(pitches, LOW_INDEX, bend) for bend in bends]) + planes = planes_from_streams(streams, pitches) + assert planes.planes.pulse1_value == bytes(flagged_value(LOW_INDEX, 0 < tick < 4) for tick in range(len(bends))) + assert planes.planes.pulse1_bend == bytes(unsigned_byte(bend) for bend in bends[1:4]) + + def test_a_divider_past_the_byte_from_its_anchor_is_refused(self, pitches: PitchTable) -> None: + streams = resting_streams((anchored_tick(pitches, LOW_INDEX, -SIGNED_BYTE_LIMIT - 1),)) + with pytest.raises(ValueError): + planes_from_streams(streams, pitches) 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..c58cc47ff --- /dev/null +++ b/tests/unit/sampletones_player/compression/planes/test_song.py @@ -0,0 +1,96 @@ +from typing import Final, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.compression.planes.flags import flagged_value +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.planes import ( + PLANE_COUNT, + PLANES, + PlaneRole, + plane_index, +) +from tests.suite.player import sounding_planes + +TIMBRE: Final[bytes] = bytes((0x3F, 0x3A)) +BEND: Final[bytes] = bytes((0x00, 0xFD)) +FLAGGED: Final[bytes] = bytes((flagged_value(3, True), flagged_value(4, True))) +PULSE1_BEND: Final[int] = plane_index(ChannelName.PULSE1, PlaneRole.BEND) +BENDS: Final[Tuple[str, ...]] = tuple(plane.name for plane in PLANES if plane.spans_flagged_ticks) + + +class TestASongGathersEveryPlaneItsBlockWrites: + """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(sounding_planes(TIMBRE, FLAGGED, BEND).planes) == PLANE_COUNT + + def test_a_plane_is_reached_by_the_name_the_block_writes_it_under( + self, + ) -> None: + planes = sounding_planes(TIMBRE, FLAGGED, BEND).planes + + assert planes.pulse1_bend == BEND + + def test_a_channel_answers_with_the_planes_it_writes(self) -> None: + planes = sounding_planes(TIMBRE, FLAGGED, BEND) + + assert planes.of(ChannelName.PULSE1) == (TIMBRE, FLAGGED, BEND) + + def test_a_song_lasts_the_ticks_its_planes_cover(self) -> None: + assert sounding_planes(TIMBRE, FLAGGED, BEND).ticks == 2 + + def test_planes_covering_different_ticks_are_refused(self) -> None: + with pytest.raises(ValidationError): + SongPlanes( + planes=PlaneOrder.across(bytes((plane.seeded,)) * index for index, plane in enumerate(PLANES, start=1)) + ) + + def test_planes_covering_no_tick_are_refused(self) -> None: + with pytest.raises(ValidationError): + SongPlanes(planes=PlaneOrder.across(b"" for _ in range(PLANE_COUNT))) + + +class TestABendPlaneCoversTheFlaggedTicks: + """A bend plane holds a value for each tick its value plane flags, and for those alone.""" + + def test_a_bend_for_every_flagged_tick_is_taken(self) -> None: + value = bytes((flagged_value(3, False), flagged_value(3, True))) + + assert sounding_planes(TIMBRE, value, BEND[:1]).planes.pulse1_bend == BEND[:1] + + def test_a_bend_plane_holding_more_than_the_flags_ask_for_is_refused( + self, + ) -> None: + with pytest.raises(ValidationError): + sounding_planes(TIMBRE, bytes((3, 4)), BEND) + + def test_a_bend_plane_holding_fewer_than_the_flags_ask_for_is_refused( + self, + ) -> None: + with pytest.raises(ValidationError): + sounding_planes(TIMBRE, FLAGGED, BEND[:1]) + + def test_a_song_returning_to_a_tick_re_enters_the_bend_plane_past_the_flags_before_it( + self, + ) -> None: + value = bytes( + ( + flagged_value(3, True), + flagged_value(3, False), + flagged_value(3, True), + ) + ) + planes = sounding_planes(TIMBRE + bytes((0x30,)), value, BEND) + + assert [planes.positions(tick)[PULSE1_BEND] for tick in range(4)] == [ + 0, + 1, + 1, + 2, + ] + dense = [position for name, position in zip(PlaneOrder.names(), planes.positions(2)) if name not in BENDS] + assert dense == [2] * len(dense) 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..ee0eec0d8 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_admit.py @@ -0,0 +1,130 @@ +import logging +from dataclasses import replace +from typing import Final, FrozenSet, 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[int]] = 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_budget.py b/tests/unit/sampletones_player/compression/test_budget.py new file mode 100644 index 000000000..d4d0f7225 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_budget.py @@ -0,0 +1,90 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_player.compression.budget import ( + DEFAULT_SEARCH_BUDGET, + SearchBudget, + shares, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestSharesMeetSmallDemandsAndSplitTheRest(BaseTestSuite): + """A claimant asking for little is served whole; the larger ones split what remains evenly.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + demands: Tuple[int, ...] + total: int + expected: Tuple[int, ...] + + test_cases = ( + TestCase( + label="every_demand_fits", + demands=(5, 5, 5), + total=30, + expected=(5, 5, 5), + ), + TestCase( + label="a_small_demand_leaves_its_surplus", + demands=(100, 1, 100), + total=41, + expected=(20, 1, 20), + ), + TestCase( + label="a_zero_demand_takes_nothing", + demands=(0, 7), + total=3, + expected=(0, 3), + ), + TestCase( + label="equal_demands_split_evenly", + demands=(9, 9), + total=8, + expected=(4, 4), + ), + TestCase( + label="a_total_short_of_one_each", + demands=(9, 9, 9), + total=2, + expected=(0, 1, 1), + ), + TestCase(label="no_claimants", demands=(), total=10, expected=()), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_shares(self, test_case: TestCase) -> None: + assert shares(test_case.demands, test_case.total) == test_case.expected + + def test_the_total_is_never_exceeded(self) -> None: + demands = (30, 70, 10, 90) + for total in range(0, 220, 7): + assert sum(shares(demands, total)) <= total + + +class TestTheBudgetBoundsEveryStep: + """Each figure is at least what one round can act on, and the default states them all.""" + + def test_the_default_gathers_ranks_and_confirms(self) -> None: + assert DEFAULT_SEARCH_BUDGET.candidate_entries >= 1 + assert DEFAULT_SEARCH_BUDGET.rounds >= 1 + assert DEFAULT_SEARCH_BUDGET.confirmed_candidates >= 1 + + def test_a_round_gathers_at_least_one_entry(self) -> None: + with pytest.raises(ValidationError): + SearchBudget(candidate_entries=0, rounds=1, confirmed_candidates=1) + + def test_a_round_confirms_at_least_one_candidate(self) -> None: + with pytest.raises(ValidationError): + SearchBudget(candidate_entries=1, rounds=1, confirmed_candidates=0) + + def test_no_rounds_is_a_budget(self) -> None: + assert SearchBudget(candidate_entries=1, rounds=0, confirmed_candidates=1).rounds == 0 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..14ea7fdbf --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_decode.py @@ -0,0 +1,80 @@ +from typing import Final + +from sampletones_core.constants.enums import ChannelName +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, +) +from sampletones_player.specification.planes import ( + PLANES, + Plane, + PlaneRole, + plane_index, +) + +MOTIF: Final[bytes] = bytes((40, 44, 47)) +SPELLED_PLANE: Final[Plane] = PLANES[plane_index(ChannelName.PULSE1, PlaneRole.VALUE)] +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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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, SPELLED_PLANE, 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..866055739 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_encode.py @@ -0,0 +1,208 @@ +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.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.player import sounding_planes +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: + return sounding_planes(control, value, b"") + + +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, default=False) + 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, default=False) + 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, default=False) + 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, default=False), + ) + 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].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..a901a0d72 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_entries.py @@ -0,0 +1,53 @@ +from typing import Final + +import pytest + +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) +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 +DICTIONARY: Final[PhraseTable] = phrase_table(()) +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, DICTIONARY) == 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, default=False), + ] + ) + assert stream_entry(stream, literal.ticks, DICTIONARY) == 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, default=False), + ] + stream = emit(tokens) + covered = sum(token.ticks for token in tokens[:2]) + assert stream_entry(stream, covered, DICTIONARY) == 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 position"): + stream_entry(stream, 3, DICTIONARY) 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..ab6ca37e4 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_pitch.py @@ -0,0 +1,83 @@ +from math import nextafter +from typing import Final + +import pytest + +from sampletones_core.constants.general import MAX_TIMER, MIN_TIMER +from sampletones_player.compression.pitch import PITCH_COUNT, PitchTable +from sampletones_player.specification.binary import SIGNED_BYTE_LIMIT +from sampletones_player.specification.registers import ( + MAX_REGISTER_VALUE, + TIMER_HIGH_SHIFT, +) +from sampletones_shared.constants.music import ( + LIMIT_MAX_PITCH, + LIMIT_MIN_PITCH, + MAX_A4_FREQUENCY, + MIN_A4_FREQUENCY, +) +from sampletones_shared.music import Tuning + +TUNING: Final[Tuning] = Tuning() +LOWEST_TUNING: Final[Tuning] = Tuning(a4_frequency=nextafter(MIN_A4_FREQUENCY, MAX_A4_FREQUENCY)) +HIGHEST_TUNING: Final[Tuning] = Tuning(a4_frequency=nextafter(MAX_A4_FREQUENCY, MIN_A4_FREQUENCY)) + + +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) + for timer in table.timers: + named = table.nearest[timer] + assert (table.timers[named.pitch], named.offset) == (timer, 0) + + def test_a_pitch_and_its_index_name_each_other(self) -> None: + table = PitchTable.from_tuning(TUNING) + for pitch in range(LIMIT_MIN_PITCH, LIMIT_MAX_PITCH + 1): + assert table.pitch(table.index(pitch)) == pitch + + def test_a_pitch_beyond_the_table_names_no_index(self) -> None: + with pytest.raises(ValueError): + PitchTable.from_tuning(TUNING).index(LIMIT_MAX_PITCH + 1) + + 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 + + +class TestEveryDividerReachesAPlane: + """A bent tick sounds a divider the table may hold nowhere, so each is named as an index and + the bend left over, and the bend must fit the signed byte its plane holds. + """ + + @pytest.mark.parametrize( + "tuning", + [LOWEST_TUNING, TUNING, HIGHEST_TUNING], + ids=lambda tuning: f"a4_{tuning.a4_frequency:g}", + ) + def test_every_divider_the_register_holds_names_a_bend_a_byte_states(self, tuning: Tuning) -> None: + table = PitchTable.from_tuning(tuning) + for divider in range(MIN_TIMER, MAX_TIMER + 1): + named = table.nearest[divider] + assert table.timers[named.pitch] + named.offset == divider + assert -SIGNED_BYTE_LIMIT <= named.offset < SIGNED_BYTE_LIMIT diff --git a/tests/unit/sampletones_player/compression/test_scheme.py b/tests/unit/sampletones_player/compression/test_scheme.py new file mode 100644 index 000000000..644d0b842 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_scheme.py @@ -0,0 +1,56 @@ +from typing import Tuple + +import pytest + +from sampletones_player.compression.options import EVERY_LAYER, CodecOptions +from sampletones_player.compression.scheme import ( + CompressionScheme, + offered_schemes, +) + + +def layers(options: CodecOptions) -> Tuple[bool, ...]: + return ( + options.holds, + options.phrases, + options.transposition, + options.search, + ) + + +class TestCompressionScheme: + """Each scheme keeps the layers of the one before it and adds its own.""" + + def test_the_lightest_scheme_spells_every_value_out(self) -> None: + assert not any(layers(CompressionScheme.NONE.options)) + + def test_the_hardest_scheme_switches_every_layer_on(self) -> None: + assert CompressionScheme.SEARCH.options == EVERY_LAYER + + @pytest.mark.parametrize( + ("lighter", "harder"), + list(zip(list(CompressionScheme)[:-1], list(CompressionScheme)[1:])), + ids=str, + ) + def test_a_harder_scheme_keeps_every_layer_of_the_lighter_one( + self, + lighter: CompressionScheme, + harder: CompressionScheme, + ) -> None: + pairs = list(zip(layers(lighter.options), layers(harder.options))) + assert all(on_harder for on_lighter, on_harder in pairs if on_lighter) + assert layers(lighter.options) != layers(harder.options) + + +class TestOfferedSchemes: + """A song is offered the schemes that write it differently.""" + + def test_a_seeded_song_is_offered_every_scheme(self) -> None: + assert offered_schemes(seeded=True) == tuple(CompressionScheme) + + def test_a_song_seeding_nothing_is_offered_every_scheme_but_the_instruments( + self, + ) -> None: + assert offered_schemes(seeded=False) == tuple( + scheme for scheme in CompressionScheme if scheme != CompressionScheme.INSTRUMENTS + ) diff --git a/tests/unit/sampletones_player/compression/test_search.py b/tests/unit/sampletones_player/compression/test_search.py new file mode 100644 index 000000000..300b322f0 --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_search.py @@ -0,0 +1,86 @@ +from random import Random +from typing import Final, FrozenSet + +import pytest + +from sampletones_player.compression.budget import SearchBudget +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 EVERY_LAYER +from sampletones_player.compression.progress.monitor import CodecMonitor +from sampletones_player.compression.search import search_phrases +from sampletones_player.specification.binary import BYTE_VALUES +from sampletones_shared.utils.progress import silent_reporter + +STREAM_START: Final[FrozenSet[int]] = frozenset({0}) +FIGURE: Final[bytes] = b"\x10\x18\x14\x22\x1c\x30\x11\x19\x15\x23\x1d\x31\x12\x1a\x16\x24" +FIGURE_REPEATS: Final[int] = 6 +CROWDED_TICKS: Final[int] = 600 +CROWDED_SEED: Final[int] = 20260912 +REPEATING: Final[bytes] = FIGURE * FIGURE_REPEATS +NARROW_ENTRIES: Final[int] = 8_000 + + +def _crowded() -> bytes: + """A plane of unrelated values, which offers many candidates and repeats none of them.""" + random = Random(CROWDED_SEED) + return bytes(random.randrange(BYTE_VALUES) for _ in range(CROWDED_TICKS)) + + +def _searched(budget: SearchBudget) -> PhraseTable: + cache = MatchCache([PlaneIndex.from_plane(_crowded()), PlaneIndex.from_plane(REPEATING)]) + return search_phrases( + cache, + phrase_table(()), + EVERY_LAYER, + (STREAM_START, STREAM_START), + CodecMonitor(silent_reporter), + budget, + ) + + +@pytest.fixture(name="narrow") +def narrow_fixture() -> SearchBudget: + """A round too small for the crowded plane alone, and ample for the repeating one.""" + return SearchBudget(candidate_entries=NARROW_ENTRIES, rounds=64, confirmed_candidates=3) + + +class TestEveryPlaneIsHeardFrom: + """A crowded plane takes its share of a round and leaves the rest to the planes after it.""" + + def test_a_plane_after_a_crowded_one_earns_its_phrase(self, narrow: SearchBudget) -> None: + table = _searched(narrow) + assert any(phrase.body in REPEATING for phrase in table.phrases) + + def test_the_crowded_plane_earns_nothing(self, narrow: SearchBudget) -> None: + crowded = _crowded() + table = _searched(narrow) + assert all(phrase.body not in crowded for phrase in table.phrases) + + +class TestTheRoundsBoundWhatTheSearchEarns: + """Each round adds at most one phrase, so the rounds cap the dictionary the search fills.""" + + def test_no_rounds_leaves_the_table_as_seeded(self) -> None: + table = _searched( + SearchBudget( + candidate_entries=NARROW_ENTRIES, + rounds=0, + confirmed_candidates=3, + ) + ) + assert len(table) == 0 + + def test_one_round_adds_one_phrase(self) -> None: + table = _searched( + SearchBudget( + candidate_entries=NARROW_ENTRIES, + rounds=1, + confirmed_candidates=3, + ) + ) + assert len(table) == 1 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..f6d074ebf --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_seeds.py @@ -0,0 +1,110 @@ +from typing import Final, FrozenSet, Tuple + +import pytest + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.features.envelope import Envelope +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.timers.utils import get_timer_table +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import channel_planes +from sampletones_player.compression.planes.symbols import pack_plane +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.registers.channel import channel_registers +from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.planes import PLANES +from sampletones_shared.music import Tuning +from tests.suite.performance import ( + make_pulse_reconstruction, + project_with_instrument, + project_with_sample, +) +from tests.suite.player import PLAYER_FULL_VOLUME, PLAYER_REFERENCE_PITCH + +TUNING: Final[Tuning] = Tuning() +ROWS_PER_PATTERN: Final[int] = 8 +SOUNDING_TICKS: Final[int] = 5 +BENDS: Final[Tuple[int, ...]] = (0, 4, -4, 0) + + +@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) -> Tuple[bytes, ...]: + """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, ALL_CHANNELS)) + + +NO_BOUNDARIES: Final[FrozenSet[int]] = frozenset() + + +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: Tuple[bytes, ...], + ) -> None: + turning = tuple( + pack_plane(plane, PLANES[index].form, boundaries=NO_BOUNDARIES) + for index, plane in enumerate(slice_planes) + 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: Tuple[bytes, ...], + ) -> None: + """A hold covers such a plane more cheaply than any phrase naming it could.""" + held = tuple(plane for plane in slice_planes 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, ALL_CHANNELS) == () + + def test_a_slice_on_a_channel_the_song_leaves_out_offers_nothing(self, project: Project) -> None: + """The slice plays on a channel the song rests, so no row of the song ever names it.""" + sounding = ALL_CHANNELS - {ChannelName.PULSE1} + assert _offered(project) + assert phrases_from_project(project, TUNING, sounding) == () + + +class TestABentSliceSeedsItsBend: + """A slice that bends turns its bend plane over, so the dictionary is offered that shape too.""" + + def test_the_bend_a_slice_plays_is_offered(self) -> None: + instrument = Instrument( + name="bent", + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(PLAYER_FULL_VOLUME,) * len(BENDS)), + pitch=Envelope(items=BENDS), + ), + initial_pitch=PLAYER_REFERENCE_PITCH, + ) + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) + + bend_plane = bytes(unsigned_byte(bend) for bend in BENDS if bend) + assert bend_plane in _offered(project) 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..c6a4dc87d --- /dev/null +++ b/tests/unit/sampletones_player/compression/test_song.py @@ -0,0 +1,98 @@ +from typing import Final, Optional, Tuple + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.compression.song import compress_song, decompress_song +from sampletones_player.registers.streams import ChannelStreams +from tests.suite.player import ( + 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], +) -> ChannelStreams: + return decompress_song( + compress_song( + streams, + PLAYER_PITCHES, + seeds=NO_SEEDS, + loop_tick=loop_tick, + scheme=CompressionScheme.SEARCH, + ), + 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, None) + assert [rebuilt.at(tick) for tick in range(streams.ticks)] == [ + streams.at(tick) for tick in range(streams.ticks) + ] + + def test_a_channel_running_out_early_is_carried_to_the_songs_length( + self, + ) -> None: + streams = resting_streams((SOUNDING, OCTAVE_UP, RESTING)) + rebuilt = played(streams, None) + 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, + scheme=CompressionScheme.SEARCH, + ) + played_once = compress_song( + streams, + PLAYER_PITCHES, + seeds=NO_SEEDS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + assert looped.size > played_once.size + + def test_the_song_writes_the_same_registers_either_way(self) -> None: + streams = resting_streams((SOUNDING,) * HELD_TICKS) + assert played(streams, MIDDLE_TICK) == played(streams, None) + + +class TestAPlaneCarriesADividerNoPitchSounds: + """A bent tick sounds a divider between the table's own, and the song writes it back exactly.""" + + def test_a_timer_no_pitch_sounds_plays_back_as_written(self) -> None: + assert UNSOUNDED_TIMER not in PLAYER_PITCHES.timers + streams = resting_streams((pulse_tick(PLAYER_FULL_VOLUME, 0, UNSOUNDED_TIMER), SOUNDING)) + rebuilt = played(streams, None) + assert [rebuilt.at(tick) for tick in range(streams.ticks)] == [ + streams.at(tick) for tick in range(streams.ticks) + ] 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..af7f0b58d --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_hold.py @@ -0,0 +1,12 @@ +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..ac708e21d --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_literal.py @@ -0,0 +1,18 @@ +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..6048c6cf3 --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_phrase.py @@ -0,0 +1,60 @@ +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, + default=False, + ) + assert token.size == test_case.expected == phrase_size(test_case.phrase_id, test_case.transpose, default=False) 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..3f4a5d94f --- /dev/null +++ b/tests/unit/sampletones_player/compression/tokens/test_span.py @@ -0,0 +1,162 @@ +from dataclasses import dataclass +from typing import Final + +import pytest + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import ( + PhraseTable, + phrase_table, +) +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 +CARRIED_TICKS: Final[int] = 6 +DICTIONARY: Final[PhraseTable] = phrase_table( + tuple(Phrase(body=bytes((value,)), default=CARRIED_TICKS) for value in range(PHRASE_ID_ESCAPE + 2)) +) + + +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, + default=False, + ), + expected=TokenSpan(size=2, ticks=PHRASE_PLAY_TICKS), + ), + TestCase( + name="phrase-shifted", + token=PhraseToken( + phrase_id=CHEAP_ID, + ticks=PHRASE_PLAY_TICKS, + transpose=SHIFT, + default=False, + ), + 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, + default=False, + ), + 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, + default=False, + ), + expected=TokenSpan(size=4, ticks=PHRASE_PLAY_TICKS), + ), + TestCase( + name="phrase-longest", + token=PhraseToken( + phrase_id=CHEAP_ID, + ticks=MAX_PHRASE_TICKS, + transpose=0, + default=False, + ), + 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, DICTIONARY) + 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, DICTIONARY) + 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, DICTIONARY).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, default=False), + ) + stream = emit(tokens) + position = 0 + for _ in tokens: + position += token_span(stream, position, DICTIONARY).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, default=False), + ) + stream = emit(tokens) + position = 0 + covered = 0 + for _ in tokens: + span = token_span(stream, position, DICTIONARY) + 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/test_addresses.py b/tests/unit/sampletones_player/driver/test_addresses.py new file mode 100644 index 000000000..cf364abf1 --- /dev/null +++ b/tests/unit/sampletones_player/driver/test_addresses.py @@ -0,0 +1,41 @@ +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..13a04fb26 --- /dev/null +++ b/tests/unit/sampletones_player/driver/test_image.py @@ -0,0 +1,80 @@ +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/export/__init__.py b/tests/unit/sampletones_player/export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_player/export/test_backend.py b/tests/unit/sampletones_player/export/test_backend.py new file mode 100644 index 000000000..c8a415343 --- /dev/null +++ b/tests/unit/sampletones_player/export/test_backend.py @@ -0,0 +1,566 @@ +import struct +from pathlib import Path +from typing import Final, FrozenSet, Optional + +import pytest + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.backend import ExportBackend +from sampletones_core.exports.format import ExportFormat +from sampletones_core.exports.progress import ExportProgress +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 SONG_TICK_BOUNDS, SongTiming +from sampletones_player.builder import SONG_START +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.driver.image import DriverImage +from sampletones_player.export.backend import NSFBackend +from sampletones_player.export.program import NSFProgram +from sampletones_player.nsf.information import NSFInformation +from sampletones_player.specification.nsf import ( + ARTIST_OFFSET, + COPYRIGHT_OFFSET, + HEADER_SIZE, + PROGRAM_SIZE, + STRING_FIELD_SIZE, + TITLE_OFFSET, +) +from sampletones_player.specification.song import ( + LOOP_TICK_OFFSET, + NO_LOOP, + TOTAL_TICKS_OFFSET, +) +from sampletones_shared.exceptions import OperationCanceled, SongTooLargeError +from sampletones_shared.paths.extensions import EXT_FILE_NSF +from tests.suite.performance import ( + 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 +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 +CHOSEN_TITLE: Final[str] = "Rainy Day Theme" +CHOSEN_ARTIST: Final[str] = "J. Kowalski" +CHOSEN_COPYRIGHT: Final[str] = "2026 J. Kowalski" +REPEATED_TICKS: Final[int] = 48 + + +def lead_slice(name: str, frames: int) -> InstrumentExport: + 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 turning over at every tick, for as many ticks as the program area holds + bytes, so its song outgrows the console. + """ + return player_sample( + SAMPLE_NAME, + ( + player_instrument( + "lead", + ChannelName.PULSE1, + varied_features(PROGRAM_SIZE, PLAYER_REFERENCE_PITCH, duty_cycle=True), + 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 song_word(data: bytes, offset: int) -> int: + """A word of a written program's song header, read out of the block behind the driver.""" + block = data[HEADER_SIZE + len(DriverImage.load().code) :] + return int(struct.unpack_from(" int: + """The tick a written program comes round to.""" + return song_word(data, LOOP_TICK_OFFSET) + + +def read_field(data: bytes, offset: int) -> str: + return data[offset : offset + STRING_FIELD_SIZE].rstrip(b"\x00").decode() + + +@pytest.fixture(name="backend") +def backend_fixture() -> NSFBackend: + return NSFBackend.stated() + + +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)) + expected = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) + assert song_word(destination.read_bytes(), TOTAL_TICKS_OFFSET) == expected + + def test_the_program_repeats_from_its_first_tick( + 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] + expected = SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick(project.song.order_length()) + assert walked + assert [report.total for report in walked] == [expected] * len(walked) + assert walked[-1].completed == expected + + 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() + + +def chosen_program( + *, + channels: FrozenSet[ChannelName], + loop_tick: Optional[int], + scheme: CompressionScheme, +) -> NSFProgram: + return NSFProgram( + information=NSFInformation( + title=CHOSEN_TITLE, + artist=CHOSEN_ARTIST, + copyright=CHOSEN_COPYRIGHT, + ), + channels=channels, + loop_tick=loop_tick, + scheme=scheme, + ) + + +def repeated_sample() -> SampleExport: + """A reconstruction holding one note, which every scheme past the first writes as a run.""" + return player_sample( + SAMPLE_NAME, + ( + lead_slice("lead", REPEATED_TICKS), + bass_slice("bass", REPEATED_TICKS), + ), + nes_frequency=NTSC_FREQUENCY, + ) + + +class TestAChosenProgram: + """A program the user settled before the run is the one the file carries.""" + + @pytest.mark.parametrize( + ("offset", "expected"), + [ + (TITLE_OFFSET, CHOSEN_TITLE), + (ARTIST_OFFSET, CHOSEN_ARTIST), + (COPYRIGHT_OFFSET, CHOSEN_COPYRIGHT), + ], + ids=["title", "artist", "copyright"], + ) + def test_the_file_is_listed_under_the_chosen_text( + self, + backend: NSFBackend, + tmp_path: Path, + offset: int, + expected: str, + ) -> None: + destination = tmp_path / FILENAME + program = chosen_program( + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) + backend.choosing(program).write_project(destination, ProjectExport(project=drum_project())) + assert read_field(destination.read_bytes(), offset) == expected + + def test_a_song_chosen_to_play_once_stops_at_its_end(self, backend: NSFBackend, tmp_path: Path) -> None: + destination = tmp_path / FILENAME + program = chosen_program( + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + backend.choosing(program).write_project(destination, ProjectExport(project=drum_project())) + assert written_loop_tick(destination.read_bytes()) == NO_LOOP + + def test_a_reconstruction_repeats_where_the_program_says(self, backend: NSFBackend, tmp_path: Path) -> None: + """The slices play once, and the program asks for them to come round all the same.""" + destination = tmp_path / FILENAME + program = chosen_program( + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) + backend.choosing(program).write_sample(destination, repeated_sample()) + assert written_loop_tick(destination.read_bytes()) == SONG_START + + def test_a_lighter_scheme_writes_a_larger_file(self, backend: NSFBackend, tmp_path: Path) -> None: + spelled_out = tmp_path / "none.nsf" + runs = tmp_path / "runs.nsf" + for destination, scheme in ( + (spelled_out, CompressionScheme.NONE), + (runs, CompressionScheme.RUNS), + ): + program = chosen_program(channels=ALL_CHANNELS, loop_tick=None, scheme=scheme) + backend.choosing(program).write_sample(destination, repeated_sample()) + + assert len(spelled_out.read_bytes()) > len(runs.read_bytes()) + + def test_choosing_a_program_leaves_the_backend_it_came_from_as_it_was( + self, + backend: NSFBackend, + tmp_path: Path, + ) -> None: + destination = tmp_path / FILENAME + backend.choosing( + chosen_program( + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.NONE, + ) + ) + backend.write_project(destination, ProjectExport(project=drum_project())) + assert read_field(destination.read_bytes(), TITLE_OFFSET) == PROJECT_TITLE diff --git a/tests/unit/sampletones_player/export/test_program.py b/tests/unit/sampletones_player/export/test_program.py new file mode 100644 index 000000000..3eb0cd7cf --- /dev/null +++ b/tests/unit/sampletones_player/export/test_program.py @@ -0,0 +1,110 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import InstrumentExport, SampleExport +from sampletones_core.project.project import Project +from sampletones_player.builder import SONG_START, loop_tick_from_instruments +from sampletones_player.compression.scheme import CompressionScheme +from sampletones_player.export.program import ( + DEFAULT_SCHEME, + NO_ARTIST, + NSFProgram, +) +from sampletones_player.nsf.information import NSFInformation +from sampletones_shared.application import SAMPLETONES_COPYRIGHT +from tests.suite.player import ( + PLAYER_REFERENCE_PITCH, + player_features, + player_instrument, + player_sample, +) + +NTSC_FREQUENCY: Final[int] = 60 +SOUNDING_TICKS: Final[int] = 6 +PROJECT_TITLE: Final[str] = "Demo" +PROJECT_AUTHOR: Final[str] = "Jakim" +SAMPLE_NAME: Final[str] = "Amen" + + +def lead(loop: bool) -> InstrumentExport: + return player_instrument( + "lead", + ChannelName.PULSE1, + player_features(SOUNDING_TICKS, PLAYER_REFERENCE_PITCH, duty_cycle=True), + nes_frequency=NTSC_FREQUENCY, + loop=loop, + ) + + +def sample(loop: bool) -> SampleExport: + return player_sample(SAMPLE_NAME, (lead(loop),), nes_frequency=NTSC_FREQUENCY) + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + return Project.create(title=PROJECT_TITLE, author=PROJECT_AUTHOR) + + +class TestTheProgramAProjectStates: + """What a composition is written as when nobody chose otherwise.""" + + def test_it_is_listed_under_the_projects_title_and_author(self, project: Project) -> None: + assert NSFProgram.for_project(project).information == NSFInformation( + title=PROJECT_TITLE, + artist=PROJECT_AUTHOR, + copyright=SAMPLETONES_COPYRIGHT, + ) + + def test_it_sounds_every_channel(self, project: Project) -> None: + assert NSFProgram.for_project(project).channels == ALL_CHANNELS + + def test_it_repeats_from_its_first_tick(self, project: Project) -> None: + assert NSFProgram.for_project(project).loop_tick == SONG_START + + def test_it_is_compressed_under_the_default_scheme(self, project: Project) -> None: + assert NSFProgram.for_project(project).scheme == DEFAULT_SCHEME + + +class TestTheProgramASampleStates: + """What a reconstruction's slices are written as when nobody chose otherwise.""" + + def test_it_is_listed_under_the_reconstructions_name_credited_to_nobody( + self, + ) -> None: + information = NSFProgram.for_sample(sample(loop=False)).information + assert (information.title, information.artist) == ( + SAMPLE_NAME, + NO_ARTIST, + ) + + def test_it_sounds_every_channel(self) -> None: + assert NSFProgram.for_sample(sample(loop=False)).channels == ALL_CHANNELS + + @pytest.mark.parametrize("loop", [True, False], ids=["repeating", "played once"]) + def test_it_repeats_where_every_slice_repeats(self, loop: bool) -> None: + request = sample(loop) + assert NSFProgram.for_sample(request).loop_tick == loop_tick_from_instruments(request.instruments) + + +class TestWhatAProgramHolds: + """A program states choices a file can carry.""" + + def test_a_program_sounding_no_channel_is_refused(self, project: Project) -> None: + with pytest.raises(ValidationError): + NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"channels": frozenset()}) + + def test_a_loop_before_the_song_starts_is_refused(self, project: Project) -> None: + with pytest.raises(ValidationError): + NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"loop_tick": -1}) + + def test_a_program_may_play_once(self, project: Project) -> None: + program = NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"loop_tick": None}) + assert program.loop_tick is None + + @pytest.mark.parametrize("scheme", list(CompressionScheme), ids=str) + def test_every_scheme_is_a_choice(self, project: Project, scheme: CompressionScheme) -> None: + program = NSFProgram.model_validate(NSFProgram.for_project(project).model_dump() | {"scheme": scheme}) + assert program.scheme == scheme diff --git a/tests/unit/sampletones_player/nsf/__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..cb4139495 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_header.py @@ -0,0 +1,199 @@ +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, + STRING_TEXT_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_before_its_terminator( + self, + ) -> None: + """A player reads each field up to its NUL, so the field keeps a byte for one.""" + overlong = "A" * (STRING_FIELD_SIZE * 2) + data = header_to_bytes(NSFInformation(title=overlong, artist=ARTIST), ADDRESSES) + assert read_string(data, TITLE_OFFSET) == overlong.encode("utf-8")[:STRING_TEXT_SIZE] + b"\x00" + + 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_information.py b/tests/unit/sampletones_player/nsf/test_information.py new file mode 100644 index 000000000..8d482da4f --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_information.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from typing import Final + +import pytest + +from sampletones_player.nsf.information import ( + TEXT_ENCODING, + NSFInformation, + field_size, + fit_field, +) +from sampletones_player.specification.nsf import ( + STRING_FIELD_SIZE, + STRING_TEXT_SIZE, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +WIDE_CHARACTER: Final[str] = "ü" +SHORT_TEXT: Final[str] = "Rainy Day Theme" + + +class TestFitField(BaseTestSuite): + """The part of a text a header string field holds in front of its terminator.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + text: str + expected: str + + test_cases = ( + TestCase(label="short text", text=SHORT_TEXT, expected=SHORT_TEXT), + TestCase(label="empty text", text="", expected=""), + TestCase( + label="exactly the room", + text="A" * STRING_TEXT_SIZE, + expected="A" * STRING_TEXT_SIZE, + ), + TestCase( + label="one byte over", + text="A" * STRING_FIELD_SIZE, + expected="A" * STRING_TEXT_SIZE, + ), + TestCase( + label="a character straddling the edge", + text="A" * (STRING_TEXT_SIZE - 1) + WIDE_CHARACTER, + expected="A" * (STRING_TEXT_SIZE - 1), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_field_holds_the_text_that_fits(self, test_case: TestCase) -> None: + assert fit_field(test_case.text) == test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_text_leaves_room_for_the_terminator(self, test_case: TestCase) -> None: + assert len(fit_field(test_case.text).encode(TEXT_ENCODING)) < STRING_FIELD_SIZE + + +class TestFieldSize: + def test_a_wide_character_takes_the_bytes_it_is_encoded_in(self) -> None: + assert field_size(SHORT_TEXT + WIDE_CHARACTER) == len(SHORT_TEXT) + len(WIDE_CHARACTER.encode(TEXT_ENCODING)) + + def test_a_fitted_text_takes_the_field_at_most(self) -> None: + assert field_size(fit_field(WIDE_CHARACTER * STRING_FIELD_SIZE)) <= STRING_TEXT_SIZE + + +class TestNSFInformation: + """Each text field is held as the part of itself the header carries.""" + + def test_every_field_is_held_as_it_fits(self) -> None: + overlong = "B" * STRING_FIELD_SIZE + information = NSFInformation(title=overlong, artist=overlong, copyright=overlong) + assert { + information.title, + information.artist, + information.copyright, + } == {fit_field(overlong)} diff --git a/tests/unit/sampletones_player/nsf/test_layout.py b/tests/unit/sampletones_player/nsf/test_layout.py new file mode 100644 index 000000000..fe6dbc1db --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_layout.py @@ -0,0 +1,140 @@ +from typing import Final, List, Tuple + +from sampletones_player.compression.decode import decode_planes +from sampletones_player.nsf.layout import SongLayout +from sampletones_player.song import Song +from sampletones_player.specification.compression import ( + PHRASE_DEFAULT_SIZE, + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_COUNT_SIZE, + PHRASE_TABLE_ENTRY_SIZE, +) +from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_player.specification.song import ( + ABSENT_STREAM, + 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, + ) + + +def present(song: Song, layout: SongLayout) -> List[Tuple[bytes, int]]: + """Each stream the block holds beside the offset it begins at, absent planes left out.""" + return [(stream, offset) for stream, offset in zip(song.planes.streams, layout.streams) if stream] + + +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 + PHRASE_DEFAULT_SIZE + last.length + + def test_each_stream_follows_the_one_before_it(self) -> None: + song = figure_song(LOOP_TICK) + held = present(song, SongLayout.of(song)) + for (stream, offset), (_, following) in zip(held, held[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) + stream, offset = present(song, layout)[-1] + assert layout.size == offset + len(stream) + + def test_an_absent_plane_states_the_sentinel_for_its_stream_and_its_entry( + self, + ) -> None: + song = figure_song(LOOP_TICK) + layout = SongLayout.of(song) + absent = [plane for plane, stream in enumerate(song.planes.streams) if not stream] + assert absent + assert all(layout.streams[plane] == ABSENT_STREAM for plane in absent) + assert all(layout.loop_entries[plane] == ABSENT_STREAM for plane in absent) + + +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.loop_entries + assert layout.loop_entries == tuple( + ABSENT_STREAM if entry is None else 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 * len(present(song, layout)) + 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..ebabedc21 --- /dev/null +++ b/tests/unit/sampletones_player/nsf/test_song.py @@ -0,0 +1,308 @@ +import struct +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_player.compression.decode import decode_planes +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.nsf.layout import NAME_SEPARATOR, SongLayout +from sampletones_player.nsf.song import STATED_BEYOND_THE_FIRST, song_to_bytes +from sampletones_player.song import Song +from sampletones_player.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import ( + PHRASE_DEFAULT_SIZE, + PHRASE_LENGTH_SIZE, +) +from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_player.specification.song import ( + ABSENT_STREAM, + 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 held_offsets(data: bytes) -> Tuple[int, ...]: + """The stream offsets of the planes the block holds, absent planes left out.""" + return tuple(offset for offset in stream_offsets(data) if offset != ABSENT_STREAM) + + +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 block holds. The song bends nowhere, so each bend plane is absent; + its second pulse channel and its noise channel rest throughout, so both control planes stand + at the value their register fixes; and its triangle never sounds, so its value plane names the + index that stands for silence. Each of those is absent, and its header entries carry the + sentinel. 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"\x33\x00" + b"\x03\x01" + b"\x04\x01\x07\x01\xff\xff" + b"\xff\xff\x0a\x01\xff\xff" + b"\xff\xff\xff\xff" + b"\xff\xff\x0d\x01" + b"\x04\x01\x07\x01\xff\xff" + b"\xff\xff\x0a\x01\xff\xff" + b"\xff\xff\xff\xff" + b"\xff\xff\x0d\x01" + ) + + EXPECTED_STREAMS: Final[bytes] = b"\x00" b"\x41\x0f\x00" b"\x40\x21\x00" b"\x40\x21\x00" b"\x40\x1a" + + 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): + body = offset + PHRASE_LENGTH_SIZE + PHRASE_DEFAULT_SIZE + assert data[offset] == phrase.length + assert data[offset + PHRASE_LENGTH_SIZE] == phrase.default - STATED_BEYOND_THE_FIRST + assert data[body : body + 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 = held_offsets(data) + assert list(offsets) == sorted(offsets) + + def test_a_plane_that_never_moves_states_the_sentinel(self) -> None: + song = two_tick_song(NTSC_FREQUENCY) + data = song_to_bytes(song, PROGRAM_AREA_BYTES) + absent = [plane for plane, stream in enumerate(song.planes.streams) if not stream] + assert absent + assert all(stream_offsets(data)[plane] == ABSENT_STREAM for plane in absent) + assert all(loop_entries(data)[plane] == ABSENT_STREAM for plane in absent) + + 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.loop_entries + assert loop_entries(data) == tuple( + ABSENT_STREAM if entry is None else 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): + if stream: + 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..7d9518e35 --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_channel.py @@ -0,0 +1,115 @@ +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_bent_frame_keeps_its_bend(self) -> None: + instructions = [ + TriangleInstruction(on=True, pitch=BASS_PITCH, detune=-5, coarse_detune=1), + TriangleInstruction(on=True, pitch=BASS_PITCH, detune=3), + ] + assert channel_instructions(instructions, TriangleInstruction) == 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_dividers.py b/tests/unit/sampletones_player/registers/test_dividers.py new file mode 100644 index 000000000..65b15fbf9 --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_dividers.py @@ -0,0 +1,68 @@ +import pytest + +from sampletones_core.constants.general import ( + MAX_PITCH, + MAX_TIMER, + MIN_PITCH, + MIN_TIMER, +) +from sampletones_core.timers.nearest import nearest_pitch +from sampletones_player.registers.dividers import ( + anchored_pitches, + bent_dividers, +) +from sampletones_player.specification.binary import SIGNED_BYTE_LIMIT +from tests.suite.player import PLAYER_REFERENCE_PITCH, PLAYER_TIMER_TABLE + + +class TestBentDividers: + """Each frame's divider is its note's own, moved by the steps the frame is bent by.""" + + def test_an_unbent_frame_sounds_its_note_s_own_divider(self) -> None: + assert bent_dividers((PLAYER_REFERENCE_PITCH,), (0,), PLAYER_TIMER_TABLE) == ( + PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH], + ) + + def test_every_frame_moves_by_its_own_bend(self) -> None: + offsets = (3, -3, 40) + dividers = bent_dividers( + (PLAYER_REFERENCE_PITCH,) * len(offsets), + offsets, + PLAYER_TIMER_TABLE, + ) + assert dividers == tuple(PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + offset for offset in offsets) + + def test_a_bend_past_the_register_stops_at_its_edge(self) -> None: + dividers = bent_dividers((MIN_PITCH, MAX_PITCH), (MAX_TIMER, -MAX_TIMER), PLAYER_TIMER_TABLE) + assert dividers == (MAX_TIMER, MIN_TIMER) + + def test_notes_and_bends_covering_different_frames_are_refused( + self, + ) -> None: + with pytest.raises(ValueError): + bent_dividers((PLAYER_REFERENCE_PITCH,), (0, 0), PLAYER_TIMER_TABLE) + + +class TestAnchoredPitches: + """A divider is counted from the frame's own note wherever the bend fits the plane's byte.""" + + def test_a_bend_past_halfway_is_counted_from_the_frames_own_note( + self, + ) -> None: + divider = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - SIGNED_BYTE_LIMIT + 1 + assert nearest_pitch(PLAYER_TIMER_TABLE, divider).pitch != PLAYER_REFERENCE_PITCH + assert anchored_pitches((PLAYER_REFERENCE_PITCH,), (divider,), PLAYER_TIMER_TABLE) == (PLAYER_REFERENCE_PITCH,) + + def test_a_bend_past_the_byte_is_counted_from_the_nearest_pitch( + self, + ) -> None: + divider = PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] - SIGNED_BYTE_LIMIT - 1 + assert anchored_pitches((PLAYER_REFERENCE_PITCH,), (divider,), PLAYER_TIMER_TABLE) == ( + nearest_pitch(PLAYER_TIMER_TABLE, divider).pitch, + ) + + def test_notes_and_dividers_covering_different_frames_are_refused( + self, + ) -> None: + with pytest.raises(ValueError): + anchored_pitches((PLAYER_REFERENCE_PITCH,), (), 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..4152a5aa9 --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_pulse.py @@ -0,0 +1,244 @@ +from dataclasses import dataclass +from typing import List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import ( + MAX_DUTY_CYCLE, + MAX_PITCH, + MAX_TIMER, + MAX_VOLUME, + MIN_PITCH, + PITCH_BEND_MAX, + PITCH_BEND_MIN, +) +from sampletones_core.generators.implementation.pulse import PulseGenerator +from sampletones_core.instructions import PulseInstruction +from sampletones_core.timers.nearest import nearest_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, +) + + +@pytest.fixture +def pulse_generator() -> PulseGenerator: + return PulseGenerator(Config(), ChannelName.PULSE1) + + +def bent_pulse(pitch: int, detune: int, coarse_detune: int) -> PulseInstruction: + return sounding_pulse(pitch, MAX_VOLUME, 0).model_copy(update={"detune": detune, "coarse_detune": coarse_detune}) + + +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) == [] + + +class TestPulseBend(BaseTestSuite): + """A bent frame's timer is the very divider the pulse generator renders it at.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: None = None + pitch: int + detune: int + coarse_detune: int + + @property + def label(self) -> str: + return f"pitch_{self.pitch}_bent_{self.detune:+d}_{self.coarse_detune:+d}" + + test_cases = ( + TestCase(pitch=PLAYER_REFERENCE_PITCH, detune=5, coarse_detune=0), + TestCase(pitch=PLAYER_REFERENCE_PITCH, detune=-7, coarse_detune=-1), + TestCase(pitch=PLAYER_REFERENCE_PITCH, detune=0, coarse_detune=12), + TestCase(pitch=MIN_PITCH, detune=PITCH_BEND_MAX, coarse_detune=PITCH_BEND_MAX), + TestCase(pitch=MAX_PITCH, detune=PITCH_BEND_MIN, coarse_detune=PITCH_BEND_MIN), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_timer_is_the_divider_the_generator_sounds( + self, + test_case: TestCase, + pulse_generator: PulseGenerator, + ) -> None: + instruction = bent_pulse(test_case.pitch, test_case.detune, test_case.coarse_detune) + registers = PulseRegisters.from_instructions([instruction], PLAYER_TIMER_TABLE)[0] + expected = pulse_generator.get_timer(instruction.pitch, instruction.timer_offset) + assert (registers.timer_low, registers.timer_high) == ( + expected & MAX_REGISTER_VALUE, + expected >> TIMER_HIGH_SHIFT, + ) + + def test_a_bend_within_a_byte_is_counted_from_the_frames_own_note( + self, + ) -> None: + instructions = [ + bent_pulse(PLAYER_REFERENCE_PITCH, -40, 0), + bent_pulse(PLAYER_REFERENCE_PITCH, 0, 7), + ] + registers = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE) + assert [tick.anchor for tick in registers] == [PLAYER_REFERENCE_PITCH] * len(registers) + + def test_a_bend_past_a_byte_is_counted_from_the_nearest_pitch(self) -> None: + instruction = bent_pulse(PLAYER_REFERENCE_PITCH, 0, PITCH_BEND_MAX) + tick = PulseRegisters.from_instructions([instruction], PLAYER_TIMER_TABLE)[0] + assert tick.anchor == nearest_pitch(PLAYER_TIMER_TABLE, tick.divider).pitch + assert tick.anchor != PLAYER_REFERENCE_PITCH + + def test_a_rest_holds_the_bent_divider(self) -> None: + instructions = [ + bent_pulse(PLAYER_REFERENCE_PITCH, 9, 1), + silent_pulse(), + ] + sounding, resting = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) + + def test_a_rest_before_the_first_note_takes_its_bent_divider(self) -> None: + instructions = [ + silent_pulse(), + bent_pulse(PLAYER_REFERENCE_PITCH, -9, 0), + ] + resting, sounding = PulseRegisters.from_instructions(instructions, PLAYER_TIMER_TABLE)[:2] + assert (resting.timer_low, resting.timer_high) == ( + sounding.timer_low, + sounding.timer_high, + ) 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..7e0256afb --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_streams.py @@ -0,0 +1,80 @@ +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..986284a29 --- /dev/null +++ b/tests/unit/sampletones_player/registers/test_triangle.py @@ -0,0 +1,116 @@ +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.generators.implementation.triangle import ( + TriangleGenerator, +) +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, + ) + + +class TestTriangleBend: + """A bent frame's timer is the very divider the triangle generator renders it at.""" + + @pytest.mark.parametrize( + ("detune", "coarse_detune"), + [(4, 0), (-6, 2), (0, -3)], + ids=lambda bend: f"{bend:+d}", + ) + def test_the_timer_is_the_divider_the_generator_sounds(self, detune: int, coarse_detune: int) -> None: + instruction = TriangleInstruction( + on=True, + pitch=PLAYER_REFERENCE_PITCH, + detune=detune, + coarse_detune=coarse_detune, + ) + registers = TriangleRegisters.from_instructions([instruction], PLAYER_TIMER_TABLE)[0] + expected = TriangleGenerator(Config(), ChannelName.TRIANGLE).get_timer( + instruction.pitch, + instruction.timer_offset, + ) + assert (registers.timer_low, registers.timer_high) == ( + expected & MAX_REGISTER_VALUE, + expected >> TIMER_HIGH_SHIFT, + ) + + def test_a_rest_holds_the_bent_divider(self) -> None: + instructions = [ + TriangleInstruction(on=True, pitch=PLAYER_REFERENCE_PITCH, detune=11), + 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, + ) diff --git a/tests/unit/sampletones_player/specification/test_planes.py b/tests/unit/sampletones_player/specification/test_planes.py new file mode 100644 index 000000000..d9fec442b --- /dev/null +++ b/tests/unit/sampletones_player/specification/test_planes.py @@ -0,0 +1,74 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ( + ALL_CHANNELS, + TONE_CHANNELS, + ChannelName, +) +from sampletones_player.compression.pitch import PITCH_COUNT +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.specification.planes import ( + PLANE_COUNT, + PLANE_NAMES, + PLANES, + SILENT_PITCH_INDEX, + PlaneRole, + channel_indices, + plane_index, +) + +PLANES_A_CHANNEL_SOUNDS_WITH: Final[int] = 2 + + +class TestOneTableStatesEveryPlaneASongBlockWrites: + """The song block writes its planes in one order, and the table is where that order is stated.""" + + def test_every_channel_names_what_it_sounds(self) -> None: + for channel in ChannelName.items(): + roles = {PLANES[index].role for index in channel_indices(channel)} + + assert PlaneRole.VALUE in roles + + def test_a_control_belongs_to_each_channel_whose_timbre_turns_over( + self, + ) -> None: + timbred = {plane.channel for plane in PLANES if plane.role is PlaneRole.CONTROL} + + assert timbred == ALL_CHANNELS - {ChannelName.TRIANGLE} + + def test_the_triangle_names_its_silence_in_the_pitch_it_stands_at( + self, + ) -> None: + triangle = PLANES[plane_index(ChannelName.TRIANGLE, PlaneRole.VALUE)] + + assert triangle.seeded == SILENT_PITCH_INDEX + assert triangle.seeded >= PITCH_COUNT + + def test_a_bend_belongs_to_each_channel_whose_divider_moves(self) -> None: + bending = {PLANES[index].channel for index, plane in enumerate(PLANES) if plane.spans_flagged_ticks} + + assert bending == TONE_CHANNELS + + def test_a_plane_is_named_by_the_channel_and_the_part_it_carries( + self, + ) -> None: + assert PLANES[plane_index(ChannelName.NOISE, PlaneRole.VALUE)].name == "noise_value" + + def test_the_table_names_each_plane_once(self) -> None: + assert len(set(PLANE_NAMES)) == PLANE_COUNT + + def test_a_channel_writing_no_plane_for_a_role_is_refused(self) -> None: + with pytest.raises(ValueError, match="writes no bend plane"): + plane_index(ChannelName.NOISE, PlaneRole.BEND) + + +class TestTheStreamsStandUnderTheNamesTheTableStates: + """A stream is reached by its own name, and the names are the table's — a test keeps them one.""" + + def test_the_streams_carry_the_names_the_table_states(self) -> None: + assert PlaneOrder._fields == PLANE_NAMES + + def test_the_noise_channel_reads_no_bend(self) -> None: + assert len(channel_indices(ChannelName.NOISE)) == PLANES_A_CHANNEL_SOUNDS_WITH diff --git a/tests/unit/sampletones_player/test_builder.py b/tests/unit/sampletones_player/test_builder.py new file mode 100644 index 000000000..a7c299faa --- /dev/null +++ b/tests/unit/sampletones_player/test_builder.py @@ -0,0 +1,434 @@ +from typing import Dict, Final, List, Optional + +import pytest + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.exports.request import InstrumentExport, SampleExport +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 SONG_TICK_BOUNDS, 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.compression.scheme import CompressionScheme +from sampletones_player.registers.channel import channel_registers +from sampletones_player.song import Song +from sampletones_player.specification.registers import ( + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SOUNDING_RELOAD, +) +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, scheme=CompressionScheme.SEARCH).ticks + == SOUNDING_TICKS + 1 + ) + + def test_the_schedule_follows_the_rate_the_reconstruction_was_built_at( + self, + ) -> None: + reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), HALF_RATE_FREQUENCY) + song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) + assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) + + def test_the_song_carries_the_loop_it_is_given(self) -> None: + reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) + assert song_from_reconstruction(reconstruction, loop_tick=0, scheme=CompressionScheme.SEARCH).loop_tick == 0 + + def test_a_loop_beyond_the_songs_ticks_raises(self) -> None: + reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) + with pytest.raises(ValueError): + song_from_reconstruction( + reconstruction, + loop_tick=SOUNDING_TICKS + 1, + scheme=CompressionScheme.SEARCH, + ) + + def test_the_timers_come_from_the_reconstructions_own_configuration( + self, + ) -> None: + reconstruction = player_reconstruction(one_channel(ChannelName.PULSE1), NTSC_FREQUENCY) + song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) + timer = get_timer_table(reconstruction.config.tuning)[PLAYER_REFERENCE_PITCH] + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) + + 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, scheme=CompressionScheme.SEARCH) + + assert timer > PLAYER_TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) + + def test_a_reconstruction_describing_no_frame_plays_one_resting_tick( + self, + ) -> None: + reconstruction = player_reconstruction({ChannelName.PULSE1: [silent_pulse()]}, NTSC_FREQUENCY) + song = song_from_reconstruction(reconstruction, loop_tick=None, scheme=CompressionScheme.SEARCH) + assert song.ticks == 1 + + +def sample_song(request: SampleExport) -> Song: + """The song a request plays as on every channel, compressed as hard as an export reaches.""" + return song_from_sample( + request, + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + + +class TestInstructionsFromInstruments: + """An export request's channel slices read back as the instructions the console sounds.""" + + 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 = sample_song( + player_sample( + "demo", + (lead(loop=False), bass(loop=False)), + nes_frequency=NTSC_FREQUENCY, + ) + ) + assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 + assert song.streams.triangle[0].linear_counter == TRIANGLE_COUNTER_CONTROL | TRIANGLE_SOUNDING_RELOAD + assert set(song.streams.pulse2) == {channel_registers(ChannelName.PULSE2, {}, PLAYER_TIMER_TABLE)[0]} + + def test_a_slice_on_a_channel_the_song_leaves_out_rests(self) -> None: + song = song_from_sample( + player_sample( + "demo", + (lead(loop=False), bass(loop=False)), + nes_frequency=NTSC_FREQUENCY, + ), + channels=frozenset({ChannelName.PULSE1}), + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + assert set(song.streams.triangle) == {channel_registers(ChannelName.TRIANGLE, {}, PLAYER_TIMER_TABLE)[0]} + assert len(song.streams.pulse1) == SOUNDING_TICKS + 1 + + def test_the_schedule_follows_the_rate_the_request_states(self) -> None: + song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=HALF_RATE_FREQUENCY)) + assert song.schedule == PlaySchedule.from_parameters(HALF_RATE_FREQUENCY) + + def test_the_song_carries_the_loop_it_is_given(self) -> None: + song = song_from_sample( + player_sample("demo", (lead(loop=True),), nes_frequency=NTSC_FREQUENCY), + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=CompressionScheme.SEARCH, + ) + assert song.loop_tick == SONG_START + + def test_the_timers_come_from_the_tuning_the_request_carries(self) -> None: + song = sample_song(player_sample("demo", (lead(loop=False),), nes_frequency=NTSC_FREQUENCY)) + timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) + + 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 = sample_song( + player_sample( + "demo", + (lead(loop=False),), + nes_frequency=NTSC_FREQUENCY, + tuning=tuning, + ) + ) + timer = get_timer_table(tuning)[PLAYER_REFERENCE_PITCH] + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) + + +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 + + +def project_song(project: Project, loop_tick: Optional[int]) -> Song: + """The song a project plays as on every channel, compressed as hard as an export reaches.""" + return song_from_project( + project, + channels=ALL_CHANNELS, + loop_tick=loop_tick, + scheme=CompressionScheme.SEARCH, + ) + + +class TestSongFromProject: + """A whole project reaching the console as the four streams the driver plays.""" + + def test_the_song_lasts_the_ticks_the_projects_groove_gives_its_rows( + self, + ) -> None: + project = drum_project() + song = project_song(project, loop_tick=None) + assert song.ticks == SongTiming.from_project(project, bounds=SONG_TICK_BOUNDS).frame_tick( + project.song.order_length() + ) + + def test_the_schedule_follows_the_rate_the_project_states(self) -> None: + song = project_song(drum_project(), loop_tick=None) + assert song.schedule == PlaySchedule.from_parameters(SONG_SETTINGS.nes_frequency) + + def test_every_channel_carries_the_songs_whole_length(self) -> None: + song = project_song(drum_project(), loop_tick=None) + assert len(song.streams.noise) == song.ticks + assert len(song.streams.triangle) == song.ticks + + def test_a_pitch_reaches_the_timer_the_tuning_names(self) -> None: + song = project_song(drum_project(), loop_tick=None) + timer = get_timer_table(Tuning())[PLAYER_REFERENCE_PITCH] + assert ( + song.streams.pulse1[0].timer_low, + song.streams.pulse1[0].timer_high, + ) == (timer & 0xFF, timer >> 8) + + def test_the_song_carries_the_loop_it_is_given(self) -> None: + song = project_song(drum_project(), loop_tick=SONG_START) + assert song.loop_tick == SONG_START + + def test_a_frame_the_order_plays_twice_lasts_twice_as_long(self) -> None: + project = drum_project() + one_frame = project_song(project, loop_tick=None).ticks + project.song.append_frame() + project.song.set_order_entry(1, ChannelName.PULSE1, 0) + assert project_song(project, loop_tick=None).ticks == 2 * one_frame + + def test_a_channel_the_song_leaves_out_rests_throughout(self) -> None: + project = drum_project() + song = song_from_project( + project, + channels=ALL_CHANNELS - {ChannelName.PULSE1}, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + silent = channel_registers(ChannelName.PULSE1, {}, PLAYER_TIMER_TABLE)[0] + assert song.ticks == project_song(project, loop_tick=None).ticks + assert set(song.streams.pulse1) == {silent} + + +class TestEveryCompressionSchemePlaysTheSameSong: + """A scheme decides how the song is written, and the console plays the same registers under each.""" + + @pytest.mark.parametrize("scheme", list(CompressionScheme), ids=str) + def test_the_song_writes_the_registers_the_hardest_scheme_writes(self, scheme: CompressionScheme) -> None: + project = drum_project() + song = song_from_project(project, channels=ALL_CHANNELS, loop_tick=SONG_START, scheme=scheme) + assert song.streams == project_song(project, loop_tick=SONG_START).streams + + def test_a_harder_scheme_takes_no_more_room(self) -> None: + project = drum_project() + sizes = [ + song_from_project( + project, + channels=ALL_CHANNELS, + loop_tick=SONG_START, + scheme=scheme, + ).planes.size + for scheme in CompressionScheme + ] + assert sizes == sorted(sizes, reverse=True) diff --git a/tests/unit/sampletones_player/test_instrument_song.py b/tests/unit/sampletones_player/test_instrument_song.py new file mode 100644 index 000000000..b90caf11f --- /dev/null +++ b/tests/unit/sampletones_player/test_instrument_song.py @@ -0,0 +1,70 @@ +from typing import Final, Tuple + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.project.patterns.pitch import Step +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 +from sampletones_player.compression.scheme import CompressionScheme + +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) + project.song[ChannelName.PULSE1].ensure_pattern(0, ROWS_PER_PATTERN) + project.song[ChannelName.PULSE1].set_row(0, 0, Row(command=NoteOn(voice_id=instrument.id), pitch=Step(value=0))) + 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(), + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + + assert song.planes.ticks > 0 + + def test_the_compiled_song_sounds_the_instrument_on_the_channel_it_was_placed_on( + self, + ) -> None: + song = song_from_project( + _project(), + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + + levels = [registers.control & VOLUME_NIBBLE for registers in song.streams.pulse1[: len(VOLUME)]] + assert levels == list(VOLUME) + + def test_the_channels_it_was_not_placed_on_stay_silent(self) -> None: + song = song_from_project( + _project(), + channels=ALL_CHANNELS, + loop_tick=None, + scheme=CompressionScheme.SEARCH, + ) + + 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..8e26e2a05 --- /dev/null +++ b/tests/unit/sampletones_player/test_song.py @@ -0,0 +1,132 @@ +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_shared/deployment/__init__.py b/tests/unit/sampletones_shared/deployment/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/deployment/test_version.py b/tests/unit/sampletones_shared/deployment/test_version.py new file mode 100644 index 000000000..fd3b58f57 --- /dev/null +++ b/tests/unit/sampletones_shared/deployment/test_version.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_shared.deployment.version import compare_versions +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestHowTwoVersionsCompare(BaseTestSuite): + """Versions compare by their numbers, a shorter one read with its missing parts at zero.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + first: str + second: str + expected: int + + test_cases = ( + TestCase(label="the same version", first="2.1", second="2.1", expected=0), + TestCase(label="a version spelled shorter", first="2.1", second="2.1.0", expected=0), + TestCase(label="an earlier minor version", first="2.0", second="2.1", expected=-1), + TestCase(label="a later major version", first="10.0", second="9.9.9", expected=1), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_order_they_stand_in(self, test_case: TestCase) -> None: + assert compare_versions(test_case.first, test_case.second) == test_case.expected + + +class TestAMalformedVersion(BaseTestSuite): + """A version that states no numbers is refused as a malformed value.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + version: str + + test_cases = ( + TestCase(label="words", version="abc"), + TestCase(label="nothing", version=""), + TestCase(label="too many parts", version="1.2.3.4"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_it_is_refused(self, test_case: TestCase) -> None: + with pytest.raises(ValueError): + compare_versions(test_case.version, "2.1") diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py b/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py deleted file mode 100644 index 79bc0bb9c..000000000 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Final - -from sampletones_shared.meta.source.bindings.environment import TypeEnvironment - -ENVIRONMENT: Final[TypeEnvironment] = TypeEnvironment( - types={ - "language_manager": "LanguageManager", - "self._language_manager": "LanguageManager", - "element": "MenuElements", - } -) - - -class TestTypeOf: - def test_a_stated_name_holds_its_type(self) -> None: - assert ENVIRONMENT.type_of("element") == "MenuElements" - - def test_a_stated_attribute_holds_its_type(self) -> None: - assert ENVIRONMENT.type_of("self._language_manager") == "LanguageManager" - - def test_a_spelling_the_environment_omits_states_nothing(self) -> None: - assert ENVIRONMENT.type_of("absent") is None - - -class TestSpellingsOf: - def test_every_holder_of_a_type_is_named(self) -> None: - assert ENVIRONMENT.spellings_of("LanguageManager") == ( - "language_manager", - "self._language_manager", - ) - - def test_holders_arrive_in_the_order_they_were_read(self) -> None: - environment = TypeEnvironment( - types={ - "second": "Manager", - "first": "Manager", - } - ) - assert environment.spellings_of("Manager") == ("second", "first") - - def test_a_type_no_spelling_holds_names_nobody(self) -> None: - assert ENVIRONMENT.spellings_of("Sequencer") == () diff --git a/tests/unit/sampletones_shared/meta/source/test_index.py b/tests/unit/sampletones_shared/meta/source/test_index.py deleted file mode 100644 index a2da5abb0..000000000 --- a/tests/unit/sampletones_shared/meta/source/test_index.py +++ /dev/null @@ -1,53 +0,0 @@ -import ast -from pathlib import Path -from typing import Final - -from sampletones_shared.meta.source.index import SourceIndex, source_index -from sampletones_shared.meta.source.modules import SourceModule -from tests.suite.source import parse_source - -TAGS_SOURCE: Final[str] = """ -from typing import Dict, Final - -FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {} -TAG_MAIN_WINDOW = "main.window" -""" - -PANEL_SOURCE: Final[str] = """ -from typing import Dict, Final - -LABELS: Final[Dict[str, MenuElements]] = {} -TAG_MAIN_WINDOW = "panel.window" -""" - - -def module(name: str, source: str) -> SourceModule: - return SourceModule(path=Path(name), tree=parse_source(source)) - - -def index_of(*sources: str) -> SourceIndex: - return source_index([module(f"module_{number}.py", source) for number, source in enumerate(sources)]) - - -class TestSourceIndex: - def test_a_container_states_its_item_types(self) -> None: - assert index_of(TAGS_SOURCE).item_types["FILTERS"] == ( - "TrackerFormat", - "FileFilterElements", - ) - - def test_a_constant_states_its_value(self) -> None: - value = index_of(TAGS_SOURCE).constants["TAG_MAIN_WINDOW"] - assert ast.unparse(value) == "'main.window'" - - def test_every_module_of_the_tree_is_read(self) -> None: - index = index_of(TAGS_SOURCE, PANEL_SOURCE) - assert {"FILTERS", "LABELS"}.issubset(index.item_types) - - def test_the_module_read_last_states_a_shared_spelling(self) -> None: - index = index_of(TAGS_SOURCE, PANEL_SOURCE) - assert ast.unparse(index.constants["TAG_MAIN_WINDOW"]) == "'panel.window'" - - def test_a_tree_of_no_modules_states_nothing(self) -> None: - index = source_index([]) - assert (index.item_types, index.constants) == ({}, {}) diff --git a/tests/unit/sampletones_shared/meta/source/test_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py deleted file mode 100644 index e18e8b853..000000000 --- a/tests/unit/sampletones_shared/meta/source/test_packages.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest - -from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths.source import SOURCE_ROOT - -SHARED_PACKAGE = "sampletones_shared" -APPLICATION_PACKAGE = "sampletones_application" - - -class TestPackageDirectory: - def test_a_top_level_package_sits_under_the_source_root(self) -> None: - assert package_directory(SHARED_PACKAGE) == SOURCE_ROOT / SHARED_PACKAGE - - def test_a_subpackage_is_named_part_by_part(self) -> None: - assert package_directory(SHARED_PACKAGE, "meta", "source") == SOURCE_ROOT / SHARED_PACKAGE / "meta" / "source" - - def test_the_answer_is_a_directory_a_sweep_reads_under(self) -> None: - """A package resource resolves to `__init__.py`, which a sweep reads nothing under.""" - assert package_directory(APPLICATION_PACKAGE, "tags").is_dir() - - def test_a_package_the_source_root_holds_no_directory_for_raises(self) -> None: - with pytest.raises(NotADirectoryError): - package_directory("sampletones_absent") - - def test_a_module_named_as_a_package_raises(self) -> None: - with pytest.raises(NotADirectoryError): - package_directory(SHARED_PACKAGE, "paths.py") - - def test_the_report_names_the_path_it_looked_at(self) -> None: - with pytest.raises(NotADirectoryError, match="sampletones_absent"): - package_directory("sampletones_absent") diff --git a/tests/unit/sampletones_shared/meta/source/test_values.py b/tests/unit/sampletones_shared/meta/source/test_values.py deleted file mode 100644 index 733ad0e94..000000000 --- a/tests/unit/sampletones_shared/meta/source/test_values.py +++ /dev/null @@ -1,187 +0,0 @@ -import ast -from dataclasses import dataclass -from typing import Dict, Final, Mapping, Tuple - -import pytest - -from sampletones_shared.meta.source.bindings.environment import TypeEnvironment -from sampletones_shared.meta.source.values import ( - UNRESOLVED, - EnumTable, - ResolvedValues, - ValueResolver, -) -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase - -ENUMS: Final[EnumTable] = { - "Page": {"GLOBAL": "global"}, - "Panel": {"DIALOG": "dialog"}, - "TextType": {"LABEL": "label"}, - "DialogElements": {"OK": "ok", "CANCEL": "cancel"}, - "MenuElements": {"GROUP_FILE": "group_file", "ITEM_EXIT": "item_exit"}, -} - -ENVIRONMENT: Final[TypeEnvironment] = TypeEnvironment( - types={ - "element": "DialogElements", - "self._element": "MenuElements", - "path": "Path", - } -) - - -def expression(text: str) -> ast.expr: - return ast.parse(text, mode="eval").body - - -CONSTANTS: Final[Mapping[str, ast.expr]] = { - "OK_KEY": expression("'global.dialog.label.ok'"), - "CHAINED_KEY": expression("OK_KEY"), - "CYCLE": expression("CYCLE"), - "BUILT": expression("build('global')"), -} - -RESOLVER: Final[ValueResolver] = ValueResolver( - environment=ENVIRONMENT, - enums=ENUMS, - constants=CONSTANTS, -) - - -class TestResolveValues(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - expression: str - expected: Tuple[Tuple[str, ...], bool] - - test_cases = ( - TestCase( - label="string_literal", - expression="'global.dialog.label.ok'", - expected=(("global.dialog.label.ok",), True), - ), - TestCase( - label="enum_member", - expression="Page.GLOBAL", - expected=(("global",), True), - ), - TestCase( - label="enum_typed_name", - expression="element", - expected=(("ok", "cancel"), False), - ), - TestCase( - label="enum_typed_attribute", - expression="self._element", - expected=(("group_file", "item_exit"), False), - ), - TestCase( - label="enum_call", - expression="MenuElements(entry.action.value)", - expected=(("group_file", "item_exit"), False), - ), - TestCase( - label="conditional_over_members", - expression="DialogElements.OK if is_confirmation else DialogElements.CANCEL", - expected=(("ok", "cancel"), True), - ), - TestCase( - label="conditional_mixing_a_member_and_a_name", - expression="DialogElements.OK if is_confirmation else element", - expected=(("ok", "cancel"), False), - ), - TestCase( - label="constant_holding_a_key", - expression="OK_KEY", - expected=(("global.dialog.label.ok",), True), - ), - TestCase( - label="constant_naming_another_constant", - expression="CHAINED_KEY", - expected=(("global.dialog.label.ok",), True), - ), - TestCase( - label="member_absent_from_its_enum", - expression="Page.MISSING", - expected=((), False), - ), - TestCase( - label="attribute_of_another_type", - expression="settings.value", - expected=((), False), - ), - TestCase( - label="call_of_another_kind", - expression="str(key)", - expected=((), False), - ), - TestCase( - label="format_string", - expression="f'global.dialog.label.{name}'", - expected=((), False), - ), - TestCase(label="number", expression="4", expected=((), False)), - TestCase( - label="name_of_another_type", - expression="path", - expected=((), False), - ), - TestCase( - label="name_the_source_never_states", - expression="unknown", - expected=((), False), - ), - TestCase( - label="constant_naming_itself", - expression="CYCLE", - expected=((), False), - ), - TestCase( - label="constant_built_by_a_call", - expression="BUILT", - expected=((), False), - ), - TestCase( - label="conditional_reaching_an_unknown_branch", - expression="DialogElements.OK if is_confirmation else unknown", - expected=((), False), - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_resolve(self, test_case: TestCase) -> None: - resolved = RESOLVER.resolve(expression(test_case.expression)) - assert (resolved.values, resolved.exact) == test_case.expected - - -class TestResolvedValues: - def test_values_mark_a_resolution(self) -> None: - assert ResolvedValues(values=("ok",), exact=True).resolved - - def test_an_empty_resolution_reaches_nothing(self) -> None: - assert not UNRESOLVED.resolved - - -class TestValueResolverTables: - def test_an_enum_absent_from_the_table_resolves_nothing(self) -> None: - resolver = ValueResolver( - environment=ENVIRONMENT, - enums={}, - constants={}, - ) - assert resolver.resolve(expression("Page.GLOBAL")) == UNRESOLVED - - def test_an_enum_holding_no_member_resolves_nothing(self) -> None: - enums: Dict[str, Dict[str, str]] = {"AbstractElement": {}} - environment = TypeEnvironment(types={"element": "AbstractElement"}) - resolver = ValueResolver( - environment=environment, - enums=enums, - constants={}, - ) - assert resolver.resolve(expression("element")) == UNRESOLVED diff --git a/tests/unit/sampletones_shared/paths/test_extensions.py b/tests/unit/sampletones_shared/paths/test_extensions.py new file mode 100644 index 000000000..faf98df45 --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_extensions.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from sampletones_shared.paths.extensions import is_audio_file +from tests.suite.files import NAMES_ONLY_FOLDER, held_at, requires_folder_permissions + + +class TestIsAudioFile: + """A recording is a file with a recording's extension that the reader may inspect.""" + + def test_a_recording_reads_as_one(self, tmp_path: Path) -> None: + recording = tmp_path / "take.WAV" + recording.touch() + + assert is_audio_file(recording) + + def test_another_kind_of_file_reads_as_none(self, tmp_path: Path) -> None: + notes = tmp_path / "notes.txt" + notes.touch() + + assert not is_audio_file(notes) + + def test_a_folder_named_like_a_recording_reads_as_none(self, tmp_path: Path) -> None: + folder = tmp_path / "takes.wav" + folder.mkdir() + + assert not is_audio_file(folder) + + @requires_folder_permissions + def test_a_recording_in_a_folder_listing_names_only_reads_as_none(self, tmp_path: Path) -> None: + folder = tmp_path / "names_only" + folder.mkdir() + (folder / "take.wav").touch() + + with held_at(folder, NAMES_ONLY_FOLDER): + assert not is_audio_file(folder / "take.wav") diff --git a/tests/unit/sampletones_shared/paths/test_package.py b/tests/unit/sampletones_shared/paths/test_package.py new file mode 100644 index 000000000..07658997f --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_package.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import pytest + +import sampletones_config +import sampletones_tools.corpus.config +from sampletones_shared.paths.package import package_directory + + +class TestPackageDirectory: + def test_the_directory_holds_the_package_s_modules_and_data(self) -> None: + directory = package_directory("sampletones_config") + + assert directory == Path(sampletones_config.__file__).parent + assert (directory / "application").is_dir() + + def test_a_nested_package_is_placed_by_its_own_location(self) -> None: + assert ( + package_directory("sampletones_tools.corpus.config") + == Path(sampletones_tools.corpus.config.__file__).parent + ) + + def test_a_directory_of_data_alone_is_placed_as_a_namespace_package( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """A bundle unpacks a package the application never imports as code this way.""" + (tmp_path / "unpacked_data").mkdir() + (tmp_path / "unpacked_data" / "values.yaml").write_text("") + monkeypatch.syspath_prepend(str(tmp_path)) + + assert package_directory("unpacked_data") == tmp_path / "unpacked_data" + + def test_an_absent_package_is_refused(self) -> None: + with pytest.raises(FileNotFoundError, match="sampletones_absent"): + package_directory("sampletones_absent") diff --git a/tests/unit/sampletones_shared/paths/test_source.py b/tests/unit/sampletones_shared/paths/test_source.py index 6d5f1ec83..3b55bd6a7 100644 --- a/tests/unit/sampletones_shared/paths/test_source.py +++ b/tests/unit/sampletones_shared/paths/test_source.py @@ -16,6 +16,3 @@ def test_the_source_root_is_where_this_package_lives(self) -> None: class TestRepositoryRoot: def test_the_repository_root_holds_the_project_file(self) -> None: assert (REPOSITORY_ROOT / PROJECT_FILE).is_file() - - def test_the_repository_root_holds_the_scripts_the_checks_run_from(self) -> None: - assert (REPOSITORY_ROOT / "scripts" / "checks").is_dir() diff --git a/tests/unit/sampletones_shared/test_command.py b/tests/unit/sampletones_shared/test_command.py new file mode 100644 index 000000000..c929c6c9b --- /dev/null +++ b/tests/unit/sampletones_shared/test_command.py @@ -0,0 +1,31 @@ +from argparse import ArgumentParser, Namespace +from dataclasses import FrozenInstanceError + +import pytest + +from sampletones_shared.command import Command + + +def _configure(parser: ArgumentParser) -> None: + parser.add_argument("--flag", action="store_true") + + +def _run(arguments: Namespace) -> int: + return 3 if arguments.flag else 0 + + +class TestCommand: + def test_a_command_carries_its_parser_and_its_work(self) -> None: + command = Command(name="probe", help="probe the thing", configure=_configure, run=_run) + parser = ArgumentParser() + + command.configure(parser) + + assert command.run(parser.parse_args(["--flag"])) == 3 + assert command.run(parser.parse_args([])) == 0 + + def test_a_command_is_settled_once_declared(self) -> None: + command = Command(name="probe", help="probe the thing", configure=_configure, run=_run) + + with pytest.raises(FrozenInstanceError): + command.name = "other" # type: ignore[misc] 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_filesystem.py b/tests/unit/sampletones_shared/utils/system/test_filesystem.py index d682af546..3f766c75f 100644 --- a/tests/unit/sampletones_shared/utils/system/test_filesystem.py +++ b/tests/unit/sampletones_shared/utils/system/test_filesystem.py @@ -1,29 +1,9 @@ -import tempfile from pathlib import Path -from typing import Final import pytest from sampletones_shared.utils.system.filesystem import remove_path - - -def _symlinks_are_permitted() -> bool: - """Reports whether this machine lets an unprivileged process create a symlink. - - Windows grants the privilege only under Developer Mode or elevation, so the probe - creates one in a throwaway directory and reads the answer from the attempt. - """ - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - try: - (root / "probe").symlink_to(root, target_is_directory=True) - except OSError: - return False - - return True - - -SYMLINKS_PERMITTED: Final[bool] = _symlinks_are_permitted() +from tests.suite.files import requires_symlinks class TestRemovePath: @@ -47,10 +27,7 @@ def test_removes_directory_recursively(self, tmp_path: Path) -> None: assert removed == target assert not target.exists() - @pytest.mark.skipif( - not SYMLINKS_PERMITTED, - reason="creating a symlink requires a privilege this machine withholds", - ) + @requires_symlinks def test_removes_directory_symlink_without_touching_target( self, tmp_path: Path, diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index b0ab5a04b..f5b82ad8d 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -9,8 +9,11 @@ from sampletones_shared.utils.system.paths import ( DEFAULT_MAX_FILENAME_DISPLAY, ensure_suffix, + first_missing, get_directory, get_filename, + is_same_path, + nearest_directory, open_directory_in_explorer_linux, open_file_in_explorer_linux, open_path_in_explorer, @@ -18,6 +21,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 +29,82 @@ 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 TestIsSamePath: + """Two spellings of one location name the same file, which is what tells a document its own.""" + + def test_a_path_is_the_same_as_itself(self, tmp_path: Path) -> None: + assert is_same_path(tmp_path / "song.stn", tmp_path / "song.stn") + + def test_a_detour_through_a_parent_reaches_the_same_file(self, tmp_path: Path) -> None: + (tmp_path / "folder").mkdir() + + assert is_same_path(tmp_path / "folder" / ".." / "song.stn", tmp_path / "song.stn") + + def test_a_relative_spelling_reaches_the_same_file( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.chdir(tmp_path) + + assert is_same_path(Path("song.stn"), tmp_path / "song.stn") + + def test_two_files_are_different(self, tmp_path: Path) -> None: + assert not is_same_path(tmp_path / "song.stn", tmp_path / "other.stn") + + +class TestNearestDirectory: + def test_a_standing_directory_answers_itself(self, tmp_path: Path) -> None: + assert nearest_directory(tmp_path) == tmp_path + + def test_a_missing_path_answers_the_closest_directory_above_it(self, tmp_path: Path) -> None: + assert nearest_directory(tmp_path / "gone" / "deeper") == tmp_path + + def test_a_file_answers_the_directory_holding_it(self, tmp_path: Path) -> None: + file = tmp_path / "song.wav" + file.touch() + + assert nearest_directory(file) == tmp_path + + def test_a_path_with_no_standing_directory_answers_none(self, tmp_path: Path) -> None: + with patch.object(Path, "is_dir", return_value=False): + assert nearest_directory(tmp_path / "drive" / "audio") 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): @@ -170,7 +250,7 @@ class TestCase(BaseRegularTestCase): name="Kick (pulse1)", extension=".fti", expected="Kick (pulse1).fti", - label="carries_a_parenthesised_slice_name", + label="carries_a_parenthesized_slice_name", ), TestCase( name="Kick v1.2", @@ -532,7 +612,7 @@ class TestCase(BaseRegularTestCase): ) def _create_resolved_mock(self, resolved_path: Any) -> MagicMock: - """Stands in for the resolved path, keeping the path flavour each case declares. + """Stands in for the resolved path, keeping the path flavor each case declares. Every case states its expectation as a ``PurePosixPath`` or a ``PureWindowsPath``, so the parts come from that pure path and the case reads the same on either platform. 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_callbacks.py b/tests/unit/sampletones_shared/utils/test_callbacks.py index a00ef0e59..917056228 100644 --- a/tests/unit/sampletones_shared/utils/test_callbacks.py +++ b/tests/unit/sampletones_shared/utils/test_callbacks.py @@ -1,3 +1,4 @@ +import logging from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple, Type, Union from unittest.mock import MagicMock @@ -178,10 +179,15 @@ def test_call(self, test_case: TestCase) -> None: result = instance.call(test_case.callback, *test_case.args, **test_case.kwargs) assert result == test_case.expected - def test_call_logs_warning_for_none_callback(self, caplog: pytest.LogCaptureFixture) -> None: + def test_call_notes_an_unset_callback_at_debug(self, caplog: pytest.LogCaptureFixture) -> None: + """Partial wiring is expected, so an unset hook is a debug note rather than a warning.""" + caplog.set_level(logging.DEBUG) instance = TestableCallbackClass() + instance.call(None) + assert "No callback for TestableCallbackClass to call" in caplog.text + assert caplog.records[-1].levelno == logging.DEBUG def test_call_with_callback_that_raises_exception(self) -> None: instance = TestableCallbackClass() @@ -214,12 +220,14 @@ def test_unset_hook_reports_the_default(self) -> None: assert instance.query(instance.on_data, default=False) is False - def test_unset_hook_logs_a_warning(self, caplog: pytest.LogCaptureFixture) -> None: + def test_unset_hook_is_noted_at_debug(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG) instance = TestableCallbackClass() instance.query(instance.on_data, default=None) assert "No callback for TestableCallbackClass to query" in caplog.text + assert caplog.records[-1].levelno == logging.DEBUG def test_answer_of_none_is_reported_as_given(self) -> None: """A hook answering ``None`` is wired, so its answer stands rather than the default.""" 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_hashing.py b/tests/unit/sampletones_shared/utils/test_hashing.py new file mode 100644 index 000000000..f9c9152c3 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_hashing.py @@ -0,0 +1,243 @@ +import pytest + +from sampletones_shared.utils.hashing import ( + HASH_LENGTH, + IDENTITY_SEPARATOR, + calculate_hash, + hash_model, + hash_models, + identity_digest, +) +from tests.suite.dummy import NestedModel, SimpleModel + + +class TestCalculateHash: + def test_hash_string(self) -> None: + hash1 = calculate_hash("test") + hash2 = calculate_hash("test".encode("utf-8")) + hash3 = calculate_hash(b"test") + hash4 = calculate_hash("different") + + assert hash1 == hash2 + assert hash1 == hash3 + assert hash1 != hash4 + assert isinstance(hash1, str) + assert len(hash1) == HASH_LENGTH + + def test_hash_integer(self) -> None: + hash1 = calculate_hash(42) + hash2 = calculate_hash(42) + hash3 = calculate_hash(1) + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_float(self) -> None: + hash1 = calculate_hash(3.14) + hash2 = calculate_hash(3.14) + hash3 = calculate_hash(2.71) + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_bytes(self) -> None: + hash1 = calculate_hash(b"same") + hash2 = calculate_hash(b"same") + hash3 = calculate_hash(b"different") + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_bytes_same_as_strings(self) -> None: + hash1 = calculate_hash(b"data") + hash2 = calculate_hash("data") + hash3 = calculate_hash("data".encode("utf-8")) + + assert hash1 == hash2 + assert hash1 == hash3 + + def test_hash_boolean(self) -> None: + hash1 = calculate_hash(True) + hash2 = calculate_hash(True) + hash3 = calculate_hash(False) + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_null_different_representations(self) -> None: + hash_false = calculate_hash(False) + hash_zero = calculate_hash(0) + hash_float_zero = calculate_hash(0.0) + hash_empty_string = calculate_hash("") + hash_null_bytes = calculate_hash(b"") + hash_none = calculate_hash(None) + + assert hash_false == hash_zero + assert hash_false == hash_float_zero + assert hash_false == hash_null_bytes + assert hash_false == hash_empty_string + assert hash_false != hash_none + + def test_hash_base_model(self) -> None: + model1 = SimpleModel(value=42, name="test") + model2 = SimpleModel(value=42, name="test") + + hash1 = calculate_hash(model1) + hash2 = calculate_hash(model2) + + assert hash1 == hash2 + + def test_hash_different_base_models(self) -> None: + model1 = SimpleModel(value=1, name="test") + model2 = SimpleModel(value=1, name="test") + model3 = SimpleModel(value=2, name="test") + + hash1 = calculate_hash(model1) + hash2 = calculate_hash(model2) + hash3 = calculate_hash(model3) + + assert hash1 == hash2 + assert hash1 != hash3 + + def test_hash_custom_length(self) -> None: + hash_16 = calculate_hash("test", length=16) + hash_64 = calculate_hash("test", length=64) + + assert hash_16 != hash_64 + assert len(hash_16) == 16 + assert len(hash_64) == 64 + assert hash_64.startswith(hash_16) + + def test_hash_zero_length_raises(self) -> None: + with pytest.raises(ValueError): + calculate_hash("test", length=0) + + def test_hash_excessive_length_raises(self) -> None: + with pytest.raises(ValueError): + calculate_hash("test", length=65) + + def test_hash_tuple(self) -> None: + hash1 = calculate_hash((1, 2, 3)) + hash2 = calculate_hash((1, 2, 3)) + + assert hash1 == hash2 + + def test_hash_frozenset(self) -> None: + hash1 = calculate_hash(frozenset([1, 2, 3])) + hash2 = calculate_hash(frozenset([1, 2, 3])) + hash3 = calculate_hash(frozenset([3, 2, 1])) + + assert hash1 == hash2 + assert hash1 == hash3 + assert isinstance(hash1, str) + + +class TestHashModels: + def test_hash_single_model(self) -> None: + model = SimpleModel(value=42, name="test") + hash1 = hash_model(model) + hash2 = hash_model(model) + + assert hash1 == hash2 + assert isinstance(hash1, str) + assert len(hash1) == HASH_LENGTH + + def test_hash_different_single_models(self) -> None: + model1 = SimpleModel(value=1, name="test1") + model2 = SimpleModel(value=2, name="test2") + + hash1 = hash_model(model1) + hash2 = hash_model(model2) + + assert hash1 != hash2 + + def test_hash_multiple_models(self) -> None: + model1 = SimpleModel(value=1, name="first") + model2 = SimpleModel(value=2, name="second") + + hash1 = hash_models(model1, model2) + hash2 = hash_models(model1, model2) + + assert hash1 == hash2 + + def test_hash_multiple_models_order_matters(self) -> None: + model1 = SimpleModel(value=1, name="first") + model2 = SimpleModel(value=2, name="second") + + hash_forward = hash_models(model1, model2) + hash_backward = hash_models(model2, model1) + + assert hash_forward != hash_backward + + def test_hash_single_vs_multiple(self) -> None: + model = SimpleModel(value=42, name="test") + + hash_single = hash_model(model) + hash_multiple = hash_models(model) + + assert hash_single == hash_multiple + + def test_hash_nested_model(self) -> None: + inner = SimpleModel(value=1, name="inner") + outer = NestedModel(simple=inner, items=[1, 2, 3]) + + hash1 = hash_model(outer) + hash2 = hash_model(outer) + + assert hash1 == hash2 + + def test_hash_models_custom_length(self) -> None: + model = SimpleModel(value=42, name="test") + + hash_16 = hash_model(model, length=16) + hash_64 = hash_model(model, length=64) + + assert len(hash_16) == 16 + assert len(hash_64) == 64 + + def test_hash_models_many(self) -> None: + models = [SimpleModel(value=i, name=f"model{i}") for i in range(10)] + + hash1 = hash_models(*models) + hash2 = hash_models(*models) + + assert hash1 == hash2 + + +class TestEdgeCases: + def test_calculate_hash_list_raises(self) -> None: + with pytest.raises(TypeError): + calculate_hash([1, 2, 3]) + + def test_calculate_hash_dict_raises(self) -> None: + with pytest.raises(TypeError): + calculate_hash({"key": "value"}) + + +class TestIdentityDigest: + def test_digest_repeats_for_one_identity(self) -> None: + assert identity_digest("folder", "take") == identity_digest("folder", "take") + + def test_identities_spelled_apart_digest_apart(self) -> None: + assert identity_digest("folder", "take") != identity_digest("folder_take") + + def test_the_order_the_parts_stand_in_settles_the_digest(self) -> None: + assert identity_digest("folder", "take") != identity_digest("take", "folder") + + def test_the_separator_a_name_carries_nowhere_joins_the_parts(self) -> None: + assert identity_digest("folder", "take") == calculate_hash(f"folder{IDENTITY_SEPARATOR}take") + + def test_parts_split_at_a_different_place_digest_apart(self) -> None: + """The separator stands between the parts, so where one ends and the next opens is read.""" + assert identity_digest("fol", "dertake") != identity_digest("folder", "take") + + def test_digest_takes_the_length_it_is_asked_for(self) -> None: + assert len(identity_digest("take", length=8)) == 8 + assert len(identity_digest("take")) == HASH_LENGTH + + def test_a_shorter_digest_opens_the_full_one(self) -> None: + assert identity_digest("take").startswith(identity_digest("take", length=8)) 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/sampletones_shared/utils/test_serialization.py b/tests/unit/sampletones_shared/utils/test_serialization.py index aac19e9de..d4351cd8d 100644 --- a/tests/unit/sampletones_shared/utils/test_serialization.py +++ b/tests/unit/sampletones_shared/utils/test_serialization.py @@ -6,12 +6,8 @@ import pytest from sampletones_shared.utils.serialization import ( - HASH_LENGTH, - calculate_hash, deserialize_array, dump, - hash_model, - hash_models, load_binary, load_json, load_yaml, @@ -20,8 +16,8 @@ save_yaml, serialize_array, snake_to_camel, + write_atomically, ) -from tests.suite.dummy import NestedModel, SimpleModel class TestDump: @@ -238,6 +234,54 @@ def test_binary_preserves_exact_bytes(self) -> None: assert loaded == data +class InterruptedWrite(Exception): + pass + + +def write_part_and_stop(path: Path) -> None: + path.write_bytes(b"part of a") + raise InterruptedWrite + + +class TestAnAtomicWrite: + """A file is replaced whole once its write succeeds, and stands as it was when the write fails.""" + + PREVIOUS: bytes = b"previous" + + def test_a_write_replaces_the_file_whole(self, tmp_path: Path) -> None: + target = tmp_path / "document.bin" + target.write_bytes(self.PREVIOUS) + + save_binary(target, b"replacement") + + assert target.read_bytes() == b"replacement" + + def test_an_interrupted_write_leaves_the_previous_file(self, tmp_path: Path) -> None: + target = tmp_path / "document.bin" + target.write_bytes(self.PREVIOUS) + + with pytest.raises(InterruptedWrite): + write_atomically(target, write_part_and_stop) + + assert target.read_bytes() == self.PREVIOUS + + def test_a_write_leaves_only_the_file_it_wrote(self, tmp_path: Path) -> None: + target = tmp_path / "document.bin" + + save_binary(target, b"content") + + assert list(tmp_path.iterdir()) == [target] + + def test_an_interrupted_write_leaves_nothing_beside_the_file(self, tmp_path: Path) -> None: + target = tmp_path / "document.bin" + target.write_bytes(self.PREVIOUS) + + with pytest.raises(InterruptedWrite): + write_atomically(target, write_part_and_stop) + + assert list(tmp_path.iterdir()) == [target] + + class TestArraySerialization: def test_serialize_deserialize_1d_array(self) -> None: array = np.array([1, 2, 3, 4, 5]) @@ -319,203 +363,6 @@ def test_serialize_different_dtypes(self) -> None: assert deserialized.dtype == dtype -class TestCalculateHash: - def test_hash_string(self) -> None: - hash1 = calculate_hash("test") - hash2 = calculate_hash("test".encode("utf-8")) - hash3 = calculate_hash(b"test") - hash4 = calculate_hash("different") - - assert hash1 == hash2 - assert hash1 == hash3 - assert hash1 != hash4 - assert isinstance(hash1, str) - assert len(hash1) == HASH_LENGTH - - def test_hash_integer(self) -> None: - hash1 = calculate_hash(42) - hash2 = calculate_hash(42) - hash3 = calculate_hash(1) - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_float(self) -> None: - hash1 = calculate_hash(3.14) - hash2 = calculate_hash(3.14) - hash3 = calculate_hash(2.71) - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_bytes(self) -> None: - hash1 = calculate_hash(b"same") - hash2 = calculate_hash(b"same") - hash3 = calculate_hash(b"different") - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_bytes_same_as_strings(self) -> None: - hash1 = calculate_hash(b"data") - hash2 = calculate_hash("data") - hash3 = calculate_hash("data".encode("utf-8")) - - assert hash1 == hash2 - assert hash1 == hash3 - - def test_hash_boolean(self) -> None: - hash1 = calculate_hash(True) - hash2 = calculate_hash(True) - hash3 = calculate_hash(False) - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_null_different_representations(self) -> None: - hash_false = calculate_hash(False) - hash_zero = calculate_hash(0) - hash_float_zero = calculate_hash(0.0) - hash_empty_string = calculate_hash("") - hash_null_bytes = calculate_hash(b"") - hash_none = calculate_hash(None) - - assert hash_false == hash_zero - assert hash_false == hash_float_zero - assert hash_false == hash_null_bytes - assert hash_false == hash_empty_string - assert hash_false != hash_none - - def test_hash_base_model(self) -> None: - model1 = SimpleModel(value=42, name="test") - model2 = SimpleModel(value=42, name="test") - - hash1 = calculate_hash(model1) - hash2 = calculate_hash(model2) - - assert hash1 == hash2 - - def test_hash_different_base_models(self) -> None: - model1 = SimpleModel(value=1, name="test") - model2 = SimpleModel(value=1, name="test") - model3 = SimpleModel(value=2, name="test") - - hash1 = calculate_hash(model1) - hash2 = calculate_hash(model2) - hash3 = calculate_hash(model3) - - assert hash1 == hash2 - assert hash1 != hash3 - - def test_hash_custom_length(self) -> None: - hash_16 = calculate_hash("test", length=16) - hash_64 = calculate_hash("test", length=64) - - assert hash_16 != hash_64 - assert len(hash_16) == 16 - assert len(hash_64) == 64 - assert hash_64.startswith(hash_16) - - def test_hash_zero_length_raises(self) -> None: - with pytest.raises(ValueError): - calculate_hash("test", length=0) - - def test_hash_excessive_length_raises(self) -> None: - with pytest.raises(ValueError): - calculate_hash("test", length=65) - - def test_hash_tuple(self) -> None: - hash1 = calculate_hash((1, 2, 3)) - hash2 = calculate_hash((1, 2, 3)) - - assert hash1 == hash2 - - def test_hash_frozenset(self) -> None: - hash1 = calculate_hash(frozenset([1, 2, 3])) - hash2 = calculate_hash(frozenset([1, 2, 3])) - hash3 = calculate_hash(frozenset([3, 2, 1])) - - assert hash1 == hash2 - assert hash1 == hash3 - assert isinstance(hash1, str) - - -class TestHashModels: - def test_hash_single_model(self) -> None: - model = SimpleModel(value=42, name="test") - hash1 = hash_model(model) - hash2 = hash_model(model) - - assert hash1 == hash2 - assert isinstance(hash1, str) - assert len(hash1) == HASH_LENGTH - - def test_hash_different_single_models(self) -> None: - model1 = SimpleModel(value=1, name="test1") - model2 = SimpleModel(value=2, name="test2") - - hash1 = hash_model(model1) - hash2 = hash_model(model2) - - assert hash1 != hash2 - - def test_hash_multiple_models(self) -> None: - model1 = SimpleModel(value=1, name="first") - model2 = SimpleModel(value=2, name="second") - - hash1 = hash_models(model1, model2) - hash2 = hash_models(model1, model2) - - assert hash1 == hash2 - - def test_hash_multiple_models_order_matters(self) -> None: - model1 = SimpleModel(value=1, name="first") - model2 = SimpleModel(value=2, name="second") - - hash_forward = hash_models(model1, model2) - hash_backward = hash_models(model2, model1) - - assert hash_forward != hash_backward - - def test_hash_single_vs_multiple(self) -> None: - model = SimpleModel(value=42, name="test") - - hash_single = hash_model(model) - hash_multiple = hash_models(model) - - assert hash_single == hash_multiple - - def test_hash_nested_model(self) -> None: - inner = SimpleModel(value=1, name="inner") - outer = NestedModel(simple=inner, items=[1, 2, 3]) - - hash1 = hash_model(outer) - hash2 = hash_model(outer) - - assert hash1 == hash2 - - def test_hash_models_custom_length(self) -> None: - model = SimpleModel(value=42, name="test") - - hash_16 = hash_model(model, length=16) - hash_64 = hash_model(model, length=64) - - assert len(hash_16) == 16 - assert len(hash_64) == 64 - - def test_hash_models_many(self) -> None: - models = [SimpleModel(value=i, name=f"model{i}") for i in range(10)] - - hash1 = hash_models(*models) - hash2 = hash_models(*models) - - assert hash1 == hash2 - - class TestSnakeToCamel: def test_snake_to_camel_basic(self) -> None: assert snake_to_camel("hello_world") == "HelloWorld" @@ -562,14 +409,6 @@ def test_dump_non_serializable_raises(self) -> None: with pytest.raises(TypeError): dump(object()) - def test_calculate_hash_list_raises(self) -> None: - with pytest.raises(TypeError): - calculate_hash([1, 2, 3]) - - def test_calculate_hash_dict_raises(self) -> None: - with pytest.raises(TypeError): - calculate_hash({"key": "value"}) - def test_save_json_nested_complex(self) -> None: data = {"level1": {"level2": {"level3": [1, 2, {"level4": "deep"}]}}} diff --git a/tests/unit/sampletones_shared/utils/test_tables.py b/tests/unit/sampletones_shared/utils/test_tables.py new file mode 100644 index 000000000..3147ed78e --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_tables.py @@ -0,0 +1,39 @@ +import csv +from pathlib import Path + +import pytest + +from sampletones_shared.utils.tables import Table + + +@pytest.fixture(name="table") +def table_fixture() -> Table: + return Table( + columns=("song", "bytes"), + rows=(("Theme, reprise", "120"), ("Café", "7")), + ) + + +class TestTable: + def test_the_csv_reads_back_cell_for_cell(self, table: Table, tmp_path: Path) -> None: + path = tmp_path / "report.csv" + + table.write_csv(path) + + with path.open(encoding="utf-8", newline="") as handle: + assert [tuple(row) for row in csv.reader(handle)] == [table.columns, *table.rows] + + def test_the_markdown_is_the_header_the_rule_and_a_line_per_row(self, table: Table) -> None: + assert table.markdown_lines() == [ + "| song | bytes |", + "|---|---|", + "| Theme, reprise | 120 |", + "| Café | 7 |", + ] + + def test_a_table_without_rows_is_its_header_and_rule(self) -> None: + assert Table(columns=("song",), rows=()).markdown_lines() == ["| song |", "|---|"] + + def test_a_row_short_of_the_columns_is_refused(self) -> None: + with pytest.raises(ValueError, match="Row 1 holds 1 cells for 2 columns"): + Table(columns=("song", "bytes"), rows=(("one", "1"), ("two",))) diff --git a/tests/unit/sampletones_shared/utils/test_text.py b/tests/unit/sampletones_shared/utils/test_text.py index c5b960799..a15c73d10 100644 --- a/tests/unit/sampletones_shared/utils/test_text.py +++ b/tests/unit/sampletones_shared/utils/test_text.py @@ -2,7 +2,7 @@ import pytest -from sampletones_shared.utils.text import natural_sort_key +from sampletones_shared.utils.text import LIST_SEPARATOR, listed_items, natural_sort_key class TestNumbers: @@ -51,3 +51,17 @@ def test_one_name_reaches_one_key(self) -> None: def test_a_name_the_reader_alone_can_spell(self) -> None: """A digit-like glyph outside the decimal digits is text, and the key states it as text.""" assert sorted(["m²", "m1"], key=natural_sort_key) == ["m1", "m²"] + + +class TestListedItems: + def test_items_keep_their_order_with_the_spaces_around_them_stripped(self) -> None: + assert listed_items(" fft ,cqt") == ["fft", "cqt"] + + def test_empty_items_are_dropped(self) -> None: + assert listed_items(f"pulse1{LIST_SEPARATOR}{LIST_SEPARATOR} {LIST_SEPARATOR}noise{LIST_SEPARATOR}") == [ + "pulse1", + "noise", + ] + + def test_an_empty_statement_lists_nothing(self) -> None: + assert listed_items("") == [] diff --git a/tests/unit/sampletones_shared/utils/test_time.py b/tests/unit/sampletones_shared/utils/test_time.py new file mode 100644 index 000000000..0d4b595c3 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_time.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_shared.utils.time import ( + format_clock, + format_span, + seconds_from_samples, + seconds_from_ticks, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestHowLongSomethingLasts(BaseTestSuite): + """A span names its units and reads in the largest ones it fills.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + seconds: float + expected: str + + test_cases = ( + TestCase(label="nothing at all", seconds=0.0, expected="0s"), + TestCase(label="a part of a second", seconds=0.4, expected="0s"), + TestCase(label="seconds alone", seconds=13.0, expected="13s"), + TestCase(label="the last second before a minute", seconds=59.0, expected="59s"), + TestCase(label="a whole minute", seconds=60.0, expected="1m 00s"), + TestCase(label="minutes and seconds", seconds=133.0, expected="2m 13s"), + TestCase(label="a whole hour", seconds=3600.0, expected="1h 00m 00s"), + TestCase(label="hours, minutes and seconds", seconds=3733.0, expected="1h 02m 13s"), + TestCase(label="a span read backwards", seconds=-5.0, expected="0s"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_span_it_reads_as(self, test_case: TestCase) -> None: + assert format_span(test_case.seconds) == test_case.expected + + +class TestHowFarIntoSomethingAMomentStands(BaseTestSuite): + """A position reads as a clock, at the precision the reading asks for.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + seconds: float + decimals: int + expected: str + + test_cases = ( + TestCase(label="the start", seconds=0.0, decimals=0, expected="0:00"), + TestCase(label="a few seconds in", seconds=3.0, decimals=0, expected="0:03"), + TestCase(label="minutes and seconds", seconds=133.0, decimals=0, expected="2:13"), + TestCase(label="past an hour", seconds=3733.0, decimals=0, expected="1:02:13"), + TestCase(label="tenths of a second", seconds=90.25, decimals=1, expected="1:30.2"), + TestCase(label="hundredths of a second", seconds=90.25, decimals=2, expected="1:30.25"), + TestCase(label="the start at hundredths", seconds=0.0, decimals=2, expected="0:00.00"), + TestCase(label="a second rounding into its minute", seconds=59.96, decimals=1, expected="1:00.0"), + TestCase(label="a minute rounding into its hour", seconds=3599.5, decimals=0, expected="1:00:00"), + TestCase(label="a moment read before the start", seconds=-5.0, decimals=0, expected="0:00"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_clock_it_reads_as(self, test_case: TestCase) -> None: + assert format_clock(test_case.seconds, test_case.decimals) == test_case.expected + + +class TestWhatALengthAmountsTo: + def test_samples_last_the_seconds_their_rate_gives_them(self) -> None: + assert seconds_from_samples(88200, 44100) == pytest.approx(2.0) + + def test_ticks_last_the_seconds_their_rate_gives_them(self) -> None: + assert seconds_from_ticks(90, 60) == pytest.approx(1.5) + + def test_a_rate_of_nothing_measures_nothing(self) -> None: + assert seconds_from_samples(88200, 0) == 0.0 + assert seconds_from_ticks(90, 0) == 0.0 diff --git a/tests/unit/sampletones_shared/utils/test_validation.py b/tests/unit/sampletones_shared/utils/test_validation.py index fa867de34..8267ff533 100644 --- a/tests/unit/sampletones_shared/utils/test_validation.py +++ b/tests/unit/sampletones_shared/utils/test_validation.py @@ -5,13 +5,14 @@ from typing import Any, Dict, List, Tuple import pytest -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_application.config.session.state.state import ApplicationState from sampletones_core.configs import Config from sampletones_shared.utils.validation import ( Location, + describe_failure, flatten_location, validate_with_recovery, ) @@ -185,8 +186,8 @@ class TestCase(BaseRegularTestCase): TestCase(label="single_key", location=("count",), expected="count"), TestCase( label="nested_keys", - location=("generation", "drive"), - expected="generation.drive", + location=("generation", "reset_phase"), + expected="generation.reset_phase", ), TestCase( label="list_index", @@ -209,18 +210,52 @@ def test_flatten(self, test_case: TestCase) -> None: assert flatten_location(test_case.location) == test_case.expected +class Refusing(BaseModel): + model_config = ConfigDict(extra="forbid") + + leaf: Leaf + count: int = Field(ge=1) + + @field_validator("count") + @classmethod + def _count_is_odd(cls, count: int) -> int: + if count % 2 == 0: + raise ValueError(f"The count {count} is even.") + + return count + + +class TestDescribeFailure: + def test_a_plain_value_error_reads_as_its_message(self) -> None: + assert describe_failure(ValueError("No file at song.wav.")) == "No file at song.wav." + + def test_a_raised_reason_keeps_its_own_words(self) -> None: + with pytest.raises(ValidationError) as failure: + Refusing(leaf=Leaf(), count=2) + + assert describe_failure(failure.value) == "The count 2 is even." + + def test_each_broken_constraint_is_one_line_naming_its_field(self) -> None: + with pytest.raises(ValidationError) as failure: + Refusing.model_validate({"leaf": {"value": 11}, "count": 0}) + + lines = describe_failure(failure.value).splitlines() + + assert [line.split(":")[0] for line in lines] == ["leaf.value", "count"] + + class TestRecoveryOnRealModels: def test_config_keeps_valid_settings_and_drops_incompatible(self) -> None: raw = { "library": {"sample_rate": 22050}, - "generation": {"drive": -5.0, "reset_phase": False}, + "generation": {"decoder": {"top_k": 0}, "reset_phase": False}, "obsolete_field": 123, } recovered = validate_with_recovery(Config, raw) assert recovered.model.library.sample_rate == 22050 assert recovered.model.generation.reset_phase is False - assert recovered.model.generation.drive == Config().generation.drive - assert set(recovered.dropped) == {("generation", "drive"), ("obsolete_field",)} + assert recovered.model.generation.decoder.top_k == Config().generation.decoder.top_k + assert set(recovered.dropped) == {("generation", "decoder", "top_k"), ("obsolete_field",)} def test_application_config_keeps_favorites_when_master_gain_invalid(self) -> None: raw = {"audio": {"master_gain": 5.0}, "favorites": {"paths": ["/x/y"]}} diff --git a/tests/unit/sampletones_synthesis/envelopes/test_envelopes.py b/tests/unit/sampletones_synthesis/envelopes/test_envelopes.py deleted file mode 100644 index 2984acaf7..000000000 --- a/tests/unit/sampletones_synthesis/envelopes/test_envelopes.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Final - -import numpy as np -import pytest - -from sampletones_synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope -from sampletones_synthesis.envelopes.linear_attack import LinearAttackEnvelope -from sampletones_synthesis.envelopes.linear_ramp import LinearRampEnvelope - -TIME_CONSTANT_SECONDS: Final[float] = 0.25 -ATTACK_SECONDS: Final[float] = 0.005 - - -class TestExponentialDecayEnvelope: - def test_starts_at_full_level_and_reaches_inverse_e_at_the_time_constant( - self, - time_axis: np.ndarray, - sample_rate: int, - ) -> None: - envelope = ExponentialDecayEnvelope(kind="exponential_decay", time_constant_seconds=TIME_CONSTANT_SECONDS) - values = envelope.render(time_axis) - constant_index = round(TIME_CONSTANT_SECONDS * sample_rate) - assert values[0] == pytest.approx(1.0) - assert values[constant_index] == pytest.approx(np.exp(-1.0), rel=1e-3) - - def test_decreases_monotonically(self, time_axis: np.ndarray) -> None: - envelope = ExponentialDecayEnvelope(kind="exponential_decay", time_constant_seconds=TIME_CONSTANT_SECONDS) - assert np.all(np.diff(envelope.render(time_axis)) < 0.0) - - -class TestLinearAttackEnvelope: - def test_rises_linearly_then_holds_full_level(self, time_axis: np.ndarray, sample_rate: int) -> None: - envelope = LinearAttackEnvelope(kind="linear_attack", attack_seconds=ATTACK_SECONDS) - values = envelope.render(time_axis) - hold_start = int(np.ceil(ATTACK_SECONDS * sample_rate)) - assert values[0] == pytest.approx(0.0) - assert values[hold_start // 2] == pytest.approx(0.5, abs=0.01) - assert np.all(values[hold_start:] == 1.0) - - -class TestLinearRampEnvelope: - def test_rises_from_zero_to_one_over_the_axis(self, time_axis: np.ndarray) -> None: - values = LinearRampEnvelope(kind="linear_ramp").render(time_axis) - assert values[0] == pytest.approx(0.0) - assert values[-1] == pytest.approx(1.0) - assert np.all(np.diff(values) > 0.0) diff --git a/tests/unit/sampletones_synthesis/oscillators/test_noise.py b/tests/unit/sampletones_synthesis/oscillators/test_noise.py deleted file mode 100644 index 727df82a5..000000000 --- a/tests/unit/sampletones_synthesis/oscillators/test_noise.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Final - -import numpy as np -import pytest - -from sampletones_synthesis.oscillators.walk_noise import WalkNoiseOscillator -from sampletones_synthesis.oscillators.white_noise import WhiteNoiseOscillator - -SEED: Final[int] = 99 - - -class TestWhiteNoiseOscillator: - def test_same_seed_reproduces_the_waveform(self, time_axis: np.ndarray) -> None: - oscillator = WhiteNoiseOscillator(kind="white_noise") - first = oscillator.render(time_axis, generator=np.random.default_rng(SEED)) - second = oscillator.render(time_axis, generator=np.random.default_rng(SEED)) - assert np.array_equal(first, second) - - def test_unit_standard_deviation(self, time_axis: np.ndarray, generator: np.random.Generator) -> None: - audio = WhiteNoiseOscillator(kind="white_noise").render(time_axis, generator=generator) - assert np.std(audio) == pytest.approx(1.0, abs=0.05) - - -class TestWalkNoiseOscillator: - def test_zero_mean_and_unit_peak(self, time_axis: np.ndarray, generator: np.random.Generator) -> None: - audio = WalkNoiseOscillator(kind="walk_noise").render(time_axis, generator=generator) - assert np.mean(audio) == pytest.approx(0.0, abs=1e-9) - assert np.max(np.abs(audio)) == pytest.approx(1.0) - - def test_energy_concentrates_at_low_frequencies( - self, - time_axis: np.ndarray, - generator: np.random.Generator, - ) -> None: - audio = WalkNoiseOscillator(kind="walk_noise").render(time_axis, generator=generator) - spectrum = np.abs(np.fft.rfft(audio)) ** 2 - half = spectrum.shape[0] // 2 - assert np.sum(spectrum[:half]) > 10.0 * np.sum(spectrum[half:]) diff --git a/tests/unit/sampletones_synthesis/oscillators/test_pulse.py b/tests/unit/sampletones_synthesis/oscillators/test_pulse.py deleted file mode 100644 index bb4c3c707..000000000 --- a/tests/unit/sampletones_synthesis/oscillators/test_pulse.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import Final, Tuple - -import numpy as np -import pytest - -from sampletones_synthesis.oscillators.pulse import PulseOscillator - -FREQUENCY: Final[float] = 220.0 -DUTY_CYCLES: Final[Tuple[float, ...]] = (0.125, 0.25, 0.5) - - -class TestPulseOscillator: - @pytest.mark.parametrize("duty_cycle", DUTY_CYCLES) - def test_positive_fraction_matches_the_duty_cycle( - self, - duty_cycle: float, - time_axis: np.ndarray, - generator: np.random.Generator, - ) -> None: - oscillator = PulseOscillator(kind="pulse", frequency=FREQUENCY, duty_cycle=duty_cycle) - audio = oscillator.render(time_axis, generator=generator) - assert np.mean(audio > 0.0) == pytest.approx(duty_cycle, abs=0.01) - - def test_values_alternate_between_full_levels( - self, - time_axis: np.ndarray, - generator: np.random.Generator, - ) -> None: - audio = PulseOscillator(kind="pulse", frequency=FREQUENCY, duty_cycle=0.5).render( - time_axis, - generator=generator, - ) - assert set(np.unique(audio)) == {-1.0, 1.0} diff --git a/tests/unit/sampletones_synthesis/test_unions.py b/tests/unit/sampletones_synthesis/test_unions.py deleted file mode 100644 index 1b70adaa3..000000000 --- a/tests/unit/sampletones_synthesis/test_unions.py +++ /dev/null @@ -1,130 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Dict, Final, Tuple, Type - -import pytest -from pydantic import BaseModel, TypeAdapter, ValidationError - -from sampletones_synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope -from sampletones_synthesis.envelopes.linear_attack import LinearAttackEnvelope -from sampletones_synthesis.envelopes.linear_ramp import LinearRampEnvelope -from sampletones_synthesis.envelopes.types import EnvelopeUnion -from sampletones_synthesis.oscillators.exponential_glide import ExponentialGlideOscillator -from sampletones_synthesis.oscillators.geometric_sweep import GeometricSweepOscillator -from sampletones_synthesis.oscillators.pulse import PulseOscillator -from sampletones_synthesis.oscillators.sine import SineOscillator -from sampletones_synthesis.oscillators.types import OscillatorUnion -from sampletones_synthesis.oscillators.walk_noise import WalkNoiseOscillator -from sampletones_synthesis.oscillators.white_noise import WhiteNoiseOscillator - -OSCILLATOR_ADAPTER: Final[TypeAdapter[Any]] = TypeAdapter(OscillatorUnion) -ENVELOPE_ADAPTER: Final[TypeAdapter[Any]] = TypeAdapter(EnvelopeUnion) - - -@dataclass(frozen=True) -class DiscriminationCase: - name: str - adapter: TypeAdapter[Any] - payload: Dict[str, Any] - expected_type: Type[BaseModel] - - -DISCRIMINATION_CASES: Final[Tuple[DiscriminationCase, ...]] = ( - DiscriminationCase( - name="sine", - adapter=OSCILLATOR_ADAPTER, - payload={"kind": "sine", "frequency": 440.0}, - expected_type=SineOscillator, - ), - DiscriminationCase( - name="geometric_sweep", - adapter=OSCILLATOR_ADAPTER, - payload={ - "kind": "geometric_sweep", - "frequency_start": 67, - "frequency_end": 29, - }, - expected_type=GeometricSweepOscillator, - ), - DiscriminationCase( - name="exponential_glide", - adapter=OSCILLATOR_ADAPTER, - payload={ - "kind": "exponential_glide", - "frequency_start": 150.0, - "frequency_end": 50.0, - "time_constant_seconds": 0.25, - }, - expected_type=ExponentialGlideOscillator, - ), - DiscriminationCase( - name="pulse", - adapter=OSCILLATOR_ADAPTER, - payload={"kind": "pulse", "frequency": 220.0, "duty_cycle": 0.25}, - expected_type=PulseOscillator, - ), - DiscriminationCase( - name="white_noise", - adapter=OSCILLATOR_ADAPTER, - payload={"kind": "white_noise"}, - expected_type=WhiteNoiseOscillator, - ), - DiscriminationCase( - name="walk_noise", - adapter=OSCILLATOR_ADAPTER, - payload={"kind": "walk_noise"}, - expected_type=WalkNoiseOscillator, - ), - DiscriminationCase( - name="exponential_decay", - adapter=ENVELOPE_ADAPTER, - payload={"kind": "exponential_decay", "time_constant_seconds": 0.15}, - expected_type=ExponentialDecayEnvelope, - ), - DiscriminationCase( - name="linear_attack", - adapter=ENVELOPE_ADAPTER, - payload={"kind": "linear_attack", "attack_seconds": 0.005}, - expected_type=LinearAttackEnvelope, - ), - DiscriminationCase( - name="linear_ramp", - adapter=ENVELOPE_ADAPTER, - payload={"kind": "linear_ramp"}, - expected_type=LinearRampEnvelope, - ), -) - - -class TestUnionDiscrimination: - @pytest.mark.parametrize( - "case", - DISCRIMINATION_CASES, - ids=lambda case: case.name, - ) - def test_kind_selects_the_member_class( - self, - case: DiscriminationCase, - ) -> None: - assert isinstance( - case.adapter.validate_python(case.payload), - case.expected_type, - ) - - @pytest.mark.parametrize( - "adapter", - (OSCILLATOR_ADAPTER, ENVELOPE_ADAPTER), - ids=("oscillators", "envelopes"), - ) - def test_unknown_kind_is_rejected(self, adapter: TypeAdapter[Any]) -> None: - with pytest.raises(ValidationError): - adapter.validate_python({"kind": "unknown"}) - - def test_extra_field_is_rejected(self) -> None: - with pytest.raises(ValidationError): - OSCILLATOR_ADAPTER.validate_python( - { - "kind": "sine", - "frequency": 440.0, - "volume": 1.0, - } - ) diff --git a/tests/unit/sampletones_synthesis/voice/test_voice.py b/tests/unit/sampletones_synthesis/voice/test_voice.py deleted file mode 100644 index 6323253a1..000000000 --- a/tests/unit/sampletones_synthesis/voice/test_voice.py +++ /dev/null @@ -1,119 +0,0 @@ -from typing import Any, Dict, Final - -import numpy as np -import pytest -from pydantic import ValidationError - -from sampletones_synthesis.oscillators.sine import SineOscillator -from sampletones_synthesis.voice.layer import Layer -from sampletones_synthesis.voice.voice import Voice - -DURATION_SECONDS: Final[float] = 0.5 -LOW_FREQUENCY: Final[float] = 220.0 -HIGH_FREQUENCY: Final[float] = 880.0 - -VOICE_MAPPING: Final[Dict[str, Any]] = { - "duration_seconds": DURATION_SECONDS, - "layers": [ - { - "oscillator": { - "kind": "geometric_sweep", - "frequency_start": 67, - "frequency_end": 29, - }, - "envelopes": [ - { - "kind": "exponential_decay", - "time_constant_seconds": 0.066, - } - ], - "gain": 1.0, - }, - { - "oscillator": {"kind": "white_noise"}, - "envelopes": [], - "gain": 0.15, - }, - ], - "filters": [ - { - "kind": "butterworth_highpass", - "cutoff_hz": 5000.0, - "order": 4, - } - ], -} - - -def _tone_layer(frequency: float, gain: float) -> Layer: - return Layer( - oscillator=SineOscillator(kind="sine", frequency=frequency), - envelopes=(), - gain=gain, - ) - - -class TestVoice: - def test_layers_sum( - self, - sample_rate: int, - generator: np.random.Generator, - ) -> None: - low = _tone_layer(LOW_FREQUENCY, gain=1.0) - high = _tone_layer(HIGH_FREQUENCY, gain=0.25) - voice = Voice( - duration_seconds=DURATION_SECONDS, - layers=(low, high), - filters=(), - ) - - audio = voice.render(sample_rate=sample_rate, generator=generator) - time = np.arange(round(DURATION_SECONDS * sample_rate), dtype=np.float64) / sample_rate - expected = low.render(time, generator=generator) + high.render(time, generator=generator) - assert np.allclose(audio, expected) - - def test_output_is_float64_of_the_configured_length( - self, - sample_rate: int, - generator: np.random.Generator, - ) -> None: - voice = Voice( - duration_seconds=DURATION_SECONDS, - layers=(_tone_layer(LOW_FREQUENCY, 1.0),), - filters=(), - ) - audio = voice.render(sample_rate=sample_rate, generator=generator) - assert audio.dtype == np.float64 - assert audio.shape == (round(DURATION_SECONDS * sample_rate),) - - def test_seeded_render_is_deterministic(self, sample_rate: int) -> None: - voice = Voice.model_validate(VOICE_MAPPING) - first = voice.render( - sample_rate=sample_rate, - generator=np.random.default_rng(7), - ) - second = voice.render( - sample_rate=sample_rate, - generator=np.random.default_rng(7), - ) - assert np.array_equal(first, second) - - def test_mapping_round_trip_preserves_the_voice(self) -> None: - voice = Voice.model_validate(VOICE_MAPPING) - assert Voice.model_validate(voice.model_dump()) == voice - - def test_voice_without_layers_is_rejected(self) -> None: - with pytest.raises(ValidationError): - Voice(duration_seconds=DURATION_SECONDS, layers=(), filters=()) - - def test_duration_below_two_samples_is_rejected( - self, - generator: np.random.Generator, - ) -> None: - voice = Voice( - duration_seconds=1e-6, - layers=(_tone_layer(LOW_FREQUENCY, 1.0),), - filters=(), - ) - with pytest.raises(ValueError): - voice.render(sample_rate=22050, generator=generator) diff --git a/tests/unit/sampletones_tools/calibration/__init__.py b/tests/unit/sampletones_tools/calibration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/calibration/board/__init__.py b/tests/unit/sampletones_tools/calibration/board/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/calibration/board/conftest.py b/tests/unit/sampletones_tools/calibration/board/conftest.py new file mode 100644 index 000000000..f48761162 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/conftest.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import List + +import pytest + +from tests.unit.sampletones_tools.calibration.runs import write_run + + +@pytest.fixture(name="run") +def run_fixture(tmp_path: Path) -> Path: + return write_run(tmp_path / "run-a", ["cqt-pe1", "fft-pe1"], 0.0) + + +@pytest.fixture(name="runs") +def runs_fixture(tmp_path: Path) -> List[Path]: + return [ + write_run(tmp_path / "before", ["cqt-pe1", "fft-pe1"], 0.0), + write_run(tmp_path / "after", ["cqt-pe1", "fft-pe1"], -1.0), + ] diff --git a/tests/unit/sampletones_tools/calibration/board/test_browser.py b/tests/unit/sampletones_tools/calibration/board/test_browser.py new file mode 100644 index 000000000..ae45fd493 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_browser.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones_tools.calibration.board.browser import open_page + +WEBBROWSER = "sampletones_tools.calibration.board.browser.webbrowser.open" + + +class TestOpenPage: + def test_the_page_is_handed_over_as_a_file_link( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + asked: List[str] = [] + monkeypatch.setattr(WEBBROWSER, lambda link: asked.append(link) or True) + page = tmp_path / "index.html" + page.write_text("", encoding="utf-8") + + assert open_page(page) + assert asked == [page.resolve().as_uri()] + + def test_a_system_with_no_browser_leaves_the_link_alone( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(WEBBROWSER, lambda link: False) + page = tmp_path / "index.html" + page.write_text("", encoding="utf-8") + + assert not open_page(page) diff --git a/tests/unit/sampletones_tools/calibration/board/test_clips.py b/tests/unit/sampletones_tools/calibration/board/test_clips.py new file mode 100644 index 000000000..5c6ebbeaf --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_clips.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import List + +from sampletones_tools.calibration.board.clips import GatheredClips, InPlaceClips, clip_store +from sampletones_tools.calibration.board.layout import AUDIO_DIRECTORY, PAGE_DIRECTORY +from sampletones_tools.calibration.board.reading import read_run, read_runs +from sampletones_tools.calibration.layout import RECORDINGS_DIRECTORY + +CLIP = "tone-a.flac" + + +class TestClipStoreChoice: + def test_a_page_in_its_own_run_plays_the_clips_where_they_lie(self, run: Path) -> None: + assert isinstance(clip_store(read_runs([run]), run), InPlaceClips) + + def test_a_page_written_elsewhere_carries_copies(self, run: Path, tmp_path: Path) -> None: + assert isinstance(clip_store(read_runs([run]), tmp_path / "page"), GatheredClips) + + def test_a_page_over_several_runs_carries_copies(self, runs: List[Path], tmp_path: Path) -> None: + assert isinstance(clip_store(read_runs(runs), runs[0]), GatheredClips) + + +class TestInPlaceClips: + def test_a_clip_is_named_relative_to_the_run(self, run: Path) -> None: + reading = read_run(run, "run-a") + source = run / RECORDINGS_DIRECTORY / CLIP + + assert InPlaceClips().href(reading, source) == f"{RECORDINGS_DIRECTORY}/{CLIP}" + + +class TestGatheredClips: + def test_a_clip_is_copied_under_the_page_and_named_relative_to_it(self, run: Path, tmp_path: Path) -> None: + reading = read_run(run, "run-a") + output = tmp_path / "page" + output.mkdir() + source = run / RECORDINGS_DIRECTORY / CLIP + + href = GatheredClips(output=output).href(reading, source) + + assert href == f"{PAGE_DIRECTORY}/{AUDIO_DIRECTORY}/run-a/{RECORDINGS_DIRECTORY}/{CLIP}" + assert (output / href).read_bytes() == source.read_bytes() diff --git a/tests/unit/sampletones_tools/calibration/board/test_composition.py b/tests/unit/sampletones_tools/calibration/board/test_composition.py new file mode 100644 index 000000000..497a9d930 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_composition.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.calibration.board.clips import InPlaceClips, clip_store +from sampletones_tools.calibration.board.composition import NO_CLIP, compose_page +from sampletones_tools.calibration.board.model import BoardPage +from sampletones_tools.calibration.board.reading import read_runs +from sampletones_tools.calibration.layout import combination_name + +TITLE: Final[str] = "Calibration" + + +def page_of(directories: List[Path], output: Path) -> BoardPage: + runs = read_runs(directories) + return compose_page(runs, clip_store(runs, output), TITLE) + + +class TestOneRun: + def test_the_variants_become_the_columns_of_one_table(self, run: Path) -> None: + page = page_of([run], run) + + assert [group.name for group in page.groups] == ["run-a"] + assert page.groups[0].columns == ("cqt-pe1", "fft-pe1") + + def test_every_sound_reads_across_the_whole_table(self, run: Path) -> None: + group = page_of([run], run).groups[0] + + assert [row.item for row in group.rows] == ["tone-a", "mix-a", "chord-a"] + assert all(len(row.cells) == len(group.columns) for row in group.rows) + + def test_a_cell_names_the_render_the_run_wrote(self, run: Path) -> None: + cell = page_of([run], run).groups[0].rows[0].cells[0] + + assert cell.render == "renders/cqt-pe1/tone-a.flac" + assert (run / cell.render).is_file() + + def test_the_referee_and_the_channel_colors_travel_with_the_page(self, run: Path) -> None: + page = page_of([run], run) + + assert page.referee == "mr-auditory-dB" + assert page.colors == {channel.value: f"channel_{channel.value}" for channel in ChannelName} + + +class TestChannels: + def test_a_lone_channel_offers_nothing_to_switch(self, run: Path) -> None: + cell = page_of([run], run).groups[0].rows[0].cells[0] + + assert [channel.name for channel in cell.channels] == ["triangle"] + assert cell.channels[0].solo == NO_CLIP + assert cell.channels[0].mute == NO_CLIP + + def test_a_channel_plays_alone_and_stands_aside(self, run: Path) -> None: + cell = page_of([run], run).groups[0].rows[2].cells[0] + noise = next(channel for channel in cell.channels if channel.name == ChannelName.NOISE.value) + + assert noise.solo.endswith(f"{combination_name([ChannelName.NOISE])}.flac") + assert noise.mute.endswith(f"{combination_name([ChannelName.PULSE1, ChannelName.TRIANGLE])}.flac") + assert (run / noise.solo).is_file() + assert (run / noise.mute).is_file() + + def test_the_channels_follow_the_order_the_hardware_holds_them(self, run: Path) -> None: + cell = page_of([run], run).groups[0].rows[2].cells[0] + + assert [channel.name for channel in cell.channels] == ["pulse1", "triangle", "noise"] + + +class TestSeveralRuns: + def test_each_variant_becomes_a_table_of_its_own(self, runs: List[Path], tmp_path: Path) -> None: + page = page_of(runs, tmp_path / "page") + + assert [group.name for group in page.groups] == ["cqt-pe1", "fft-pe1"] + assert all(group.columns == ("before", "after") for group in page.groups) + + def test_a_row_holds_one_cell_per_run(self, runs: List[Path], tmp_path: Path) -> None: + group = page_of(runs, tmp_path / "page").groups[0] + + assert [cell.score for cell in group.rows[0].cells] == [10.0, 9.0] + + +class TestNoRun: + def test_a_page_without_a_run_is_refused(self) -> None: + with pytest.raises(ValueError, match="at least one run"): + compose_page([], InPlaceClips(), TITLE) diff --git a/tests/unit/sampletones_tools/calibration/board/test_data.py b/tests/unit/sampletones_tools/calibration/board/test_data.py new file mode 100644 index 000000000..21cefddbc --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_data.py @@ -0,0 +1,29 @@ +import json +from typing import Tuple + +from sampletones_tools.calibration.board.data import DATA_GLOBAL, data_script +from sampletones_tools.calibration.board.model import BoardCell, BoardChannel, BoardGroup, BoardPage, BoardRow + +PREFIX: Tuple[str, str] = (f"const {DATA_GLOBAL} = ", ";") + + +def page() -> BoardPage: + channel = BoardChannel(name="noise", timeline="1100", solo="a.flac", mute="b.flac") + cell = BoardCell(render="renders/cqt-pe1/tone-a.flac", score=9.5, silence=20.0, channels=(channel,)) + row = BoardRow(item="tone-a", category="tone", recording="recordings/tone-a.flac", cells=(cell,)) + group = BoardGroup(name="run-a", columns=("cqt-pe1",), rows=(row,)) + return BoardPage(title="Calibration", referee="mr-auditory-dB", colors={"noise": "channel_noise"}, groups=(group,)) + + +class TestDataScript: + def test_the_page_travels_as_the_one_global_the_script_reads(self) -> None: + script = data_script(page()) + + assert script.startswith(PREFIX[0]) + assert script.rstrip().endswith(PREFIX[1]) + + def test_everything_the_page_draws_reads_back_unchanged(self) -> None: + script = data_script(page()) + + body = script.strip()[len(PREFIX[0]) : -1] + assert BoardPage.model_validate(json.loads(body)) == page() diff --git a/tests/unit/sampletones_tools/calibration/board/test_fonts.py b/tests/unit/sampletones_tools/calibration/board/test_fonts.py new file mode 100644 index 000000000..3044906db --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_fonts.py @@ -0,0 +1,37 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_tools.calibration.board.fonts import ( + FONT_MEDIA_TYPE, + PAGE_FONTS, + PageFont, + fonts_stylesheet, +) +from sampletones_tools.calibration.board.paths import FONTS_DIRECTORY + +FACE_RULE: Final[str] = "@font-face" + + +class TestFontsStylesheet: + def test_every_face_carries_its_own_data(self) -> None: + stylesheet = fonts_stylesheet() + + assert stylesheet.count(FACE_RULE) == len(PAGE_FONTS) + assert stylesheet.count(f"data:{FONT_MEDIA_TYPE};base64,") == len(PAGE_FONTS) + + @pytest.mark.parametrize("font", PAGE_FONTS, ids=lambda font: font.filename) + def test_a_face_names_the_family_and_weight_it_answers_at(self, font: PageFont) -> None: + stylesheet = fonts_stylesheet() + + assert f'font-family: "{font.family}";' in stylesheet + assert f"font-weight: {font.weight};" in stylesheet + + @pytest.mark.parametrize("font", PAGE_FONTS, ids=lambda font: font.filename) + def test_the_assets_hold_the_face_it_reads(self, font: PageFont) -> None: + assert (FONTS_DIRECTORY / font.filename).is_file() + + def test_a_missing_face_is_reported(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + fonts_stylesheet(tmp_path) diff --git a/tests/unit/sampletones_tools/calibration/board/test_palette.py b/tests/unit/sampletones_tools/calibration/board/test_palette.py new file mode 100644 index 000000000..ecc74de25 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_palette.py @@ -0,0 +1,46 @@ +import re +from typing import Dict, Final + +import pytest + +from sampletones_tools.calibration.board.palette import ( + CHANNEL_TOKEN_PREFIX, + board_palette, + channel_token, + css_color, + palette_names, + palette_stylesheet, +) + +DECLARATION: Final[re.Pattern[str]] = re.compile(r"--([a-z0-9_]+): (#[0-9a-f]{6}(?:[0-9a-f]{2})?);") + + +def declared(stylesheet: str) -> Dict[str, str]: + return dict(DECLARATION.findall(stylesheet)) + + +class TestPaletteStylesheet: + @pytest.mark.parametrize("name", palette_names()) + def test_every_token_reaches_the_page_under_its_own_name(self, name: str) -> None: + palette = board_palette(name) + + written = declared(palette_stylesheet(palette)) + + assert written == {token: css_color(color) for token, color in palette.colors.items()} + + def test_an_unknown_palette_is_refused(self) -> None: + with pytest.raises(KeyError): + board_palette("sunset") + + +class TestCssColor: + def test_an_opaque_color_is_six_digits(self) -> None: + assert css_color((26, 27, 38, 255)) == "#1a1b26" + + def test_a_translucent_color_carries_its_alpha(self) -> None: + assert css_color((26, 27, 38, 170)) == "#1a1b26aa" + + +class TestChannelToken: + def test_a_channel_stands_under_the_prefix_the_palette_uses(self) -> None: + assert channel_token("noise") == f"{CHANNEL_TOKEN_PREFIX}noise" diff --git a/tests/unit/sampletones_tools/calibration/board/test_reading.py b/tests/unit/sampletones_tools/calibration/board/test_reading.py new file mode 100644 index 000000000..4b740cb2c --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_reading.py @@ -0,0 +1,46 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones_tools.calibration.board.reading import read_run, read_runs +from tests.unit.sampletones_tools.calibration.runs import REFEREE, SILENCE_SCORE, write_run + + +class TestReadRun: + def test_a_run_reads_back_every_render_it_wrote(self, run: Path) -> None: + reading = read_run(run, "run-a") + + assert reading.label == "run-a" + assert reading.directory == run + assert reading.variants == ("cqt-pe1", "fft-pe1") + assert len(reading.records) == 6 + + def test_the_records_follow_the_order_the_corpus_generates(self, run: Path) -> None: + reading = read_run(run, "run-a") + + assert [record.position for record in reading.records] == [0, 0, 1, 1, 2, 2] + + def test_the_headline_referee_is_the_one_the_report_leads_with(self, run: Path) -> None: + reading = read_run(run, "run-a") + + assert reading.referee == REFEREE + assert reading.silence(reading.records[0]) == SILENCE_SCORE + assert reading.score(reading.records[0]) == 10.0 + + def test_a_directory_without_renders_is_reported(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="holds no calibration renders"): + read_run(tmp_path, "empty") + + +class TestReadRuns: + def test_each_run_is_called_by_its_directory(self, runs: List[Path]) -> None: + assert [reading.label for reading in read_runs(runs)] == ["before", "after"] + + def test_a_name_two_runs_share_is_numbered(self, tmp_path: Path) -> None: + directories = [ + write_run(tmp_path / "left" / "run", ["cqt-pe1"], 0.0), + write_run(tmp_path / "right" / "run", ["cqt-pe1"], 0.0), + ] + + assert [reading.label for reading in read_runs(directories)] == ["run", "run (2)"] diff --git a/tests/unit/sampletones_tools/calibration/board/test_session.py b/tests/unit/sampletones_tools/calibration/board/test_session.py new file mode 100644 index 000000000..781d00e01 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_session.py @@ -0,0 +1,86 @@ +import re +from pathlib import Path, PurePosixPath +from typing import Final, List, Set + +import pytest + +from sampletones_tools.calibration.board.layout import ( + DATA_FILE, + FONTS_FILE, + GENERATED_FILES, + PAGE_DIRECTORY, + PAGE_FILE, + PALETTE_FILE, + SHIPPED_FILES, +) +from sampletones_tools.calibration.board.palette import board_palette, palette_names, palette_stylesheet +from sampletones_tools.calibration.board.paths import STATIC_DIRECTORY +from sampletones_tools.calibration.board.session import BoardRequest, build_board + +MARKUP_REFERENCE: Final[re.Pattern[str]] = re.compile(r'(?:src|href)="([^"]+)"') +CLIP_REFERENCE: Final[re.Pattern[str]] = re.compile(r'"((?:[\w.+-]+/)+[\w.+-]+\.flac)"') +STYLE_REFERENCE: Final[re.Pattern[str]] = re.compile(r'url\("(?!data:)([^"]+)"\)') + + +def referenced(page: Path) -> Set[str]: + """Every file the written page asks for, by the reference the page spells.""" + references = set(MARKUP_REFERENCE.findall(page.read_text(encoding="utf-8"))) + directory = page.parent / PAGE_DIRECTORY + data = (directory / DATA_FILE).read_text(encoding="utf-8") + references.update(CLIP_REFERENCE.findall(data)) + for name in (PALETTE_FILE, FONTS_FILE): + references.update( + f"{PAGE_DIRECTORY}/{reference}" + for reference in STYLE_REFERENCE.findall((directory / name).read_text(encoding="utf-8")) + ) + + return references + + +class TestBuildBoard: + def test_a_run_gains_one_page_and_the_files_it_reads(self, run: Path) -> None: + page = build_board(BoardRequest(runs=[run], output=run)) + + assert page == run / PAGE_FILE + assert page.is_file() + for name in (*SHIPPED_FILES, *GENERATED_FILES): + if name != PAGE_FILE: + assert (run / PAGE_DIRECTORY / name).is_file() + + def test_every_reference_of_a_page_in_its_run_resolves(self, run: Path) -> None: + page = build_board(BoardRequest(runs=[run], output=run)) + + missing = [reference for reference in referenced(page) if not (run / PurePosixPath(reference)).is_file()] + assert missing == [] + + def test_every_reference_of_a_page_comparing_runs_resolves(self, runs: List[Path], tmp_path: Path) -> None: + output = tmp_path / "comparison" + + page = build_board(BoardRequest(runs=runs, output=output)) + + references = referenced(page) + assert any(reference.endswith(".flac") for reference in references) + missing = [reference for reference in references if not (output / PurePosixPath(reference)).is_file()] + assert missing == [] + + def test_the_shipped_files_travel_unchanged(self, run: Path) -> None: + build_board(BoardRequest(runs=[run], output=run)) + + for name in SHIPPED_FILES: + written = run / name if name == PAGE_FILE else run / PAGE_DIRECTORY / name + assert written.read_bytes() == (STATIC_DIRECTORY / name).read_bytes() + + @pytest.mark.parametrize("name", palette_names()) + def test_a_page_is_drawn_in_the_palette_it_is_asked_for(self, run: Path, name: str) -> None: + build_board(BoardRequest(runs=[run], output=run, palette=name)) + + stylesheet = (run / PAGE_DIRECTORY / PALETTE_FILE).read_text(encoding="utf-8") + assert stylesheet == palette_stylesheet(board_palette(name)) + + def test_an_unknown_palette_is_refused(self, run: Path) -> None: + with pytest.raises(KeyError): + build_board(BoardRequest(runs=[run], output=run, palette="sunset")) + + def test_a_page_without_a_run_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + BoardRequest(runs=[], output=tmp_path) diff --git a/tests/unit/sampletones_tools/calibration/board/test_static.py b/tests/unit/sampletones_tools/calibration/board/test_static.py new file mode 100644 index 000000000..e57d35777 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/board/test_static.py @@ -0,0 +1,89 @@ +import re +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.calibration.board.layout import ( + DATA_FILE, + FONTS_FILE, + PAGE_DIRECTORY, + PAGE_FILE, + PALETTE_FILE, + SCRIPT_FILE, + SHIPPED_FILES, + STYLE_FILE, +) +from sampletones_tools.calibration.board.palette import board_palette, palette_names +from sampletones_tools.calibration.board.paths import STATIC_DIRECTORY + +REACHING_OUT: Final[Tuple[str, ...]] = ( + "fetch(", + "XMLHttpRequest", + "WebSocket", + "EventSource", + "sendBeacon", + "http://", + "https://", + 'src="//', + 'href="//', +) +REFERENCE: Final[re.Pattern[str]] = re.compile(r'(?:src|href)="([^"]+)"') +COLOR_LITERAL: Final[re.Pattern[str]] = re.compile(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(") +TOKEN: Final[re.Pattern[str]] = re.compile(r"var\(--([a-z0-9_]+)\)") +REFEREE_NAMES: Final[Tuple[str, ...]] = ("mr-auditory", "mr-loudness", "zimtohrli") + + +def shipped(name: str) -> str: + return (STATIC_DIRECTORY / name).read_text(encoding="utf-8") + + +class TestShippedFilesStayOffline: + @pytest.mark.parametrize("name", SHIPPED_FILES) + def test_a_shipped_file_reaches_nothing_beyond_the_page(self, name: str) -> None: + content = shipped(name) + + assert [reaching for reaching in REACHING_OUT if reaching in content] == [] + + @pytest.mark.parametrize("name", SHIPPED_FILES) + def test_a_shipped_file_is_present(self, name: str) -> None: + assert (STATIC_DIRECTORY / name).is_file() + + +class TestPageMarkup: + def test_every_reference_is_relative(self) -> None: + references = REFERENCE.findall(shipped(PAGE_FILE)) + + assert references + assert [reference for reference in references if Path(reference).is_absolute()] == [] + + def test_the_page_names_exactly_the_files_the_layout_declares(self) -> None: + expected = { + f"{PAGE_DIRECTORY}/{name}" for name in (PALETTE_FILE, FONTS_FILE, STYLE_FILE, DATA_FILE, SCRIPT_FILE) + } + + assert set(REFERENCE.findall(shipped(PAGE_FILE))) == expected + + +class TestStylesheetDrawsFromThePalette: + def test_no_color_is_written_into_the_stylesheet(self) -> None: + assert COLOR_LITERAL.findall(shipped(STYLE_FILE)) == [] + + @pytest.mark.parametrize("name", palette_names()) + def test_every_token_it_names_exists_in_every_palette(self, name: str) -> None: + colors = board_palette(name).colors + named = set(TOKEN.findall(shipped(STYLE_FILE))) + + assert named + assert [token for token in sorted(named) if token not in colors] == [] + + +class TestScriptStatesNoMeasurement: + @pytest.mark.parametrize("channel", list(ChannelName)) + def test_the_script_spells_no_channel(self, channel: ChannelName) -> None: + assert channel.value not in shipped(SCRIPT_FILE) + + @pytest.mark.parametrize("referee", REFEREE_NAMES) + def test_the_script_spells_no_referee(self, referee: str) -> None: + assert referee not in shipped(SCRIPT_FILE) diff --git a/tests/unit/sampletones_tools/calibration/config/__init__.py b/tests/unit/sampletones_tools/calibration/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/calibration/config/test_corpus.py b/tests/unit/sampletones_tools/calibration/config/test_corpus.py new file mode 100644 index 000000000..bfb41602e --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/config/test_corpus.py @@ -0,0 +1,170 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final + +import pytest +from pydantic import ValidationError + +from sampletones_tools.calibration.config.corpus import CorpusConfig +from tests.suite.case import BaseRegularTestCase + +VALID_TRANSIENT: Final[Dict[str, Any]] = { + "snare_decay_seconds": 0.15, + "kick_decay_seconds": 0.25, + "kick_sweep_frequencies": (150.0, 50.0), + "attack_seconds": 0.005, + "attack_tone_decay_seconds": 0.3, +} + +VALID_POLYPHONY: Final[Dict[str, Any]] = { + "chord_frequencies": (220.0, 277.18), + "melody_frequencies": (440.0, 523.25), + "note_seconds": 0.25, + "note_decay_seconds": 0.4, + "snare_period_seconds": 0.5, + "snare_delay_seconds": 0.25, + "snare_decay_seconds": 0.04, + "snare_level": 0.35, +} + +VALID_MIX: Final[Dict[str, Any]] = { + "noise_levels": (0.05, 0.15), + "bass_frequency": 55.0, + "hat_period_seconds": 0.25, + "hat_decay_seconds": 0.008, + "hat_level": 0.4, +} + +VALID_DYNAMICS: Final[Dict[str, Any]] = { + "burst_seconds": 0.2, + "hiss_level": 0.09, +} + +VALID_FIELDS: Final[Dict[str, Any]] = { + "seed": 1, + "item_seconds": 1.5, + "amplitude": 0.5, + "reference_frequency": 440.0, + "tone": {"frequencies": (55.0, 440.0)}, + "timbre": {"duty_cycles": (0.125, 0.5), "frequency": 220.0}, + "noise": {"white_level": 0.5}, + "mix": VALID_MIX, + "transient": VALID_TRANSIENT, + "polyphony": VALID_POLYPHONY, + "dynamics": VALID_DYNAMICS, +} + + +class TestCorpusConfig: + @dataclass(frozen=True, kw_only=True) + class InvalidFieldCase(BaseRegularTestCase): + field: str + value: Any + + test_cases = ( + InvalidFieldCase( + field="seed", + value=-1, + label="negative_seed", + ), + InvalidFieldCase( + field="item_seconds", + value=0.0, + label="zero_item_seconds", + ), + InvalidFieldCase( + field="amplitude", + value=0.0, + label="zero_amplitude", + ), + InvalidFieldCase( + field="amplitude", + value=1.5, + label="amplitude_above_full_scale", + ), + InvalidFieldCase( + field="reference_frequency", + value=0.0, + label="zero_reference_frequency", + ), + InvalidFieldCase( + field="tone", + value={"frequencies": ()}, + label="empty_tone_frequencies", + ), + InvalidFieldCase( + field="tone", + value={"frequencies": (440.0, 0.0)}, + label="nonpositive_tone_frequency", + ), + InvalidFieldCase( + field="timbre", + value={"duty_cycles": (), "frequency": 220.0}, + label="empty_duty_cycles", + ), + InvalidFieldCase( + field="timbre", + value={"duty_cycles": (1.0,), "frequency": 220.0}, + label="duty_cycle_at_full_width", + ), + InvalidFieldCase( + field="timbre", + value={"duty_cycles": (0.5,), "frequency": 0.0}, + label="zero_timbre_frequency", + ), + InvalidFieldCase( + field="noise", + value={"white_level": 0.0}, + label="zero_white_noise_level", + ), + InvalidFieldCase( + field="mix", + value={**VALID_MIX, "noise_levels": ()}, + label="empty_mix_noise_levels", + ), + InvalidFieldCase( + field="transient", + value={**VALID_TRANSIENT, "snare_decay_seconds": 0.0}, + label="zero_snare_decay", + ), + InvalidFieldCase( + field="transient", + value={**VALID_TRANSIENT, "attack_seconds": 0.0}, + label="zero_attack", + ), + InvalidFieldCase( + field="polyphony", + value={**VALID_POLYPHONY, "chord_frequencies": (220.0,)}, + label="single_note_chord", + ), + InvalidFieldCase( + field="polyphony", + value={**VALID_POLYPHONY, "note_seconds": 0.0}, + label="zero_note_seconds", + ), + InvalidFieldCase( + field="dynamics", + value={**VALID_DYNAMICS, "hiss_level": 1.0}, + label="hiss_as_loud_as_the_burst", + ), + InvalidFieldCase( + field="mix", + value={**VALID_MIX, "hat_period_seconds": 0.0}, + label="zero_hat_period", + ), + ) + + def test_packaged_configuration_loads(self) -> None: + config = CorpusConfig.load() + assert isinstance(config, CorpusConfig) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_out_of_bounds_field_is_rejected(self, case: InvalidFieldCase) -> None: + fields = {**VALID_FIELDS, case.field: case.value} + with pytest.raises(ValidationError): + CorpusConfig.model_validate(fields) + + @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) + def test_missing_field_is_rejected(self, field: str) -> None: + fields = {key: value for key, value in VALID_FIELDS.items() if key != field} + with pytest.raises(ValidationError): + CorpusConfig.model_validate(fields) diff --git a/tests/unit/sampletones_core/calibration/config/test_referee.py b/tests/unit/sampletones_tools/calibration/config/test_referee.py similarity index 78% rename from tests/unit/sampletones_core/calibration/config/test_referee.py rename to tests/unit/sampletones_tools/calibration/config/test_referee.py index 21120e5ad..51f941984 100644 --- a/tests/unit/sampletones_core/calibration/config/test_referee.py +++ b/tests/unit/sampletones_tools/calibration/config/test_referee.py @@ -4,7 +4,7 @@ import pytest from pydantic import ValidationError -from sampletones_core.calibration.config.referee import RefereeConfig +from sampletones_tools.calibration.config.referee import RefereeConfig from tests.suite.case import BaseRegularTestCase VALID_FIELDS: Final[Dict[str, Any]] = { @@ -14,6 +14,9 @@ "low_frequency": 30.0, "energy_floor": 1e-10, "audibility_range_decibels": 60.0, + "compression_exponent": 0.3, + "dynamic_range_decibels": 60.0, + "level_matching": True, } @@ -59,6 +62,21 @@ class InvalidFieldCase(BaseRegularTestCase): value=0.0, label="zero_audibility_range", ), + InvalidFieldCase( + field="compression_exponent", + value=0.0, + label="zero_compression_exponent", + ), + InvalidFieldCase( + field="compression_exponent", + value=1.5, + label="expanding_compression_exponent", + ), + InvalidFieldCase( + field="dynamic_range_decibels", + value=0.0, + label="zero_dynamic_range", + ), ) def test_packaged_configuration_loads(self) -> None: diff --git a/tests/unit/sampletones_tools/calibration/config/test_suite.py b/tests/unit/sampletones_tools/calibration/config/test_suite.py new file mode 100644 index 000000000..7f81c75d2 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/config/test_suite.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final + +import pytest +from pydantic import ValidationError + +from sampletones_tools.calibration.config.suite import SuiteConfig +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +VALID_FIELDS: Final[Dict[str, Any]] = { + "methods": ("fft", "cqt"), + "perceptual_exponents": (1.0,), + "temporal_weights": (), + "channels": ("pulse1", "triangle"), +} + + +class TestSuiteConfig(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + field: str + value: Any + + test_cases = ( + TestCase(field="methods", value=(), label="no_methods"), + TestCase(field="methods", value=("dct",), label="unknown_method"), + TestCase(field="perceptual_exponents", value=(), label="no_exponents"), + TestCase(field="perceptual_exponents", value=(-0.5,), label="negative_exponent"), + TestCase(field="temporal_weights", value=(1.5,), label="temporal_weight_above_one"), + TestCase(field="channels", value=(), label="no_channels"), + TestCase(field="channels", value=("dpcm",), label="unknown_channel"), + ) + + def test_packaged_suite_loads(self) -> None: + assert isinstance(SuiteConfig.load(), SuiteConfig) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_out_of_bounds_field_is_rejected(self, test_case: TestCase) -> None: + with pytest.raises(ValidationError): + SuiteConfig.model_validate({**VALID_FIELDS, test_case.field: test_case.value}) + + @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) + def test_missing_field_is_rejected(self, field: str) -> None: + with pytest.raises(ValidationError): + SuiteConfig.model_validate({key: value for key, value in VALID_FIELDS.items() if key != field}) diff --git a/tests/unit/sampletones_tools/calibration/corpus/__init__.py b/tests/unit/sampletones_tools/calibration/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/calibration/corpus/conftest.py b/tests/unit/sampletones_tools/calibration/corpus/conftest.py new file mode 100644 index 000000000..6a89d2862 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/corpus/conftest.py @@ -0,0 +1,24 @@ +from typing import Final, List + +import pytest + +from sampletones_tools.calibration.config.corpus import CorpusConfig +from sampletones_tools.calibration.corpus.item import CorpusItem +from sampletones_tools.calibration.corpus.synthesis import build_corpus + +SAMPLE_RATE: Final[int] = 22050 + + +@pytest.fixture(scope="session") +def sample_rate() -> int: + return SAMPLE_RATE + + +@pytest.fixture(scope="session") +def corpus_config() -> CorpusConfig: + return CorpusConfig.load() + + +@pytest.fixture(scope="session") +def corpus_items(corpus_config: CorpusConfig, sample_rate: int) -> List[CorpusItem]: + return build_corpus(sample_rate, config=corpus_config) diff --git a/tests/unit/sampletones_core/calibration/corpus/test_synthesis.py b/tests/unit/sampletones_tools/calibration/corpus/test_synthesis.py similarity index 79% rename from tests/unit/sampletones_core/calibration/corpus/test_synthesis.py rename to tests/unit/sampletones_tools/calibration/corpus/test_synthesis.py index 38350b1cc..892ffb627 100644 --- a/tests/unit/sampletones_core/calibration/corpus/test_synthesis.py +++ b/tests/unit/sampletones_tools/calibration/corpus/test_synthesis.py @@ -2,11 +2,13 @@ import numpy as np -from sampletones_core.calibration.config.corpus import CorpusConfig -from sampletones_core.calibration.corpus.item import CorpusItem -from sampletones_core.calibration.corpus.synthesis import build_corpus +from sampletones_tools.calibration.config.corpus import CorpusConfig +from sampletones_tools.calibration.corpus.item import CorpusItem +from sampletones_tools.calibration.corpus.synthesis import build_corpus -EXPECTED_CATEGORIES: Final[FrozenSet[str]] = frozenset({"tone", "timbre", "noise", "mix", "transient", "dynamics"}) +EXPECTED_CATEGORIES: Final[FrozenSet[str]] = frozenset( + {"tone", "timbre", "noise", "mix", "transient", "dynamics", "polyphony"} +) class TestBuildCorpus: diff --git a/tests/unit/sampletones_tools/calibration/corpus/test_writer.py b/tests/unit/sampletones_tools/calibration/corpus/test_writer.py new file mode 100644 index 000000000..9d1873d65 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/corpus/test_writer.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import List + +from sampletones_tools.calibration.corpus.item import CorpusItem +from sampletones_tools.calibration.corpus.writer import write_corpus + + +class TestWriteCorpus: + def test_writes_one_wav_per_item( + self, + corpus_items: List[CorpusItem], + tmp_path: Path, + sample_rate: int, + ) -> None: + paths = write_corpus(corpus_items, tmp_path / "corpus", sample_rate) + assert set(paths) == {item.name for item in corpus_items} + for path in paths.values(): + assert path.exists() + assert path.suffix == ".wav" diff --git a/tests/unit/sampletones_tools/calibration/referee/__init__.py b/tests/unit/sampletones_tools/calibration/referee/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/calibration/referee/conftest.py b/tests/unit/sampletones_tools/calibration/referee/conftest.py new file mode 100644 index 000000000..f87d59e53 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/conftest.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Final + +import numpy as np +import pytest +from scipy.signal import sawtooth + +from sampletones_tools.calibration.config.referee import RefereeConfig + +SAMPLE_RATE: Final[int] = 22050 +SIGNAL_SECONDS: Final[float] = 1.0 +TONE_FREQUENCY: Final[float] = 220.0 +TONE_AMPLITUDE: Final[float] = 0.3 +SEMITONE_RATIO: Final[float] = 2.0 ** (1.0 / 12.0) +TRIANGLE_WIDTH: Final[float] = 0.5 +PLUCK_ATTACK_SECONDS: Final[float] = 0.005 +PLUCK_DECAY_SECONDS: Final[float] = 0.3 +ROLL_SECONDS: Final[float] = 0.005 +NOISE_SEED: Final[int] = 5 + + +def decibels_to_gain(decibels: float) -> float: + return float(10.0 ** (decibels / 20.0)) + + +def root_mean_square(audio: np.ndarray) -> float: + return float(np.sqrt(np.mean(np.square(audio)))) + + +@dataclass(frozen=True) +class ProbeSignals: + """Probe signals around a steady tone and a plucked one, sharing one time axis. + + Attributes: + sine: The reference tone. + noise: White noise at the tone's RMS, scaled by a gain to reach a level under it. + pluck: The reference tone with a short attack and an exponential decay. + """ + + time: np.ndarray + sine: np.ndarray + noise: np.ndarray + pluck: np.ndarray + + @property + def silence(self) -> np.ndarray: + return np.zeros_like(self.sine) + + def tone(self, frequency: float) -> np.ndarray: + return TONE_AMPLITUDE * np.sin(2.0 * np.pi * frequency * self.time) + + def triangle(self) -> np.ndarray: + wave = sawtooth(2.0 * np.pi * TONE_FREQUENCY * self.time, TRIANGLE_WIDTH) + return wave * root_mean_square(self.sine) / root_mean_square(wave) + + def pulse(self) -> np.ndarray: + return TONE_AMPLITUDE * np.sign(np.sin(2.0 * np.pi * TONE_FREQUENCY * self.time)) + + def hissing(self, signal: np.ndarray, decibels: float) -> np.ndarray: + """The signal with white noise ``decibels`` under the steady tone's RMS.""" + return signal + self.noise * decibels_to_gain(decibels) + + def rolled_pluck(self) -> np.ndarray: + return np.roll(self.pluck, round(ROLL_SECONDS * SAMPLE_RATE)) + + +@pytest.fixture(scope="session") +def referee_config() -> RefereeConfig: + return RefereeConfig.load() + + +@pytest.fixture(scope="session") +def probes() -> ProbeSignals: + time = np.arange(round(SIGNAL_SECONDS * SAMPLE_RATE)) / SAMPLE_RATE + sine = TONE_AMPLITUDE * np.sin(2.0 * np.pi * TONE_FREQUENCY * time) + noise = np.random.default_rng(NOISE_SEED).standard_normal(time.shape[0]) + envelope = np.minimum(time / PLUCK_ATTACK_SECONDS, 1.0) * np.exp(-time / PLUCK_DECAY_SECONDS) + return ProbeSignals( + time=time, + sine=sine, + noise=noise * root_mean_square(sine) / root_mean_square(noise), + pluck=sine * envelope, + ) diff --git a/tests/unit/sampletones_core/calibration/referee/test_auditory.py b/tests/unit/sampletones_tools/calibration/referee/test_auditory.py similarity index 78% rename from tests/unit/sampletones_core/calibration/referee/test_auditory.py rename to tests/unit/sampletones_tools/calibration/referee/test_auditory.py index 7581f7804..d6c912f87 100644 --- a/tests/unit/sampletones_core/calibration/referee/test_auditory.py +++ b/tests/unit/sampletones_tools/calibration/referee/test_auditory.py @@ -3,8 +3,8 @@ import numpy as np import pytest -from sampletones_core.calibration.config.referee import RefereeConfig -from sampletones_core.calibration.referee.auditory import MultiResolutionAuditoryReferee +from sampletones_tools.calibration.config.referee import RefereeConfig +from sampletones_tools.calibration.referee.auditory import MultiResolutionAuditoryReferee SAMPLE_RATE: Final[int] = 22050 SIGNAL_SECONDS: Final[float] = 1.0 @@ -24,15 +24,15 @@ def referee() -> MultiResolutionAuditoryReferee: class TestMultiResolutionAuditoryReferee: def test_identical_signals_score_zero(self, referee: MultiResolutionAuditoryReferee) -> None: tone = _tone(440.0) - assert referee.score(tone, tone) == pytest.approx(0.0, abs=1e-9) + assert referee.judge(tone, tone).score == pytest.approx(0.0, abs=1e-9) def test_score_grows_with_the_distortion_level(self, referee: MultiResolutionAuditoryReferee) -> None: generator = np.random.default_rng(0) tone = _tone(440.0) noise = generator.standard_normal(tone.shape[0]).astype(np.float32) - slightly_noisy = referee.score(tone, tone + 0.01 * noise) - very_noisy = referee.score(tone, tone + 0.2 * noise) + slightly_noisy = referee.judge(tone, tone + 0.01 * noise).score + very_noisy = referee.judge(tone, tone + 0.2 * noise).score assert 0.0 < slightly_noisy < very_noisy @@ -50,7 +50,7 @@ def test_related_timbre_scores_closer_than_unrelated_noise( generator = np.random.default_rng(1) noise = (0.5 * generator.standard_normal(target.shape[0])).astype(np.float32) - assert referee.score(target, square) < referee.score(target, noise) + assert referee.judge(target, square).score < referee.judge(target, noise).score def test_score_is_invariant_under_a_common_gain(self, referee: MultiResolutionAuditoryReferee) -> None: """ @@ -61,11 +61,11 @@ def test_score_is_invariant_under_a_common_gain(self, referee: MultiResolutionAu tone = _tone(440.0) estimate = tone + (0.05 * generator.standard_normal(tone.shape[0])).astype(np.float32) - base = referee.score(tone, estimate) - scaled = referee.score(0.25 * tone, 0.25 * estimate) + base = referee.judge(tone, estimate).score + scaled = referee.judge(0.25 * tone, 0.25 * estimate).score assert scaled == pytest.approx(base, rel=1e-6) def test_length_mismatch_raises_value_error(self, referee: MultiResolutionAuditoryReferee) -> None: tone = _tone(440.0) with pytest.raises(ValueError): - referee.score(tone, tone[:-1]) + referee.judge(tone, tone[:-1]) diff --git a/tests/unit/sampletones_tools/calibration/referee/test_axioms.py b/tests/unit/sampletones_tools/calibration/referee/test_axioms.py new file mode 100644 index 000000000..8bd94afeb --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/test_axioms.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass +from typing import Callable, Dict, Final, FrozenSet, List, Sequence, Tuple + +import numpy as np +import pytest +from _pytest.mark import ParameterSet + +from sampletones_tools.calibration.config.referee import RefereeConfig +from sampletones_tools.calibration.referee.auditory import AUDITORY_REFEREE_NAME, MultiResolutionAuditoryReferee +from sampletones_tools.calibration.referee.loudness import LOUDNESS_REFEREE_NAME, LoudnessWeightedReferee +from sampletones_tools.calibration.referee.protocol import Referee +from sampletones_tools.calibration.referee.zimtohrli import ( + ZIMTOHRLI_REFEREE_NAME, + ZimtohrliReferee, + zimtohrli_available, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +from .conftest import SAMPLE_RATE, SEMITONE_RATIO, TONE_FREQUENCY, ProbeSignals + +Pair = Tuple[np.ndarray, np.ndarray] +PairBuilder = Callable[[ProbeSignals], Pair] + +FAINT_HISS_DECIBELS: Final[float] = -40.0 +MEDIUM_HISS_DECIBELS: Final[float] = -30.0 +LOUD_HISS_DECIBELS: Final[float] = -20.0 +QUIETER_DECIBELS: Final[float] = -3.0 +IDENTITY_TOLERANCE: Final[float] = 1e-9 +REFEREE_NAMES: Final[Tuple[str, ...]] = (AUDITORY_REFEREE_NAME, LOUDNESS_REFEREE_NAME, ZIMTOHRLI_REFEREE_NAME) + +KNOWN_FAILURES: Final[Dict[str, FrozenSet[str]]] = { + AUDITORY_REFEREE_NAME: frozenset( + { + "triangle_beats_silence", + "pulse_beats_silence", + "semitone_costs_more_than_faint_hiss", + } + ), + LOUDNESS_REFEREE_NAME: frozenset(), + ZIMTOHRLI_REFEREE_NAME: frozenset( + { + "triangle_beats_silence", + "pulse_beats_silence", + "semitone_costs_more_than_faint_hiss", + } + ), +} + + +def _sine_against(estimate: Callable[[ProbeSignals], np.ndarray]) -> PairBuilder: + return lambda probes: (probes.sine, estimate(probes)) + + +def _availability_marks(name: str) -> List[pytest.MarkDecorator]: + if name == ZIMTOHRLI_REFEREE_NAME and not zimtohrli_available(): + return [pytest.mark.skip(reason="the calibration dependency group provides the model")] + + return [] + + +def _axiom_parameters(cases: Sequence[BaseRegularTestCase]) -> List[ParameterSet]: + parameters: List[ParameterSet] = [] + for name in REFEREE_NAMES: + for case in cases: + marks = _availability_marks(name) + if case.label in KNOWN_FAILURES[name]: + marks.append(pytest.mark.xfail(strict=True, reason=f"{name} is known to break this axiom")) + parameters.append(pytest.param(name, case, marks=marks, id=f"{name}-{case.label}")) + + return parameters + + +@pytest.fixture(scope="module", name="referees") +def referees_fixture(referee_config: RefereeConfig) -> Dict[str, Callable[[], Referee]]: + return { + AUDITORY_REFEREE_NAME: lambda: MultiResolutionAuditoryReferee(SAMPLE_RATE, config=referee_config), + LOUDNESS_REFEREE_NAME: lambda: LoudnessWeightedReferee(SAMPLE_RATE, config=referee_config), + ZIMTOHRLI_REFEREE_NAME: lambda: ZimtohrliReferee(SAMPLE_RATE), + } + + +class TestRefereeAxioms(BaseTestSuite): + """ + What any referee standing in for the ear must hear: silence as far from a tone, noise growing + with its level, a timing roll and a level offset as nearly free, and a wrong pitch as audible. + + A referee known to break an axiom holds it as a strict expected failure, so a fix shows up + as an unexpected pass. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + closer: PairBuilder + farther: PairBuilder + + test_cases = ( + TestCase( + label="triangle_beats_silence", + closer=_sine_against(lambda probes: probes.triangle()), + farther=_sine_against(lambda probes: probes.silence), + ), + TestCase( + label="faint_hiss_beats_silence", + closer=_sine_against(lambda probes: probes.hissing(probes.sine, FAINT_HISS_DECIBELS)), + farther=_sine_against(lambda probes: probes.silence), + ), + TestCase( + label="pulse_beats_silence", + closer=_sine_against(lambda probes: probes.pulse()), + farther=_sine_against(lambda probes: probes.silence), + ), + TestCase( + label="hiss_grows_from_faint_to_medium", + closer=_sine_against(lambda probes: probes.hissing(probes.sine, FAINT_HISS_DECIBELS)), + farther=_sine_against(lambda probes: probes.hissing(probes.sine, MEDIUM_HISS_DECIBELS)), + ), + TestCase( + label="hiss_grows_from_medium_to_loud", + closer=_sine_against(lambda probes: probes.hissing(probes.sine, MEDIUM_HISS_DECIBELS)), + farther=_sine_against(lambda probes: probes.hissing(probes.sine, LOUD_HISS_DECIBELS)), + ), + TestCase( + label="rolled_pluck_is_nearly_free", + closer=lambda probes: (probes.pluck, probes.rolled_pluck()), + farther=lambda probes: (probes.pluck, probes.hissing(probes.pluck, FAINT_HISS_DECIBELS)), + ), + TestCase( + label="quieter_copy_is_nearly_free", + closer=_sine_against(lambda probes: probes.sine * 10.0 ** (QUIETER_DECIBELS / 20.0)), + farther=_sine_against(lambda probes: probes.hissing(probes.sine, FAINT_HISS_DECIBELS)), + ), + TestCase( + label="semitone_costs_more_than_faint_hiss", + closer=_sine_against(lambda probes: probes.hissing(probes.sine, FAINT_HISS_DECIBELS)), + farther=_sine_against(lambda probes: probes.tone(TONE_FREQUENCY * SEMITONE_RATIO)), + ), + ) + + @pytest.mark.parametrize(("referee_name", "test_case"), _axiom_parameters(test_cases)) + def test_the_closer_pair_scores_lower( + self, + referees: Dict[str, Callable[[], Referee]], + probes: ProbeSignals, + referee_name: str, + test_case: TestCase, + ) -> None: + referee = referees[referee_name]() + closer = referee.judge(*test_case.closer(probes)).score + farther = referee.judge(*test_case.farther(probes)).score + assert closer < farther + + @pytest.mark.parametrize( + "referee_name", [pytest.param(name, marks=_availability_marks(name)) for name in REFEREE_NAMES] + ) + def test_identical_signals_score_zero( + self, + referees: Dict[str, Callable[[], Referee]], + probes: ProbeSignals, + referee_name: str, + ) -> None: + referee = referees[referee_name]() + assert referee.judge(probes.sine, probes.sine).score == pytest.approx(0.0, abs=IDENTITY_TOLERANCE) diff --git a/tests/unit/sampletones_tools/calibration/referee/test_bands.py b/tests/unit/sampletones_tools/calibration/referee/test_bands.py new file mode 100644 index 000000000..d4183e7f6 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/test_bands.py @@ -0,0 +1,70 @@ +from typing import Final + +import numpy as np +import pytest +from scipy.signal import stft + +from sampletones_tools.calibration.config.referee import RefereeConfig +from sampletones_tools.calibration.referee.bands import BandAnalyzer + +from .conftest import SAMPLE_RATE, ProbeSignals + +TONE_BAND_SHARE: Final[float] = 0.9 + + +@pytest.fixture(scope="module", name="analyzer") +def analyzer_fixture(referee_config: RefereeConfig) -> BandAnalyzer: + return BandAnalyzer(SAMPLE_RATE, config=referee_config) + + +class TestBandAnalyzer: + def test_every_configured_window_size_is_analyzed( + self, analyzer: BandAnalyzer, referee_config: RefereeConfig + ) -> None: + assert analyzer.window_sizes == referee_config.window_sizes + + def test_band_energies_hold_every_bin_once( + self, + analyzer: BandAnalyzer, + referee_config: RefereeConfig, + probes: ProbeSignals, + ) -> None: + """The bands partition the spectrum, so per frame they sum to the STFT's whole energy.""" + window_size = referee_config.window_sizes[0] + _, _, spectrum = stft( + probes.noise, + fs=SAMPLE_RATE, + nperseg=window_size, + noverlap=window_size - window_size // referee_config.hop_divisor, + ) + + energies = analyzer.band_energies(probes.noise, window_size) + + assert energies.shape == (referee_config.band_count, spectrum.shape[1]) + assert np.allclose(energies.sum(axis=0), (np.abs(spectrum) ** 2).sum(axis=0)) + + def test_a_tone_fills_the_band_of_its_frequency( + self, + analyzer: BandAnalyzer, + referee_config: RefereeConfig, + probes: ProbeSignals, + ) -> None: + energies = analyzer.band_energies(probes.sine, referee_config.window_sizes[-1]).sum(axis=1) + assert np.max(energies) > TONE_BAND_SHARE * np.sum(energies) + + def test_the_audibility_floor_sits_the_range_under_the_loudest_band( + self, + analyzer: BandAnalyzer, + referee_config: RefereeConfig, + ) -> None: + energies = np.array([[1.0, 4.0], [0.5, 2.0]]) + floor = analyzer.audibility_floor(energies, range_decibels=30.0) + assert floor == pytest.approx(4.0e-3) + + def test_a_silent_reference_keeps_a_finite_floor( + self, + analyzer: BandAnalyzer, + referee_config: RefereeConfig, + ) -> None: + floor = analyzer.audibility_floor(np.zeros((2, 3)), range_decibels=10.0) + assert floor == pytest.approx(referee_config.energy_floor * 0.1) diff --git a/tests/unit/sampletones_tools/calibration/referee/test_factory.py b/tests/unit/sampletones_tools/calibration/referee/test_factory.py new file mode 100644 index 000000000..6b3ae7f57 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/test_factory.py @@ -0,0 +1,23 @@ +import pytest + +from sampletones_tools.calibration.referee.auditory import AUDITORY_REFEREE_NAME +from sampletones_tools.calibration.referee.factory import build_referees +from sampletones_tools.calibration.referee.loudness import LOUDNESS_REFEREE_NAME +from sampletones_tools.calibration.referee.zimtohrli import ZIMTOHRLI_REFEREE_NAME, zimtohrli_available + +from .conftest import SAMPLE_RATE + +AVAILABILITY = "sampletones_tools.calibration.referee.factory.zimtohrli_available" + + +class TestBuildReferees: + def test_the_built_in_referees_lead_with_the_headline(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AVAILABILITY, lambda: False) + assert [referee.name for referee in build_referees(SAMPLE_RATE)] == [ + AUDITORY_REFEREE_NAME, + LOUDNESS_REFEREE_NAME, + ] + + @pytest.mark.skipif(not zimtohrli_available(), reason="the calibration dependency group provides the model") + def test_zimtohrli_joins_where_its_extra_is_installed(self) -> None: + assert [referee.name for referee in build_referees(SAMPLE_RATE)][-1] == ZIMTOHRLI_REFEREE_NAME diff --git a/tests/unit/sampletones_tools/calibration/referee/test_level.py b/tests/unit/sampletones_tools/calibration/referee/test_level.py new file mode 100644 index 000000000..dfe599b9f --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/test_level.py @@ -0,0 +1,54 @@ +from typing import Final + +import numpy as np +import pytest + +from sampletones_tools.calibration.referee.level import ( + k_weighted_energy, + level_difference_decibels, + matched_to_reference, +) + +from .conftest import SAMPLE_RATE, ProbeSignals, decibels_to_gain + +ENERGY_FLOOR: Final[float] = 1e-10 +RANGE_DECIBELS: Final[float] = 60.0 +LEVEL_OFFSET_DECIBELS: Final[float] = 3.0 + + +class TestKWeightedEnergy: + def test_energy_grows_with_the_square_of_a_gain(self, probes: ProbeSignals) -> None: + assert k_weighted_energy(2.0 * probes.sine, SAMPLE_RATE) == pytest.approx( + 4.0 * k_weighted_energy(probes.sine, SAMPLE_RATE) + ) + + def test_a_low_tone_weighs_less_than_a_mid_tone_of_the_same_amplitude(self, probes: ProbeSignals) -> None: + assert k_weighted_energy(probes.tone(40.0), SAMPLE_RATE) < k_weighted_energy(probes.tone(1000.0), SAMPLE_RATE) + + +class TestLevelDifference: + @pytest.mark.parametrize("offset", (LEVEL_OFFSET_DECIBELS, -LEVEL_OFFSET_DECIBELS), ids=("louder", "quieter")) + def test_a_scaled_copy_reads_its_gain_in_decibels(self, probes: ProbeSignals, offset: float) -> None: + level = level_difference_decibels( + probes.sine, + probes.sine * decibels_to_gain(offset), + SAMPLE_RATE, + energy_floor=ENERGY_FLOOR, + range_decibels=RANGE_DECIBELS, + ) + assert level == pytest.approx(offset, abs=1e-6) + + def test_silence_reads_the_whole_range_quieter(self, probes: ProbeSignals) -> None: + level = level_difference_decibels( + probes.sine, + probes.silence, + SAMPLE_RATE, + energy_floor=ENERGY_FLOOR, + range_decibels=RANGE_DECIBELS, + ) + assert level == -RANGE_DECIBELS + + def test_matching_brings_a_scaled_copy_back_to_the_reference(self, probes: ProbeSignals) -> None: + louder = probes.sine * decibels_to_gain(LEVEL_OFFSET_DECIBELS) + matched = matched_to_reference(louder, level_decibels=LEVEL_OFFSET_DECIBELS) + assert np.allclose(matched, probes.sine) diff --git a/tests/unit/sampletones_tools/calibration/referee/test_loudness.py b/tests/unit/sampletones_tools/calibration/referee/test_loudness.py new file mode 100644 index 000000000..e58df313d --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/test_loudness.py @@ -0,0 +1,72 @@ +from typing import Final + +import numpy as np +import pytest + +from sampletones_tools.calibration.config.referee import RefereeConfig +from sampletones_tools.calibration.referee.loudness import ( + ADDED_COMPONENT, + LEVEL_COMPONENT, + MISSING_COMPONENT, + LoudnessWeightedReferee, +) + +from .conftest import SAMPLE_RATE, ProbeSignals, decibels_to_gain + +HARMONIC_DECIBELS: Final[float] = -20.0 +LEVEL_OFFSET_DECIBELS: Final[float] = 3.0 +NEARLY_FREE_SCORE: Final[float] = 1e-6 + + +@pytest.fixture(scope="module", name="referee") +def referee_fixture(referee_config: RefereeConfig) -> LoudnessWeightedReferee: + return LoudnessWeightedReferee(SAMPLE_RATE, config=referee_config) + + +class TestLoudnessWeightedReferee: + def test_identical_signals_score_zero(self, referee: LoudnessWeightedReferee, probes: ProbeSignals) -> None: + assert referee.judge(probes.sine, probes.sine).score == pytest.approx(0.0, abs=1e-9) + + def test_missing_and_added_sum_to_the_score(self, referee: LoudnessWeightedReferee, probes: ProbeSignals) -> None: + judgment = referee.judge(probes.sine, probes.triangle()) + components = judgment.components + assert components[MISSING_COMPONENT] + components[ADDED_COMPONENT] == pytest.approx(judgment.score) + + def test_silence_only_misses(self, referee: LoudnessWeightedReferee, probes: ProbeSignals) -> None: + judgment = referee.judge(probes.sine, probes.silence) + assert judgment.components[ADDED_COMPONENT] == pytest.approx(0.0, abs=1e-9) + assert judgment.components[MISSING_COMPONENT] == pytest.approx(judgment.score) + + def test_an_added_harmonic_reads_as_added(self, referee: LoudnessWeightedReferee, probes: ProbeSignals) -> None: + harmonic = probes.sine + probes.tone(440.0) * decibels_to_gain(HARMONIC_DECIBELS) + components = referee.judge(probes.sine, harmonic).components + assert components[MISSING_COMPONENT] < 0.1 * components[ADDED_COMPONENT] + + @pytest.mark.parametrize("offset", (LEVEL_OFFSET_DECIBELS, -LEVEL_OFFSET_DECIBELS), ids=("louder", "quieter")) + def test_a_level_offset_reads_as_level_alone( + self, + referee: LoudnessWeightedReferee, + probes: ProbeSignals, + offset: float, + ) -> None: + judgment = referee.judge(probes.sine, probes.sine * decibels_to_gain(offset)) + assert judgment.components[LEVEL_COMPONENT] == pytest.approx(offset, abs=1e-6) + assert judgment.score < NEARLY_FREE_SCORE + + def test_without_level_matching_a_level_offset_costs( + self, + referee_config: RefereeConfig, + probes: ProbeSignals, + ) -> None: + unmatched = LoudnessWeightedReferee( + SAMPLE_RATE, config=referee_config.model_copy(update={"level_matching": False}) + ) + judgment = unmatched.judge(probes.sine, probes.sine * decibels_to_gain(-LEVEL_OFFSET_DECIBELS)) + assert judgment.score == pytest.approx(LEVEL_OFFSET_DECIBELS, rel=0.05) + + def test_length_mismatch_raises_value_error(self, referee: LoudnessWeightedReferee, probes: ProbeSignals) -> None: + with pytest.raises(ValueError): + referee.judge(probes.sine, probes.sine[:-1]) + + def test_a_silent_pair_scores_zero(self, referee: LoudnessWeightedReferee, probes: ProbeSignals) -> None: + assert referee.judge(probes.silence, np.zeros_like(probes.silence)).score == pytest.approx(0.0, abs=1e-9) diff --git a/tests/unit/sampletones_tools/calibration/referee/test_zimtohrli.py b/tests/unit/sampletones_tools/calibration/referee/test_zimtohrli.py new file mode 100644 index 000000000..7e5582ea4 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/referee/test_zimtohrli.py @@ -0,0 +1,34 @@ +import pytest + +from sampletones_tools.calibration.referee.zimtohrli import OPINION_COMPONENT, ZimtohrliReferee, zimtohrli_available + +from .conftest import SAMPLE_RATE, ProbeSignals + +zimtohrli_installed = pytest.mark.skipif( + not zimtohrli_available(), + reason="the calibration dependency group provides the model", +) + + +@pytest.fixture(scope="module", name="referee") +def referee_fixture() -> ZimtohrliReferee: + return ZimtohrliReferee(SAMPLE_RATE) + + +@zimtohrli_installed +class TestZimtohrliReferee: + def test_identical_signals_score_zero(self, referee: ZimtohrliReferee, probes: ProbeSignals) -> None: + assert referee.judge(probes.sine, probes.sine).score == pytest.approx(0.0, abs=1e-9) + + def test_the_opinion_score_is_reported_beside_the_distance( + self, + referee: ZimtohrliReferee, + probes: ProbeSignals, + ) -> None: + identical = referee.judge(probes.sine, probes.sine).components[OPINION_COMPONENT] + noisy = referee.judge(probes.sine, probes.hissing(probes.sine, -20.0)).components[OPINION_COMPONENT] + assert noisy < identical + + def test_length_mismatch_raises_value_error(self, referee: ZimtohrliReferee, probes: ProbeSignals) -> None: + with pytest.raises(ValueError): + referee.judge(probes.sine, probes.sine[:-1]) diff --git a/tests/unit/sampletones_tools/calibration/runs.py b/tests/unit/sampletones_tools/calibration/runs.py new file mode 100644 index 000000000..1c19a0beb --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/runs.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Sequence, Tuple + +import numpy as np + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.calibration.renders import ( + RenderRecord, + write_channel_renders, + write_recording, + write_render, +) + +SAMPLE_RATE: Final[int] = 22050 +CLIP_LENGTH: Final[int] = SAMPLE_RATE // 8 +REFEREE: Final[str] = "mr-auditory-dB" +SECOND_REFEREE: Final[str] = "mr-loudness-dB" +SILENCE_SCORE: Final[float] = 20.0 + + +@dataclass(frozen=True) +class Probe: + item: str + category: str + channels: Tuple[ChannelName, ...] + + +ITEMS: Final[Tuple[Probe, ...]] = ( + Probe(item="tone-a", category="tone", channels=(ChannelName.TRIANGLE,)), + Probe(item="mix-a", category="mix", channels=(ChannelName.TRIANGLE, ChannelName.NOISE)), + Probe( + item="chord-a", + category="polyphony", + channels=(ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE), + ), +) + + +def channel_audio(channel: ChannelName) -> np.ndarray: + """A tone of the channel's own pitch, so a part of a render is told from another by ear.""" + steps = np.arange(CLIP_LENGTH, dtype=np.float32) + return np.float32(0.1 * (1 + list(ChannelName).index(channel))) * np.sin(steps / SAMPLE_RATE) + + +def write_run(directory: Path, variants: Sequence[str], offset: float) -> Path: + """A calibration run of a few items, written exactly as a measured run writes one.""" + for position, probe in enumerate(ITEMS): + write_recording(directory, probe.item, channel_audio(ChannelName.TRIANGLE), SAMPLE_RATE) + for variant in variants: + approximations: Dict[ChannelName, np.ndarray] = { + channel: channel_audio(channel) for channel in probe.channels + } + write_render( + directory, + RenderRecord( + variant=variant, + item=probe.item, + position=position, + category=probe.category, + timelines={channel.value: "1010" for channel in probe.channels}, + judgments={ + REFEREE: {"score": 10.0 + position + offset}, + SECOND_REFEREE: {"score": 8.0 + position}, + }, + silence={REFEREE: SILENCE_SCORE, SECOND_REFEREE: SILENCE_SCORE}, + ), + sum(approximations.values()), + SAMPLE_RATE, + ) + write_channel_renders(directory, variant, probe.item, approximations, SAMPLE_RATE) + + return directory diff --git a/tests/unit/sampletones_tools/calibration/test_command.py b/tests/unit/sampletones_tools/calibration/test_command.py new file mode 100644 index 000000000..d0383e8a5 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/test_command.py @@ -0,0 +1,227 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_tools.calibration.board.layout import PAGE_FILE +from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE +from sampletones_tools.calibration.board.session import BoardRequest +from sampletones_tools.calibration.config.suite import SuiteConfig +from sampletones_tools.calibration.layout import MARKDOWN_REPORT +from sampletones_tools.calibration.session import ( + BOARD_ROOT, + OUTPUT_ROOT, + CalibrationOutcome, + CalibrationRequest, +) +from tests.unit.sampletones_tools.calibration.runs import write_run + +CALIBRATE = "sampletones_tools.calibration.session.calibrate" +BUILD_BOARD = "sampletones_tools.calibration.board.session.build_board" +OPEN_PAGE = "sampletones_tools.calibration.board.browser.open_page" + + +class RecordedCalibration: + def __init__(self) -> None: + self.requests: List[CalibrationRequest] = [] + + def __call__(self, request: CalibrationRequest) -> CalibrationOutcome: + self.requests.append(request) + return CalibrationOutcome( + report=request.output / MARKDOWN_REPORT, + page=request.output / PAGE_FILE, + ) + + +class RecordedBoard: + def __init__(self) -> None: + self.requests: List[BoardRequest] = [] + + def __call__(self, request: BoardRequest) -> Path: + self.requests.append(request) + return request.output / PAGE_FILE + + +@pytest.fixture(name="calibration") +def calibration_fixture(monkeypatch: pytest.MonkeyPatch) -> RecordedCalibration: + recorded = RecordedCalibration() + monkeypatch.setattr(CALIBRATE, recorded) + monkeypatch.setattr(OPEN_PAGE, lambda page: True) + return recorded + + +@pytest.fixture(name="board") +def board_fixture(monkeypatch: pytest.MonkeyPatch) -> RecordedBoard: + recorded = RecordedBoard() + monkeypatch.setattr(BUILD_BOARD, recorded) + monkeypatch.setattr(OPEN_PAGE, lambda page: True) + return recorded + + +class TestCalibration: + def test_the_sweep_is_read_from_the_options(self, calibration: RecordedCalibration, tmp_path: Path) -> None: + status = dispatch( + COMMANDS, + [ + "calibration", + "--methods", + "fft", + "--perceptual-exponents", + "0.5,1", + "--temporal-weights", + "0.25", + "--channels", + "pulse1", + "-o", + str(tmp_path / "run"), + ], + ) + + assert status == 0 + request = calibration.requests[0] + assert request.methods == [SpectrumMethod.FFT] + assert request.perceptual_exponents == [0.5, 1.0] + assert request.temporal_weights == [0.25] + assert request.channels == [ChannelName.PULSE1] + assert request.output == tmp_path / "run" + + def test_without_options_the_run_measures_the_default_settings_under_the_suite( + self, + calibration: RecordedCalibration, + ) -> None: + suite = SuiteConfig.load() + + assert dispatch(COMMANDS, ["calibration"]) == 0 + + request = calibration.requests[0] + assert request.base == Config() + assert request.methods == list(suite.methods) + assert request.perceptual_exponents == list(suite.perceptual_exponents) + assert request.temporal_weights == list(suite.temporal_weights) + assert request.channels == list(suite.channels) + assert request.output.parent == OUTPUT_ROOT + + def test_a_configuration_file_is_measured_in_place_of_the_defaults( + self, + calibration: RecordedCalibration, + tmp_path: Path, + ) -> None: + defaults = Config() + saved = defaults.model_copy(update={"general": defaults.general.model_copy(update={"max_workers": 2})}) + path = tmp_path / "config.json" + saved.save(path) + + assert dispatch(COMMANDS, ["calibration", "--config", str(path)]) == 0 + + assert calibration.requests[0].base.general.max_workers == 2 + + def test_an_unknown_method_is_refused(self, calibration: RecordedCalibration) -> None: + with pytest.raises(SystemExit, match="Unknown spectrum method"): + dispatch(COMMANDS, ["calibration", "--methods", "dct"]) + + assert calibration.requests == [] + + def test_a_value_that_is_no_number_is_refused(self, calibration: RecordedCalibration) -> None: + with pytest.raises(SystemExit, match="Not a number"): + dispatch(COMMANDS, ["calibration", "--perceptual-exponents", "high"]) + + assert calibration.requests == [] + + def test_the_page_is_drawn_in_the_palette_named(self, calibration: RecordedCalibration) -> None: + assert dispatch(COMMANDS, ["calibration", "--palette", "light"]) == 0 + + assert calibration.requests[0].palette == "light" + + def test_without_a_palette_the_page_takes_the_application_default( + self, + calibration: RecordedCalibration, + ) -> None: + assert dispatch(COMMANDS, ["calibration"]) == 0 + + assert calibration.requests[0].palette == DEFAULT_PALETTE + + +class TestBoard: + def test_the_runs_named_are_compared_on_one_page( + self, + board: RecordedBoard, + tmp_path: Path, + ) -> None: + runs = [write_run(tmp_path / name, ["cqt-pe1"], 0.0) for name in ("before", "after")] + output = tmp_path / "comparison" + + status = dispatch(COMMANDS, ["calibration", "--board", *[str(run) for run in runs], "-o", str(output)]) + + assert status == 0 + assert board.requests[0].runs == runs + assert board.requests[0].output == output + + def test_without_an_output_the_page_lands_beside_the_runs( + self, + board: RecordedBoard, + tmp_path: Path, + ) -> None: + run = write_run(tmp_path / "before", ["cqt-pe1"], 0.0) + + assert dispatch(COMMANDS, ["calibration", "--board", str(run)]) == 0 + + assert board.requests[0].output.parent == BOARD_ROOT + + def test_a_page_measures_nothing_so_it_takes_no_measuring_option( + self, + board: RecordedBoard, + tmp_path: Path, + ) -> None: + run = write_run(tmp_path / "before", ["cqt-pe1"], 0.0) + + with pytest.raises(SystemExit, match="measures nothing"): + dispatch(COMMANDS, ["calibration", "--board", str(run), "--methods", "fft"]) + + assert board.requests == [] + + def test_a_run_without_renders_is_reported( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(OPEN_PAGE, lambda page: True) + empty = tmp_path / "empty" + empty.mkdir() + + with pytest.raises(SystemExit, match="holds no calibration renders"): + dispatch(COMMANDS, ["calibration", "--board", str(empty), "-o", str(tmp_path / "page")]) + + +class TestPrintedLinks: + def test_a_run_prints_the_links_to_its_report_and_its_page( + self, + calibration: RecordedCalibration, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + run = tmp_path / "run" + + assert dispatch(COMMANDS, ["calibration", "-o", str(run)]) == 0 + + assert capsys.readouterr().out.splitlines() == [ + f"Report: {(run / MARKDOWN_REPORT).resolve().as_uri()}", + f"Page: {(run / PAGE_FILE).resolve().as_uri()}", + ] + + def test_the_page_is_opened_unless_it_is_left_closed( + self, + calibration: RecordedCalibration, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + opened: List[Path] = [] + monkeypatch.setattr(OPEN_PAGE, lambda page: bool(opened.append(page)) or True) + + assert dispatch(COMMANDS, ["calibration", "-o", str(tmp_path / "shown")]) == 0 + assert dispatch(COMMANDS, ["calibration", "-o", str(tmp_path / "quiet"), "--no-open"]) == 0 + + assert opened == [tmp_path / "shown" / PAGE_FILE] diff --git a/tests/unit/sampletones_tools/calibration/test_layout.py b/tests/unit/sampletones_tools/calibration/test_layout.py new file mode 100644 index 000000000..f8a919387 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/test_layout.py @@ -0,0 +1,18 @@ +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.calibration.layout import CHANNEL_SEPARATOR, combination_name + + +class TestCombinationName: + def test_one_channel_is_named_by_itself(self) -> None: + assert combination_name([ChannelName.NOISE]) == ChannelName.NOISE.value + + def test_several_channels_are_joined_in_the_order_they_are_given(self) -> None: + name = combination_name([ChannelName.PULSE1, ChannelName.TRIANGLE]) + + assert name == f"{ChannelName.PULSE1.value}{CHANNEL_SEPARATOR}{ChannelName.TRIANGLE.value}" + + def test_every_combination_carries_its_own_name(self) -> None: + channels = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] + names = [combination_name(part) for part in ([channels[0]], channels[:2], channels)] + + assert len(set(names)) == len(names) diff --git a/tests/unit/sampletones_tools/calibration/test_renders.py b/tests/unit/sampletones_tools/calibration/test_renders.py new file mode 100644 index 000000000..987d6afb9 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/test_renders.py @@ -0,0 +1,124 @@ +from pathlib import Path +from typing import Dict, Final, List + +import numpy as np + +from sampletones_core.audio.io import read_wave +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction +from sampletones_shared.paths.extensions import EXT_FILE_FLAC, EXT_FILE_JSON +from sampletones_shared.utils.system.paths import get_filename +from sampletones_tools.calibration.layout import RENDERS_DIRECTORY, combination_name +from sampletones_tools.calibration.renders import ( + RenderRecord, + channel_subsets, + item_directory, + sounding_timelines, + write_channel_renders, + write_render, +) + +SAMPLE_RATE: Final[int] = 22050 +FREQUENCY: Final[float] = 220.0 +VARIANT: Final[str] = "fft-pe1" +ITEM: Final[str] = "tone-a" +QUANTIZATION_TOLERANCE: Final[float] = 1.0 / 2**15 + + +class TestSoundingTimelines: + def test_every_channel_marks_the_frames_it_sounds(self) -> None: + sounding = PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=1) + resting = PulseInstruction.null_instruction() + instructions: Dict[ChannelName, List[InstructionUnion]] = { + ChannelName.PULSE1: [sounding, resting, sounding], + ChannelName.NOISE: [NoiseInstruction.null_instruction()] * 3, + } + + assert sounding_timelines(instructions) == {"pulse1": "101", "noise": "000"} + + +class TestWriteRender: + def test_a_render_lands_beside_the_record_that_reads_back_unchanged(self, tmp_path: Path) -> None: + record = RenderRecord( + variant="fft-pe1", + item="tone-a", + position=0, + category="tone", + timelines={"pulse1": "0110", "triangle": "1111"}, + judgments={"referee": {"score": 1.25, "missing": 0.5}}, + silence={"referee": 50.0}, + ) + audio = 0.5 * np.sin(2.0 * np.pi * FREQUENCY * np.arange(SAMPLE_RATE // 4) / SAMPLE_RATE) + + write_render(tmp_path, record, audio, SAMPLE_RATE) + + directory = tmp_path / RENDERS_DIRECTORY / record.variant + written = directory / get_filename(record.item, EXT_FILE_JSON) + assert RenderRecord.model_validate_json(written.read_text(encoding="utf-8")) == record + rendered, sample_rate = read_wave(directory / get_filename(record.item, EXT_FILE_FLAC)) + assert sample_rate == SAMPLE_RATE + assert np.allclose(rendered, audio, atol=QUANTIZATION_TOLERANCE) + + +class TestChannelSubsets: + def test_one_channel_leaves_nothing_to_switch(self) -> None: + assert channel_subsets([ChannelName.NOISE]) == [] + + def test_every_part_short_of_the_whole_is_offered(self) -> None: + channels = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] + + subsets = channel_subsets(channels) + + assert len(subsets) == 2 ** len(channels) - 2 + assert tuple(channels) not in subsets + assert (ChannelName.PULSE1,) in subsets + + +class TestWriteChannelRenders: + def test_the_parts_add_up_to_the_whole_render(self, tmp_path: Path) -> None: + approximations = _approximations() + + write_channel_renders(tmp_path, VARIANT, ITEM, approximations, SAMPLE_RATE) + + directory = item_directory(tmp_path, VARIANT, ITEM) + parts = [ + read_wave(directory / get_filename(combination_name([channel]), EXT_FILE_FLAC))[0] + for channel in approximations + ] + tolerance = len(parts) * QUANTIZATION_TOLERANCE + assert np.allclose(sum(parts), sum(approximations.values()), atol=tolerance) + + def test_a_part_stands_beside_the_whole_it_is_cut_from(self, tmp_path: Path) -> None: + approximations = _approximations() + + written = write_channel_renders(tmp_path, VARIANT, ITEM, approximations, SAMPLE_RATE) + + directory = item_directory(tmp_path, VARIANT, ITEM) + assert len(written) == 2 ** len(approximations) - 2 + for subset in written: + assert (directory / get_filename(combination_name(subset), EXT_FILE_FLAC)).is_file() + + def test_a_render_of_one_channel_writes_no_part(self, tmp_path: Path) -> None: + written = write_channel_renders( + tmp_path, + VARIANT, + ITEM, + {ChannelName.NOISE: _tone(FREQUENCY)}, + SAMPLE_RATE, + ) + + assert written == [] + assert not item_directory(tmp_path, VARIANT, ITEM).exists() + + +def _tone(frequency: float) -> np.ndarray: + steps = np.arange(SAMPLE_RATE // 4) + return (0.25 * np.sin(2.0 * np.pi * frequency * steps / SAMPLE_RATE)).astype(np.float32) + + +def _approximations() -> Dict[ChannelName, np.ndarray]: + return { + ChannelName.PULSE1: _tone(FREQUENCY), + ChannelName.TRIANGLE: _tone(2.0 * FREQUENCY), + ChannelName.NOISE: _tone(3.0 * FREQUENCY), + } diff --git a/tests/unit/sampletones_tools/calibration/test_report.py b/tests/unit/sampletones_tools/calibration/test_report.py new file mode 100644 index 000000000..9063e0e27 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/test_report.py @@ -0,0 +1,62 @@ +import csv +from pathlib import Path +from typing import Final, List + +from sampletones_shared.utils.tables import Table +from sampletones_tools.calibration.report import CSV_COLUMNS, OVERALL_COLUMN, VARIANT_COLUMN, write_csv, write_markdown +from sampletones_tools.calibration.runner import CalibrationRow + +ROWS: Final[List[CalibrationRow]] = [ + CalibrationRow(variant="fft", item="tone-a", category="tones", referee="spectral", component="score", score=0.25), + CalibrationRow(variant="fft", item="tone-b", category="tones", referee="spectral", component="score", score=0.75), + CalibrationRow(variant="fft", item="hiss", category="noise", referee="spectral", component="score", score=1.0), + CalibrationRow(variant="cqt", item="tone-a", category="tones", referee="spectral", component="score", score=0.5), + CalibrationRow(variant="cqt", item="hiss", category="noise", referee="spectral", component="score", score=0.5), + CalibrationRow(variant="fft", item="tone-a", category="tones", referee="envelope", component="score", score=2.0), + CalibrationRow(variant="fft", item="tone-a", category="tones", referee="envelope", component="added", score=1.5), +] + + +class TestWriteCsv: + def test_every_row_reads_back_under_the_columns(self, tmp_path: Path) -> None: + path = tmp_path / "report.csv" + + write_csv(ROWS, path) + + with path.open(newline="", encoding="utf-8") as handle: + header, *cells = list(csv.reader(handle)) + assert tuple(header) == CSV_COLUMNS + assert [(row[0], row[1], row[2], row[3], row[4], float(row[5])) for row in cells] == [ + (row.variant, row.item, row.category, row.referee, row.component, row.score) for row in ROWS + ] + + +class TestWriteMarkdown: + def test_each_referee_pivots_its_score_then_every_further_reading(self, tmp_path: Path) -> None: + path = tmp_path / "report.md" + + write_markdown(ROWS, path) + + lines = path.read_text(encoding="utf-8").split("\n") + spectral = Table( + columns=(VARIANT_COLUMN, "tones", "noise", OVERALL_COLUMN), + rows=(("fft", "0.500", "1.000", "0.667"), ("cqt", "0.500", "0.500", "0.500")), + ) + envelope = Table(columns=(VARIANT_COLUMN, "tones", OVERALL_COLUMN), rows=(("fft", "2.000", "2.000"),)) + added = Table(columns=(VARIANT_COLUMN, "tones", OVERALL_COLUMN), rows=(("fft", "1.500", "1.500"),)) + assert lines == [ + "# Calibration report", + "", + "## spectral", + "", + *spectral.markdown_lines(), + "", + "## envelope", + "", + *envelope.markdown_lines(), + "", + "### added", + "", + *added.markdown_lines(), + "", + ] diff --git a/tests/unit/sampletones_tools/calibration/test_runner.py b/tests/unit/sampletones_tools/calibration/test_runner.py new file mode 100644 index 000000000..329c1dbcb --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/test_runner.py @@ -0,0 +1,34 @@ +import warnings +from typing import Final, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_tools.calibration.runner import build_variants + +METHODS: Final[List[SpectrumMethod]] = [SpectrumMethod.FFT, SpectrumMethod.CQT] +EXPONENTS: Final[List[float]] = [1.0] + + +class TestBuildVariants: + def test_sweeps_every_combination(self) -> None: + variants = build_variants(Config(), METHODS, [1.0, 0.5], [0.25]) + assert [variant.label for variant in variants] == [ + "fft-pe1-tw0.25", + "fft-pe0.5-tw0.25", + "cqt-pe1-tw0.25", + "cqt-pe0.5-tw0.25", + ] + + @pytest.mark.parametrize("method", METHODS, ids=lambda method: method.value) + def test_variant_holds_the_spectrum_method_member(self, method: SpectrumMethod) -> None: + (variant,) = build_variants(Config(), [method], EXPONENTS, []) + assert variant.config.library.spectrum_method is method + + def test_variant_configuration_serializes_cleanly(self) -> None: + variants = build_variants(Config(), METHODS, EXPONENTS, []) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + for variant in variants: + variant.config.model_dump() diff --git a/tests/unit/sampletones_tools/calibration/test_session.py b/tests/unit/sampletones_tools/calibration/test_session.py new file mode 100644 index 000000000..c04852089 --- /dev/null +++ b/tests/unit/sampletones_tools/calibration/test_session.py @@ -0,0 +1,228 @@ +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, FrozenSet, List + +import pytest + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_tools.calibration.board.layout import PAGE_FILE +from sampletones_tools.calibration.board.palette import DEFAULT_PALETTE +from sampletones_tools.calibration.board.session import BoardRequest +from sampletones_tools.calibration.corpus.item import CorpusItem +from sampletones_tools.calibration.layout import CSV_REPORT, MARKDOWN_REPORT +from sampletones_tools.calibration.referee.protocol import Referee +from sampletones_tools.calibration.runner import CalibrationRow, CalibrationVariant +from sampletones_tools.calibration.session import ( + BOARD_ROOT, + OUTPUT_ROOT, + CalibrationRequest, + base_configuration, + calibrate, + default_output, + default_page_output, + floats_named, + methods_named, +) +from sampletones_tools.runs import BOARD_STAMP, RUN_STAMP +from tests.unit.sampletones_tools.calibration.runs import write_run + +EVALUATE = "sampletones_tools.calibration.session.evaluate_variants" +BUILD_BOARD = "sampletones_tools.calibration.session.build_board" + + +class TestMethodsNamed: + def test_nothing_named_is_the_default(self) -> None: + assert methods_named(None, (SpectrumMethod.LOG_SPACED_FFT,)) == [SpectrumMethod.LOG_SPACED_FFT] + + def test_names_are_read_in_order(self) -> None: + assert methods_named("cqt, fft", ()) == [SpectrumMethod.CQT, SpectrumMethod.FFT] + + def test_an_unknown_method_is_refused_with_the_known_ones(self) -> None: + with pytest.raises(ValueError, match="Unknown spectrum method 'dct'; the methods are"): + methods_named("fft,dct", ()) + + +class TestFloatsNamed: + def test_nothing_named_is_the_default(self) -> None: + assert floats_named(None, (1.0,)) == [1.0] + assert floats_named(None, ()) == [] + + def test_values_are_read_in_order(self) -> None: + assert floats_named("0.5, 1", ()) == [0.5, 1.0] + + def test_a_value_that_is_no_number_is_refused(self) -> None: + with pytest.raises(ValueError, match="Not a number: 'high'"): + floats_named("0.5,high", ()) + + +class TestDefaultOutput: + def test_a_run_lands_in_a_timestamped_directory_under_the_documents(self) -> None: + output = default_output() + + assert output.parent == OUTPUT_ROOT + assert datetime.strptime(output.name, RUN_STAMP) + + def test_a_page_comparing_runs_lands_beside_them(self) -> None: + output = default_page_output() + + assert output.parent == BOARD_ROOT + assert datetime.strptime(output.name, BOARD_STAMP) + + +class TestBaseConfiguration: + def test_without_a_file_the_run_measures_the_default_settings(self) -> None: + assert base_configuration(None) == Config() + + def test_a_file_is_read_as_it_stands(self, tmp_path: Path) -> None: + defaults = Config() + saved = defaults.model_copy(update={"general": defaults.general.model_copy(update={"max_workers": 3})}) + path = tmp_path / "config.json" + saved.save(path) + + assert base_configuration(path).general.max_workers == 3 + + +class TestCalibrate: + def test_a_run_returns_its_report_beside_the_table_of_scores( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(EVALUATE, _evaluated([])) + request = CalibrationRequest( + base=Config(), + output=tmp_path / "run", + methods=[SpectrumMethod.FFT], + perceptual_exponents=[1.0], + temporal_weights=[], + channels=[ChannelName.PULSE1], + ) + + outcome = calibrate(request) + + assert outcome.report == tmp_path / "run" / MARKDOWN_REPORT + assert outcome.report.is_file() + assert (tmp_path / "run" / CSV_REPORT).is_file() + + def test_a_run_ends_with_the_page_its_renders_are_heard_on( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setattr(EVALUATE, _evaluated([])) + request = CalibrationRequest( + base=Config(), + output=tmp_path / "run", + methods=[SpectrumMethod.FFT], + perceptual_exponents=[1.0], + temporal_weights=[], + channels=[ChannelName.PULSE1], + ) + + outcome = calibrate(request) + + assert outcome.page == tmp_path / "run" / PAGE_FILE + assert outcome.page.is_file() + + def test_a_run_draws_its_page_in_the_palette_it_is_asked_for( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + drawn: List[str] = [] + monkeypatch.setattr(EVALUATE, _evaluated([])) + monkeypatch.setattr(BUILD_BOARD, _built(drawn)) + request = CalibrationRequest( + base=Config(), + output=tmp_path / "run", + methods=[SpectrumMethod.FFT], + perceptual_exponents=[1.0], + temporal_weights=[], + channels=[ChannelName.PULSE1], + palette="light", + ) + + calibrate(request) + + assert drawn == ["light"] + + def test_a_run_asked_for_no_palette_draws_its_page_in_the_application_default( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + drawn: List[str] = [] + monkeypatch.setattr(EVALUATE, _evaluated([])) + monkeypatch.setattr(BUILD_BOARD, _built(drawn)) + request = CalibrationRequest( + base=Config(), + output=tmp_path / "run", + methods=[SpectrumMethod.FFT], + perceptual_exponents=[1.0], + temporal_weights=[], + channels=[ChannelName.PULSE1], + ) + + calibrate(request) + + assert drawn == [DEFAULT_PALETTE] + + def test_a_run_evaluates_with_the_channels_it_is_asked_for( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + asked: List[FrozenSet[ChannelName]] = [] + monkeypatch.setattr(EVALUATE, _evaluated(asked)) + request = CalibrationRequest( + base=Config(), + output=tmp_path / "run", + methods=[SpectrumMethod.FFT], + perceptual_exponents=[1.0], + temporal_weights=[], + channels=[ChannelName.PULSE1, ChannelName.NOISE], + ) + + calibrate(request) + + assert asked == [frozenset({ChannelName.PULSE1, ChannelName.NOISE})] + + +class TestCalibrationRequest: + def test_an_empty_sweep_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + CalibrationRequest( + base=Config(), + output=tmp_path, + methods=[], + perceptual_exponents=[1.0], + temporal_weights=[], + channels=[ChannelName.PULSE1], + ) + + +def _evaluated(asked: List[FrozenSet[ChannelName]]) -> Callable[..., List[CalibrationRow]]: + """An evaluation writing the renders a run writes, so the page the run ends with has clips.""" + + def evaluated( + variants: List[CalibrationVariant], + items: List[CorpusItem], + item_paths: Dict[str, Path], + referees: List[Referee], + run_directory: Path, + channels: FrozenSet[ChannelName], + ) -> List[CalibrationRow]: + asked.append(channels) + write_run(run_directory, [variant.label for variant in variants], 0.0) + return [] + + return evaluated + + +def _built(drawn: List[str]) -> Callable[[BoardRequest], Path]: + def built(request: BoardRequest) -> Path: + drawn.append(request.palette) + return request.output / PAGE_FILE + + return built diff --git a/tests/unit/sampletones_tools/checks/boundary/__init__.py b/tests/unit/sampletones_tools/checks/boundary/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/checks/boundary/configs/__init__.py b/tests/unit/sampletones_tools/checks/boundary/configs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/checks/boundary/configs/test_declaration.py b/tests/unit/sampletones_tools/checks/boundary/configs/test_declaration.py new file mode 100644 index 000000000..333b71165 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/configs/test_declaration.py @@ -0,0 +1,61 @@ +from typing import Final + +from sampletones_tools.checks.boundary.configs.declaration import BoundaryDeclaration +from sampletones_tools.checks.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_tools/checks/boundary/configs/test_general.py b/tests/unit/sampletones_tools/checks/boundary/configs/test_general.py new file mode 100644 index 000000000..c1b86c2ea --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/configs/test_general.py @@ -0,0 +1,33 @@ +from typing import Final + +import pytest + +from sampletones_tools.checks.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_tools/checks/boundary/configs/test_rules.py b/tests/unit/sampletones_tools/checks/boundary/configs/test_rules.py new file mode 100644 index 000000000..7ea2179e9 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/configs/test_rules.py @@ -0,0 +1,214 @@ +from collections import Counter +from pathlib import Path +from typing import Dict, Final, List, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_shared.paths.source import SOURCE_ROOT +from sampletones_tools.checks.boundary.check import check_boundaries +from sampletones_tools.checks.boundary.configs.declaration import BoundaryDeclaration +from sampletones_tools.checks.boundary.configs.general import GeneralBoundaries +from sampletones_tools.checks.boundary.configs.rules import ImportBoundaryRules +from sampletones_tools.checks.boundary.graph import reached_units +from sampletones_tools.checks.boundary.rule import BoundaryRule +from sampletones_tools.checks.boundary.scope import rule_modules +from sampletones_tools.checks.boundary.standalone import check_standalone +from sampletones_tools.checks.paths import SCRIPTS_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" +TOOLS: Final[str] = "sampletones_tools" +ENTRY: Final[str] = "sampletones" + +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" +TOOLS_IMPORT: Final[str] = "from sampletones_tools.registry import DEVELOPER_COMMANDS\n" +PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n" +THIRD_PARTY_IMPORT: Final[str] = "import numpy\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_entry_is_the_one_importer_of_the_tools(self) -> None: + """The wheel carries the tools, and the command line is where a developer reaches them.""" + assert {unit for unit, layers in self.LAYERS.items() if TOOLS in layers} == {ENTRY} + + def test_no_shipped_package_reaches_the_tools(self) -> None: + shipped = set(self.LAYERS) - {ENTRY, TOOLS} + + assert all(TOOLS not in reached_units(self.LAYERS, package) for package in shipped) + + def test_the_tools_reach_the_application(self) -> None: + assert APPLICATION in self.LAYERS[TOOLS] + + +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_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",), + ), + ), + checkout=(), + tokens=(), + standalone=(), + ) + + +class TestCheckoutUnits: + """A unit beside the source tree imports the program and never the tests, and the program imports no unit.""" + + def test_the_automation_unit_is_declared(self) -> None: + assert "automation" in BOUNDARIES.checkout_units + + def test_a_unit_reaching_the_tests_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / "automation", "driver.py", "from tests.suite.source import write_module\n") + + violations = check_boundaries(tmp_path, BOUNDARIES.checkout_rules(), (), None) + + assert [violation.kind for violation in violations] == ["tests"] + + def test_the_program_reaching_a_unit_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path / APPLICATION / "logic", "direct.py", "from automation.screen import Screen\n") + + assert reported(tmp_path) == ["automation"] + + +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_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 + + def test_a_shipped_module_naming_the_tools_is_reported_by_the_graph_and_by_name(self, tmp_path: Path) -> None: + write_module(tmp_path / CORE / "formats", "tooling.py", TOOLS_IMPORT) + + kinds = reported(tmp_path) + + assert TOOLS in kinds + assert any("names no tool" in kind for kind in kinds) + + def test_the_entry_naming_the_tools_is_left_alone(self, tmp_path: Path) -> None: + write_module(tmp_path / ENTRY / "commands", "registry.py", TOOLS_IMPORT) + + assert reported(tmp_path) == [] + + +class TestStandaloneRules: + """The scripts that run on the system interpreter, held to the standard library.""" + + def test_the_scripts_tree_holds_to_the_rule(self) -> None: + assert check_standalone(SCRIPTS_ROOT, BOUNDARIES.standalone, None) == [] + + def test_a_bootstrap_script_reaching_a_third_party_package_is_reported(self, tmp_path: Path) -> None: + write_module(tmp_path, "bundle.py", THIRD_PARTY_IMPORT) + + reported = check_standalone(tmp_path, BOUNDARIES.standalone, None) + + assert [violation.kind for violation in reported] == [rule.message for rule in BOUNDARIES.standalone] + + +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) + + def test_every_standalone_rule_reaches_a_script(self) -> None: + swept = swept_paths(SCRIPTS_ROOT) + + assert all(rule_modules(SCRIPTS_ROOT, rule.pattern, (), swept, None) for rule in BOUNDARIES.standalone) diff --git a/tests/unit/sampletones_tools/checks/boundary/test_check.py b/tests/unit/sampletones_tools/checks/boundary/test_check.py new file mode 100644 index 000000000..59554dc72 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_check.py @@ -0,0 +1,100 @@ +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from sampletones_tools.checks.boundary.check import check_boundaries +from sampletones_tools.checks.boundary.rule import BoundaryRule +from sampletones_tools.checks.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_tools/checks/boundary/test_graph.py b/tests/unit/sampletones_tools/checks/boundary/test_graph.py new file mode 100644 index 000000000..f7006ffef --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_graph.py @@ -0,0 +1,78 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_tools.checks.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_tools/checks/boundary/test_imports.py b/tests/unit/sampletones_tools/checks/boundary/test_imports.py new file mode 100644 index 000000000..bb9bd8bdc --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_imports.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_tools.checks.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_tools/checks/boundary/test_packages.py b/tests/unit/sampletones_tools/checks/boundary/test_packages.py new file mode 100644 index 000000000..2a54d5c78 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_packages.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones_tools.checks.boundary.packages import ( + NAMESPACE_PACKAGE, + check_packages, + enclosing_directories, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.source import write_module + +INITIALIZER: Final[str] = "__init__.py" + + +def build_tree(root: Path, files: Tuple[str, ...]) -> None: + """Writes each file a case names under ``root``, empty.""" + for file in files: + relative = Path(file) + write_module(root / relative.parent, relative.name, "") + + +def reported(root: Path) -> List[Path]: + """The directories the check reports over a whole tree, relative to its root.""" + resolved = root.resolve() + return [Path(violation.location).relative_to(resolved) for violation in check_packages(root, None)] + + +class TestEnclosingDirectories: + def test_the_directories_run_from_the_innermost_and_leave_the_root_out(self, tmp_path: Path) -> None: + module = tmp_path / "outer" / "inner" / "module.py" + + assert enclosing_directories(module, tmp_path) == (tmp_path / "outer" / "inner", tmp_path / "outer") + + def test_a_module_at_the_root_sits_in_no_directory(self, tmp_path: Path) -> None: + assert enclosing_directories(tmp_path / "module.py", tmp_path) == () + + +class TestCheckPackages(BaseTestSuite): + """Every directory holding modules below the source root is a package of its own.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + files: Tuple[str, ...] + expected: Tuple[str, ...] + + test_cases = ( + TestCase( + label="a_directory_with_a_module", + files=("package/module.py",), + expected=("package",), + ), + TestCase( + label="a_directory_holding_only_subpackages", + files=(f"outer/inner/{INITIALIZER}", "outer/inner/module.py"), + expected=("outer",), + ), + TestCase( + label="every_directory_on_the_way_to_a_module", + files=("outer/inner/module.py",), + expected=("outer", "outer/inner"), + ), + TestCase( + label="a_package_with_its_initializer", + files=(f"package/{INITIALIZER}", "package/module.py"), + expected=(), + ), + TestCase( + label="a_data_only_directory", + files=(f"package/{INITIALIZER}", "package/data/notes.txt", "package/data/table.yaml"), + expected=(), + ), + TestCase( + label="the_source_root_itself", + files=("module.py",), + expected=(), + ), + TestCase( + label="a_hidden_directory", + files=("module.py", ".cache/tool/module.py"), + expected=(), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_check_packages(self, test_case: "TestCheckPackages.TestCase", tmp_path: Path) -> None: + build_tree(tmp_path, test_case.files) + + assert reported(tmp_path) == [Path(directory) for directory in test_case.expected] + + +class TestCheckPackagesReport: + def test_a_directory_is_reported_as_a_namespace_package(self, tmp_path: Path) -> None: + build_tree(tmp_path, ("package/module.py",)) + + violations = check_packages(tmp_path, None) + + assert [violation.kind for violation in violations] == [NAMESPACE_PACKAGE] + assert Path(violations[0].location) == (tmp_path / "package").resolve() + + def test_a_selection_reports_the_directories_its_modules_sit_in(self, tmp_path: Path) -> None: + build_tree(tmp_path, ("selected/module.py", "other/module.py")) + selection = {(tmp_path / "selected" / "module.py").resolve()} + + violations = check_packages(tmp_path, selection) + + assert [Path(violation.location) for violation in violations] == [(tmp_path / "selected").resolve()] + + def test_a_tree_without_modules_stops_the_check(self, tmp_path: Path) -> None: + build_tree(tmp_path, ("data/notes.txt",)) + + with pytest.raises(FileNotFoundError): + check_packages(tmp_path, None) diff --git a/tests/unit/sampletones_tools/checks/boundary/test_rule.py b/tests/unit/sampletones_tools/checks/boundary/test_rule.py new file mode 100644 index 000000000..bf398cb4a --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_rule.py @@ -0,0 +1,63 @@ +from pathlib import Path +from typing import Final + +from sampletones_tools.checks.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_tools/checks/boundary/test_scope.py b/tests/unit/sampletones_tools/checks/boundary/test_scope.py new file mode 100644 index 000000000..94b58a597 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_scope.py @@ -0,0 +1,52 @@ +from pathlib import Path +from typing import Final + +from sampletones_tools.checks.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_tools/checks/boundary/test_standalone.py b/tests/unit/sampletones_tools/checks/boundary/test_standalone.py new file mode 100644 index 000000000..6057c838a --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_standalone.py @@ -0,0 +1,125 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_tools.checks.boundary.standalone import ( + StandaloneRule, + check_standalone, + local_names, +) +from tests.suite.source import write_module + +MESSAGE: Final[str] = "a bootstrap script imports the standard library and the tree alone" + +RULE: Final[StandaloneRule] = StandaloneRule( + pattern="**/*.py", + reserved=("tests",), + message=MESSAGE, +) + +THIRD_PARTY: Final[str] = "import numpy\n" +REACHABLE: Final[str] = ( + "import argparse\n" + "from pathlib import Path\n" + "from bootstrap.processes import run\n" + "import detect_cuda\n" + "from . import sibling\n" +) + + +def _tree(root: Path) -> None: + write_module(root / "bootstrap", "__init__.py", "") + write_module(root / "bootstrap", "processes.py", "import subprocess\n") + write_module(root, "detect_cuda.py", "import re\n") + + +def kinds(root: Path) -> List[str]: + """What the rule reports over a tree a test builds.""" + return [violation.kind for violation in check_standalone(root, (RULE,), None)] + + +class TestLocalNames: + def test_the_modules_and_the_packages_of_the_tree_are_its_names(self, tmp_path: Path) -> None: + _tree(tmp_path) + (tmp_path / "runtime_hooks").mkdir() + + assert local_names(tmp_path) == {"bootstrap", "detect_cuda"} + + +class TestStandaloneViolations: + """The imports of one script that reach past the standard library and the tree.""" + + def test_a_third_party_import_is_reported_with_the_rules_message(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "bundle.py", THIRD_PARTY) + + assert [violation.kind for violation in RULE.violations(path, set())] == [MESSAGE] + + def test_the_standard_library_and_the_tree_stay_reachable(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "bundle.py", REACHABLE) + + assert RULE.violations(path, {"bootstrap", "detect_cuda"}) == [] + + def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "bundle.py", f"import sys\n{THIRD_PARTY}") + + assert RULE.violations(path, set())[0].location == f"{path}:2: import numpy" + + +class TestShadowing: + """A name in the tree that stands in for a module the tree sits beside.""" + + def test_a_standard_library_name_is_reported_once_at_its_first_script(self, tmp_path: Path) -> None: + first = write_module(tmp_path / "venv", "first.py", "") + write_module(tmp_path / "venv", "second.py", "") + + reported = RULE.shadowing(tmp_path, [first, tmp_path / "venv" / "second.py"]) + + assert [violation.kind for violation in reported] == ["venv stands in for a module the tree sits beside"] + assert reported[0].location == str(first) + + def test_a_reserved_name_is_reported(self, tmp_path: Path) -> None: + path = write_module(tmp_path, "tests.py", "") + + assert [violation.kind for violation in RULE.shadowing(tmp_path, [path])] == [ + "tests stands in for a module the tree sits beside", + ] + + def test_a_name_of_the_trees_own_is_left_alone(self, tmp_path: Path) -> None: + path = write_module(tmp_path / "bootstrap", "venv_build.py", "") + + assert RULE.shadowing(tmp_path, [path]) == [] + + +class TestCheckStandalone: + """Every rule read over one tree, from the sweep to the report.""" + + def test_a_clean_tree_reports_nothing(self, tmp_path: Path) -> None: + _tree(tmp_path) + write_module(tmp_path, "bundle.py", REACHABLE) + + assert kinds(tmp_path) == [] + + def test_a_script_in_a_subdirectory_is_held_to_the_rule(self, tmp_path: Path) -> None: + _tree(tmp_path) + write_module(tmp_path / "ci", "archive.py", THIRD_PARTY) + + assert kinds(tmp_path) == [MESSAGE] + + def test_the_shadowing_names_lead_the_imports(self, tmp_path: Path) -> None: + _tree(tmp_path) + write_module(tmp_path, "bundle.py", THIRD_PARTY) + write_module(tmp_path, "platform.py", "") + + assert kinds(tmp_path) == ["platform stands in for a module the tree sits beside", MESSAGE] + + def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None: + _tree(tmp_path) + checked = write_module(tmp_path, "checked.py", THIRD_PARTY) + write_module(tmp_path, "other.py", THIRD_PARTY) + + assert len(check_standalone(tmp_path, (RULE,), {checked.resolve()})) == 1 + + def test_a_tree_holding_no_script_stops_the_check(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + check_standalone(tmp_path, (RULE,), None) diff --git a/tests/unit/sampletones_tools/checks/boundary/test_token.py b/tests/unit/sampletones_tools/checks/boundary/test_token.py new file mode 100644 index 000000000..ec2881921 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_token.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_tools.checks.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_tools/checks/boundary/test_units.py b/tests/unit/sampletones_tools/checks/boundary/test_units.py new file mode 100644 index 000000000..a3df988a2 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_units.py @@ -0,0 +1,32 @@ +from sampletones_tools.checks.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_tools/checks/boundary/test_violation.py b/tests/unit/sampletones_tools/checks/boundary/test_violation.py new file mode 100644 index 000000000..2b4939a8f --- /dev/null +++ b/tests/unit/sampletones_tools/checks/boundary/test_violation.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from sampletones_tools.checks.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_tools/checks/commands/__init__.py b/tests/unit/sampletones_tools/checks/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/checks/commands/test_palette_colors.py b/tests/unit/sampletones_tools/checks/commands/test_palette_colors.py new file mode 100644 index 000000000..2fcedacaa --- /dev/null +++ b/tests/unit/sampletones_tools/checks/commands/test_palette_colors.py @@ -0,0 +1,59 @@ +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_application.paths import PALETTES_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY +from sampletones_tools.checks import palette_colors +from sampletones_tools.checks.palette_colors import APPLICATION_PACKAGE, ColorFinding + +GUARD: Final[str] = "sampletones_tools.checkout.require_checkout" + + +class RecordedCheck: + def __init__(self) -> None: + self.checked: List[Tuple[Path, Path, Path]] = [] + + def __call__(self, package: Path, config: Path, palettes: Path) -> List[ColorFinding]: + self.checked.append((package, config, palettes)) + return [] + + +@pytest.fixture(name="check") +def check_fixture(monkeypatch: pytest.MonkeyPatch) -> RecordedCheck: + recorded = RecordedCheck() + guarded: List[str] = [] + monkeypatch.setattr(palette_colors, "check_colors", recorded) + monkeypatch.setattr(GUARD, guarded.append) + return recorded + + +class TestPaletteColorsCommand: + def test_the_shipped_trees_are_checked_when_none_is_named(self, check: RecordedCheck) -> None: + assert dispatch(COMMANDS, ["check", "palette-colors"]) == 0 + assert check.checked == [(APPLICATION_PACKAGE, CONFIG_DIRECTORY, PALETTES_DIRECTORY)] + + def test_the_trees_named_are_the_ones_checked(self, check: RecordedCheck, tmp_path: Path) -> None: + package = tmp_path / "package" + config = tmp_path / "config" + palettes = tmp_path / "palettes" + + status = dispatch( + COMMANDS, + [ + "check", + "palette-colors", + "--package", + str(package), + "--config-directory", + str(config), + "--palettes", + str(palettes), + ], + ) + + assert status == 0 + assert check.checked == [(package, config, palettes)] diff --git a/tests/unit/sampletones_tools/checks/source/__init__.py b/tests/unit/sampletones_tools/checks/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/checks/source/bindings/__init__.py b/tests/unit/sampletones_tools/checks/source/bindings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py b/tests/unit/sampletones_tools/checks/source/bindings/test_containers.py similarity index 85% rename from tests/unit/sampletones_shared/meta/source/bindings/test_containers.py rename to tests/unit/sampletones_tools/checks/source/bindings/test_containers.py index 2145ce5b8..c99070413 100644 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py +++ b/tests/unit/sampletones_tools/checks/source/bindings/test_containers.py @@ -1,19 +1,19 @@ import ast from typing import Dict, Final, List, Optional, Tuple -from sampletones_shared.meta.source.bindings.containers import ( +from sampletones_tools.checks.source.bindings.containers import ( container_item_types, iterated_container, iterated_types, ) 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_tools/checks/source/bindings/test_environment.py b/tests/unit/sampletones_tools/checks/source/bindings/test_environment.py new file mode 100644 index 000000000..a088ae164 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/source/bindings/test_environment.py @@ -0,0 +1,42 @@ +from typing import Final + +from sampletones_tools.checks.source.bindings.environment import TypeEnvironment + +ENVIRONMENT: Final[TypeEnvironment] = TypeEnvironment( + types={ + "language_manager": "LanguageManager", + "self._language_manager": "LanguageManager", + "element": "MenuElements", + } +) + + +class TestTypeOf: + def test_a_stated_name_holds_its_type(self) -> None: + assert ENVIRONMENT.type_of("element") == "MenuElements" + + def test_a_stated_attribute_holds_its_type(self) -> None: + assert ENVIRONMENT.type_of("self._language_manager") == "LanguageManager" + + def test_a_spelling_the_environment_omits_states_nothing(self) -> None: + assert ENVIRONMENT.type_of("absent") is None + + +class TestSpellingsOf: + def test_every_holder_of_a_type_is_named(self) -> None: + assert ENVIRONMENT.spellings_of("LanguageManager") == ( + "language_manager", + "self._language_manager", + ) + + def test_holders_arrive_in_the_order_they_were_read(self) -> None: + environment = TypeEnvironment( + types={ + "second": "Manager", + "first": "Manager", + } + ) + assert environment.spellings_of("Manager") == ("second", "first") + + def test_a_type_no_spelling_holds_names_nobody(self) -> None: + assert ENVIRONMENT.spellings_of("Sequencer") == () diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py b/tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py similarity index 77% rename from tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py rename to tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py index 22ee5cc57..901e85ff2 100644 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py +++ b/tests/unit/sampletones_tools/checks/source/bindings/test_scopes.py @@ -1,13 +1,13 @@ from typing import Final, List, Mapping, Tuple -from sampletones_shared.meta.source.bindings.environment import TypeEnvironment -from sampletones_shared.meta.source.bindings.scopes import Scope, module_scopes +from sampletones_tools.checks.source.bindings.environment import TypeEnvironment +from sampletones_tools.checks.source.bindings.scopes import Scope, module_scopes from tests.suite.source import parse_source, scope_named PANEL_SOURCE: Final[str] = """ from typing import Dict, Final -FILTERS: Final[Dict[TrackerFormat, FileFilterElements]] = {} +FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {} class Panel: @@ -29,33 +29,42 @@ 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: print(name) """ +DECLARED_ATTRIBUTE_SOURCE: Final[str] = """ +class Browser(Panel): + _language_manager: LanguageManager + _plain = 1 + + def _menu_label(self, element: MenuElements) -> str: + return self._language_manager[element] +""" + 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"), } @@ -133,14 +142,27 @@ def test_the_spellings_of_a_type_leave_other_names_aside(self) -> None: assert "element" not in panel_environment("_menu_label").spellings_of("LanguageManager") +class TestAttributesAClassBodyDeclares: + def test_a_declared_attribute_states_its_type_to_every_method(self) -> None: + """A class inheriting an attribute declares what it holds rather than assigning it again.""" + environment = environment_of(DECLARED_ATTRIBUTE_SOURCE, "_menu_label", {}) + + assert environment.type_of("self._language_manager") == "LanguageManager" + + def test_an_attribute_the_body_only_assigns_states_nothing(self) -> None: + environment = environment_of(DECLARED_ATTRIBUTE_SOURCE, "_menu_label", {}) + + assert environment.type_of("self._plain") is None + + 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 +170,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/bindings/test_statements.py b/tests/unit/sampletones_tools/checks/source/bindings/test_statements.py similarity index 98% rename from tests/unit/sampletones_shared/meta/source/bindings/test_statements.py rename to tests/unit/sampletones_tools/checks/source/bindings/test_statements.py index 8aa1043fe..6b1467ea5 100644 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_statements.py +++ b/tests/unit/sampletones_tools/checks/source/bindings/test_statements.py @@ -1,7 +1,7 @@ import ast from typing import Optional, Type -from sampletones_shared.meta.source.bindings.statements import ( +from sampletones_tools.checks.source.bindings.statements import ( AliasStatement, LoopStatement, Statement, diff --git a/tests/unit/sampletones_shared/meta/source/test_annotations.py b/tests/unit/sampletones_tools/checks/source/test_annotations.py similarity index 94% rename from tests/unit/sampletones_shared/meta/source/test_annotations.py rename to tests/unit/sampletones_tools/checks/source/test_annotations.py index e08917df6..c00b4c58e 100644 --- a/tests/unit/sampletones_shared/meta/source/test_annotations.py +++ b/tests/unit/sampletones_tools/checks/source/test_annotations.py @@ -4,7 +4,7 @@ import pytest -from sampletones_shared.meta.source.annotations import ( +from sampletones_tools.checks.source.annotations import ( annotation_item_types, annotation_type_name, unwrap_annotation, @@ -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_classes.py b/tests/unit/sampletones_tools/checks/source/test_classes.py similarity index 96% rename from tests/unit/sampletones_shared/meta/source/test_classes.py rename to tests/unit/sampletones_tools/checks/source/test_classes.py index 00603297f..4174ab6ed 100644 --- a/tests/unit/sampletones_shared/meta/source/test_classes.py +++ b/tests/unit/sampletones_tools/checks/source/test_classes.py @@ -1,6 +1,6 @@ from typing import Final, List -from sampletones_shared.meta.source.classes import declared_subclasses +from sampletones_tools.checks.source.classes import declared_subclasses from tests.suite.source import parse_source CLASSES_SOURCE: Final[str] = """ diff --git a/tests/unit/sampletones_shared/meta/source/test_constants.py b/tests/unit/sampletones_tools/checks/source/test_constants.py similarity index 96% rename from tests/unit/sampletones_shared/meta/source/test_constants.py rename to tests/unit/sampletones_tools/checks/source/test_constants.py index 1c76fadd1..6ea61c404 100644 --- a/tests/unit/sampletones_shared/meta/source/test_constants.py +++ b/tests/unit/sampletones_tools/checks/source/test_constants.py @@ -1,7 +1,7 @@ import ast from typing import Dict, Final, List -from sampletones_shared.meta.source.constants import ModuleConstant, module_constants +from sampletones_tools.checks.source.constants import ModuleConstant, module_constants from tests.suite.source import parse_source CONSTANTS_SOURCE: Final[str] = """ diff --git a/tests/unit/sampletones_tools/checks/source/test_index.py b/tests/unit/sampletones_tools/checks/source/test_index.py new file mode 100644 index 000000000..4828305b6 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/source/test_index.py @@ -0,0 +1,53 @@ +import ast +from pathlib import Path +from typing import Final + +from sampletones_tools.checks.source.index import SourceIndex, source_index +from sampletones_tools.checks.source.modules import SourceModule +from tests.suite.source import parse_source + +TAGS_SOURCE: Final[str] = """ +from typing import Dict, Final + +FILTERS: Final[Dict[ExportFormat, FileFilterElements]] = {} +TAG_MAIN_WINDOW = "main.window" +""" + +PANEL_SOURCE: Final[str] = """ +from typing import Dict, Final + +LABELS: Final[Dict[str, MenuElements]] = {} +TAG_MAIN_WINDOW = "panel.window" +""" + + +def module(name: str, source: str) -> SourceModule: + return SourceModule(path=Path(name), tree=parse_source(source)) + + +def index_of(*sources: str) -> SourceIndex: + return source_index([module(f"module_{number}.py", source) for number, source in enumerate(sources)]) + + +class TestSourceIndex: + def test_a_container_states_its_item_types(self) -> None: + assert index_of(TAGS_SOURCE).item_types["FILTERS"] == ( + "ExportFormat", + "FileFilterElements", + ) + + def test_a_constant_states_its_value(self) -> None: + value = index_of(TAGS_SOURCE).constants["TAG_MAIN_WINDOW"] + assert ast.unparse(value) == "'main.window'" + + def test_every_module_of_the_tree_is_read(self) -> None: + index = index_of(TAGS_SOURCE, PANEL_SOURCE) + assert {"FILTERS", "LABELS"}.issubset(index.item_types) + + def test_the_module_read_last_states_a_shared_spelling(self) -> None: + index = index_of(TAGS_SOURCE, PANEL_SOURCE) + assert ast.unparse(index.constants["TAG_MAIN_WINDOW"]) == "'panel.window'" + + def test_a_tree_of_no_modules_states_nothing(self) -> None: + index = source_index([]) + assert (index.item_types, index.constants) == ({}, {}) diff --git a/tests/unit/sampletones_shared/meta/source/test_lookups.py b/tests/unit/sampletones_tools/checks/source/test_lookups.py similarity index 96% rename from tests/unit/sampletones_shared/meta/source/test_lookups.py rename to tests/unit/sampletones_tools/checks/source/test_lookups.py index 1db807dbc..613a8da8c 100644 --- a/tests/unit/sampletones_shared/meta/source/test_lookups.py +++ b/tests/unit/sampletones_tools/checks/source/test_lookups.py @@ -4,15 +4,15 @@ import pytest -from sampletones_shared.meta.source.index import source_index -from sampletones_shared.meta.source.lookups import ( +from sampletones_tools.checks.source.index import source_index +from sampletones_tools.checks.source.lookups import ( LookupSite, composed_values, module_lookups, tree_lookups, ) -from sampletones_shared.meta.source.modules import SourceModule -from sampletones_shared.meta.source.values import UNRESOLVED, EnumTable, ResolvedValues +from sampletones_tools.checks.source.modules import SourceModule +from sampletones_tools.checks.source.values import UNRESOLVED, EnumTable, ResolvedValues from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.source import parse_source diff --git a/tests/unit/sampletones_shared/meta/source/test_modules.py b/tests/unit/sampletones_tools/checks/source/test_modules.py similarity index 91% rename from tests/unit/sampletones_shared/meta/source/test_modules.py rename to tests/unit/sampletones_tools/checks/source/test_modules.py index 777586ccb..6c39c0fc4 100644 --- a/tests/unit/sampletones_shared/meta/source/test_modules.py +++ b/tests/unit/sampletones_tools/checks/source/test_modules.py @@ -5,7 +5,7 @@ import pytest -from sampletones_shared.meta.source.modules import ( +from sampletones_tools.checks.source.modules import ( discover_modules, is_visible, module_name, @@ -52,7 +52,7 @@ def test_a_location_names_the_path_and_the_line(self, tmp_path: Path) -> None: class TestIsVisible: def test_a_plain_path_is_visible(self) -> None: - assert is_visible(Path("src/sampletones_shared/meta/source/modules.py")) + assert is_visible(Path("src/sampletones_tools/checks/source/modules.py")) def test_a_hidden_directory_hides_the_path(self) -> None: assert not is_visible(Path(".venv/lib/python3.12/ast.py")) @@ -91,6 +91,12 @@ def test_a_hidden_directory_stays_aside(self, tmp_path: Path) -> None: write_module(tmp_path / ".cache", "cached.py", MODULE_BODY) assert source_paths([tmp_path]) == [visible] + def test_a_root_inside_a_hidden_folder_is_swept(self, tmp_path: Path) -> None: + """A worktree kept in a hidden folder is a tree to read, whatever the folders above it are named.""" + root = tmp_path / ".worktrees" / "branch" + path = write_module(root / "package", "first.py", MODULE_BODY) + assert source_paths([root]) == [path] + def test_a_file_of_another_kind_stays_aside(self, tmp_path: Path) -> None: visible = write_module(tmp_path, "first.py", MODULE_BODY) write_module(tmp_path, "notes.md", "# notes\n") diff --git a/tests/unit/sampletones_shared/meta/source/test_nodes.py b/tests/unit/sampletones_tools/checks/source/test_nodes.py similarity index 98% rename from tests/unit/sampletones_shared/meta/source/test_nodes.py rename to tests/unit/sampletones_tools/checks/source/test_nodes.py index 1d68f1b39..fc939274b 100644 --- a/tests/unit/sampletones_shared/meta/source/test_nodes.py +++ b/tests/unit/sampletones_tools/checks/source/test_nodes.py @@ -1,7 +1,7 @@ import ast from typing import Final, Iterable, List -from sampletones_shared.meta.source.nodes import ( +from sampletones_tools.checks.source.nodes import ( expression_spelling, is_attribute_spelling, nested_scopes, diff --git a/tests/unit/sampletones_tools/checks/source/test_packages.py b/tests/unit/sampletones_tools/checks/source/test_packages.py new file mode 100644 index 000000000..9ee79c658 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/source/test_packages.py @@ -0,0 +1,34 @@ +import pytest + +from sampletones_shared.paths.source import SOURCE_ROOT +from sampletones_tools.checks.source.packages import source_package_directory + +SHARED_PACKAGE = "sampletones_shared" +APPLICATION_PACKAGE = "sampletones_application" + + +class TestSourcePackageDirectory: + def test_a_top_level_package_sits_under_the_source_root(self) -> None: + assert source_package_directory(SHARED_PACKAGE) == SOURCE_ROOT / SHARED_PACKAGE + + def test_a_subpackage_is_named_part_by_part(self) -> None: + assert ( + source_package_directory(SHARED_PACKAGE, "utils", "system") + == SOURCE_ROOT / SHARED_PACKAGE / "utils" / "system" + ) + + def test_the_answer_is_a_directory_a_sweep_reads_under(self) -> None: + """A package resource resolves to `__init__.py`, which a sweep reads nothing under.""" + assert source_package_directory(APPLICATION_PACKAGE, "tags").is_dir() + + def test_a_package_the_source_root_holds_no_directory_for_raises(self) -> None: + with pytest.raises(NotADirectoryError): + source_package_directory("sampletones_absent") + + def test_a_module_named_as_a_package_raises(self) -> None: + with pytest.raises(NotADirectoryError): + source_package_directory(SHARED_PACKAGE, "paths.py") + + def test_the_report_names_the_path_it_looked_at(self) -> None: + with pytest.raises(NotADirectoryError, match="sampletones_absent"): + source_package_directory("sampletones_absent") diff --git a/tests/unit/sampletones_shared/meta/source/test_references.py b/tests/unit/sampletones_tools/checks/source/test_references.py similarity index 95% rename from tests/unit/sampletones_shared/meta/source/test_references.py rename to tests/unit/sampletones_tools/checks/source/test_references.py index 50456eef8..195d98aa6 100644 --- a/tests/unit/sampletones_shared/meta/source/test_references.py +++ b/tests/unit/sampletones_tools/checks/source/test_references.py @@ -1,6 +1,6 @@ from typing import Dict, Final -from sampletones_shared.meta.source.references import count_identifier_loads +from sampletones_tools.checks.source.references import count_identifier_loads from tests.suite.source import parse_source REFERENCES_SOURCE: Final[str] = """ diff --git a/tests/unit/sampletones_shared/meta/source/test_subscripts.py b/tests/unit/sampletones_tools/checks/source/test_subscripts.py similarity index 93% rename from tests/unit/sampletones_shared/meta/source/test_subscripts.py rename to tests/unit/sampletones_tools/checks/source/test_subscripts.py index 5285a216a..88437fc37 100644 --- a/tests/unit/sampletones_shared/meta/source/test_subscripts.py +++ b/tests/unit/sampletones_tools/checks/source/test_subscripts.py @@ -1,7 +1,7 @@ from typing import Final, List -from sampletones_shared.meta.source.bindings.scopes import module_scopes -from sampletones_shared.meta.source.subscripts import SubscriptSite, find_subscripts +from sampletones_tools.checks.source.bindings.scopes import module_scopes +from sampletones_tools.checks.source.subscripts import SubscriptSite, find_subscripts from tests.suite.source import parse_source, scope_named RECEIVER_TYPE: Final[str] = "LanguageManager" diff --git a/tests/unit/sampletones_tools/checks/source/test_values.py b/tests/unit/sampletones_tools/checks/source/test_values.py new file mode 100644 index 000000000..025ab8349 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/source/test_values.py @@ -0,0 +1,187 @@ +import ast +from dataclasses import dataclass +from typing import Dict, Final, Mapping, Tuple + +import pytest + +from sampletones_tools.checks.source.bindings.environment import TypeEnvironment +from sampletones_tools.checks.source.values import ( + UNRESOLVED, + EnumTable, + ResolvedValues, + ValueResolver, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ENUMS: Final[EnumTable] = { + "Page": {"GLOBAL": "global"}, + "Panel": {"DIALOG": "dialog"}, + "TextType": {"LABEL": "label"}, + "DialogElements": {"OK": "ok", "CANCEL": "cancel"}, + "MenuElements": {"GROUP_FILE": "group_file", "ITEM_EXIT": "item_exit"}, +} + +ENVIRONMENT: Final[TypeEnvironment] = TypeEnvironment( + types={ + "element": "DialogElements", + "self._element": "MenuElements", + "path": "Path", + } +) + + +def expression(text: str) -> ast.expr: + return ast.parse(text, mode="eval").body + + +CONSTANTS: Final[Mapping[str, ast.expr]] = { + "OK_KEY": expression("'global.dialog.label.ok'"), + "CHAINED_KEY": expression("OK_KEY"), + "CYCLE": expression("CYCLE"), + "BUILT": expression("build('global')"), +} + +RESOLVER: Final[ValueResolver] = ValueResolver( + environment=ENVIRONMENT, + enums=ENUMS, + constants=CONSTANTS, +) + + +class TestResolveValues(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expression: str + expected: Tuple[Tuple[str, ...], bool] + + test_cases = ( + TestCase( + label="string_literal", + expression="'global.dialog.label.ok'", + expected=(("global.dialog.label.ok",), True), + ), + TestCase( + label="enum_member", + expression="Page.GLOBAL", + expected=(("global",), True), + ), + TestCase( + label="enum_typed_name", + expression="element", + expected=(("ok", "cancel"), False), + ), + TestCase( + label="enum_typed_attribute", + expression="self._element", + expected=(("group_file", "item_exit"), False), + ), + TestCase( + label="enum_call", + expression="MenuElements(entry.action.value)", + expected=(("group_file", "item_exit"), False), + ), + TestCase( + label="conditional_over_members", + expression="DialogElements.OK if is_confirmation else DialogElements.CANCEL", + expected=(("ok", "cancel"), True), + ), + TestCase( + label="conditional_mixing_a_member_and_a_name", + expression="DialogElements.OK if is_confirmation else element", + expected=(("ok", "cancel"), False), + ), + TestCase( + label="constant_holding_a_key", + expression="OK_KEY", + expected=(("global.dialog.label.ok",), True), + ), + TestCase( + label="constant_naming_another_constant", + expression="CHAINED_KEY", + expected=(("global.dialog.label.ok",), True), + ), + TestCase( + label="member_absent_from_its_enum", + expression="Page.MISSING", + expected=((), False), + ), + TestCase( + label="attribute_of_another_type", + expression="settings.value", + expected=((), False), + ), + TestCase( + label="call_of_another_kind", + expression="str(key)", + expected=((), False), + ), + TestCase( + label="format_string", + expression="f'global.dialog.label.{name}'", + expected=((), False), + ), + TestCase(label="number", expression="4", expected=((), False)), + TestCase( + label="name_of_another_type", + expression="path", + expected=((), False), + ), + TestCase( + label="name_the_source_never_states", + expression="unknown", + expected=((), False), + ), + TestCase( + label="constant_naming_itself", + expression="CYCLE", + expected=((), False), + ), + TestCase( + label="constant_built_by_a_call", + expression="BUILT", + expected=((), False), + ), + TestCase( + label="conditional_reaching_an_unknown_branch", + expression="DialogElements.OK if is_confirmation else unknown", + expected=((), False), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_resolve(self, test_case: TestCase) -> None: + resolved = RESOLVER.resolve(expression(test_case.expression)) + assert (resolved.values, resolved.exact) == test_case.expected + + +class TestResolvedValues: + def test_values_mark_a_resolution(self) -> None: + assert ResolvedValues(values=("ok",), exact=True).resolved + + def test_an_empty_resolution_reaches_nothing(self) -> None: + assert not UNRESOLVED.resolved + + +class TestValueResolverTables: + def test_an_enum_absent_from_the_table_resolves_nothing(self) -> None: + resolver = ValueResolver( + environment=ENVIRONMENT, + enums={}, + constants={}, + ) + assert resolver.resolve(expression("Page.GLOBAL")) == UNRESOLVED + + def test_an_enum_holding_no_member_resolves_nothing(self) -> None: + enums: Dict[str, Dict[str, str]] = {"AbstractElement": {}} + environment = TypeEnvironment(types={"element": "AbstractElement"}) + resolver = ValueResolver( + environment=environment, + enums=enums, + constants={}, + ) + assert resolver.resolve(expression("element")) == UNRESOLVED diff --git a/tests/unit/sampletones_tools/checks/test_command.py b/tests/unit/sampletones_tools/checks/test_command.py new file mode 100644 index 000000000..6b3d52989 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/test_command.py @@ -0,0 +1,61 @@ +from argparse import ArgumentParser, Namespace +from typing import Final, List + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import build_parser, dispatch +from sampletones_shared.command import Command +from sampletones_tools.checks import command +from sampletones_tools.checks.registry import GATES + +GUARD: Final[str] = "sampletones_tools.checkout.require_checkout" + + +def _gate(name: str, calls: List[str]) -> Command: + def configure(parser: ArgumentParser) -> None: + parser.add_argument("--flag", action="store_true") + + def run(arguments: Namespace) -> int: + calls.append(f"{name} {arguments.flag}") + return 3 + + return Command(name=name, help=f"{name} help", configure=configure, run=run) + + +class TestCheck: + def test_every_gate_is_listed(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as leaving: + build_parser(COMMANDS).parse_args(["check", "--help"]) + + assert leaving.value.code == 0 + listing = capsys.readouterr().out + assert GATES + assert all(gate.name in listing for gate in GATES) + + def test_the_gate_named_runs_from_a_checkout(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls: List[str] = [] + guarded: List[str] = [] + monkeypatch.setattr(command, "GATES", (_gate("probe", calls),)) + monkeypatch.setattr(GUARD, guarded.append) + + assert dispatch(COMMANDS, ["check", "probe", "--flag"]) == 3 + assert calls == ["probe True"] + assert guarded == ["check probe"] + + def test_an_unknown_check_is_refused(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["check", "absent"]) + + assert leaving.value.code == 2 + + def test_a_check_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["check"]) + + assert leaving.value.code == 2 + + def test_every_gate_has_a_name_of_its_own(self) -> None: + names = [gate.name for gate in GATES] + + assert len(set(names)) == len(names) diff --git a/tests/unit/sampletones_tools/checks/test_import_boundary.py b/tests/unit/sampletones_tools/checks/test_import_boundary.py new file mode 100644 index 000000000..6f054f1f2 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/test_import_boundary.py @@ -0,0 +1,94 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_tools.checks import import_boundary as check_import_boundary +from tests.suite.source import write_module + +APPLICATION: Final[str] = "sampletones_application" + +VISUAL_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n" +PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n" +THIRD_PARTY_IMPORT: Final[str] = "import numpy\n" +INITIALIZER: Final[str] = "__init__.py" + + +def logic_package(source: Path) -> Path: + """The application's logic package in a source tree a test builds, each directory a package.""" + write_module(source / APPLICATION, INITIALIZER, "") + write_module(source / APPLICATION / "logic", INITIALIZER, "") + return source / APPLICATION / "logic" + + +class TestMain: + def test_the_repository_holds_its_import_boundaries(self) -> None: + assert dispatch(COMMANDS, ["check", "import-boundary", "--all"]) == 0 + + def test_a_forbidden_import_is_reported_where_it_sits( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + path = write_module(logic_package(tmp_path), "direct.py", VISUAL_IMPORT) + + exit_code = dispatch(COMMANDS, ["check", "import-boundary", "--all", "--source", str(tmp_path)]) + + assert exit_code == 1 + error = capsys.readouterr().err + assert f"{path}:1" in error + assert "dearpygui" in error + + def test_a_bootstrap_script_reaching_past_the_standard_library_is_reported( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + write_module(logic_package(tmp_path / "src"), "clean.py", PLAIN_IMPORT) + path = write_module(tmp_path / "scripts", "bundle.py", THIRD_PARTY_IMPORT) + + exit_code = dispatch( + COMMANDS, + [ + "check", + "import-boundary", + "--all", + "--source", + str(tmp_path / "src"), + "--scripts", + str(tmp_path / "scripts"), + ], + ) + + assert exit_code == 1 + error = capsys.readouterr().err + assert f"{path}:1" in error + assert "system interpreter" in error + + def test_named_files_narrow_the_run_to_themselves( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + write_module(logic_package(tmp_path), "reported.py", VISUAL_IMPORT) + clean = write_module(logic_package(tmp_path), "clean.py", PLAIN_IMPORT) + + assert dispatch(COMMANDS, ["check", "import-boundary", str(clean), "--source", str(tmp_path)]) == 0 + assert capsys.readouterr().err == "" + + def test_a_directory_without_an_initializer_is_reported( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + write_module(logic_package(tmp_path), "clean.py", PLAIN_IMPORT) + write_module(tmp_path / APPLICATION / "ui" / "elements", "trace.py", "") + + exit_code = dispatch(COMMANDS, ["check", "import-boundary", "--all", "--source", str(tmp_path)]) + + assert exit_code == 1 + error = capsys.readouterr().err + assert "namespace package" in error + assert str((tmp_path / APPLICATION / "ui" / "elements").resolve()) in error diff --git a/tests/unit/scripts/checks/test_language_keys.py b/tests/unit/sampletones_tools/checks/test_language_keys.py similarity index 94% rename from tests/unit/scripts/checks/test_language_keys.py rename to tests/unit/sampletones_tools/checks/test_language_keys.py index 3e74ffab2..6f47d2b5b 100644 --- a/tests/unit/scripts/checks/test_language_keys.py +++ b/tests/unit/sampletones_tools/checks/test_language_keys.py @@ -3,15 +3,15 @@ import pytest +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch from sampletones_application.categories.elements.global_ import DialogElements from sampletones_application.logic.history.action import HistoryAction from sampletones_application.paths import LANG_EN -from sampletones_shared.meta.source.lookups import LookupSite -from sampletones_shared.meta.source.modules import source_paths -from sampletones_shared.meta.source.values import EnumTable -from tests.suite.scripts import load_script - -check_language_keys = load_script("checks/language_keys.py") +from sampletones_tools.checks import language_keys as check_language_keys +from sampletones_tools.checks.source.lookups import LookupSite +from sampletones_tools.checks.source.modules import source_paths +from sampletones_tools.checks.source.values import EnumTable ENUMS: Final[EnumTable] = check_language_keys.enum_table() @@ -251,7 +251,7 @@ def test_the_language_file_is_there_to_read(self) -> None: class TestMain: def test_the_repository_and_its_language_file_agree(self) -> None: - assert check_language_keys.main([]) == 0 + assert dispatch(COMMANDS, ["check", "language-keys"]) == 0 def test_a_disagreement_is_reported_by_kind( self, @@ -261,7 +261,9 @@ def test_a_disagreement_is_reported_by_kind( source = source_tree(tmp_path, LOOKUP_SOURCE.format(key=ABSENT_KEY)) entries = language_file(tmp_path, f'{OK_KEY}: "OK"\n') - exit_code = check_language_keys.main(["--source", str(source), "--language-file", str(entries)]) + exit_code = dispatch( + COMMANDS, ["check", "language-keys", "--source", str(source), "--language-file", str(entries)] + ) assert exit_code == 1 error = capsys.readouterr().err diff --git a/tests/unit/sampletones_tools/checks/test_palette_colors.py b/tests/unit/sampletones_tools/checks/test_palette_colors.py new file mode 100644 index 000000000..c0bc27174 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/test_palette_colors.py @@ -0,0 +1,153 @@ +from pathlib import Path +from typing import Final, List, Tuple + +from pytest import fixture + +from sampletones_application.paths import PALETTES_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY +from sampletones_tools.checks import palette_colors as check_palette_colors +from sampletones_tools.checks.palette_colors import dpg_module_helper +from sampletones_tools.checks.source.modules import SourceModule, source_paths +from tests.suite.source import parse_source + +PANEL_MODULE: Final[Path] = Path("ui/panel.py") + +PANEL_SOURCE: Final[str] = """ +class GUIPanel: + def __init__(self, layout) -> None: + self._tint = layout.colors.accent.rgba + self._colors = layout.colors + cls._border: ColorRGBA = layout.colors.border.rgba + self._theme = create_theme(layout.colors.text.rgba) + local = layout.colors.text.rgba + + def draw(self) -> None: + dpg.add_text("value", color=self._colors.accent.rgba) +""" + +PALETTE_FILE: Final[str] = 'colors:\n accent: "#a97fe3"\n' +LAYOUT_FILE: Final[str] = 'label_color: .accent\nclip_color: "#ff5555"\n' + + +@fixture +def module_helpers() -> Tuple[Path, str]: + return dpg_module_helper() + + +def messages(source: str) -> List[str]: + module = SourceModule(path=PANEL_MODULE, tree=parse_source(source)) + return [finding.message for finding in check_palette_colors.stored_colors(module)] + + +def locations(source: str) -> List[str]: + module = SourceModule(path=PANEL_MODULE, tree=parse_source(source)) + return [finding.location for finding in check_palette_colors.stored_colors(module)] + + +def theme_color_messages( + source: str, + module_helpers: Tuple[Path, str], + path: Path = PANEL_MODULE, +) -> List[str]: + bindings_module, theme_color_helper = module_helpers + module = SourceModule(path=path, tree=parse_source(source)) + return [ + finding.message + for finding in check_palette_colors.unregistered_theme_colors( + module, + bindings_module=bindings_module, + theme_color_helper=theme_color_helper, + ) + ] + + +class TestStoredColors: + def test_an_attribute_assigned_the_resolved_value_is_reported(self) -> None: + assert len(messages(PANEL_SOURCE)) == 2 + + 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_color_passes(self) -> None: + source = "class GUIPanel:\n def __init__(self, layout) -> None:\n self._c = layout.colors\n" + + assert not messages(source) + + def test_a_resolved_value_handed_straight_to_dearpygui_passes(self) -> None: + assert not messages('def draw(self) -> None:\n dpg.add_text("v", color=self._colors.accent.rgba)\n') + + def test_a_resolved_value_reaching_a_call_passes(self) -> None: + assert not messages("class P:\n def f(self, layout) -> None:\n self._t = build(layout.c.text.rgba)\n") + + def test_a_local_holding_the_resolved_value_passes(self) -> None: + assert not messages("def f(layout) -> None:\n local = layout.colors.text.rgba\n") + + +class TestUnregisteredThemeColors: + def test_a_theme_color_filled_directly_is_reported( + self, + module_helpers: Tuple[Path, str], + ) -> None: + source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" + + assert len(theme_color_messages(source, module_helpers)) == 1 + + def test_the_helper_that_records_it_passes( + self, + module_helpers: Tuple[Path, str], + ) -> None: + source = "def build() -> None:\n dpg_add_palette_theme_color(dpg.mvThemeCol_Text, color)\n" + + assert not theme_color_messages(source, module_helpers) + + def test_the_bindings_module_may_fill_it( + self, + module_helpers: Tuple[Path, str], + ) -> None: + """The helper is where the call belongs, since it records the token in the same breath.""" + bindings_module, _ = module_helpers + source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" + + assert not theme_color_messages(source, module_helpers, bindings_module) + + def test_a_theme_style_passes( + self, + module_helpers: Tuple[Path, str], + ) -> None: + source = "def build() -> None:\n dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 0, 0)\n" + + assert not theme_color_messages(source, module_helpers) + + +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_palette_colors.APPLICATION_PACKAGE]) + + def test_the_configuration_package_holds_files_to_read(self) -> None: + assert list(CONFIG_DIRECTORY.rglob(check_palette_colors.CONFIG_PATTERN)) + + def test_the_palettes_sit_inside_the_configuration_package(self) -> None: + assert CONFIG_DIRECTORY in PALETTES_DIRECTORY.parents + + +class TestLiteralColors: + 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_colors_as_values(self, tmp_path: Path) -> None: + palettes = tmp_path / "palettes" + palettes.mkdir() + (palettes / "studio.yaml").write_text(PALETTE_FILE) + + assert check_palette_colors.find_literal_colors(tmp_path, palettes) == [] + + def test_a_token_reference_passes(self, tmp_path: Path) -> None: + (tmp_path / "settings.yaml").write_text("label_color: .accent\n") + + assert check_palette_colors.find_literal_colors(tmp_path, tmp_path / "palettes") == [] diff --git a/tests/unit/sampletones_tools/checks/test_paths.py b/tests/unit/sampletones_tools/checks/test_paths.py new file mode 100644 index 000000000..d94e64cef --- /dev/null +++ b/tests/unit/sampletones_tools/checks/test_paths.py @@ -0,0 +1,6 @@ +from sampletones_tools.checks.paths import SCRIPTS_ROOT + + +class TestScriptsRoot: + def test_the_scripts_root_holds_the_bootstrap_tree(self) -> None: + assert (SCRIPTS_ROOT / "bootstrap" / "__init__.py").is_file() diff --git a/tests/unit/sampletones_tools/checks/test_rendered_literals.py b/tests/unit/sampletones_tools/checks/test_rendered_literals.py new file mode 100644 index 000000000..833df5d15 --- /dev/null +++ b/tests/unit/sampletones_tools/checks/test_rendered_literals.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_tools.checks import rendered_literals as check_rendered_literals +from sampletones_tools.checks.source.modules import SourceModule +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.source import parse_source + +CASE_MODULE: Final[Path] = Path("tests/unit/test_card.py") + + +def locations(source: str) -> List[str]: + module = SourceModule(path=CASE_MODULE, tree=parse_source(source)) + return [finding.location for finding in check_rendered_literals.findings([module])] + + +def renderings(source: str) -> List[str]: + module = SourceModule(path=CASE_MODULE, tree=parse_source(source)) + return [finding.rendering for finding in check_rendered_literals.findings([module])] + + +class TestWhatIsReported(BaseTestSuite): + """A case rendering a value and holding the result against a written-out string. + + Which rendering it used is named in the report, so a reader meets the expression the finding is + about rather than hunting for it on the line. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + source: str + expected: str + + test_cases = ( + TestCase( + label="rendered_on_the_left", + source='assert str(view.path) == "/audio/kick.wav"\n', + expected="str()", + ), + TestCase( + label="rendered_on_the_right", + source='assert "/audio/kick.wav" == str(view.path)\n', + expected="str()", + ), + TestCase( + label="rendered_by_a_template", + source='assert f"{view.path}" == "/audio/kick.wav"\n', + expected="an f-string", + ), + TestCase( + label="rendered_inside_a_chain", + source='assert "a" == str(view.path) == other\n', + expected="str()", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_is_reported_where_it_sits(self, test_case: TestCase) -> None: + assert locations(test_case.source) == [f"{CASE_MODULE}:1"] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rendering_it_used_is_named(self, test_case: TestCase) -> None: + assert renderings(test_case.source) == [test_case.expected] + + +class TestWhatIsLeftAlone(BaseTestSuite): + """A case comparing values, or rendering both sides, states no one platform's answer. + + A call the case makes is left alone whatever it returns, which is what keeps the check to the + one shape it is about rather than to every comparison holding a string. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + source: str + + test_cases = ( + TestCase(label="a_path_against_a_path", source='assert view.path == Path("/audio/kick.wav")\n'), + TestCase(label="both_sides_rendered", source="assert str(view.path) == str(expected)\n"), + TestCase(label="a_value_against_a_literal", source='assert view.name == "kick"\n'), + TestCase( + label="another_call_against_a_literal", + source='assert abbreviate_channel_names([PULSE1]) == "P"\n', + ), + TestCase(label="a_template_holding_no_value", source='assert f"kick" == "kick"\n'), + TestCase(label="a_fragment_read_out_of_a_rendering", source='assert "kick" in str(view.path)\n'), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_is_passed_over(self, test_case: TestCase) -> None: + assert locations(test_case.source) == [] + + +class TestMain: + def test_no_case_in_the_repository_holds_rendered_text_against_a_literal(self) -> None: + assert dispatch(COMMANDS, ["check", "rendered-literals"]) == 0 + + def test_a_case_that_does_is_reported_where_it_sits( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + cases = tmp_path / "cases" + cases.mkdir() + module = cases / "test_card.py" + module.write_text( + 'def test_it() -> None:\n assert str(view.path) == "/audio/kick.wav"\n', + encoding="utf-8", + ) + + exit_code = dispatch(COMMANDS, ["check", "rendered-literals", "--tests", str(cases)]) + + assert exit_code == 1 + assert f"{module}:2" in capsys.readouterr().err diff --git a/tests/unit/sampletones_tools/checks/test_shortcut_actions.py b/tests/unit/sampletones_tools/checks/test_shortcut_actions.py new file mode 100644 index 000000000..1c821a66a --- /dev/null +++ b/tests/unit/sampletones_tools/checks/test_shortcut_actions.py @@ -0,0 +1,115 @@ +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_tools.checks import shortcut_actions as check_shortcut_actions +from sampletones_tools.checks.source.modules import SourceModule +from tests.suite.source import parse_source + +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_tag_names.py b/tests/unit/sampletones_tools/checks/test_tag_names.py similarity index 93% rename from tests/unit/scripts/checks/test_tag_names.py rename to tests/unit/sampletones_tools/checks/test_tag_names.py index 3b03f4fcb..a9b7d51b4 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/sampletones_tools/checks/test_tag_names.py @@ -4,17 +4,17 @@ import pytest +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch from sampletones_application.categories.hierarchy import Page, Panel, Widget from sampletones_application.categories.key.tag import TagName -from sampletones_shared.meta.source.modules import SourceModule, source_paths from sampletones_shared.paths.source import SOURCE_ROOT +from sampletones_tools.checks import tag_names as check_tag_names +from sampletones_tools.checks.source.modules import SourceModule, source_paths from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase -from tests.suite.scripts import load_script from tests.suite.source import parse_source -check_tag_names = load_script("checks/tag_names.py") - MODULE_PATH: Final[Path] = Path("src/sampletones_application/tags/general.py") WINDOW_TAG: Final[str] = 'TagName(Page.GLOBAL, Panel.IMPLICIT, Widget.WINDOW, "main")' @@ -157,7 +157,7 @@ def test_the_tags_package_sits_under_the_source_root(self) -> None: class TestMain: def test_the_tags_package_names_its_tags_after_them(self) -> None: - assert check_tag_names.main(["--all"]) == 0 + assert dispatch(COMMANDS, ["check", "tag-names", "--all"]) == 0 def test_a_mistyped_tag_is_reported_where_it_sits( self, @@ -167,7 +167,7 @@ def test_a_mistyped_tag_is_reported_where_it_sits( module = tmp_path / "tags.py" module.write_text(f"TAG_GLOBAL_WINDOW_MIAN = {WINDOW_TAG}\n", encoding="utf-8") - assert check_tag_names.main([str(module)]) == 1 + assert dispatch(COMMANDS, ["check", "tag-names", str(module)]) == 1 error = capsys.readouterr().err assert f"{module}:1" in error @@ -181,5 +181,5 @@ def test_a_well_named_module_reports_nothing( module = tmp_path / "tags.py" module.write_text(f"TAG_GLOBAL_WINDOW_MAIN = {WINDOW_TAG}\n", encoding="utf-8") - assert check_tag_names.main([str(module)]) == 0 + assert dispatch(COMMANDS, ["check", "tag-names", str(module)]) == 0 assert capsys.readouterr().err == "" diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/sampletones_tools/checks/test_unused_tags.py similarity index 84% rename from tests/unit/scripts/checks/test_unused_tags.py rename to tests/unit/sampletones_tools/checks/test_unused_tags.py index 167e4a90a..633fa86a8 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/sampletones_tools/checks/test_unused_tags.py @@ -3,13 +3,13 @@ import pytest -from sampletones_shared.meta.source.modules import SourceModule, source_paths +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch from sampletones_shared.paths.source import SOURCE_ROOT -from tests.suite.scripts import load_script +from sampletones_tools.checks import unused_tags as check_unused_tags +from sampletones_tools.checks.source.modules import SourceModule, source_paths from tests.suite.source import parse_source -check_unused_tags = load_script("checks/unused_tags.py") - TAGS_MODULE: Final[Path] = Path("tags/general.py") PANEL_MODULE: Final[Path] = Path("ui/panel.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) == [] @@ -102,7 +102,7 @@ def test_the_tags_package_sits_under_the_source_root(self) -> None: class TestMain: def test_the_repository_reads_every_fragment_it_declares(self) -> None: - assert check_unused_tags.main([]) == 0 + assert dispatch(COMMANDS, ["check", "unused-tags"]) == 0 def test_an_unread_fragment_is_reported_where_it_sits( self, @@ -116,7 +116,7 @@ def test_an_unread_fragment_is_reported_where_it_sits( sources.mkdir() (sources / "panel.py").write_text("show(TAG_READ)\n", encoding="utf-8") - exit_code = check_unused_tags.main(["--tags", str(tags), "--reference-root", str(sources)]) + exit_code = dispatch(COMMANDS, ["check", "unused-tags", "--tags", str(tags), "--reference-root", str(sources)]) assert exit_code == 1 error = capsys.readouterr().err diff --git a/tests/unit/sampletones_tools/codec/study/report/test_run.py b/tests/unit/sampletones_tools/codec/study/report/test_run.py new file mode 100644 index 000000000..dbdf563ec --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/report/test_run.py @@ -0,0 +1,38 @@ +import subprocess +from typing import List, Sequence + +import pytest + +from sampletones_tools.codec.study.report import run +from sampletones_tools.codec.study.report.run import UNKNOWN_COMMIT, commit_hash + + +class TestCommitHash: + def test_a_copy_outside_a_checkout_records_unknown_without_asking_git( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + asked: List[Sequence[str]] = [] + monkeypatch.setattr(run, "is_checkout", lambda root: False) + monkeypatch.setattr(run.subprocess, "run", lambda command, **options: asked.append(command)) + + assert commit_hash() == UNKNOWN_COMMIT + assert asked == [] + + def test_a_machine_without_git_records_unknown(self, monkeypatch: pytest.MonkeyPatch) -> None: + def missing_git(command: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]: + raise FileNotFoundError(command[0]) + + monkeypatch.setattr(run, "is_checkout", lambda root: True) + monkeypatch.setattr(run.subprocess, "run", missing_git) + + assert commit_hash() == UNKNOWN_COMMIT + + def test_a_checkout_records_what_git_reports(self, monkeypatch: pytest.MonkeyPatch) -> None: + def reporting_git(command: Sequence[str], **options: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 0, stdout="abc1234\n", stderr="") + + monkeypatch.setattr(run, "is_checkout", lambda root: True) + monkeypatch.setattr(run.subprocess, "run", reporting_git) + + assert commit_hash() == "abc1234" diff --git a/tests/unit/sampletones_tools/codec/study/test_accounting.py b/tests/unit/sampletones_tools/codec/study/test_accounting.py new file mode 100644 index 000000000..3a11c4e33 --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_accounting.py @@ -0,0 +1,245 @@ +from dataclasses import dataclass +from typing import Dict, Final, Sequence, Tuple + +import pytest + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.encode import emit +from sampletones_player.compression.planes.order import PlaneOrder +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, TokenTag +from sampletones_player.specification.planes import PLANES, PlaneRole +from sampletones_tools.codec.study.accounting.coincident import coincident_starts +from sampletones_tools.codec.study.accounting.dictionary import plateaus +from sampletones_tools.codec.study.accounting.finding import Finding +from sampletones_tools.codec.study.accounting.pairs import ramps_in_literals, set_holds +from sampletones_tools.codec.study.accounting.runs import ramps, runs +from sampletones_tools.codec.study.accounting.shares import hold_chains +from sampletones_tools.codec.study.accounting.tokens import ReadToken, read_tokens +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ESCAPED_PHRASE_ID: Final[int] = 70 +REST: Final[int] = 32 + + +class TestReadTokensReadsBackWhatEmitWrote: + """Every token comes back with its tag, where it starts, what it takes and what it carries.""" + + tokens: Final[Tuple[TokenUnion, ...]] = ( + HoldToken(ticks=3), + LiteralToken(values=b"\x01\x02"), + PhraseToken(phrase_id=2, ticks=4, transpose=0, default=False), + PhraseToken(phrase_id=ESCAPED_PHRASE_ID, ticks=2, transpose=3, default=False), + ) + + def test_the_tokens_come_back_in_order(self) -> None: + read = read_tokens(emit(self.tokens), DICTIONARY) + + assert [token.tag for token in read] == [ + TokenTag.HOLD, + TokenTag.LITERAL, + TokenTag.PHRASE, + TokenTag.TRANSPOSED_PHRASE, + ] + assert [token.tick for token in read] == [0, 3, 5, 9] + assert [token.ticks for token in read] == [3, 2, 4, 2] + assert [token.size for token in read] == [token.size for token in self.tokens] + + def test_the_operands_come_back(self) -> None: + read = read_tokens(emit(self.tokens), DICTIONARY) + + assert [token.payload for token in read] == [b"", b"\x01\x02", b"", b""] + assert [token.phrase_id for token in read] == [None, None, 2, ESCAPED_PHRASE_ID] + assert [token.transpose for token in read] == [0, 0, 0, 3] + + +class TestRuns(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + data: bytes + expected: Tuple[int, ...] + + test_cases = ( + TestCase(label="empty", data=b"", expected=()), + TestCase(label="one value", data=b"\x07", expected=(1,)), + TestCase(label="two plateaus", data=b"\x01\x01\x01\x02\x02", expected=(3, 2)), + TestCase(label="no repeat", data=b"\x01\x02\x03", expected=(1, 1, 1)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_run_lengths_cover_the_data(self, test_case: TestCase) -> None: + assert runs(test_case.data) == test_case.expected + assert sum(test_case.expected) == len(test_case.data) + + +class TestRamps(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + data: bytes + expected: Tuple[int, ...] + + test_cases = ( + TestCase(label="a rise", data=b"\x01\x02\x03\x04\x05", expected=(5,)), + TestCase(label="a plateau steps nowhere", data=b"\x05\x05\x05", expected=()), + TestCase(label="a rise across the byte wraps", data=b"\xfe\xff\x00\x01", expected=(4,)), + TestCase(label="two steps of different size", data=b"\x01\x02\x04", expected=(2, 2)), + TestCase(label="a fall after a rest", data=b"\x09\x09\x08\x07", expected=(3,)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_constant_step_runs_are_found(self, test_case: TestCase) -> None: + assert ramps(test_case.data) == test_case.expected + + +DICTIONARY: Final[PhraseTable] = phrase_table(tuple(Phrase(body=bytes((value,))) for value in range(4))) + + +class TestHoldChains(BaseTestSuite): + """A wide hold pays two bytes for up to sixty-four full holds, and one for the ticks left.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + holds: Tuple[int, ...] + expected: Finding + + test_cases = ( + TestCase(label="one hold is no chain", holds=(MAX_HOLD_TICKS,), expected=Finding(0, 0)), + TestCase(label="a full hold and a rest break even", holds=(MAX_HOLD_TICKS, REST), expected=Finding(2, 0)), + TestCase( + label="three full holds and a rest spare one", + holds=(MAX_HOLD_TICKS, MAX_HOLD_TICKS, MAX_HOLD_TICKS, REST), + expected=Finding(4, 1), + ), + TestCase( + label="sixty-four full holds become one wide hold", + holds=(MAX_HOLD_TICKS,) * MAX_HOLD_TICKS, + expected=Finding(MAX_HOLD_TICKS, MAX_HOLD_TICKS - 2), + ), + TestCase( + label="sixty-five full holds need two wide holds", + holds=(MAX_HOLD_TICKS,) * (MAX_HOLD_TICKS + 1), + expected=Finding(MAX_HOLD_TICKS + 1, MAX_HOLD_TICKS + 1 - 4), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_chain_is_priced_against_wide_holds(self, test_case: TestCase) -> None: + stream = emit([HoldToken(ticks=ticks) for ticks in test_case.holds]) + + assert hold_chains(read_tokens(stream, DICTIONARY)) == test_case.expected + + def test_a_literal_breaks_the_chain(self) -> None: + stream = emit( + [ + HoldToken(ticks=MAX_HOLD_TICKS), + LiteralToken(values=b"\x01"), + HoldToken(ticks=MAX_HOLD_TICKS), + HoldToken(ticks=MAX_HOLD_TICKS), + ] + ) + + assert hold_chains(read_tokens(stream, DICTIONARY)) == Finding(2, 0) + + +class TestSetHolds(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + tokens: Tuple[TokenUnion, ...] + expected: Finding + + test_cases = ( + TestCase( + label="a value then a rest is a pair", + tokens=(LiteralToken(values=b"\x05"), HoldToken(ticks=10)), + expected=Finding(3, 1), + ), + TestCase( + label="two values then a rest is no pair", + tokens=(LiteralToken(values=b"\x05\x06"), HoldToken(ticks=10)), + expected=Finding(0, 0), + ), + TestCase( + label="a rest then a value is no pair", + tokens=(HoldToken(ticks=10), LiteralToken(values=b"\x05")), + expected=Finding(0, 0), + ), + TestCase( + label="every pair counts", + tokens=( + LiteralToken(values=b"\x05"), + HoldToken(ticks=10), + LiteralToken(values=b"\x06"), + HoldToken(ticks=10), + ), + expected=Finding(6, 2), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_pairs_are_priced_at_two_bytes(self, test_case: TestCase) -> None: + assert set_holds(read_tokens(emit(list(test_case.tokens)), DICTIONARY)) == test_case.expected + + +class TestRampsInLiterals(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + values: bytes + expected: Finding + + test_cases = ( + TestCase(label="a rise of five", values=b"\x01\x02\x03\x04\x05", expected=Finding(5, 2)), + TestCase(label="a rise of three breaks even", values=b"\x01\x02\x03", expected=Finding(3, 0)), + TestCase(label="a rise of two is no ramp", values=b"\x01\x02", expected=Finding(0, 0)), + TestCase(label="a plateau is no ramp", values=b"\x05\x05\x05\x05", expected=Finding(0, 0)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_ramps_are_priced_at_three_bytes(self, test_case: TestCase) -> None: + assert ( + ramps_in_literals(read_tokens(emit([LiteralToken(values=test_case.values)]), DICTIONARY)) + == test_case.expected + ) + + def test_a_ramp_inside_a_hold_is_none(self) -> None: + assert ramps_in_literals(read_tokens(emit([HoldToken(ticks=5)]), DICTIONARY)) == Finding(0, 0) + + +def _starting_at(ticks: Sequence[int]) -> Tuple[ReadToken, ...]: + return tuple( + ReadToken( + tag=TokenTag.HOLD, + tick=tick, + size=1, + ticks=1, + payload=b"", + phrase_id=None, + transpose=0, + ) + for tick in ticks + ) + + +class TestCoincidentStarts: + """Every channel carrying a timbre shares its first tick, and pulse one shares a second.""" + + def test_a_channel_counts_the_ticks_both_its_planes_start_a_token_on(self) -> None: + tokens: Dict[str, Tuple[ReadToken, ...]] = {name: _starting_at((0,)) for name in PlaneOrder.names()} + tokens["pulse1_control"] = _starting_at((0, 5)) + tokens["pulse1_value"] = _starting_at((0, 5, 9)) + tokens["noise_value"] = _starting_at((0, 7)) + timbred = sum(1 for plane in PLANES if plane.role is PlaneRole.CONTROL) + shared = timbred + 1 + + assert coincident_starts(tokens) == Finding(2 * shared, shared) + + +class TestPlateausInBodies: + def test_the_repeats_beyond_a_runs_first_byte_are_targeted(self) -> None: + table = phrase_table((Phrase(body=b"\x01\x01\x01\x02\x02"), Phrase(body=b"\x03\x04"))) + + assert plateaus(table) == Finding(3, 1) diff --git a/tests/unit/sampletones_tools/codec/study/test_layouts.py b/tests/unit/sampletones_tools/codec/study/test_layouts.py new file mode 100644 index 000000000..57ac13a04 --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_layouts.py @@ -0,0 +1,198 @@ +from pathlib import Path +from typing import Dict, Final, List, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ALL_CHANNELS, ChannelName +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.timers.utils import get_timer_table +from sampletones_player.builder import streams_from_instructions +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.separate import channel_planes, planes_from_streams +from sampletones_player.compression.seeds import phrases_from_project +from sampletones_player.registers.channel import channel_registers +from sampletones_player.specification.binary import unsigned_byte +from sampletones_player.specification.compression import BEND_FLAG +from sampletones_player.specification.planes import PLANES +from sampletones_shared.music import Tuning +from sampletones_tools.codec.study.corpus.notes import channel_notes, song_notes +from sampletones_tools.codec.study.corpus.slices import project_slices +from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong +from sampletones_tools.codec.study.layouts.encode import encode_layout +from sampletones_tools.codec.study.layouts.layout import Anchor, BendForm, PlaneLayout +from sampletones_tools.codec.study.layouts.planes import layout_planes, layout_seeds +from sampletones_tools.codec.study.layouts.tone import tone_planes +from tests.suite.performance import project_with_instrument +from tests.suite.player import PLAYER_FULL_VOLUME, PLAYER_REFERENCE_PITCH +from tests.suite.study import NO_STUDY_SLICES + +TUNING: Final[Tuning] = Tuning() +PITCHES: Final[PitchTable] = PitchTable.from_tuning(TUNING) +TIMER_TABLE: Final[Dict[int, int]] = get_timer_table(TUNING) +LOW_PITCH: Final[int] = PLAYER_REFERENCE_PITCH - 12 +PAST_HALFWAY: Final[int] = -20 +BEYOND_A_BYTE: Final[int] = 10 +ROWS_PER_PATTERN: Final[int] = 8 + +NEAREST_DENSE: Final[PlaneLayout] = PlaneLayout(Anchor.NEAREST, BendForm.DENSE) +NAMED_DENSE: Final[PlaneLayout] = PlaneLayout(Anchor.NAMED, BendForm.DENSE) +PRODUCTION: Final[PlaneLayout] = PlaneLayout(Anchor.NAMED, BendForm.FLAGGED_NOTES) +EVERY_LAYOUT: Final[Tuple[PlaneLayout, ...]] = tuple( + PlaneLayout(anchor, form) for anchor in Anchor for form in BendForm +) + + +def bent_frames(pitch: int, bends: Sequence[int]) -> List[InstructionUnion]: + return [ + PulseInstruction(on=True, pitch=pitch, volume=PLAYER_FULL_VOLUME, duty_cycle=0, detune=bend) for bend in bends + ] + + +def coarse_frame(pitch: int, coarse: int) -> List[InstructionUnion]: + return [PulseInstruction(on=True, pitch=pitch, volume=PLAYER_FULL_VOLUME, duty_cycle=0, coarse_detune=coarse)] + + +VALUE_PLANE: Final[int] = 1 + + +def pulse_planes(instructions: Sequence[InstructionUnion]) -> Tuple[bytes, ...]: + registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: instructions}, TIMER_TABLE) + return channel_planes(ChannelName.PULSE1, registers, PITCHES) + + +def written(instructions: Sequence[InstructionUnion], layout: PlaneLayout) -> Tuple[bytes, bytes, bytes]: + return tone_planes( + pulse_planes(instructions), + channel_notes(ChannelName.PULSE1, instructions), + PITCHES, + layout, + ) + + +def study_song(instructions: Sequence[InstructionUnion]) -> StudySong: + played = {ChannelName.PULSE1: instructions} + streams = streams_from_instructions(played, TIMER_TABLE) + return StudySong( + name="bent", + group=SongGroup.RECONSTRUCTION, + source=Path("bent.stn"), + planes=planes_from_streams(streams, PITCHES), + seeds=(), + pitches=PITCHES, + notes=song_notes(played, streams.ticks), + slices=NO_STUDY_SLICES, + ) + + +class TestTheNotesATickNames: + """The notes line up with the ticks a tone channel's registers cover.""" + + def test_the_notes_cover_the_registers_ticks(self) -> None: + instructions = bent_frames(PLAYER_REFERENCE_PITCH, (0, 3)) + registers = channel_registers(ChannelName.PULSE1, {ChannelName.PULSE1: instructions}, TIMER_TABLE) + assert channel_notes(ChannelName.PULSE1, instructions) == bytes((PLAYER_REFERENCE_PITCH,)) * len(registers) + + def test_a_rest_holds_the_note_last_sounded(self) -> None: + instructions = [*bent_frames(LOW_PITCH, (0,)), PulseInstruction.null_instruction()] + assert channel_notes(ChannelName.PULSE1, instructions) == bytes((LOW_PITCH, LOW_PITCH)) + + def test_a_channel_running_out_holds_its_note_through_the_song(self) -> None: + notes = song_notes({ChannelName.PULSE1: bent_frames(LOW_PITCH, (0,))}, 4) + assert notes[0] == bytes((LOW_PITCH,)) * 4 + + def test_the_noise_channel_names_no_note(self) -> None: + with pytest.raises(ValueError): + channel_notes(ChannelName.NOISE, []) + + +class TestWhatTheValuePlaneNames: + def test_the_named_flagged_notes_layout_is_the_production_separation(self) -> None: + instructions = [ + *bent_frames(PLAYER_REFERENCE_PITCH, (0, 5, PAST_HALFWAY, -5)), + *coarse_frame(PLAYER_REFERENCE_PITCH, BEYOND_A_BYTE), + ] + planes = pulse_planes(instructions) + assert written(instructions, PRODUCTION) == planes + + def test_a_named_note_keeps_a_bend_past_halfway(self) -> None: + instructions = bent_frames(PLAYER_REFERENCE_PITCH, (PAST_HALFWAY,)) + _, value, bend = written(instructions, NAMED_DENSE) + _, nearest, _ = written(instructions, NEAREST_DENSE) + assert PITCHES.timers[value[0]] == TIMER_TABLE[PLAYER_REFERENCE_PITCH] + assert bend[0] == unsigned_byte(PAST_HALFWAY) + assert nearest[0] != value[0] + + def test_a_bend_beyond_a_byte_falls_back_to_the_nearest_pitch(self) -> None: + instructions = coarse_frame(PLAYER_REFERENCE_PITCH, BEYOND_A_BYTE) + assert written(instructions, NAMED_DENSE) == written(instructions, NEAREST_DENSE) + + def test_a_named_bend_survives_a_transposition_byte_for_byte(self) -> None: + """The song walk moves a transposed note and keeps its bend in divider steps.""" + bends = (0, PAST_HALFWAY, PAST_HALFWAY, 0) + low = written(bent_frames(LOW_PITCH, bends), NAMED_DENSE) + high = written(bent_frames(PLAYER_REFERENCE_PITCH, bends), NAMED_DENSE) + assert low[2] == high[2] + assert ( + written(bent_frames(LOW_PITCH, bends), NEAREST_DENSE)[2] + != written(bent_frames(PLAYER_REFERENCE_PITCH, bends), NEAREST_DENSE)[2] + ) + + +class TestAFlaggedBendPlane: + def test_the_flag_marks_each_bent_tick_and_the_plane_holds_those_alone(self) -> None: + bends = (0, 3, 0, -3, 0) + _, value, bend = written( + bent_frames(PLAYER_REFERENCE_PITCH, bends), PlaneLayout(Anchor.NEAREST, BendForm.FLAGGED_TICKS) + ) + assert [bool(byte & BEND_FLAG) for byte in value[: len(bends)]] == [offset != 0 for offset in bends] + assert bend == bytes(unsigned_byte(offset) for offset in bends if offset) + + def test_a_flagged_note_spans_its_first_bend_to_its_last(self) -> None: + bends = (0, 3, 0, -3, 0) + _, value, bend = written( + bent_frames(PLAYER_REFERENCE_PITCH, bends), PlaneLayout(Anchor.NEAREST, BendForm.FLAGGED_NOTES) + ) + assert [bool(byte & BEND_FLAG) for byte in value[: len(bends)]] == [False, True, True, True, False] + assert bend == bytes(unsigned_byte(offset) for offset in bends[1:4]) + + @pytest.mark.parametrize("form", (BendForm.FLAGGED_TICKS, BendForm.FLAGGED_NOTES), ids=str) + def test_an_unbent_channel_holds_no_bend_and_no_flag(self, form: BendForm) -> None: + instructions = bent_frames(PLAYER_REFERENCE_PITCH, (0, 0, 0)) + _, value, bend = written(instructions, PlaneLayout(Anchor.NEAREST, form)) + assert bend == b"" + assert value == pulse_planes(instructions)[VALUE_PLANE] + + +class TestALayoutsSeeds: + def test_the_named_flagged_notes_layout_seeds_what_production_seeds(self) -> None: + instrument = Instrument( + name="bent", + envelopes=InstrumentEnvelopes( + volume=Envelope(items=(PLAYER_FULL_VOLUME,) * 4), + pitch=Envelope(items=(0, 4, PAST_HALFWAY, 0)), + ), + initial_pitch=PLAYER_REFERENCE_PITCH, + ) + project = project_with_instrument(instrument, rows_per_pattern=ROWS_PER_PATTERN) + slices = project_slices(project, TUNING) + assert layout_seeds(slices, PITCHES, PRODUCTION) == phrases_from_project(project, TUNING, ALL_CHANNELS) + + +class TestEncodingALayout: + @pytest.mark.parametrize("layout", EVERY_LAYOUT, ids=lambda layout: f"{layout.anchor}-{layout.form}") + def test_every_layout_plays_back_what_it_wrote(self, layout: PlaneLayout) -> None: + song = study_song(bent_frames(PLAYER_REFERENCE_PITCH, (0, 3, 6, 3, 0, PAST_HALFWAY, 0))) + assert encode_layout(song, layout).lossless + + @pytest.mark.parametrize("layout", EVERY_LAYOUT, ids=lambda layout: f"{layout.anchor}-{layout.form}") + def test_an_absent_plane_costs_no_stream(self, layout: PlaneLayout) -> None: + song = study_song(bent_frames(PLAYER_REFERENCE_PITCH, (0, 3, 0))) + planes = layout_planes(song, layout) + streams = encode_layout(song, layout).streams + idle = tuple(plane.idles(played) for plane, played in zip(PLANES, planes, strict=True)) + assert any(idle) + assert all(size == 0 for resting, size in zip(idle, streams) if resting) + assert all(size > 0 for resting, size in zip(idle, streams) if not resting) diff --git a/tests/unit/sampletones_tools/codec/study/test_manifest.py b/tests/unit/sampletones_tools/codec/study/test_manifest.py new file mode 100644 index 000000000..76802717d --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_manifest.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import pytest + +from sampletones_tools.codec.study.manifest import NAMES_A_SOURCE, StudyManifest, StudySource +from tests.suite.files import empty_file + + +class TestStudySource: + def test_a_file_is_labeled_by_its_stem_and_a_directory_by_its_name(self, tmp_path: Path) -> None: + project = empty_file(tmp_path, "one.stp") + + assert StudySource.at(project) == StudySource(label="one", path=project) + assert StudySource.at(tmp_path) == StudySource(label=tmp_path.name, path=tmp_path) + + def test_a_source_naming_nothing_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No file at"): + StudySource.at(tmp_path / "absent.stp") + + +class TestStudyManifest: + def test_a_manifest_naming_no_source_is_refused(self) -> None: + with pytest.raises(ValueError, match=NAMES_A_SOURCE): + StudyManifest(projects=(), reconstructions=(), lengthen_seconds=45, variants=("wide-hold",)) + + def test_a_saved_manifest_loads_as_it_was_written(self, tmp_path: Path) -> None: + written = StudyManifest( + projects=(StudySource.at(empty_file(tmp_path, "one.stp")),), + reconstructions=(StudySource.at(tmp_path),), + lengthen_seconds=45, + variants=("wide-hold",), + ) + path = tmp_path / "manifest.json" + written.save(path) + + assert StudyManifest.load(path) == written diff --git a/tests/unit/sampletones_tools/codec/study/test_plan.py b/tests/unit/sampletones_tools/codec/study/test_plan.py new file mode 100644 index 000000000..8b63028ae --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_plan.py @@ -0,0 +1,32 @@ +from pathlib import Path +from typing import Tuple + +import pytest + +from sampletones_tools.codec.study.manifest import StudyManifest, StudySource +from sampletones_tools.codec.study.plan import plan_study +from sampletones_tools.codec.study.variants.production import BASELINE_NAME +from tests.suite.files import empty_file + + +def _manifest(directory: Path, variants: Tuple[str, ...]) -> StudyManifest: + return StudyManifest( + projects=(StudySource.at(empty_file(directory, "one.stp")),), + reconstructions=(), + lengthen_seconds=45, + variants=variants, + ) + + +class TestPlanStudy: + def test_the_variants_are_the_ones_the_manifest_names_after_the_baseline(self, tmp_path: Path) -> None: + manifest = _manifest(tmp_path, ("wide-hold",)) + + plan = plan_study(manifest) + + assert plan.manifest == manifest + assert [variant.name for variant in plan.variants] == [BASELINE_NAME, "wide-hold"] + + def test_an_unknown_variant_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No variant is called bogus"): + plan_study(_manifest(tmp_path, ("bogus",))) diff --git a/tests/unit/sampletones_tools/codec/study/test_sandbox.py b/tests/unit/sampletones_tools/codec/study/test_sandbox.py new file mode 100644 index 000000000..cd3198cac --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_sandbox.py @@ -0,0 +1,399 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from random import Random +from typing import Final, FrozenSet, List, Sequence, Tuple + +import pytest + +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import PhraseTable, phrase_table +from sampletones_player.compression.encode import STREAM_START +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 EVERY_LAYER +from sampletones_player.compression.parse.boundaries import Boundaries +from sampletones_player.compression.parse.plane import parse_plane as parse_production +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.compression import ( + CHEAP_PHRASE_IDS, + MAX_HOLD_TICKS, +) +from sampletones_player.specification.planes import NO_BITS, PLANE_COUNT, PLANES, PlaneRole +from sampletones_shared.music import Tuning +from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong +from sampletones_tools.codec.study.sandbox.context import PlaneContext +from sampletones_tools.codec.study.sandbox.costs import PRODUCTION_COSTS, SET_HOLD_BOUND, Costs +from sampletones_tools.codec.study.sandbox.decode import play_tokens +from sampletones_tools.codec.study.sandbox.defaults import modal_counts, no_defaults +from sampletones_tools.codec.study.sandbox.edges.generator import offered_lengths +from sampletones_tools.codec.study.sandbox.encode import encode_grammar +from sampletones_tools.codec.study.sandbox.grammar import BASELINE_GRAMMAR, Grammar +from sampletones_tools.codec.study.sandbox.parse import StudyParse, parse_plane +from sampletones_tools.codec.study.sandbox.reference import reference +from sampletones_tools.codec.study.sandbox.tokens import Hold, Literal, Play, SetHold, StudyToken, WideHold +from sampletones_tools.codec.study.sandbox.verify import verify_baseline +from sampletones_tools.codec.study.variants.production import compress_baseline +from sampletones_tools.codec.study.variants.sandbox import GRAMMAR_VARIANTS, GrammarVariant +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.player import playable +from tests.suite.study import NO_STUDY_SLICES, lowest_notes + +SEEDED_SYMBOL: Final[int] = NO_BITS +ENTRIES: Final[FrozenSet[int]] = frozenset({STREAM_START}) +FIGURES: Final[Tuple[bytes, ...]] = (b"\x0a\x0c\x0f\x0f\x0f\x0f\x0f\x0f", b"\x03\x04\x05\x06") +TABLE: Final[PhraseTable] = phrase_table(Phrase(body=body) for body in FIGURES) +NO_TABLE: Final[PhraseTable] = phrase_table(()) +SONG_TICKS: Final[int] = 200 +SEED: Final[int] = 20260912 +WIDE_HOLD: Final[Grammar] = replace(BASELINE_GRAMMAR, wide_hold=True) +START_HOLD: Final[Grammar] = replace(BASELINE_GRAMMAR, start_hold=True) +EVERY_HOLD: Final[Grammar] = replace(BASELINE_GRAMMAR, every_length=True) +SET_HOLD_ESCAPE: Final[Grammar] = replace(BASELINE_GRAMMAR, set_hold=True) +SET_HOLD_REALLOCATED: Final[Grammar] = replace( + BASELINE_GRAMMAR, set_hold=True, costs=replace(PRODUCTION_COSTS, set_hold=SET_HOLD_BOUND) +) + + +def _figures_plane(random: Random, ticks: int) -> bytes: + values = bytearray() + while len(values) < ticks: + figure = random.choice(FIGURES) + shift = random.randrange(20) + values.extend((value + shift) % 256 for value in figure) + values.extend([values[-1]] * random.randrange(7)) + + return bytes(values[:ticks]) + + +def _runs_plane(random: Random, ticks: int) -> bytes: + values = bytearray() + while len(values) < ticks: + values.extend([random.randrange(16)] * random.randint(1, 40)) + + return bytes(values[:ticks]) + + +def _dense_plane(random: Random, ticks: int) -> bytes: + return bytes(random.randrange(50) for _ in range(ticks)) + + +def _song(ticks: int) -> StudySong: + random = Random(SEED) + planes: List[bytes] = [] + for plane in PLANES: + if plane.spans_flagged_ticks: + planes.append(b"") + elif plane.role is PlaneRole.CONTROL: + planes.append(playable(plane, _runs_plane(random, ticks))) + else: + planes.append(playable(plane, _figures_plane(random, ticks))) + + return StudySong( + name="song", + group=SongGroup.PROJECT, + source=Path("song.stp"), + planes=SongPlanes(planes=PlaneOrder.across(planes)), + seeds=tuple(Phrase(body=body) for body in FIGURES), + pitches=PitchTable.from_tuning(Tuning()), + notes=lowest_notes(ticks), + slices=NO_STUDY_SLICES, + ) + + +def _context( + plane: bytes, + table: PhraseTable, + costs: Costs, + defaults: Sequence[int], +) -> PlaneContext: + cache = MatchCache([PlaneIndex.from_plane(plane)]) + return PlaneContext( + index=cache.index(0), + matcher=PhraseMatcher(table, 0, cache), + boundaries=Boundaries.across(len(plane), ENTRIES), + transposition=EVERY_LAYER.transposition, + defaults=tuple(defaults), + seeded=SEEDED_SYMBOL, + costs=costs, + ) + + +def _parse( + plane: bytes, + table: PhraseTable, + grammar: Grammar, + defaults: Sequence[int], +) -> StudyParse: + return parse_plane(_context(plane, table, grammar.costs, defaults), grammar) + + +def _production_size(plane: bytes, table: PhraseTable) -> int: + cache = MatchCache([PlaneIndex.from_plane(plane)]) + return parse_production(PhraseMatcher(table, 0, cache), EVERY_LAYER, ENTRIES).size + + +class TestTheBaselineGrammarReadsAsTheCodec(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + plane: bytes + + test_cases = ( + TestCase(label="an idle plane", plane=bytes(SONG_TICKS)), + TestCase(label="a ramp", plane=bytes(range(SONG_TICKS))), + TestCase(label="two runs", plane=bytes([5] * 10 + [7] * 10)), + TestCase(label="figures at several pitches", plane=_figures_plane(Random(SEED), SONG_TICKS)), + TestCase(label="runs of many lengths", plane=_runs_plane(Random(SEED), SONG_TICKS)), + TestCase(label="dense values", plane=_dense_plane(Random(SEED), SONG_TICKS)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_plane_costs_what_the_production_parser_charges(self, test_case: TestCase) -> None: + parse = _parse(test_case.plane, TABLE, BASELINE_GRAMMAR, no_defaults(len(TABLE))) + + assert parse.size == _production_size(test_case.plane, TABLE) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_tokens_play_back(self, test_case: TestCase) -> None: + parse = _parse(test_case.plane, TABLE, BASELINE_GRAMMAR, no_defaults(len(TABLE))) + + assert play_tokens(parse.tokens, TABLE, len(test_case.plane)) == test_case.plane + + +class TestPlayTokens(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + tokens: Tuple[StudyToken, ...] + expected: bytes + + test_cases = ( + TestCase( + label="a hold keeps the value a literal reached", + tokens=(Literal(values=b"\x07"), Hold(ticks=3)), + expected=b"\x07\x07\x07\x07", + ), + TestCase( + label="a hold opening the stream keeps the seeded value", + tokens=(Hold(ticks=2),), + expected=b"\x00\x00", + ), + TestCase( + label="a wide hold counts blocks of the longest plain hold", + tokens=(Literal(values=b"\x09"), WideHold(blocks=2)), + expected=b"\x09" * (1 + 2 * MAX_HOLD_TICKS), + ), + TestCase( + label="a set-hold takes its value and keeps it", + tokens=(SetHold(value=5, ticks=3), Hold(ticks=1)), + expected=b"\x05\x05\x05\x05", + ), + TestCase( + label="a phrase played past its end holds its final value", + tokens=(Play(phrase_id=1, ticks=6, transpose=0, default=False),), + expected=b"\x03\x04\x05\x06\x06\x06", + ), + TestCase( + label="a shifted phrase plays every value shifted", + tokens=(Play(phrase_id=1, ticks=2, transpose=10, default=True),), + expected=b"\x0d\x0e", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_values_are_played(self, test_case: TestCase) -> None: + assert play_tokens(test_case.tokens, TABLE, len(test_case.expected)) == test_case.expected + + def test_the_song_end_cuts_the_final_token(self) -> None: + assert play_tokens((Literal(values=b"\x01"), Hold(ticks=5)), NO_TABLE, 3) == b"\x01\x01\x01" + + +class TestGrammarsPriceAPlane(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + plane: bytes + table: PhraseTable + grammar: Grammar + defaults: Tuple[int, ...] + expected: int + + idle = bytes(SONG_TICKS) + two_runs = bytes([5] * 10 + [7] * 10) + run_then_figure = bytes([5] * 6 + [9] * 5) + figure_table = phrase_table((Phrase(body=b"\x05\x05\x09"),)) + repeated = bytes(b"\x01\x02\x03\x03" * 3) + repeated_table = phrase_table((Phrase(body=b"\x01\x02\x03"),)) + test_cases = ( + TestCase( + label="an idle plane pays a literal and a hold per block", + plane=idle, + table=NO_TABLE, + grammar=BASELINE_GRAMMAR, + defaults=(), + expected=6, + ), + TestCase( + label="a hold may open the stream over the seeded value", + plane=idle, + table=NO_TABLE, + grammar=START_HOLD, + defaults=(), + expected=4, + ), + TestCase( + label="a wide hold covers the full blocks at once", + plane=idle, + table=NO_TABLE, + grammar=WIDE_HOLD, + defaults=(), + expected=5, + ), + TestCase( + label="a wide hold opening the stream costs two bytes and a remainder", + plane=idle, + table=NO_TABLE, + grammar=replace(WIDE_HOLD, start_hold=True), + defaults=(), + expected=3, + ), + TestCase( + label="two runs pay a literal and a hold each", + plane=two_runs, + table=NO_TABLE, + grammar=BASELINE_GRAMMAR, + defaults=(), + expected=6, + ), + TestCase( + label="a three-byte set-hold breaks even with a literal and a hold", + plane=two_runs, + table=NO_TABLE, + grammar=SET_HOLD_ESCAPE, + defaults=(), + expected=6, + ), + TestCase( + label="a two-byte set-hold spares the hold", + plane=two_runs, + table=NO_TABLE, + grammar=SET_HOLD_REALLOCATED, + defaults=(), + expected=4, + ), + TestCase( + label="the longest hold alone overshoots where a figure begins", + plane=run_then_figure, + table=figure_table, + grammar=BASELINE_GRAMMAR, + defaults=(0,), + expected=6, + ), + TestCase( + label="a shorter hold lets the figure start where it matches", + plane=run_then_figure, + table=figure_table, + grammar=EVERY_HOLD, + defaults=(0,), + expected=5, + ), + TestCase( + label="a phrase played at its default count carries no count", + plane=repeated, + table=repeated_table, + grammar=BASELINE_GRAMMAR, + defaults=(4,), + expected=3, + ), + TestCase( + label="a phrase played at another count carries it", + plane=repeated, + table=repeated_table, + grammar=BASELINE_GRAMMAR, + defaults=(3,), + expected=6, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_plane_costs_what_the_grammar_charges(self, test_case: TestCase) -> None: + parse = _parse(test_case.plane, test_case.table, test_case.grammar, test_case.defaults) + + assert parse.size == test_case.expected + assert play_tokens(parse.tokens, test_case.table, len(test_case.plane)) == test_case.plane + + +class TestCosts: + def test_a_phrase_beyond_the_cheap_ids_pays_the_escape(self) -> None: + assert PRODUCTION_COSTS.phrase(CHEAP_PHRASE_IDS - 1, 0, default=False) == 2 + assert PRODUCTION_COSTS.phrase(CHEAP_PHRASE_IDS, 0, default=False) == 3 + assert PRODUCTION_COSTS.phrase(0, 3, default=False) == 3 + + def test_offered_lengths_are_the_longest_alone_or_every_one(self) -> None: + assert tuple(offered_lengths(5, least=2, every=False)) == (5,) + assert tuple(offered_lengths(5, least=2, every=True)) == (2, 3, 4, 5) + + +class TestModalCounts: + def test_the_count_played_most_often_is_the_default(self) -> None: + parse = StudyParse( + tokens=( + Play(phrase_id=0, ticks=4, transpose=0, default=False), + Play(phrase_id=0, ticks=8, transpose=2, default=False), + Play(phrase_id=0, ticks=4, transpose=0, default=True), + Hold(ticks=1), + ), + costs=(0, 2, 5, 6, 7), + ) + + assert modal_counts((parse,), 2) == (4, 0) + + def test_a_tie_takes_the_shorter_count(self) -> None: + parse = StudyParse( + tokens=( + Play(phrase_id=0, ticks=8, transpose=0, default=False), + Play(phrase_id=0, ticks=4, transpose=0, default=False), + ), + costs=(0, 2, 4), + ) + + assert modal_counts((parse,), 1) == (4,) + + +class TestTheReferenceHoldsTheSandboxToTheCodec: + song = _song(SONG_TICKS) + compressed = compress_baseline(song) + + def test_the_baseline_parse_costs_what_the_codec_wrote(self) -> None: + read = reference(self.song, self.compressed) + + assert [parse.size for parse in read.baseline] == [len(stream) for stream in self.compressed.streams] + + def test_a_stream_priced_differently_stops_the_study(self) -> None: + read = reference(self.song, self.compressed) + tampered = self.compressed.model_copy( + update={"streams": PlaneOrder.across([stream + b"\x00" for stream in self.compressed.streams])} + ) + + with pytest.raises(ValueError, match="parted from the production parser"): + verify_baseline(read.baseline, tampered) + + @pytest.mark.parametrize("entry", GRAMMAR_VARIANTS, ids=lambda entry: entry.name) + def test_every_grammar_plays_back_and_adds_no_bytes_over_a_small_table(self, entry: GrammarVariant) -> None: + read = reference(self.song, self.compressed) + + encoding = encode_grammar(read, entry.grammar) + + assert encoding.lossless + assert encoding.written is None + assert sum(encoding.streams) <= sum(len(stream) for stream in self.compressed.streams) + + @pytest.mark.parametrize("entry", GRAMMAR_VARIANTS, ids=lambda entry: entry.name) + def test_a_plane_holding_zero_throughout_is_absent_under_every_grammar(self, entry: GrammarVariant) -> None: + read = reference(self.song, self.compressed) + + encoding = encode_grammar(read, entry.grammar) + + bends = [index for index, plane in enumerate(PLANES) if plane.spans_flagged_ticks] + assert [encoding.streams[index] for index in bends] == [0] * len(bends) diff --git a/tests/unit/sampletones_tools/codec/study/test_session.py b/tests/unit/sampletones_tools/codec/study/test_session.py new file mode 100644 index 000000000..b9a645832 --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_session.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest + +from sampletones_tools.codec.study.manifest import NAMES_A_SOURCE, StudyManifest, StudySource +from sampletones_tools.codec.study.session import resolve_manifest, variant_names +from sampletones_tools.codec.study.variants.registry import EVERY_VARIANT +from tests.suite.files import empty_file + + +class TestVariantNames: + def test_nothing_named_is_every_variant(self) -> None: + assert variant_names(None) == (EVERY_VARIANT,) + + def test_names_are_read_in_order_with_their_spaces_stripped(self) -> None: + assert variant_names("wide-hold, default-count") == ("wide-hold", "default-count") + + +class TestResolveManifest: + def test_sources_named_outright_are_measured(self, tmp_path: Path) -> None: + project = empty_file(tmp_path / "songs", "one.stp") + stems = tmp_path / "stems" / "two" + stems.mkdir(parents=True) + + manifest = resolve_manifest( + None, + projects=(project,), + reconstructions=(stems,), + lengthen_seconds=30, + variants=("wide-hold",), + ) + + assert manifest.projects == (StudySource(label="one", path=project),) + assert manifest.reconstructions == (StudySource(label="two", path=stems),) + assert manifest.lengthen_seconds == 30 + assert manifest.variants == ("wide-hold",) + + def test_a_run_naming_no_source_and_no_manifest_is_refused(self) -> None: + with pytest.raises(ValueError, match=NAMES_A_SOURCE): + resolve_manifest( + None, + projects=(), + reconstructions=(), + lengthen_seconds=30, + variants=(EVERY_VARIANT,), + ) + + def test_a_manifest_missing_from_its_path_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="No manifest at"): + resolve_manifest( + tmp_path / "absent.json", + projects=(), + reconstructions=(), + lengthen_seconds=30, + variants=(EVERY_VARIANT,), + ) + + def test_a_manifest_file_is_measured_as_it_stands(self, tmp_path: Path) -> None: + written = StudyManifest( + projects=(StudySource.at(empty_file(tmp_path, "one.stp")),), + reconstructions=(), + lengthen_seconds=45, + variants=("wide-hold",), + ) + path = tmp_path / "manifest.json" + written.save(path) + + manifest = resolve_manifest( + path, + projects=(), + reconstructions=(), + lengthen_seconds=30, + variants=(EVERY_VARIANT,), + ) + + assert manifest == written + + def test_sources_named_outright_replace_a_manifest_s_own_and_keep_its_sweep(self, tmp_path: Path) -> None: + path = tmp_path / "manifest.json" + StudyManifest( + projects=(StudySource.at(empty_file(tmp_path, "one.stp")),), + reconstructions=(), + lengthen_seconds=45, + variants=("wide-hold",), + ).save(path) + stems = tmp_path / "stems" / "two" + stems.mkdir(parents=True) + + manifest = resolve_manifest( + path, + projects=(), + reconstructions=(stems,), + lengthen_seconds=30, + variants=(EVERY_VARIANT,), + ) + + assert manifest.projects == () + assert manifest.reconstructions == (StudySource(label="two", path=stems),) + assert (manifest.lengthen_seconds, manifest.variants) == (45, ("wide-hold",)) diff --git a/tests/unit/sampletones_tools/codec/study/test_variants.py b/tests/unit/sampletones_tools/codec/study/test_variants.py new file mode 100644 index 000000000..2fd94787a --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_variants.py @@ -0,0 +1,156 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Tuple + +import pytest + +from sampletones_player.compression.compressed import CompressedPlanes +from sampletones_player.compression.dictionary.phrase import Phrase +from sampletones_player.compression.dictionary.table import phrase_table +from sampletones_player.compression.encode import emit +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.compression.tokens.hold import HoldToken +from sampletones_player.compression.tokens.literal import LiteralToken +from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_shared.music import Tuning +from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong +from sampletones_tools.codec.study.measure import Measurement, production_encoding +from sampletones_tools.codec.study.variants.seeds import split, trimmed, whole_and_split +from sampletones_tools.codec.study.variants.strategy import DEPTH_PREFIX, depth_measurements +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.study import NO_STUDY_SLICES, lowest_notes, resting_planes + +TICKS: Final[int] = 4 +BASELINE: Final[str] = "baseline" +TRIMMED: Final[str] = "seeds-trimmed" +WIDE: Final[str] = "search-wide" +ORDER: Final[Tuple[str, ...]] = (BASELINE, TRIMMED, "seeds-split-4", WIDE) + + +class TestTrimmed(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + body: bytes + expected: bytes + + test_cases = ( + TestCase( + label="a trailing plateau ends at its first value", body=b"\x01\x02\x03\x03\x03", expected=b"\x01\x02\x03" + ), + TestCase(label="a body of one value keeps one", body=b"\x05\x05", expected=b"\x05"), + TestCase(label="a body moving to its end stays", body=b"\x01\x02", expected=b"\x01\x02"), + TestCase(label="a plateau inside the body stays", body=b"\x01\x01\x02", expected=b"\x01\x01\x02"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_tail_is_cut(self, test_case: TestCase) -> None: + assert trimmed((Phrase(body=test_case.body),)) == (Phrase(body=test_case.expected),) + + +class TestSplit(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + body: bytes + threshold: int + expected: Tuple[bytes, ...] + + test_cases = ( + TestCase( + label="plateaus of three cut the body", + body=b"\x01\x02\x02\x02\x03\x04\x04\x04\x04\x05", + threshold=3, + expected=(b"\x01\x02", b"\x03\x04"), + ), + TestCase( + label="a threshold above every plateau keeps the body whole", + body=b"\x01\x02\x02\x02\x03", + threshold=4, + expected=(b"\x01\x02\x02\x02\x03",), + ), + TestCase( + label="plateaus of two cut too", + body=b"\x01\x01\x02\x03\x03\x04", + threshold=2, + expected=(b"\x02\x03",), + ), + TestCase( + label="a piece of one value is dropped", + body=b"\x01\x02\x02\x02\x03", + threshold=3, + expected=(b"\x01\x02",), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_pieces_end_where_a_plateau_begins(self, test_case: TestCase) -> None: + assert split((Phrase(body=test_case.body),), test_case.threshold) == tuple( + Phrase(body=piece) for piece in test_case.expected + ) + + def test_whole_and_split_offers_both(self) -> None: + seeds = (Phrase(body=b"\x01\x02\x02\x02\x03\x04"),) + + assert whole_and_split(seeds, 3) == (*seeds, Phrase(body=b"\x01\x02"), Phrase(body=b"\x03\x04")) + + +def _song() -> StudySong: + return StudySong( + name="song", + group=SongGroup.PROJECT, + source=Path("song.stp"), + planes=resting_planes(TICKS), + seeds=(), + pitches=PitchTable.from_tuning(Tuning()), + notes=lowest_notes(TICKS), + slices=NO_STUDY_SLICES, + ) + + +def _measurement( + song: StudySong, + variant: str, + *, + spelled_out: bool, + seconds: float, +) -> Measurement: + stream = emit([LiteralToken(values=bytes(TICKS))] if spelled_out else [HoldToken(ticks=TICKS)]) + compressed = CompressedPlanes( + phrases=phrase_table(()), + streams=PlaneOrder.across([stream] * PLANE_COUNT), + loop_entries=(0,) * PLANE_COUNT, + ticks=TICKS, + ) + return Measurement( + song=song, + variant=variant, + encoding=production_encoding(song, compressed, seconds), + ) + + +class TestDepthMeasurements: + def test_each_depth_keeps_the_smallest_so_far_and_sums_the_time(self) -> None: + song = _song() + measurements = ( + _measurement(song, BASELINE, spelled_out=True, seconds=1.0), + _measurement(song, TRIMMED, spelled_out=False, seconds=2.0), + _measurement(song, WIDE, spelled_out=True, seconds=4.0), + ) + + depths = depth_measurements(measurements, ORDER) + + assert [depth.variant for depth in depths] == [f"{DEPTH_PREFIX}{depth}" for depth in (1, 2, 3)] + assert [depth.block for depth in depths] == [ + measurements[0].block, + measurements[1].block, + measurements[1].block, + ] + assert [depth.seconds for depth in depths] == [1.0, 3.0, 7.0] + + def test_a_strategy_no_song_was_measured_under_takes_no_depth(self) -> None: + song = _song() + measurements = (_measurement(song, BASELINE, spelled_out=True, seconds=1.0),) + + assert [depth.variant for depth in depth_measurements(measurements, ORDER)] == [f"{DEPTH_PREFIX}1"] diff --git a/tests/unit/sampletones_tools/codec/study/test_verdicts.py b/tests/unit/sampletones_tools/codec/study/test_verdicts.py new file mode 100644 index 000000000..6e2ccff2b --- /dev/null +++ b/tests/unit/sampletones_tools/codec/study/test_verdicts.py @@ -0,0 +1,201 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_player.compression.pitch import PitchTable +from sampletones_player.compression.planes.order import PlaneOrder +from sampletones_player.compression.planes.song import SongPlanes +from sampletones_player.specification.planes import PLANE_COUNT +from sampletones_player.specification.song import SONG_HEADER_SIZE +from sampletones_shared.music import Tuning +from sampletones_tools.codec.study.corpus.song import SongGroup, StudySong +from sampletones_tools.codec.study.measure import Encoding, Measurement +from sampletones_tools.codec.study.report.aggregate import group_rows +from sampletones_tools.codec.study.report.verdicts import Verdict, judge, verdict_rows +from sampletones_tools.codec.study.variants.variant import Variant, VariantKind +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.study import NO_STUDY_SLICES, lowest_notes, resting_planes + +TICKS: Final[int] = 4 +BASELINE: Final[str] = "baseline" +STREAM: Final[int] = 100 + + +class TestJudge(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + projects: Optional[float] + reconstructions: Optional[float] + worst: float + priced: bool + expected: Verdict + + test_cases = ( + TestCase( + label="saving enough over the projects graduates", + projects=-0.03, + reconstructions=0.0, + worst=0.0, + priced=False, + expected=Verdict.GRADUATES, + ), + TestCase( + label="saving enough over the reconstructions graduates", + projects=0.0, + reconstructions=-0.05, + worst=-0.02, + priced=False, + expected=Verdict.GRADUATES, + ), + TestCase( + label="a token-level price is provisional", + projects=-0.1, + reconstructions=-0.1, + worst=0.0, + priced=True, + expected=Verdict.PROVISIONAL, + ), + TestCase( + label="a song growing past the bar rejects the variant", + projects=-0.1, + reconstructions=-0.1, + worst=0.011, + priced=False, + expected=Verdict.REJECTED, + ), + TestCase( + label="saving too little rejects the variant", + projects=-0.029, + reconstructions=-0.049, + worst=0.0, + priced=False, + expected=Verdict.REJECTED, + ), + TestCase( + label="a group the variant left unmeasured counts for nothing", + projects=None, + reconstructions=-0.05, + worst=0.0, + priced=False, + expected=Verdict.GRADUATES, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rule_answers(self, test_case: TestCase) -> None: + verdict = judge( + test_case.projects, + test_case.reconstructions, + test_case.worst, + priced=test_case.priced, + ) + + assert verdict is test_case.expected + + +def _song(name: str, group: SongGroup) -> StudySong: + return StudySong( + name=name, + group=group, + source=Path(f"{name}.stp"), + planes=resting_planes(TICKS), + seeds=(), + pitches=PitchTable.from_tuning(Tuning()), + notes=lowest_notes(TICKS), + slices=NO_STUDY_SLICES, + ) + + +def _measurement( + song: StudySong, + variant: str, + streams: int, +) -> Measurement: + return Measurement( + song=song, + variant=variant, + encoding=Encoding( + header=SONG_HEADER_SIZE, + phrases=0, + dictionary=0, + streams=(streams,) + (0,) * (PLANE_COUNT - 1), + seconds=0.0, + lossless=True, + written=None, + ), + ) + + +def _variant(name: str, kind: VariantKind) -> Variant: + return Variant( + name=name, + hypothesis="H", + kind=kind, + note="", + encode=lambda song: Encoding( + header=SONG_HEADER_SIZE, + phrases=0, + dictionary=0, + streams=(), + seconds=0.0, + lossless=True, + written=None, + ), + needs_seeds=False, + ) + + +class TestVerdictRows: + short = _song("short", SongGroup.PROJECT) + long = _song("long", SongGroup.LONG_PROJECT) + stem = _song("stem", SongGroup.RECONSTRUCTION) + variants: Tuple[Variant, ...] = ( + _variant(BASELINE, VariantKind.BASELINE), + _variant("shrinks", VariantKind.FORMAT), + _variant("projects-only", VariantKind.ENCODER), + ) + + def test_the_projects_are_judged_together_and_the_baseline_is_left_out(self) -> None: + measurements = ( + _measurement(self.short, BASELINE, STREAM), + _measurement(self.long, BASELINE, STREAM), + _measurement(self.stem, BASELINE, STREAM), + _measurement(self.short, "shrinks", STREAM), + _measurement(self.long, "shrinks", STREAM - 60), + _measurement(self.stem, "shrinks", STREAM - 10), + _measurement(self.short, "projects-only", STREAM - 40), + _measurement(self.long, "projects-only", STREAM - 40), + ) + baseline = 2 * measurements[0].block + + rows = verdict_rows(group_rows(measurements, BASELINE), measurements, self.variants) + + assert [row.variant for row in rows] == ["shrinks", "projects-only"] + assert rows[0].projects == pytest.approx(-60 / baseline) + assert rows[0].reconstructions == pytest.approx(-10 / measurements[2].block) + assert rows[0].worst == pytest.approx(0.0) + assert rows[0].verdict is Verdict.PROVISIONAL + assert rows[1].reconstructions is None + assert rows[1].cells[4] == "" + + def test_a_variant_the_run_left_out_takes_no_row(self) -> None: + measurements = (_measurement(self.short, BASELINE, STREAM), _measurement(self.short, "shrinks", STREAM)) + + rows = verdict_rows(group_rows(measurements, BASELINE), measurements, self.variants) + + assert [row.variant for row in rows] == ["shrinks"] + assert rows[0].verdict is Verdict.REJECTED + + def test_a_written_encoding_graduates_outright(self) -> None: + written = replace(self.variants[2], kind=VariantKind.ENCODER) + measurements = ( + _measurement(self.short, BASELINE, STREAM), + _measurement(self.short, written.name, STREAM - 50), + ) + + rows = verdict_rows(group_rows(measurements, BASELINE), measurements, (self.variants[0], written)) + + assert rows[0].verdict is Verdict.PROVISIONAL diff --git a/tests/unit/sampletones_tools/codec/test_command.py b/tests/unit/sampletones_tools/codec/test_command.py new file mode 100644 index 000000000..cc4919054 --- /dev/null +++ b/tests/unit/sampletones_tools/codec/test_command.py @@ -0,0 +1,170 @@ +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List, Optional, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_tools.codec.command import DEFAULT_LENGTHEN_SECONDS, NO_SOURCE +from sampletones_tools.codec.report.session import CompressionReport +from sampletones_tools.codec.study.plan import StudyPlan +from sampletones_tools.codec.study.variants.production import BASELINE_NAME +from sampletones_tools.codec.study.variants.registry import EVERY_VARIANT +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.files import empty_file + +RUNNER: Final[str] = "sampletones_tools.codec.study.session.run_study" +REPORTER: Final[str] = "sampletones_tools.codec.report.session.run_report" + + +class RecordedStudy: + def __init__(self) -> None: + self.runs: List[Tuple[StudyPlan, Optional[Path]]] = [] + + def __call__(self, plan: StudyPlan, output: Optional[Path]) -> Path: + self.runs.append((plan, output)) + return output if output is not None else Path("run") + + +@dataclass(frozen=True) +class StudySources: + project: Path + reconstruction: Path + + +@pytest.fixture(name="study") +def study_fixture(monkeypatch: pytest.MonkeyPatch) -> RecordedStudy: + recorded = RecordedStudy() + monkeypatch.setattr(RUNNER, recorded) + return recorded + + +@pytest.fixture(name="sources") +def sources_fixture(tmp_path: Path) -> StudySources: + reconstruction = tmp_path / "stems" / "two" + reconstruction.mkdir(parents=True) + return StudySources(project=empty_file(tmp_path / "songs", "one.stp"), reconstruction=reconstruction) + + +class TestCodecReport: + def test_the_report_is_written_into_the_output_and_its_tables_are_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + outputs: List[Path] = [] + + def run_report(output: Path) -> CompressionReport: + outputs.append(output) + return CompressionReport( + entries=(), + encodings=(), + csv_path=output / "report.csv", + markdown_path=output / "report.md", + ) + + monkeypatch.setattr(REPORTER, run_report) + + assert dispatch(COMMANDS, ["codec", "report", "-o", str(tmp_path)]) == 0 + assert outputs == [tmp_path] + assert capsys.readouterr().out.splitlines() == [ + f"Wrote {tmp_path / 'report.csv'}", + f"Wrote {tmp_path / 'report.md'}", + ] + + def test_the_output_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["codec", "report"]) + + assert leaving.value.code == 2 + + +class TestCodecStudy: + def test_the_sources_and_the_sweep_are_read_from_the_options( + self, + study: RecordedStudy, + sources: StudySources, + tmp_path: Path, + ) -> None: + status = dispatch( + COMMANDS, + [ + "codec", + "study", + "--project", + str(sources.project), + "--reconstruction", + str(sources.reconstruction), + "--variants", + "wide-hold", + "--lengthen", + "30", + "-o", + str(tmp_path), + ], + ) + + assert status == 0 + plan, output = study.runs[0] + assert [source.path for source in plan.manifest.projects] == [sources.project] + assert [source.path for source in plan.manifest.reconstructions] == [sources.reconstruction] + assert plan.manifest.variants == ("wide-hold",) + assert [variant.name for variant in plan.variants] == [BASELINE_NAME, "wide-hold"] + assert plan.manifest.lengthen_seconds == 30 + assert output == tmp_path + + def test_a_run_naming_no_source_is_refused_with_the_way_to_name_one(self, study: RecordedStudy) -> None: + with pytest.raises(SystemExit, match=re.escape(NO_SOURCE)): + dispatch(COMMANDS, ["codec", "study"]) + + assert all(flag in NO_SOURCE for flag in ("--project", "--reconstruction", "--manifest")) + assert study.runs == [] + + def test_the_lengthening_defaults_to_its_constant(self, study: RecordedStudy, sources: StudySources) -> None: + assert dispatch(COMMANDS, ["codec", "study", "--project", str(sources.project)]) == 0 + plan, output = study.runs[0] + assert plan.manifest.lengthen_seconds == DEFAULT_LENGTHEN_SECONDS + assert plan.manifest.variants == (EVERY_VARIANT,) + assert output is None + + def test_an_action_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["codec"]) + + assert leaving.value.code == 2 + + +class TestRefusedStudies(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + argv: Tuple[str, ...] + refusal: str + + test_cases = ( + TestCase(label="an unknown variant", argv=("--variants", "bogus"), refusal="No variant is called bogus"), + TestCase(label="no lengthening", argv=("--lengthen", "0"), refusal="lengthen_seconds"), + TestCase(label="a missing manifest", argv=("--manifest", "{directory}/absent.json"), refusal="No manifest at"), + TestCase(label="a missing project", argv=("--project", "{directory}/absent.stp"), refusal="No file at"), + TestCase(label="a broken manifest", argv=("--manifest", "{directory}/broken.json"), refusal="JSON"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_run_asking_the_impossible_is_refused_in_one_line_before_it_starts( + self, + study: RecordedStudy, + sources: StudySources, + tmp_path: Path, + test_case: TestCase, + ) -> None: + (tmp_path / "broken.json").write_text("{", encoding="utf-8") + argv = [argument.format(directory=tmp_path) for argument in test_case.argv] + + with pytest.raises(SystemExit, match=test_case.refusal) as leaving: + dispatch(COMMANDS, ["codec", "study", "--project", str(sources.project), *argv]) + + assert len(str(leaving.value).splitlines()) == 1 + assert study.runs == [] diff --git a/tests/unit/sampletones_tools/compatibility/__init__.py b/tests/unit/sampletones_tools/compatibility/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/compatibility/test_writer.py b/tests/unit/sampletones_tools/compatibility/test_writer.py new file mode 100644 index 000000000..9f3000cde --- /dev/null +++ b/tests/unit/sampletones_tools/compatibility/test_writer.py @@ -0,0 +1,125 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.library.data import InstructionLibraryData +from sampletones_core.project.container import ProjectContainer +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.application import ( + SAMPLETONES_LIBRARY_DATA_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + SAMPLETONES_RECONSTRUCTION_DATA_VERSION, +) +from sampletones_tools.compatibility.documents import SOURCE_PATH, library_tones +from sampletones_tools.compatibility.paths import archived_path, version_part +from sampletones_tools.compatibility.writer import archive + +CORPUS_FORMATS: Final[int] = 3 + + +@pytest.fixture(name="corpus") +def corpus_fixture(tmp_path: Path) -> Path: + """A corpus this build has just written, which is what a release archives.""" + archive(tmp_path) + return tmp_path + + +class TestTheSpellingAVersionTakes: + """A file is named after the data version it was written at, as the step modules are.""" + + def test_a_version_reads_as_the_step_modules_spell_one(self) -> None: + assert version_part("2.1") == "v2_1" + + def test_a_format_keeps_its_files_apart_from_another_s(self) -> None: + reconstruction = archived_path(ObjectKind.RECONSTRUCTION, "2.1", Path("corpus")) + library = archived_path(ObjectKind.LIBRARY, "2.1", Path("corpus")) + + assert reconstruction != library + assert reconstruction.parent.name == ObjectKind.RECONSTRUCTION.value + + +class TestWhatTheCorpusIsWrittenWith: + """Archiving writes one document per format at the versions this build states.""" + + def test_every_format_answers_with_a_file(self, corpus: Path) -> None: + written = sorted(path for path in corpus.rglob("*") if path.is_file()) + + assert len(written) == CORPUS_FORMATS + + def test_each_file_stands_where_its_version_names(self, corpus: Path) -> None: + for kind, version in ( + (ObjectKind.RECONSTRUCTION, SAMPLETONES_RECONSTRUCTION_DATA_VERSION), + (ObjectKind.LIBRARY, SAMPLETONES_LIBRARY_DATA_VERSION), + (ObjectKind.PROJECT, SAMPLETONES_PROJECT_DATA_VERSION), + ): + assert archived_path(kind, version, corpus).is_file() + + def test_a_version_already_archived_stands_as_it_was_written(self, corpus: Path) -> None: + path = archived_path(ObjectKind.RECONSTRUCTION, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, corpus) + written = path.read_bytes() + + assert archive(corpus) == [] + assert path.read_bytes() == written + + def test_a_deliberate_replacement_writes_it_again(self, corpus: Path) -> None: + assert len(archive(corpus, force=True)) == CORPUS_FORMATS + + +class TestWhatAnArchivedReconstructionStates: + """The archived reconstruction names every channel, its recording, and one owner per frame.""" + + @staticmethod + def _loaded(corpus: Path) -> Reconstruction: + path = archived_path(ObjectKind.RECONSTRUCTION, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, corpus) + return Reconstruction.load(path, fast=False) + + def test_every_channel_sounds(self, corpus: Path) -> None: + assert frozenset(self._loaded(corpus).playing_channels) == frozenset(ChannelName.items()) + + def test_a_silent_frame_answers_to_rest(self, corpus: Path) -> None: + """A record naming a recording on a silent frame is what the archived file must not carry.""" + loaded = self._loaded(corpus) + for channel_name, stem_ids in loaded.stems_data.assignments_by_channel.items(): + stream = loaded.instructions[channel_name] + assert [stem_id == RESTING_STEM_ID for stem_id in stem_ids] == [not item.on for item in stream] + + def test_a_channel_stands_silent_somewhere(self, corpus: Path) -> None: + """The rule above says nothing unless a silent frame is actually stored.""" + loaded = self._loaded(corpus) + assert any(RESTING_STEM_ID in stem_ids for stem_ids in loaded.stems_data.assignments_by_channel.values()) + + def test_the_recording_it_was_built_from_is_named(self, corpus: Path) -> None: + assert self._loaded(corpus).audio_filepath == (SOURCE_PATH,) + + +class TestWhatAnArchivedLibraryStates: + + @staticmethod + def _loaded(corpus: Path) -> InstructionLibraryData: + path = archived_path(ObjectKind.LIBRARY, SAMPLETONES_LIBRARY_DATA_VERSION, corpus) + return InstructionLibraryData.load(path, fast=False) + + def test_every_tone_it_was_given_is_stored(self, corpus: Path) -> None: + stored = self._loaded(corpus).data + + assert all(instruction in stored for instruction in library_tones()) + + +class TestWhatAnArchivedProjectStates: + """The archived project keeps one copy of the reconstruction its voices share.""" + + @staticmethod + def _loaded(corpus: Path) -> object: + path = archived_path(ObjectKind.PROJECT, SAMPLETONES_PROJECT_DATA_VERSION, corpus) + return ProjectContainer.load(path) + + def test_two_voices_share_one_reconstruction(self, corpus: Path) -> None: + project = self._loaded(corpus) + voices = list(project.voices) # type: ignore[attr-defined] + + assert len(voices) == 2 + assert len({id(voice.reconstruction) for voice in voices}) == 1 diff --git a/tests/unit/sampletones_tools/console/__init__.py b/tests/unit/sampletones_tools/console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/console/files.py b/tests/unit/sampletones_tools/console/files.py new file mode 100644 index 000000000..75bd79b89 --- /dev/null +++ b/tests/unit/sampletones_tools/console/files.py @@ -0,0 +1,93 @@ +from dataclasses import dataclass, field +from typing import Dict, Final, Tuple + +from sampletones_core.formats.binary import BinaryWriter +from sampletones_player.specification.nsf import ( + BANKSWITCH_SIZE, + NSF2_LENGTH_SIZE, + NSF_MAGIC, + NSF_VERSION, + NTSC_PLAY_PERIOD_MICROSECONDS, + NTSC_REGION, + PAL_PLAY_PERIOD_MICROSECONDS, + PROGRAM_START, + STRING_FIELD_SIZE, +) +from sampletones_tools.console.cartridge import BANK_SIZE + +INIT: Final[int] = PROGRAM_START +PLAY: Final[int] = PROGRAM_START + 0x10 +LOADED_WHOLE: Final[Tuple[int, ...]] = (0,) * BANKSWITCH_SIZE +ONE_SONG: Final[int] = 1 + +STORE_ACCUMULATOR: Final[int] = 0x8D +STORE_INDEX: Final[int] = 0x8E +LOAD_IMMEDIATE: Final[int] = 0xA9 +LOAD_ABSOLUTE: Final[int] = 0xAD +JUMP: Final[int] = 0x4C +RETURN: Final[int] = 0x60 + + +def absolute(address: int) -> Tuple[int, int]: + """An operand address as the 6502 stores it, low byte first.""" + return address & 0xFF, address >> 8 + + +@dataclass(frozen=True) +class NSFFile: + """A small NSF file written for a case: a header and the program bytes placed at their addresses. + + Attributes: + load: The load address. + banks: The bank each slot starts with, all zero for a file loaded whole. + region: The region byte. + songs: How many songs the header names. + first_song: The song a player starts with, counted from 1. + program: The program's bytes, keyed by their offset within the image. + """ + + load: int = PROGRAM_START + banks: Tuple[int, ...] = LOADED_WHOLE + region: int = NTSC_REGION + songs: int = ONE_SONG + first_song: int = ONE_SONG + program: Dict[int, Tuple[int, ...]] = field(default_factory=dict) + + @property + def image(self) -> bytes: + """The bytes behind the header, each part of the program at its offset and zeros between.""" + size = max((offset + len(code) for offset, code in self.program.items()), default=0) + image = bytearray(size) + for offset, code in self.program.items(): + image[offset : offset + len(code)] = bytes(code) + + return bytes(image) + + @property + def data(self) -> bytes: + """The whole file, header first.""" + writer = BinaryWriter() + writer.write_bytes(NSF_MAGIC) + writer.write_uint8(NSF_VERSION) + writer.write_uint8(self.songs) + writer.write_uint8(self.first_song) + writer.write_uint16(self.load) + writer.write_uint16(INIT) + writer.write_uint16(PLAY) + for text in ("Title", "Artist", "Copyright"): + writer.write_fixed_string(text, STRING_FIELD_SIZE) + + writer.write_uint16(NTSC_PLAY_PERIOD_MICROSECONDS) + writer.write_bytes(bytes(self.banks)) + writer.write_uint16(PAL_PLAY_PERIOD_MICROSECONDS) + writer.write_uint8(self.region) + writer.write_uint8(0) + writer.write_uint8(0) + writer.write_bytes(bytes(NSF2_LENGTH_SIZE)) + writer.write_bytes(self.image) + return writer.data + + +def bank_offset(bank: int) -> int: + """Where a bank starts in an image loaded at the start of ``$8000``.""" + return bank * BANK_SIZE diff --git a/tests/unit/sampletones_tools/console/test_cartridge.py b/tests/unit/sampletones_tools/console/test_cartridge.py new file mode 100644 index 000000000..c6250cc78 --- /dev/null +++ b/tests/unit/sampletones_tools/console/test_cartridge.py @@ -0,0 +1,69 @@ +from typing import Final, Tuple + +from py65.memory import ObservableMemory + +from sampletones_player.specification.nsf import PROGRAM_START +from sampletones_tools.console.cartridge import BANK_SIZE, EMPTY_BYTE, FIRST_BANK_REGISTER, Cartridge +from sampletones_tools.console.header import NSFHeader +from tests.unit.sampletones_tools.console.files import NSFFile, bank_offset + +BANKS: Final[Tuple[int, ...]] = (0, 2, 0, 0, 0, 0, 0, 0) +MARKS: Final[Tuple[int, ...]] = (0x11, 0x22, 0x33) +SECOND_SLOT: Final[int] = PROGRAM_START + BANK_SIZE + + +def _banked_file(load: int = PROGRAM_START) -> NSFFile: + return NSFFile( + load=load, + banks=BANKS, + program={bank_offset(bank): (mark,) for bank, mark in enumerate(MARKS)}, + ) + + +def _cartridge(file: NSFFile) -> Cartridge: + return Cartridge(NSFHeader.read(file.data), file.image) + + +class TestBanks: + def test_a_bank_holds_its_four_kilobytes_of_the_image(self) -> None: + cartridge = _cartridge(_banked_file()) + + assert [cartridge.bank(bank)[0] for bank in range(len(MARKS))] == list(MARKS) + assert all(len(cartridge.bank(bank)) == BANK_SIZE for bank in range(len(MARKS))) + + def test_a_bank_past_the_image_reads_empty(self) -> None: + cartridge = _cartridge(_banked_file()) + + assert set(cartridge.bank(len(MARKS) + 1)) == {EMPTY_BYTE} + + def test_the_image_is_padded_by_where_the_load_address_falls_in_its_bank(self) -> None: + offset = 0x0100 + cartridge = _cartridge(_banked_file(load=PROGRAM_START + offset)) + + assert cartridge.bank(0)[offset] == MARKS[0] + + +class TestMounting: + def test_a_file_loaded_whole_lands_at_its_load_address(self) -> None: + load = PROGRAM_START + 0x0100 + file = NSFFile(load=load, program={0: (0xAB, 0xCD)}) + memory = ObservableMemory() + + _cartridge(file).mount(memory) + + assert (memory[load], memory[load + 1]) == (0xAB, 0xCD) + + def test_each_slot_starts_with_the_bank_the_header_names(self) -> None: + memory = ObservableMemory() + + _cartridge(_banked_file()).mount(memory) + + assert (memory[PROGRAM_START], memory[SECOND_SLOT]) == (MARKS[0], MARKS[2]) + + def test_a_write_to_a_slots_register_maps_the_bank_written(self) -> None: + memory = ObservableMemory() + _cartridge(_banked_file()).mount(memory) + + memory[FIRST_BANK_REGISTER + 1] = 1 + + assert memory[SECOND_SLOT] == MARKS[1] diff --git a/tests/unit/sampletones_tools/console/test_header.py b/tests/unit/sampletones_tools/console/test_header.py new file mode 100644 index 000000000..c974445d5 --- /dev/null +++ b/tests/unit/sampletones_tools/console/test_header.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_player.specification.nsf import ( + DUAL_REGION_FLAG, + NTSC_PLAY_PERIOD_MICROSECONDS, + NTSC_REGION, + PAL_PLAY_PERIOD_MICROSECONDS, + PAL_REGION_FLAG, + PROGRAM_START, +) +from sampletones_shared.exceptions.validation import TruncatedDataError +from sampletones_tools.console.errors import NotAnNSFError +from sampletones_tools.console.header import NTSC_MACHINE, PAL_MACHINE, NSFHeader +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +from tests.unit.sampletones_tools.console.files import INIT, LOADED_WHOLE, PLAY, NSFFile + +BANKS: Final[Tuple[int, ...]] = (0, 1, 2, 3, 4, 5, 6, 7) +LOAD: Final[int] = PROGRAM_START + 0x0100 + + +class TestReadingAHeader: + def test_every_field_a_player_reads_comes_back(self) -> None: + header = NSFHeader.read(NSFFile(load=LOAD, banks=BANKS, songs=3, first_song=2).data) + + assert header == NSFHeader( + songs=3, + first_song=2, + load=LOAD, + init=INIT, + play=PLAY, + ntsc_period=NTSC_PLAY_PERIOD_MICROSECONDS, + banks=BANKS, + pal_period=PAL_PLAY_PERIOD_MICROSECONDS, + region=NTSC_REGION, + ) + + def test_the_first_song_is_handed_to_init_counted_from_zero(self) -> None: + assert NSFHeader.read(NSFFile(songs=3, first_song=2).data).first_song_index == 1 + + def test_a_header_naming_a_bank_is_banked(self) -> None: + assert NSFHeader.read(NSFFile(banks=BANKS).data).banked + assert not NSFHeader.read(NSFFile(banks=LOADED_WHOLE).data).banked + + def test_data_without_the_signature_is_refused(self) -> None: + with pytest.raises(NotAnNSFError): + NSFHeader.read(b"NESM\x00" + NSFFile().data[5:]) + + def test_data_ending_inside_the_header_is_refused(self) -> None: + with pytest.raises(TruncatedDataError): + NSFHeader.read(NSFFile().data[:40]) + + +class TestTheMachine(BaseTestSuite): + """The init routine is told PAL for a file made for PAL alone, and NTSC for any other.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + region: int + expected: int + + @property + def label(self) -> str: + return f"region-{self.region}" + + test_cases: Tuple[TestCase, ...] = ( + TestCase(region=NTSC_REGION, expected=NTSC_MACHINE), + TestCase(region=PAL_REGION_FLAG, expected=PAL_MACHINE), + TestCase(region=PAL_REGION_FLAG | DUAL_REGION_FLAG, expected=NTSC_MACHINE), + TestCase(region=DUAL_REGION_FLAG, expected=NTSC_MACHINE), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_region_names_the_machine(self, test_case: TestCase) -> None: + assert NSFHeader.read(NSFFile(region=test_case.region).data).machine == test_case.expected diff --git a/tests/unit/sampletones_tools/console/test_machine.py b/tests/unit/sampletones_tools/console/test_machine.py new file mode 100644 index 000000000..b99fa5114 --- /dev/null +++ b/tests/unit/sampletones_tools/console/test_machine.py @@ -0,0 +1,137 @@ +from typing import Final, Tuple + +import pytest + +from sampletones_player.specification.nsf import PAL_REGION_FLAG, PROGRAM_START +from sampletones_player.specification.registers import APU_STATUS, PULSE1_CONTROL +from sampletones_tools.console.cartridge import BANK_SIZE, FIRST_BANK_REGISTER +from sampletones_tools.console.errors import RoutineOverrunError +from sampletones_tools.console.header import PAL_MACHINE +from sampletones_tools.console.machine import Console +from sampletones_tools.player.trace.write import RegisterWrite +from tests.unit.sampletones_tools.console.files import ( + INIT, + JUMP, + LOAD_ABSOLUTE, + LOAD_IMMEDIATE, + PLAY, + RETURN, + STORE_ACCUMULATOR, + STORE_INDEX, + NSFFile, + absolute, + bank_offset, +) + +SONG_REGISTER: Final[int] = 0x4002 +MACHINE_REGISTER: Final[int] = 0x4003 +CHANNELS_ON: Final[int] = 0x0F +PLAYED: Final[int] = 0x3F +SECOND_SLOT: Final[int] = PROGRAM_START + BANK_SIZE +BANK_MARKS: Final[Tuple[int, int]] = (0x77, 0x55) +STEP_BUDGET: Final[int] = 1000 +ENDLESS_BUDGET: Final[int] = 50 + +INIT_PROGRAM: Final[Tuple[int, ...]] = ( + STORE_ACCUMULATOR, + *absolute(SONG_REGISTER), + STORE_INDEX, + *absolute(MACHINE_REGISTER), + LOAD_IMMEDIATE, + CHANNELS_ON, + STORE_ACCUMULATOR, + *absolute(APU_STATUS), + RETURN, +) +PLAY_PROGRAM: Final[Tuple[int, ...]] = ( + LOAD_IMMEDIATE, + PLAYED, + STORE_ACCUMULATOR, + *absolute(PULSE1_CONTROL), + RETURN, +) +SWITCHING_INIT: Final[Tuple[int, ...]] = ( + LOAD_IMMEDIATE, + 1, + STORE_ACCUMULATOR, + *absolute(FIRST_BANK_REGISTER + 1), + RETURN, +) +READING_PLAY: Final[Tuple[int, ...]] = ( + LOAD_ABSOLUTE, + *absolute(SECOND_SLOT), + STORE_ACCUMULATOR, + *absolute(PULSE1_CONTROL), + RETURN, +) +ENDLESS_INIT: Final[Tuple[int, ...]] = (JUMP, *absolute(INIT)) +INIT_WRITES: Final[Tuple[RegisterWrite, ...]] = ( + RegisterWrite(SONG_REGISTER, 1), + RegisterWrite(MACHINE_REGISTER, PAL_MACHINE), + RegisterWrite(APU_STATUS, CHANNELS_ON), +) + + +def _console(file: NSFFile) -> Console: + return Console(file.data, step_budget=STEP_BUDGET) + + +@pytest.fixture(name="console") +def console_fixture() -> Console: + """A PAL file holding three songs and starting on the second, whose routines write what they are given.""" + return _console( + NSFFile( + region=PAL_REGION_FLAG, + songs=3, + first_song=2, + program={INIT - PROGRAM_START: INIT_PROGRAM, PLAY - PROGRAM_START: PLAY_PROGRAM}, + ) + ) + + +@pytest.fixture(name="banked") +def banked_fixture() -> Console: + """A banked file whose second slot starts on bank 2, which its init routine switches to bank 1.""" + return _console( + NSFFile( + banks=(0, 2, 0, 0, 0, 0, 0, 0), + program={ + INIT - PROGRAM_START: SWITCHING_INIT, + PLAY - PROGRAM_START: READING_PLAY, + bank_offset(1): (BANK_MARKS[0],), + bank_offset(2): (BANK_MARKS[1],), + }, + ) + ) + + +class TestRoutines: + def test_init_is_handed_the_first_song_and_the_machine(self, console: Console) -> None: + assert console.initialize() == INIT_WRITES + + def test_a_play_call_answers_with_its_own_writes_alone(self, console: Console) -> None: + console.initialize() + + assert console.play() == (RegisterWrite(PULSE1_CONTROL, PLAYED),) + + def test_a_trace_holds_the_initialization_and_every_play_call(self, console: Console) -> None: + trace = console.trace(3) + + assert trace.initialization == INIT_WRITES + assert trace.play_calls == ((RegisterWrite(PULSE1_CONTROL, PLAYED),),) * 3 + + def test_a_routine_that_never_returns_is_stopped(self) -> None: + console = Console(NSFFile(program={INIT - PROGRAM_START: ENDLESS_INIT}).data, step_budget=ENDLESS_BUDGET) + + with pytest.raises(RoutineOverrunError): + console.initialize() + + +class TestBankSwitching: + def test_a_slot_plays_the_bank_the_header_names_until_the_program_switches_it(self, banked: Console) -> None: + assert banked.play() == (RegisterWrite(PULSE1_CONTROL, BANK_MARKS[1]),) + + def test_a_switch_maps_the_bank_the_program_names(self, banked: Console) -> None: + banked.initialize() + + assert banked.play() == (RegisterWrite(PULSE1_CONTROL, BANK_MARKS[0]),) diff --git a/tests/unit/sampletones_tools/corpus/test_build.py b/tests/unit/sampletones_tools/corpus/test_build.py new file mode 100644 index 000000000..9d295626b --- /dev/null +++ b/tests/unit/sampletones_tools/corpus/test_build.py @@ -0,0 +1,35 @@ +from typing import Dict + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.voices.sample import Sample +from sampletones_tools.corpus.build import build_project +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import ChannelSpec, RowSpec, SongSpec +from tests.suite.performance import make_pulse_reconstruction, make_triangle_reconstruction + +MODULE: ModuleConfig = ModuleConfig(title="Demo", author="Someone", tempo=125, speed=3, nes_frequency=50) + + +def _catalog() -> Dict[str, Sample]: + return { + "lead": Sample(name="lead", reconstruction=make_pulse_reconstruction()), + "bass": Sample(name="bass", reconstruction=make_triangle_reconstruction()), + } + + +class TestBuildProject: + def test_the_project_carries_the_module_the_voices_and_the_song(self) -> None: + catalog = _catalog() + spec = SongSpec( + rows_per_pattern=2, + order=[{ChannelName.TRIANGLE: 0}], + channels={ChannelName.TRIANGLE: ChannelSpec(patterns={0: [RowSpec(row=0, voice="bass")]})}, + ) + + project = build_project(catalog, MODULE, spec) + + assert (project.info.title, project.info.author) == ("Demo", "Someone") + assert (project.settings.tempo, project.settings.speed, project.settings.nes_frequency) == (125, 3, 50) + assert [voice.name for voice in project.voices] == ["lead", "bass"] + assert project.song.rows_per_pattern == 2 + assert project.song.channels[ChannelName.TRIANGLE].patterns[0].rows[0].volume is None diff --git a/tests/unit/sampletones_tools/corpus/test_catalog.py b/tests/unit/sampletones_tools/corpus/test_catalog.py new file mode 100644 index 000000000..9b0e86efa --- /dev/null +++ b/tests/unit/sampletones_tools/corpus/test_catalog.py @@ -0,0 +1,13 @@ +from sampletones_tools.corpus.catalog import CatalogSpec +from sampletones_tools.corpus.synth import SynthConfig + + +class TestCatalogSpec: + def test_every_shipped_instrument_is_rendered_from_a_voice_the_synthesizer_holds(self) -> None: + voices = SynthConfig.load().voices + + instruments = CatalogSpec.load().instruments + + assert instruments + assert all(instrument.synth in voices for instrument in instruments) + assert all(instrument.channels for instrument in instruments) diff --git a/tests/unit/sampletones_tools/corpus/test_module.py b/tests/unit/sampletones_tools/corpus/test_module.py new file mode 100644 index 000000000..a5146c346 --- /dev/null +++ b/tests/unit/sampletones_tools/corpus/test_module.py @@ -0,0 +1,9 @@ +from sampletones_tools.corpus.module import ModuleConfig + + +class TestModuleConfig: + def test_the_shipped_module_loads_from_the_package(self) -> None: + module = ModuleConfig.load() + + assert module.title + assert module.tempo > 0 diff --git a/tests/unit/sampletones_tools/corpus/test_song.py b/tests/unit/sampletones_tools/corpus/test_song.py new file mode 100644 index 000000000..ee429f2e1 --- /dev/null +++ b/tests/unit/sampletones_tools/corpus/test_song.py @@ -0,0 +1,75 @@ +from typing import Dict + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.patterns.pitch import Step +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_tools.corpus.song import ChannelSpec, RowSpec, SongSpec, build_song +from tests.suite.performance import make_noise_reconstruction, make_pulse_reconstruction + +ROWS_PER_PATTERN: int = 4 + + +def _catalog() -> Dict[str, Sample]: + return { + "lead": Sample(name="lead", reconstruction=make_pulse_reconstruction()), + "hihat": Sample(name="hihat", reconstruction=make_noise_reconstruction()), + } + + +def _spec(channel: ChannelName, rows: Dict[int, RowSpec]) -> SongSpec: + return SongSpec( + rows_per_pattern=ROWS_PER_PATTERN, + order=[{channel: 0}], + channels={channel: ChannelSpec(patterns={0: list(rows.values())})}, + ) + + +class TestSongSpec: + def test_the_shipped_arrangement_loads_from_the_package(self) -> None: + spec = SongSpec.load() + + assert spec.rows_per_pattern > 0 + assert spec.order + assert set(spec.channels) <= set(ChannelName.items()) + + +class TestBuildSong: + def test_every_written_row_reaches_its_pattern(self) -> None: + catalog = _catalog() + spec = _spec( + ChannelName.PULSE1, + { + 0: RowSpec(row=0, voice="lead", transpose=12, volume=10), + 2: RowSpec(row=2, off=True), + 3: RowSpec(row=3, volume=4), + }, + ) + + song = build_song(spec, catalog) + + rows = song.channels[ChannelName.PULSE1].patterns[0].rows + assert len(rows) == ROWS_PER_PATTERN + assert rows[0].command == NoteOn(voice_id=catalog["lead"].id) + assert (rows[0].pitch, rows[0].volume) == (Step(value=12), 10) + assert rows[1].command is None + assert isinstance(rows[2].command, NoteOff) + assert (rows[3].command, rows[3].volume) == (None, 4) + + def test_the_order_names_every_channel_and_every_channel_is_present(self) -> None: + song = build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, voice="hihat")}), _catalog()) + + assert song.order == [{channel: 0 if channel == ChannelName.NOISE else None for channel in ChannelName.items()}] + assert set(song.channels) == set(ChannelName.items()) + assert song.channels[ChannelName.PULSE1].patterns == {} + + def test_a_voice_the_catalog_lacks_is_refused(self) -> None: + with pytest.raises(KeyError, match="snare"): + build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, voice="snare")}), _catalog()) + + def test_a_voice_on_a_channel_it_has_no_slice_for_is_refused(self) -> None: + with pytest.raises(ValueError, match="Voice 'lead' has no 'noise' slice"): + build_song(_spec(ChannelName.NOISE, {0: RowSpec(row=0, voice="lead")}), _catalog()) diff --git a/tests/unit/sampletones_tools/corpus/test_written.py b/tests/unit/sampletones_tools/corpus/test_written.py new file mode 100644 index 000000000..e2c36bd32 --- /dev/null +++ b/tests/unit/sampletones_tools/corpus/test_written.py @@ -0,0 +1,45 @@ +from typing import Dict, Final, List + +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.instructions import InstructionUnion, PulseInstruction +from sampletones_tools.corpus.written import ( + SINGLE_STEM_ID, + single_recording_record, + written_reconstruction, +) + +SCALE: Final[float] = 0.5 +COEFFICIENT: Final[float] = 0.75 +FRAMES: Final[Dict[ChannelName, List[InstructionUnion]]] = { + ChannelName.PULSE1: [ + PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=2), + PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=2), + PulseInstruction(on=True, pitch=62, volume=9, duty_cycle=1), + ] +} + + +class TestSingleRecordingRecord: + def test_a_sounding_frame_answers_to_the_recording_and_a_silent_one_to_rest(self) -> None: + record = single_recording_record(FRAMES, SCALE) + + assert [(assignment.channel_name, assignment.stem_ids) for assignment in record.assignments] == [ + (ChannelName.PULSE1, (SINGLE_STEM_ID, RESTING_STEM_ID, SINGLE_STEM_ID)) + ] + assert record.scale == SCALE + + +class TestWrittenReconstruction: + def test_the_reconstruction_plays_the_frames_it_is_given_and_names_no_recording(self) -> None: + reconstruction = written_reconstruction( + FRAMES, + coefficient=COEFFICIENT, + scale=SCALE, + audio_filepath=(), + ) + + assert list(reconstruction.instructions[ChannelName.PULSE1]) == FRAMES[ChannelName.PULSE1] + assert reconstruction.playing_channels == (ChannelName.PULSE1,) + assert reconstruction.coefficient == COEFFICIENT + assert not reconstruction.stems_data.sources diff --git a/tests/unit/sampletones_tools/player/assembler/__init__.py b/tests/unit/sampletones_tools/player/assembler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/player/assembler/test_builder.py b/tests/unit/sampletones_tools/player/assembler/test_builder.py new file mode 100644 index 000000000..fea7fbb4a --- /dev/null +++ b/tests/unit/sampletones_tools/player/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.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 sampletones_tools.player.assembler.builder import build_driver, verify_addresses +from sampletones_tools.player.assembler.toolchain import ASSEMBLER +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 `uv run sampletones driver`" + 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_tools/player/assembler/test_labels.py b/tests/unit/sampletones_tools/player/assembler/test_labels.py new file mode 100644 index 000000000..0fa438e84 --- /dev/null +++ b/tests/unit/sampletones_tools/player/assembler/test_labels.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_player.specification.driver import INIT_ADDRESS, LOAD_ADDRESS, PLAY_ADDRESS +from sampletones_shared.exceptions import DriverBuildError +from sampletones_tools.player.assembler.labels import ( + INIT_SYMBOL, + LOAD_SYMBOL, + PLAY_SYMBOL, + SONG_SYMBOL, + read_addresses, + read_labels, +) +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_tools/player/assembler/test_report.py b/tests/unit/sampletones_tools/player/assembler/test_report.py new file mode 100644 index 000000000..95932fb7a --- /dev/null +++ b/tests/unit/sampletones_tools/player/assembler/test_report.py @@ -0,0 +1,19 @@ +from typing import Final + +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import JUMP_ABSOLUTE_OPCODE +from sampletones_tools.player.assembler.report import layout_lines + +RETURN_OPCODE: Final[int] = 0x60 +CODE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, RETURN_OPCODE)) + + +class TestLayoutLines: + def test_the_figures_a_reader_checks_against_the_header(self) -> None: + image = DriverImage(code=CODE, addresses=DriverAddresses.for_code(len(CODE))) + + lines = layout_lines(image) + + assert lines[0] == "driver.bin 7 bytes, $8000-$8006" + assert lines[1:] == ["init $8000", "play $8003", "song $8007"] diff --git a/tests/unit/sampletones_tools/player/assembler/test_toolchain.py b/tests/unit/sampletones_tools/player/assembler/test_toolchain.py new file mode 100644 index 000000000..4ee145f24 --- /dev/null +++ b/tests/unit/sampletones_tools/player/assembler/test_toolchain.py @@ -0,0 +1,44 @@ +import shutil +import sys +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_shared.exceptions import DriverBuildError, ToolchainMissingError +from sampletones_shared.utils.system.system import System +from sampletones_tools.player.assembler.toolchain import ( + ASSEMBLER, + INSTALL_HINTS, + LINKER, + Toolchain, +) +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_tools/player/test_command.py b/tests/unit/sampletones_tools/player/test_command.py new file mode 100644 index 000000000..c9b7d56e6 --- /dev/null +++ b/tests/unit/sampletones_tools/player/test_command.py @@ -0,0 +1,65 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_player.driver.addresses import DriverAddresses +from sampletones_player.driver.image import DriverImage +from sampletones_player.specification.driver import JUMP_ABSOLUTE_OPCODE +from sampletones_shared.exceptions.player import DriverBuildError +from sampletones_tools.player.assembler.layout import BINARY_DIRECTORY + +BUILDER: Final[str] = "sampletones_tools.player.assembler.builder.build_driver" +GUARD: Final[str] = "sampletones_tools.checkout.require_checkout" +RETURN_OPCODE: Final[int] = 0x60 +CODE: Final[bytes] = bytes((JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, JUMP_ABSOLUTE_OPCODE, 0x00, 0x80, RETURN_OPCODE)) +IMAGE: Final[DriverImage] = DriverImage(code=CODE, addresses=DriverAddresses.for_code(len(CODE))) + + +class RecordedBuild: + def __init__(self) -> None: + self.destinations: List[Path] = [] + + def __call__(self, destination: Path) -> DriverImage: + self.destinations.append(destination) + return IMAGE + + +def _refuse(command: str) -> None: + raise AssertionError(f"the checkout guard ran for {command}") + + +class TestDriver: + def test_without_a_directory_the_shipped_driver_is_written_from_a_checkout( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + build = RecordedBuild() + guarded: List[str] = [] + monkeypatch.setattr(BUILDER, build) + monkeypatch.setattr(GUARD, guarded.append) + + assert dispatch(COMMANDS, ["driver"]) == 0 + assert build.destinations == [BINARY_DIRECTORY] + assert guarded == ["driver"] + assert "driver.bin 7 bytes" in capsys.readouterr().out + + def test_a_directory_of_its_own_needs_no_checkout(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + build = RecordedBuild() + monkeypatch.setattr(BUILDER, build) + monkeypatch.setattr(GUARD, _refuse) + + assert dispatch(COMMANDS, ["driver", "--output", str(tmp_path)]) == 0 + assert build.destinations == [tmp_path] + + def test_a_failing_build_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def fail(destination: Path) -> DriverImage: + raise DriverBuildError(f"ca65 failed: boom in {destination}") + + monkeypatch.setattr(BUILDER, fail) + + with pytest.raises(SystemExit, match="ca65 failed"): + dispatch(COMMANDS, ["driver", "--output", str(tmp_path)]) diff --git a/tests/unit/sampletones_tools/player/test_song_include.py b/tests/unit/sampletones_tools/player/test_song_include.py new file mode 100644 index 000000000..03248a9c1 --- /dev/null +++ b/tests/unit/sampletones_tools/player/test_song_include.py @@ -0,0 +1,178 @@ +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.specification.binary import WORD_SIZE +from sampletones_player.specification.compression import ( + BEND_FLAG, + DEFAULT_COUNT_FLAG, + OPCODE_SIZE, + PHRASE_DEFAULT_SIZE, + PHRASE_ID_ESCAPE, + PHRASE_ID_MASK, + PHRASE_LENGTH_SIZE, + PHRASE_TABLE_COUNT_SIZE, + PHRASE_TABLE_ENTRY_SIZE, + PITCH_INDEX_MASK, + PLANE_STATE_SIZE, + TOKEN_OPERAND_MASK, + TOKEN_TAG_MASK, + TokenTag, +) +from sampletones_player.specification.planes import ( + COUNT_STEP, + NOISE_CONTROL_FORM, + NOISE_VALUE_FORM, + PLANE_COUNT, + PULSE_CONTROL_FORM, + SILENT_PITCH_INDEX, +) +from sampletones_player.specification.registers import ( + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_SILENT_RELOAD, + TRIANGLE_SOUNDING_RELOAD, +) +from sampletones_player.specification.song import ( + ABSENT_STREAM, + 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, +) +from sampletones_tools.player.assembler.layout import ASSEMBLY_DIRECTORY, INCLUDE_DIRECTORY + +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, + "ABSENT_STREAM": ABSENT_STREAM, + "PITCH_COUNT": PITCH_COUNT, + "BEND_FLAG": BEND_FLAG, + "PITCH_INDEX_MASK": PITCH_INDEX_MASK, + "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, + "PHRASE_DEFAULT_SIZE": PHRASE_DEFAULT_SIZE, + "DEFAULT_COUNT_FLAG": DEFAULT_COUNT_FLAG, + "PHRASE_ID_MASK": PHRASE_ID_MASK, + "PLANE_STATE_SIZE": PLANE_STATE_SIZE, + "PLANE_STATE_BYTES": PLANE_COUNT * PLANE_STATE_SIZE, + "COUNT_STEP": COUNT_STEP, + "SILENT_PITCH_INDEX": SILENT_PITCH_INDEX, + "TRIANGLE_COUNTER": TRIANGLE_COUNTER_CONTROL, + "TRIANGLE_SOUNDING": TRIANGLE_SOUNDING_RELOAD, + "TRIANGLE_SILENT": TRIANGLE_SILENT_RELOAD, + "PULSE_CONTROL_MASK": PULSE_CONTROL_FORM.value_mask, + "PULSE_CONTROL_FIXED": PULSE_CONTROL_FORM.value_or, + "NOISE_CONTROL_MASK": NOISE_CONTROL_FORM.value_mask, + "NOISE_CONTROL_FIXED": NOISE_CONTROL_FORM.value_or, + "NOISE_VALUE_MASK": NOISE_VALUE_FORM.value_mask, + "NOISE_VALUE_FIXED": NOISE_VALUE_FORM.value_or, +} + + +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.Sub(), 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 a sum, a difference 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(ASSEMBLY_DIRECTORY / 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_tools/player/trace/__init__.py b/tests/unit/sampletones_tools/player/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/player/trace/test_trace.py b/tests/unit/sampletones_tools/player/trace/test_trace.py new file mode 100644 index 000000000..2cebe5570 --- /dev/null +++ b/tests/unit/sampletones_tools/player/trace/test_trace.py @@ -0,0 +1,224 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.channels import CHANNEL_REGISTER_ADDRESSES +from sampletones_player.specification.registers import ( + APU_FRAME_COUNTER, + APU_STATUS, + 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_tools.player.trace.trace import RegisterTrace, channel_writes, setup_writes +from sampletones_tools.player.trace.write import RegisterWrite +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase +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 TestTickWrites: + """The writes the init routine opens with and the writes one tick's channels make.""" + + def test_the_setup_opens_the_initialization(self) -> None: + song = player_song(resting_streams((SOUNDING,)), NTSC_FREQUENCY, loop_tick=None) + + initialization = RegisterTrace.from_song(song, play_calls=0).initialization + + assert initialization[: len(setup_writes())] == setup_writes() + + def test_a_tick_writes_every_register_each_channel_owns_in_channel_order(self) -> None: + streams = resting_streams((SOUNDING,)) + + writes = channel_writes(streams.at(0)) + + assert addresses(writes) == tuple( + address for channel in ChannelName.items() for address in CHANNEL_REGISTER_ADDRESSES[channel] + ) + assert tuple(write.value for write in writes) == tuple( + value for registers in streams.at(0) for value in registers.values + ) + + +class TestChangeSuppression: + """The three registers that disturb a running channel are written only where they change.""" + + 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_tools/samples/commands/test_btp.py b/tests/unit/sampletones_tools/samples/commands/test_btp.py new file mode 100644 index 000000000..02c5801b2 --- /dev/null +++ b/tests/unit/sampletones_tools/samples/commands/test_btp.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_tools.samples.bitphase import write_samples +from sampletones_tools.samples.emit import Emitter + +EMITTER: Final[str] = "sampletones_tools.samples.emit.emit_samples" + + +class TestBtpSamples: + def test_the_bitphase_emitter_writes_into_the_output_and_every_file_is_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + calls: List[Tuple[Path, Emitter]] = [] + + def emit_samples(output: Path, emitter: Emitter) -> List[Path]: + calls.append((output, emitter)) + return [output / "drums.btp"] + + monkeypatch.setattr(EMITTER, emit_samples) + + assert dispatch(COMMANDS, ["btp", "samples", "-o", str(tmp_path)]) == 0 + assert calls == [(tmp_path, write_samples)] + assert capsys.readouterr().out.splitlines() == [f"Wrote {tmp_path / 'drums.btp'}"] + + def test_the_output_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["btp", "samples"]) + + assert leaving.value.code == 2 + + def test_an_action_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["btp"]) + + assert leaving.value.code == 2 diff --git a/tests/unit/sampletones_tools/samples/commands/test_ftm.py b/tests/unit/sampletones_tools/samples/commands/test_ftm.py new file mode 100644 index 000000000..21d0b8c81 --- /dev/null +++ b/tests/unit/sampletones_tools/samples/commands/test_ftm.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_tools.samples.emit import Emitter +from sampletones_tools.samples.famitracker import write_samples + +EMITTER: Final[str] = "sampletones_tools.samples.emit.emit_samples" + + +class TestFtmSamples: + def test_the_famitracker_emitter_writes_into_the_output_and_every_file_is_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + calls: List[Tuple[Path, Emitter]] = [] + + def emit_samples(output: Path, emitter: Emitter) -> List[Path]: + calls.append((output, emitter)) + return [output / "drums.ftm"] + + monkeypatch.setattr(EMITTER, emit_samples) + + assert dispatch(COMMANDS, ["ftm", "samples", "-o", str(tmp_path)]) == 0 + assert calls == [(tmp_path, write_samples)] + assert capsys.readouterr().out.splitlines() == [f"Wrote {tmp_path / 'drums.ftm'}"] + + def test_the_output_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["ftm", "samples"]) + + assert leaving.value.code == 2 + + def test_an_action_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["ftm"]) + + assert leaving.value.code == 2 diff --git a/tests/unit/sampletones_tools/samples/commands/test_nsf.py b/tests/unit/sampletones_tools/samples/commands/test_nsf.py new file mode 100644 index 000000000..1ef0970c2 --- /dev/null +++ b/tests/unit/sampletones_tools/samples/commands/test_nsf.py @@ -0,0 +1,83 @@ +from pathlib import Path +from typing import Final, List, Tuple + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_tools.samples.emit import Emitter +from sampletones_tools.samples.nsf import write_samples +from sampletones_tools.samples.render import RenderedWave, RenderingError + +RENDERER: Final[str] = "sampletones_tools.samples.render.render_directory" +EMITTER: Final[str] = "sampletones_tools.samples.emit.emit_samples" + + +class TestNsfSamples: + def test_the_nsf_emitter_writes_into_the_output_and_every_file_is_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + calls: List[Tuple[Path, Emitter]] = [] + + def emit_samples(output: Path, emitter: Emitter) -> List[Path]: + calls.append((output, emitter)) + return [output / "kick.nsf", output / "song.nsf"] + + monkeypatch.setattr(EMITTER, emit_samples) + + assert dispatch(COMMANDS, ["nsf", "samples", "--output", str(tmp_path)]) == 0 + assert calls == [(tmp_path, write_samples)] + assert capsys.readouterr().out.splitlines() == [ + f"Wrote {tmp_path / 'kick.nsf'}", + f"Wrote {tmp_path / 'song.nsf'}", + ] + + def test_the_output_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["nsf", "samples"]) + + assert leaving.value.code == 2 + + +class TestNsfRender: + def test_every_wave_written_is_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + calls: List[Tuple[Path, float]] = [] + + def render_directory(directory: Path, tail_seconds: float) -> List[RenderedWave]: + calls.append((directory, tail_seconds)) + return [RenderedWave(source=directory / "a.nsf", destination=directory / "a.wav", seconds=1.5)] + + monkeypatch.setattr(RENDERER, render_directory) + + assert dispatch(COMMANDS, ["nsf", "render", "--directory", str(tmp_path)]) == 0 + assert calls == [(tmp_path, 0.5)] + assert f"{tmp_path / 'a.wav'} 1.500 s" in capsys.readouterr().out + + def test_the_directory_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["nsf", "render"]) + + assert leaving.value.code == 2 + + def test_an_action_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, ["nsf"]) + + assert leaving.value.code == 2 + + def test_a_rendering_failure_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def fail(directory: Path, tail_seconds: float) -> List[RenderedWave]: + raise RenderingError(f"ffmpeg is missing for {directory} at {tail_seconds}") + + monkeypatch.setattr(RENDERER, fail) + + with pytest.raises(SystemExit, match="ffmpeg is missing"): + dispatch(COMMANDS, ["nsf", "render", "--directory", str(tmp_path)]) diff --git a/tests/unit/sampletones_tools/samples/test_render.py b/tests/unit/sampletones_tools/samples/test_render.py new file mode 100644 index 000000000..132ece2c3 --- /dev/null +++ b/tests/unit/sampletones_tools/samples/test_render.py @@ -0,0 +1,44 @@ +import struct +from pathlib import Path + +import pytest + +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_tools.samples import render +from sampletones_tools.samples.render import RenderingError, render_directory, song_seconds + +CODE_LENGTH = 10 + + +def _exported(ticks: int, whole: int, fraction: int) -> bytes: + block = bytearray(max(TOTAL_TICKS_OFFSET, STEP_FRACTION_OFFSET, STEP_WHOLE_OFFSET) + 2) + struct.pack_into(" None: + data = _exported(ticks=600, whole=1, fraction=0) + + assert song_seconds(data, CODE_LENGTH) == pytest.approx(600 / float(NTSC_FRAME_RATE)) + + def test_a_fractional_step_takes_fewer_calls(self) -> None: + data = _exported(ticks=600, whole=1, fraction=FIXED_POINT_SCALE // 2) + + assert song_seconds(data, CODE_LENGTH) == pytest.approx(400 / float(NTSC_FRAME_RATE)) + + +class TestRenderDirectory: + def test_a_directory_without_exports_is_refused(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(render, "require_renderer", lambda: None) + + with pytest.raises(RenderingError, match="no .nsf files"): + render_directory(tmp_path, 0.5) diff --git a/tests/unit/sampletones_tools/synthesis/__init__.py b/tests/unit/sampletones_tools/synthesis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_synthesis/conftest.py b/tests/unit/sampletones_tools/synthesis/conftest.py similarity index 100% rename from tests/unit/sampletones_synthesis/conftest.py rename to tests/unit/sampletones_tools/synthesis/conftest.py diff --git a/tests/unit/sampletones_tools/synthesis/envelopes/__init__.py b/tests/unit/sampletones_tools/synthesis/envelopes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/synthesis/envelopes/test_envelopes.py b/tests/unit/sampletones_tools/synthesis/envelopes/test_envelopes.py new file mode 100644 index 000000000..d9044e49f --- /dev/null +++ b/tests/unit/sampletones_tools/synthesis/envelopes/test_envelopes.py @@ -0,0 +1,86 @@ +from typing import Final + +import numpy as np +import pytest +from pydantic import ValidationError + +from sampletones_tools.synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope +from sampletones_tools.synthesis.envelopes.gate import GateEnvelope +from sampletones_tools.synthesis.envelopes.linear_attack import LinearAttackEnvelope +from sampletones_tools.synthesis.envelopes.linear_ramp import LinearRampEnvelope +from sampletones_tools.synthesis.envelopes.periodic_decay import PeriodicDecayEnvelope + +TIME_CONSTANT_SECONDS: Final[float] = 0.25 +ATTACK_SECONDS: Final[float] = 0.005 +GATE_START_SECONDS: Final[float] = 0.25 +GATE_END_SECONDS: Final[float] = 0.5 +STRIKE_PERIOD_SECONDS: Final[float] = 0.25 +STRIKE_DELAY_SECONDS: Final[float] = 0.1 +STRIKE_DECAY_SECONDS: Final[float] = 0.02 + + +class TestExponentialDecayEnvelope: + def test_starts_at_full_level_and_reaches_inverse_e_at_the_time_constant( + self, + time_axis: np.ndarray, + sample_rate: int, + ) -> None: + envelope = ExponentialDecayEnvelope(kind="exponential_decay", time_constant_seconds=TIME_CONSTANT_SECONDS) + values = envelope.render(time_axis) + constant_index = round(TIME_CONSTANT_SECONDS * sample_rate) + assert values[0] == pytest.approx(1.0) + assert values[constant_index] == pytest.approx(np.exp(-1.0), rel=1e-3) + + def test_decreases_monotonically(self, time_axis: np.ndarray) -> None: + envelope = ExponentialDecayEnvelope(kind="exponential_decay", time_constant_seconds=TIME_CONSTANT_SECONDS) + assert np.all(np.diff(envelope.render(time_axis)) < 0.0) + + +class TestLinearAttackEnvelope: + def test_rises_linearly_then_holds_full_level(self, time_axis: np.ndarray, sample_rate: int) -> None: + envelope = LinearAttackEnvelope(kind="linear_attack", attack_seconds=ATTACK_SECONDS) + values = envelope.render(time_axis) + hold_start = int(np.ceil(ATTACK_SECONDS * sample_rate)) + assert values[0] == pytest.approx(0.0) + assert values[hold_start // 2] == pytest.approx(0.5, abs=0.01) + assert np.all(values[hold_start:] == 1.0) + + +class TestLinearRampEnvelope: + def test_rises_from_zero_to_one_over_the_axis(self, time_axis: np.ndarray) -> None: + values = LinearRampEnvelope(kind="linear_ramp").render(time_axis) + assert values[0] == pytest.approx(0.0) + assert values[-1] == pytest.approx(1.0) + assert np.all(np.diff(values) > 0.0) + + +class TestGateEnvelope: + def test_holds_full_level_inside_the_gate_and_silence_outside(self, time_axis: np.ndarray) -> None: + envelope = GateEnvelope(kind="gate", start_seconds=GATE_START_SECONDS, end_seconds=GATE_END_SECONDS) + values = envelope.render(time_axis) + opening = int(np.searchsorted(time_axis, GATE_START_SECONDS)) + closing = int(np.searchsorted(time_axis, GATE_END_SECONDS)) + assert np.all(values[:opening] == 0.0) + assert np.all(values[opening:closing] == 1.0) + assert np.all(values[closing:] == 0.0) + + def test_a_gate_closing_before_it_opens_is_refused(self) -> None: + with pytest.raises(ValidationError): + GateEnvelope(kind="gate", start_seconds=GATE_END_SECONDS, end_seconds=GATE_START_SECONDS) + + +class TestPeriodicDecayEnvelope: + def test_is_silent_until_the_first_strike_and_full_on_every_strike(self, time_axis: np.ndarray) -> None: + envelope = PeriodicDecayEnvelope( + kind="periodic_decay", + period_seconds=STRIKE_PERIOD_SECONDS, + time_constant_seconds=STRIKE_DECAY_SECONDS, + delay_seconds=STRIKE_DELAY_SECONDS, + ) + values = envelope.render(time_axis) + first_strike = int(np.searchsorted(time_axis, STRIKE_DELAY_SECONDS)) + second_strike = int(np.searchsorted(time_axis, STRIKE_DELAY_SECONDS + STRIKE_PERIOD_SECONDS)) + assert np.all(values[:first_strike] == 0.0) + assert values[first_strike + 1] == pytest.approx(1.0, abs=1e-2) + assert values[second_strike + 1] == pytest.approx(1.0, abs=1e-2) + assert values[second_strike - 1] < np.exp(-STRIKE_PERIOD_SECONDS / STRIKE_DECAY_SECONDS / 2.0) diff --git a/tests/unit/sampletones_tools/synthesis/filters/__init__.py b/tests/unit/sampletones_tools/synthesis/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_synthesis/filters/test_butterworth_highpass.py b/tests/unit/sampletones_tools/synthesis/filters/test_butterworth_highpass.py similarity index 90% rename from tests/unit/sampletones_synthesis/filters/test_butterworth_highpass.py rename to tests/unit/sampletones_tools/synthesis/filters/test_butterworth_highpass.py index 90e2623fa..3c4ddc7ae 100644 --- a/tests/unit/sampletones_synthesis/filters/test_butterworth_highpass.py +++ b/tests/unit/sampletones_tools/synthesis/filters/test_butterworth_highpass.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_synthesis.filters.butterworth_highpass import ButterworthHighpassFilter +from sampletones_tools.synthesis.filters.butterworth_highpass import ButterworthHighpassFilter CUTOFF_HZ: Final[float] = 2000.0 ORDER: Final[int] = 4 diff --git a/tests/unit/sampletones_tools/synthesis/oscillators/__init__.py b/tests/unit/sampletones_tools/synthesis/oscillators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/synthesis/oscillators/test_noise.py b/tests/unit/sampletones_tools/synthesis/oscillators/test_noise.py new file mode 100644 index 000000000..23fa37c32 --- /dev/null +++ b/tests/unit/sampletones_tools/synthesis/oscillators/test_noise.py @@ -0,0 +1,38 @@ +from typing import Final + +import numpy as np +import pytest + +from sampletones_tools.synthesis.oscillators.walk_noise import WalkNoiseOscillator +from sampletones_tools.synthesis.oscillators.white_noise import WhiteNoiseOscillator + +SEED: Final[int] = 99 + + +class TestWhiteNoiseOscillator: + def test_same_seed_reproduces_the_waveform(self, time_axis: np.ndarray) -> None: + oscillator = WhiteNoiseOscillator(kind="white_noise") + first = oscillator.render(time_axis, generator=np.random.default_rng(SEED)) + second = oscillator.render(time_axis, generator=np.random.default_rng(SEED)) + assert np.array_equal(first, second) + + def test_unit_standard_deviation(self, time_axis: np.ndarray, generator: np.random.Generator) -> None: + audio = WhiteNoiseOscillator(kind="white_noise").render(time_axis, generator=generator) + assert np.std(audio) == pytest.approx(1.0, abs=0.05) + + +class TestWalkNoiseOscillator: + def test_zero_mean_and_unit_peak(self, time_axis: np.ndarray, generator: np.random.Generator) -> None: + audio = WalkNoiseOscillator(kind="walk_noise").render(time_axis, generator=generator) + assert np.mean(audio) == pytest.approx(0.0, abs=1e-9) + assert np.max(np.abs(audio)) == pytest.approx(1.0) + + def test_energy_concentrates_at_low_frequencies( + self, + time_axis: np.ndarray, + generator: np.random.Generator, + ) -> None: + audio = WalkNoiseOscillator(kind="walk_noise").render(time_axis, generator=generator) + spectrum = np.abs(np.fft.rfft(audio)) ** 2 + half = spectrum.shape[0] // 2 + assert np.sum(spectrum[:half]) > 10.0 * np.sum(spectrum[half:]) diff --git a/tests/unit/sampletones_tools/synthesis/oscillators/test_pulse.py b/tests/unit/sampletones_tools/synthesis/oscillators/test_pulse.py new file mode 100644 index 000000000..c8d6f7c11 --- /dev/null +++ b/tests/unit/sampletones_tools/synthesis/oscillators/test_pulse.py @@ -0,0 +1,33 @@ +from typing import Final, Tuple + +import numpy as np +import pytest + +from sampletones_tools.synthesis.oscillators.pulse import PulseOscillator + +FREQUENCY: Final[float] = 220.0 +DUTY_CYCLES: Final[Tuple[float, ...]] = (0.125, 0.25, 0.5) + + +class TestPulseOscillator: + @pytest.mark.parametrize("duty_cycle", DUTY_CYCLES) + def test_positive_fraction_matches_the_duty_cycle( + self, + duty_cycle: float, + time_axis: np.ndarray, + generator: np.random.Generator, + ) -> None: + oscillator = PulseOscillator(kind="pulse", frequency=FREQUENCY, duty_cycle=duty_cycle) + audio = oscillator.render(time_axis, generator=generator) + assert np.mean(audio > 0.0) == pytest.approx(duty_cycle, abs=0.01) + + def test_values_alternate_between_full_levels( + self, + time_axis: np.ndarray, + generator: np.random.Generator, + ) -> None: + audio = PulseOscillator(kind="pulse", frequency=FREQUENCY, duty_cycle=0.5).render( + time_axis, + generator=generator, + ) + assert set(np.unique(audio)) == {-1.0, 1.0} diff --git a/tests/unit/sampletones_synthesis/oscillators/test_sine.py b/tests/unit/sampletones_tools/synthesis/oscillators/test_sine.py similarity index 95% rename from tests/unit/sampletones_synthesis/oscillators/test_sine.py rename to tests/unit/sampletones_tools/synthesis/oscillators/test_sine.py index d015852f2..cd5393e30 100644 --- a/tests/unit/sampletones_synthesis/oscillators/test_sine.py +++ b/tests/unit/sampletones_tools/synthesis/oscillators/test_sine.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from sampletones_synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.oscillators.sine import SineOscillator FREQUENCY: Final[float] = 440.0 diff --git a/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py b/tests/unit/sampletones_tools/synthesis/oscillators/test_sweeps.py similarity index 90% rename from tests/unit/sampletones_synthesis/oscillators/test_sweeps.py rename to tests/unit/sampletones_tools/synthesis/oscillators/test_sweeps.py index 49c1a351a..25667dfa0 100644 --- a/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py +++ b/tests/unit/sampletones_tools/synthesis/oscillators/test_sweeps.py @@ -5,10 +5,10 @@ import pytest from scipy.integrate import cumulative_trapezoid -from sampletones_synthesis.oscillators.exponential_glide import ExponentialGlideOscillator -from sampletones_synthesis.oscillators.geometric_sweep import GeometricSweepOscillator -from sampletones_synthesis.oscillators.sine import SineOscillator -from sampletones_synthesis.protocols import Oscillator +from sampletones_tools.synthesis.oscillators.exponential_glide import ExponentialGlideOscillator +from sampletones_tools.synthesis.oscillators.geometric_sweep import GeometricSweepOscillator +from sampletones_tools.synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.protocols import Oscillator FREQUENCY_START: Final[float] = 392.0 FREQUENCY_END: Final[float] = 44.0 diff --git a/tests/unit/sampletones_synthesis/test_frequency.py b/tests/unit/sampletones_tools/synthesis/test_frequency.py similarity index 95% rename from tests/unit/sampletones_synthesis/test_frequency.py rename to tests/unit/sampletones_tools/synthesis/test_frequency.py index 314c0b5b2..b408ae398 100644 --- a/tests/unit/sampletones_synthesis/test_frequency.py +++ b/tests/unit/sampletones_tools/synthesis/test_frequency.py @@ -4,7 +4,7 @@ import pytest from pydantic import TypeAdapter, ValidationError -from sampletones_synthesis.frequency import FrequencySpec, resolve_frequency +from sampletones_tools.synthesis.frequency import FrequencySpec, resolve_frequency FREQUENCY_SPEC_ADAPTER: Final[TypeAdapter[Any]] = TypeAdapter(FrequencySpec) diff --git a/tests/unit/sampletones_tools/synthesis/test_unions.py b/tests/unit/sampletones_tools/synthesis/test_unions.py new file mode 100644 index 000000000..f4c2a99ee --- /dev/null +++ b/tests/unit/sampletones_tools/synthesis/test_unions.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final, Tuple, Type + +import pytest +from pydantic import BaseModel, TypeAdapter, ValidationError + +from sampletones_tools.synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope +from sampletones_tools.synthesis.envelopes.gate import GateEnvelope +from sampletones_tools.synthesis.envelopes.linear_attack import LinearAttackEnvelope +from sampletones_tools.synthesis.envelopes.linear_ramp import LinearRampEnvelope +from sampletones_tools.synthesis.envelopes.periodic_decay import PeriodicDecayEnvelope +from sampletones_tools.synthesis.envelopes.types import EnvelopeUnion +from sampletones_tools.synthesis.oscillators.exponential_glide import ExponentialGlideOscillator +from sampletones_tools.synthesis.oscillators.geometric_sweep import GeometricSweepOscillator +from sampletones_tools.synthesis.oscillators.pulse import PulseOscillator +from sampletones_tools.synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.oscillators.types import OscillatorUnion +from sampletones_tools.synthesis.oscillators.walk_noise import WalkNoiseOscillator +from sampletones_tools.synthesis.oscillators.white_noise import WhiteNoiseOscillator + +OSCILLATOR_ADAPTER: Final[TypeAdapter[Any]] = TypeAdapter(OscillatorUnion) +ENVELOPE_ADAPTER: Final[TypeAdapter[Any]] = TypeAdapter(EnvelopeUnion) + + +@dataclass(frozen=True) +class DiscriminationCase: + name: str + adapter: TypeAdapter[Any] + payload: Dict[str, Any] + expected_type: Type[BaseModel] + + +DISCRIMINATION_CASES: Final[Tuple[DiscriminationCase, ...]] = ( + DiscriminationCase( + name="sine", + adapter=OSCILLATOR_ADAPTER, + payload={"kind": "sine", "frequency": 440.0}, + expected_type=SineOscillator, + ), + DiscriminationCase( + name="geometric_sweep", + adapter=OSCILLATOR_ADAPTER, + payload={ + "kind": "geometric_sweep", + "frequency_start": 67, + "frequency_end": 29, + }, + expected_type=GeometricSweepOscillator, + ), + DiscriminationCase( + name="exponential_glide", + adapter=OSCILLATOR_ADAPTER, + payload={ + "kind": "exponential_glide", + "frequency_start": 150.0, + "frequency_end": 50.0, + "time_constant_seconds": 0.25, + }, + expected_type=ExponentialGlideOscillator, + ), + DiscriminationCase( + name="pulse", + adapter=OSCILLATOR_ADAPTER, + payload={"kind": "pulse", "frequency": 220.0, "duty_cycle": 0.25}, + expected_type=PulseOscillator, + ), + DiscriminationCase( + name="white_noise", + adapter=OSCILLATOR_ADAPTER, + payload={"kind": "white_noise"}, + expected_type=WhiteNoiseOscillator, + ), + DiscriminationCase( + name="walk_noise", + adapter=OSCILLATOR_ADAPTER, + payload={"kind": "walk_noise"}, + expected_type=WalkNoiseOscillator, + ), + DiscriminationCase( + name="exponential_decay", + adapter=ENVELOPE_ADAPTER, + payload={"kind": "exponential_decay", "time_constant_seconds": 0.15}, + expected_type=ExponentialDecayEnvelope, + ), + DiscriminationCase( + name="linear_attack", + adapter=ENVELOPE_ADAPTER, + payload={"kind": "linear_attack", "attack_seconds": 0.005}, + expected_type=LinearAttackEnvelope, + ), + DiscriminationCase( + name="linear_ramp", + adapter=ENVELOPE_ADAPTER, + payload={"kind": "linear_ramp"}, + expected_type=LinearRampEnvelope, + ), + DiscriminationCase( + name="gate", + adapter=ENVELOPE_ADAPTER, + payload={"kind": "gate", "start_seconds": 0.25, "end_seconds": 0.5}, + expected_type=GateEnvelope, + ), + DiscriminationCase( + name="periodic_decay", + adapter=ENVELOPE_ADAPTER, + payload={"kind": "periodic_decay", "period_seconds": 0.25, "time_constant_seconds": 0.01, "delay_seconds": 0.0}, + expected_type=PeriodicDecayEnvelope, + ), +) + + +class TestUnionDiscrimination: + @pytest.mark.parametrize( + "case", + DISCRIMINATION_CASES, + ids=lambda case: case.name, + ) + def test_kind_selects_the_member_class( + self, + case: DiscriminationCase, + ) -> None: + assert isinstance( + case.adapter.validate_python(case.payload), + case.expected_type, + ) + + @pytest.mark.parametrize( + "adapter", + (OSCILLATOR_ADAPTER, ENVELOPE_ADAPTER), + ids=("oscillators", "envelopes"), + ) + def test_unknown_kind_is_rejected(self, adapter: TypeAdapter[Any]) -> None: + with pytest.raises(ValidationError): + adapter.validate_python({"kind": "unknown"}) + + def test_extra_field_is_rejected(self) -> None: + with pytest.raises(ValidationError): + OSCILLATOR_ADAPTER.validate_python( + { + "kind": "sine", + "frequency": 440.0, + "volume": 1.0, + } + ) diff --git a/tests/unit/sampletones_tools/synthesis/voice/__init__.py b/tests/unit/sampletones_tools/synthesis/voice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_synthesis/voice/test_layer.py b/tests/unit/sampletones_tools/synthesis/voice/test_layer.py similarity index 86% rename from tests/unit/sampletones_synthesis/voice/test_layer.py rename to tests/unit/sampletones_tools/synthesis/voice/test_layer.py index 92ee232e1..70237aa10 100644 --- a/tests/unit/sampletones_synthesis/voice/test_layer.py +++ b/tests/unit/sampletones_tools/synthesis/voice/test_layer.py @@ -3,10 +3,10 @@ import numpy as np import pytest -from sampletones_synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope -from sampletones_synthesis.envelopes.linear_attack import LinearAttackEnvelope -from sampletones_synthesis.oscillators.sine import SineOscillator -from sampletones_synthesis.voice.layer import Layer +from sampletones_tools.synthesis.envelopes.exponential_decay import ExponentialDecayEnvelope +from sampletones_tools.synthesis.envelopes.linear_attack import LinearAttackEnvelope +from sampletones_tools.synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.voice.layer import Layer FREQUENCY: Final[float] = 220.0 TIME_CONSTANT_SECONDS: Final[float] = 0.3 diff --git a/tests/unit/sampletones_tools/synthesis/voice/test_voice.py b/tests/unit/sampletones_tools/synthesis/voice/test_voice.py new file mode 100644 index 000000000..e21cd3213 --- /dev/null +++ b/tests/unit/sampletones_tools/synthesis/voice/test_voice.py @@ -0,0 +1,119 @@ +from typing import Any, Dict, Final + +import numpy as np +import pytest +from pydantic import ValidationError + +from sampletones_tools.synthesis.oscillators.sine import SineOscillator +from sampletones_tools.synthesis.voice.layer import Layer +from sampletones_tools.synthesis.voice.voice import Voice + +DURATION_SECONDS: Final[float] = 0.5 +LOW_FREQUENCY: Final[float] = 220.0 +HIGH_FREQUENCY: Final[float] = 880.0 + +VOICE_MAPPING: Final[Dict[str, Any]] = { + "duration_seconds": DURATION_SECONDS, + "layers": [ + { + "oscillator": { + "kind": "geometric_sweep", + "frequency_start": 67, + "frequency_end": 29, + }, + "envelopes": [ + { + "kind": "exponential_decay", + "time_constant_seconds": 0.066, + } + ], + "gain": 1.0, + }, + { + "oscillator": {"kind": "white_noise"}, + "envelopes": [], + "gain": 0.15, + }, + ], + "filters": [ + { + "kind": "butterworth_highpass", + "cutoff_hz": 5000.0, + "order": 4, + } + ], +} + + +def _tone_layer(frequency: float, gain: float) -> Layer: + return Layer( + oscillator=SineOscillator(kind="sine", frequency=frequency), + envelopes=(), + gain=gain, + ) + + +class TestVoice: + def test_layers_sum( + self, + sample_rate: int, + generator: np.random.Generator, + ) -> None: + low = _tone_layer(LOW_FREQUENCY, gain=1.0) + high = _tone_layer(HIGH_FREQUENCY, gain=0.25) + voice = Voice( + duration_seconds=DURATION_SECONDS, + layers=(low, high), + filters=(), + ) + + audio = voice.render(sample_rate=sample_rate, generator=generator) + time = np.arange(round(DURATION_SECONDS * sample_rate), dtype=np.float64) / sample_rate + expected = low.render(time, generator=generator) + high.render(time, generator=generator) + assert np.allclose(audio, expected) + + def test_output_is_float64_of_the_configured_length( + self, + sample_rate: int, + generator: np.random.Generator, + ) -> None: + voice = Voice( + duration_seconds=DURATION_SECONDS, + layers=(_tone_layer(LOW_FREQUENCY, 1.0),), + filters=(), + ) + audio = voice.render(sample_rate=sample_rate, generator=generator) + assert audio.dtype == np.float64 + assert audio.shape == (round(DURATION_SECONDS * sample_rate),) + + def test_seeded_render_is_deterministic(self, sample_rate: int) -> None: + voice = Voice.model_validate(VOICE_MAPPING) + first = voice.render( + sample_rate=sample_rate, + generator=np.random.default_rng(7), + ) + second = voice.render( + sample_rate=sample_rate, + generator=np.random.default_rng(7), + ) + assert np.array_equal(first, second) + + def test_mapping_round_trip_preserves_the_voice(self) -> None: + voice = Voice.model_validate(VOICE_MAPPING) + assert Voice.model_validate(voice.model_dump()) == voice + + def test_voice_without_layers_is_rejected(self) -> None: + with pytest.raises(ValidationError): + Voice(duration_seconds=DURATION_SECONDS, layers=(), filters=()) + + def test_duration_below_two_samples_is_rejected( + self, + generator: np.random.Generator, + ) -> None: + voice = Voice( + duration_seconds=1e-6, + layers=(_tone_layer(LOW_FREQUENCY, 1.0),), + filters=(), + ) + with pytest.raises(ValueError): + voice.render(sample_rate=22050, generator=generator) diff --git a/tests/unit/sampletones_tools/test_checkout.py b/tests/unit/sampletones_tools/test_checkout.py new file mode 100644 index 000000000..9cdaf98e1 --- /dev/null +++ b/tests/unit/sampletones_tools/test_checkout.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import pytest + +from sampletones_tools import checkout +from sampletones_tools.checkout import is_checkout, require_checkout + +COMMAND = "check import-boundary --all" + + +class TestIsCheckout: + def test_the_project_file_beside_the_source_tree_makes_a_checkout(self, tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") + (tmp_path / "src").mkdir() + + assert is_checkout(tmp_path) + + def test_an_installed_copy_is_no_checkout(self, tmp_path: Path) -> None: + (tmp_path / "site-packages").mkdir() + + assert not is_checkout(tmp_path) + + +class TestRequireCheckout: + def test_the_repository_passes(self) -> None: + require_checkout(COMMAND) + + def test_outside_a_checkout_the_command_is_refused_with_the_way_to_run_it( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr(checkout, "REPOSITORY_ROOT", tmp_path) + + with pytest.raises(SystemExit, match=f"uv run sampletones {COMMAND}"): + require_checkout(COMMAND) diff --git a/tests/unit/sampletones_tools/test_registry.py b/tests/unit/sampletones_tools/test_registry.py new file mode 100644 index 000000000..b01cdc8d5 --- /dev/null +++ b/tests/unit/sampletones_tools/test_registry.py @@ -0,0 +1,10 @@ +from sampletones.commands.registry import USER_COMMANDS +from sampletones_tools.registry import DEVELOPER_COMMANDS + + +class TestDeveloperCommands: + def test_every_developer_command_has_a_name_of_its_own(self) -> None: + names = [command.name for command in DEVELOPER_COMMANDS] + + assert len(set(names)) == len(names) + assert not set(names) & {command.name for command in USER_COMMANDS} diff --git a/tests/unit/sampletones_tools/test_runs.py b/tests/unit/sampletones_tools/test_runs.py new file mode 100644 index 000000000..c2321fb20 --- /dev/null +++ b/tests/unit/sampletones_tools/test_runs.py @@ -0,0 +1,42 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from sampletones_tools.runs import ( + BOARD_STAMP, + RUN_STAMP, + stamped_board_directory, + stamped_directory, + stamped_run_directory, +) + + +class TestStampedRunDirectory: + def test_a_run_lands_under_the_root_named_by_its_start(self, tmp_path: Path) -> None: + before = datetime.now(UTC).replace(microsecond=0) + + directory = stamped_run_directory(tmp_path) + + started = datetime.strptime(directory.name, RUN_STAMP).replace(tzinfo=UTC) + assert directory.parent == tmp_path + assert before <= started <= datetime.now(UTC) + + +class TestStampedBoardDirectory: + def test_a_page_lands_under_the_root_named_by_the_moment_it_is_built(self, tmp_path: Path) -> None: + directory = stamped_board_directory(tmp_path) + + assert directory.parent == tmp_path + assert datetime.strptime(directory.name, BOARD_STAMP) + + +class TestStampedDirectory: + @pytest.mark.parametrize("stamp", (RUN_STAMP, BOARD_STAMP)) + def test_a_directory_is_named_by_the_stamp_it_is_given(self, tmp_path: Path, stamp: str) -> None: + directory = stamped_directory(tmp_path, stamp) + + assert datetime.strptime(directory.name, stamp) + + def test_the_two_stamps_tell_a_run_from_a_page(self) -> None: + assert RUN_STAMP != BOARD_STAMP diff --git a/tests/unit/sampletones_tools/tracker_playback/__init__.py b/tests/unit/sampletones_tools/tracker_playback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/commands/__init__.py b/tests/unit/sampletones_tools/tracker_playback/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/conftest.py b/tests/unit/sampletones_tools/tracker_playback/conftest.py new file mode 100644 index 000000000..5b08bbbf9 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from tests.suite.playback import ReplayingTarget + + +@pytest.fixture(name="replaying_target") +def replaying_target_fixture() -> ReplayingTarget: + return ReplayingTarget() diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/__init__.py b/tests/unit/sampletones_tools/tracker_playback/corpus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/test_build.py b/tests/unit/sampletones_tools/tracker_playback/corpus/test_build.py new file mode 100644 index 000000000..f3da2f169 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/corpus/test_build.py @@ -0,0 +1,104 @@ +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.bitphase.builder import build_bitphase +from sampletones_core.project.project import Project +from sampletones_core.project.voices.note_on import NoteOn +from sampletones_tools.corpus.build import Corpus +from sampletones_tools.corpus.module import ModuleConfig +from sampletones_tools.corpus.song import ChannelSpec, RowSpec, SongSpec +from sampletones_tools.tracker_playback.corpus import build +from sampletones_tools.tracker_playback.corpus.build import ( + arrangement_project, + comparison_corpus, + written_project, +) +from sampletones_tools.tracker_playback.corpus.spec import ( + ArrangementSpec, + CorpusSpec, + FrameRun, + ProjectSpec, + SampleSpec, +) +from sampletones_tools.tracker_playback.trace.application import application_trace + +MODULE: Final = ModuleConfig(title="Tone", author="Someone", tempo=150, speed=6, nes_frequency=60) +TONE: Final = SampleSpec( + kind="sample", + channels={ + ChannelName.PULSE1: [ + FrameRun(count=4, frame={"on": True, "pitch": 60, "volume": 15, "duty_cycle": 2}), + ] + }, +) +PROJECT: Final = ProjectSpec( + name="tone", + purpose="A tone.", + module=MODULE, + voices=["tone"], + song=SongSpec( + rows_per_pattern=2, + order=[{ChannelName.PULSE1: 0}], + channels={ChannelName.PULSE1: ChannelSpec(patterns={0: [RowSpec(row=0, voice="tone", volume=12)]})}, + ), +) +ARRANGEMENT: Final = ArrangementSpec(name="arrangement-groove", purpose="The arrangement, faster.", tempo=210) +CORPUS: Final = CorpusSpec(voices={"tone": TONE}, projects=[PROJECT], arrangements=[ARRANGEMENT]) + + +class TestWrittenProject: + def test_every_shipped_project_builds_exports_and_plays(self) -> None: + corpus = CorpusSpec.load() + + for spec in corpus.projects: + project = written_project(spec, corpus).project + build_bitphase(project) + assert application_trace(project).ticks > 0, spec.name + + def test_the_project_holds_the_voices_it_lists_and_plays_its_song(self) -> None: + written = written_project(PROJECT, CORPUS) + + project = written.project + assert (written.name, written.purpose) == ("tone", "A tone.") + assert [voice.name for voice in project.voices] == ["tone"] + row = project.song.channels[ChannelName.PULSE1].patterns[0].rows[0] + assert row.command == NoteOn(voice_id=project.voices[0].id) + assert row.volume == 12 + assert (project.settings.tempo, project.settings.speed) == (MODULE.tempo, MODULE.speed) + + def test_each_project_holds_voices_of_its_own(self) -> None: + first = written_project(PROJECT, CORPUS).project + second = written_project(PROJECT, CORPUS).project + + assert first.voices[0] is not second.voices[0] + + def test_a_voice_the_corpus_lacks_is_refused(self) -> None: + with pytest.raises(KeyError, match="lead"): + written_project(PROJECT.model_copy(update={"voices": ["lead"]}), CORPUS) + + +class TestArrangementProject: + def test_the_arrangement_plays_at_the_tempo_the_spec_names(self) -> None: + arrangement = written_project(PROJECT, CORPUS).project + + played = arrangement_project(ARRANGEMENT, arrangement) + + assert (played.name, played.purpose) == (ARRANGEMENT.name, ARRANGEMENT.purpose) + assert played.project.settings.tempo == ARRANGEMENT.tempo + assert arrangement.settings.tempo == MODULE.tempo + + +class TestComparisonCorpus: + def test_the_written_projects_come_first_then_the_arrangement_at_each_tempo( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + arrangement: Project = written_project(PROJECT, CORPUS).project + monkeypatch.setattr(build, "build_synthetic_corpus", lambda: Corpus(catalog={}, project=arrangement)) + + projects = comparison_corpus(CORPUS) + + assert [project.name for project in projects] == ["tone", "arrangement-groove"] + assert projects[1].project.song is arrangement.song diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/test_spec.py b/tests/unit/sampletones_tools/tracker_playback/corpus/test_spec.py new file mode 100644 index 000000000..e0c03b7d1 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/corpus/test_spec.py @@ -0,0 +1,22 @@ +from sampletones_tools.tracker_playback.corpus.spec import CorpusSpec + + +class TestCorpusSpec: + def test_the_shipped_corpus_loads_from_the_package(self) -> None: + corpus = CorpusSpec.load() + + assert corpus.projects + assert corpus.arrangements + + def test_every_project_and_arrangement_writes_its_files_under_a_name_of_its_own(self) -> None: + corpus = CorpusSpec.load() + names = [project.name for project in corpus.projects] + [ + arrangement.name for arrangement in corpus.arrangements + ] + + assert len(set(names)) == len(names) + + def test_every_voice_a_project_holds_is_a_corpus_voice(self) -> None: + corpus = CorpusSpec.load() + + assert {voice for project in corpus.projects for voice in project.voices} <= set(corpus.voices) diff --git a/tests/unit/sampletones_tools/tracker_playback/corpus/test_voices.py b/tests/unit/sampletones_tools/tracker_playback/corpus/test_voices.py new file mode 100644 index 000000000..d57b694cc --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/corpus/test_voices.py @@ -0,0 +1,65 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.features.envelope import Envelope +from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction +from sampletones_core.project.voices.envelopes import InstrumentEnvelopes +from sampletones_core.project.voices.instrument import Instrument +from sampletones_core.project.voices.sample import Sample +from sampletones_tools.tracker_playback.corpus.spec import FrameRun, InstrumentSpec, SampleSpec +from sampletones_tools.tracker_playback.corpus.voices import build_voice, channel_frames + +PULSE_FRAME: Final = PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=2) +REST_FRAME: Final = PulseInstruction(on=False, pitch=60, volume=0, duty_cycle=2) + + +class TestChannelFrames: + def test_each_run_repeats_its_frame_in_order(self) -> None: + runs = [ + FrameRun(count=2, frame=PULSE_FRAME.model_dump()), + FrameRun(count=1, frame=REST_FRAME.model_dump()), + ] + + assert channel_frames(ChannelName.PULSE1, runs) == [PULSE_FRAME, PULSE_FRAME, REST_FRAME] + + def test_a_frame_is_read_as_the_instruction_its_channel_takes(self) -> None: + triangle = channel_frames(ChannelName.TRIANGLE, [FrameRun(count=1, frame={"on": True, "pitch": 48})]) + noise = channel_frames( + ChannelName.NOISE, + [FrameRun(count=1, frame={"on": True, "period": 3, "volume": 9, "short": True})], + ) + + assert triangle == [TriangleInstruction(on=True, pitch=48)] + assert noise == [NoiseInstruction(on=True, period=3, volume=9, short=True)] + + def test_a_frame_the_channel_cannot_play_is_refused(self) -> None: + with pytest.raises(ValidationError): + channel_frames(ChannelName.NOISE, [FrameRun(count=1, frame=PULSE_FRAME.model_dump())]) + + +class TestBuildVoice: + def test_a_sample_plays_the_frames_it_writes_on_its_channels(self) -> None: + spec = SampleSpec( + kind="sample", + channels={ChannelName.PULSE2: [FrameRun(count=3, frame=PULSE_FRAME.model_dump())]}, + ) + + voice = build_voice("tone", spec) + + assert isinstance(voice, Sample) + assert voice.name == "tone" + assert voice.reconstruction.playing_channels == (ChannelName.PULSE2,) + assert list(voice.reconstruction.instructions[ChannelName.PULSE2]) == [PULSE_FRAME] * 3 + + def test_an_instrument_carries_its_envelopes_and_references(self) -> None: + envelopes = InstrumentEnvelopes(volume=Envelope[int](items=(15, 0))) + spec = InstrumentSpec(kind="instrument", initial_pitch=57, initial_period=4, envelopes=envelopes) + + voice = build_voice("pluck", spec) + + assert isinstance(voice, Instrument) + assert (voice.name, voice.initial_pitch, voice.initial_period) == ("pluck", 57, 4) + assert voice.envelopes.envelope(FeatureKey.VOLUME).items == (15, 0) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_engine.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_engine.py new file mode 100644 index 000000000..011c04c7e --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_engine.py @@ -0,0 +1,118 @@ +import json +import subprocess +from pathlib import Path +from typing import Any, Final, List, Optional, Sequence + +import pytest + +from sampletones_tools.tracker_playback.paths import BITPHASE_TRACE_SCRIPT_PATH +from sampletones_tools.tracker_playback.targets.bitphase import engine +from sampletones_tools.tracker_playback.targets.bitphase.engine import ( + SOURCE_FILES, + TSX_CLI, + BitphaseEngine, + BitphaseSource, + EngineError, +) + +NODE: Final[Path] = Path("node") +ONE_SILENT_TICK: Final[str] = json.dumps({"ticks": [{"frame": 0, "row": 0, "writes": []}]}) + + +def _source(root: Path) -> BitphaseSource: + for relative in SOURCE_FILES: + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).touch() + + return BitphaseSource.located(root) + + +class TestBitphaseSource: + def test_source_code_holding_every_file_the_trace_loads_is_located(self, tmp_path: Path) -> None: + assert _source(tmp_path).root == tmp_path + + def test_source_code_without_its_packages_is_refused_naming_what_it_lacks(self, tmp_path: Path) -> None: + _source(tmp_path) + (tmp_path / TSX_CLI).unlink() + + with pytest.raises(EngineError, match="pnpm install") as refusal: + BitphaseSource.located(tmp_path) + + assert str(TSX_CLI) in str(refusal.value) + + +class TestBitphaseEngine: + def test_an_absent_node_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _source(tmp_path) + monkeypatch.setattr(engine, "locate_program", lambda program: None) + + with pytest.raises(EngineError, match="node"): + BitphaseEngine.located(tmp_path) + + def test_the_engine_takes_the_node_this_system_has(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _source(tmp_path) + monkeypatch.setattr(engine, "locate_program", lambda program: Path("/usr/bin") / program) + + located = BitphaseEngine.located(tmp_path) + + assert located.node == Path("/usr/bin") / "node" + + def test_the_trace_script_runs_through_the_source_codes_own_tsx(self, tmp_path: Path) -> None: + played = BitphaseEngine(node=NODE, source=_source(tmp_path)) + document = tmp_path / "song.btp" + output = tmp_path / "song.json" + + assert played.command(document, output) == [ + str(NODE), + str(tmp_path / TSX_CLI), + str(BITPHASE_TRACE_SCRIPT_PATH), + str(tmp_path), + str(document), + str(output), + ] + + def test_the_script_is_handed_absolute_paths(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + played = BitphaseEngine(node=NODE, source=_source(tmp_path / "bitphase")) + monkeypatch.chdir(tmp_path) + + command = played.command(Path("run") / "song.btp", Path("run") / "song.json") + + assert [Path(argument) for argument in command[-2:]] == [ + tmp_path / "run" / "song.btp", + tmp_path / "run" / "song.json", + ] + + def test_a_trace_is_read_from_the_file_the_run_writes( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + played = BitphaseEngine(node=NODE, source=_source(tmp_path)) + output = tmp_path / "song.json" + runs: List[Optional[Path]] = [] + + def run(command: Sequence[str], *, cwd: Path, **options: Any) -> None: + runs.append(cwd) + Path(command[-1]).write_text(ONE_SILENT_TICK, encoding="utf-8") + + monkeypatch.setattr(engine.subprocess, "run", run) + + trace = played.trace(tmp_path / "song.btp", output) + + assert runs == [tmp_path] + assert trace.ticks == 1 + + def test_a_failed_run_is_reported_with_what_it_printed( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + played = BitphaseEngine(node=NODE, source=_source(tmp_path)) + + def run(command: Sequence[str], **options: Any) -> None: + raise subprocess.CalledProcessError(1, list(command), stderr="Song is empty") + + monkeypatch.setattr(engine.subprocess, "run", run) + + with pytest.raises(EngineError, match="Song is empty"): + played.trace(tmp_path / "song.btp", tmp_path / "song.json") diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py new file mode 100644 index 000000000..2f42d51c9 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_target.py @@ -0,0 +1,80 @@ +import gzip +import json +from pathlib import Path +from typing import Any, Final, List, Sequence + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON +from sampletones_tools.tracker_playback.targets.bitphase import engine +from sampletones_tools.tracker_playback.targets.bitphase.engine import ( + SOURCE_FILES, + BitphaseEngine, + BitphaseSource, +) +from sampletones_tools.tracker_playback.targets.bitphase.target import TITLE, BitphaseTarget +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample + +NAME: Final[str] = "tone" + + +def _target(root: Path) -> BitphaseTarget: + for relative in SOURCE_FILES: + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).touch() + + return BitphaseTarget(engine=BitphaseEngine(node=Path("node"), source=BitphaseSource.located(root))) + + +class TestBitphaseTarget: + def test_the_report_names_bitphase_and_the_source_code_that_plays(self, tmp_path: Path) -> None: + target = _target(tmp_path) + + assert target.title == TITLE + assert str(tmp_path) in target.player + + def test_the_target_takes_the_node_this_system_has( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + _target(tmp_path) + monkeypatch.setattr(engine, "locate_program", lambda program: Path("bin") / program) + + located = BitphaseTarget.located(tmp_path) + + assert located.engine.source.root == tmp_path + + def test_a_project_is_exported_and_its_document_played_beside_the_trace( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=1, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + played: List[Sequence[str]] = [] + + def run(command: Sequence[str], **options: Any) -> None: + played.append(command) + ticks = [{"frame": 0, "row": 0, "writes": []}] + Path(command[-1]).write_text(json.dumps({"ticks": ticks}), encoding="utf-8") + + monkeypatch.setattr(engine.subprocess, "run", run) + documents = tmp_path / "documents" + documents.mkdir() + + playback = _target(tmp_path / "bitphase").play(project, documents, NAME) + + assert playback.document == documents / f"{NAME}{EXT_FILE_BITPHASE}" + assert json.loads(gzip.decompress(playback.document.read_bytes())) + assert [command[-2:] for command in played] == [ + [str(playback.document), str(documents / f"{NAME}{EXT_FILE_JSON}")], + ] + assert playback.trace.ticks == 1 + assert (playback.skipped_rows, playback.truncation) == ((), None) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_trace.py b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_trace.py new file mode 100644 index 000000000..1b24d7195 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/bitphase/test_trace.py @@ -0,0 +1,183 @@ +import json +from dataclasses import dataclass +from typing import Any, Dict, Final, List, Sequence, Tuple + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.registers import ( + APU_STATUS, + DUTY_CYCLE_SHIFT, + MAX_REGISTER_VALUE, + NOISE_CONTROL, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.targets.bitphase.trace import ( + UNIT_STATUS_BITS, + EmulatorUnit, + UnitWrite, + read_engine_trace, +) +from sampletones_tools.tracker_playback.trace.sound import ABSENT_REGISTER, ChannelSound, TickPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +PULSE_STATUS: Final[int] = 0x03 +TRIANGLE_NOISE_STATUS: Final[int] = 0x0C +EVERY_STATUS_BIT: Final[int] = 0x1F +LENGTH_INDEX_BITS: Final[int] = 0x78 +PULSE_TIMER: Final[int] = 427 +PULSE_LEVEL: Final[int] = 15 +PULSE_DUTY: Final[int] = 2 +TRIANGLE_TIMER: Final[int] = 854 +NOISE_LEVEL: Final[int] = 14 +NOISE_REGISTER_PERIOD: Final[int] = 9 +SOUNDING_PULSE: Final[int] = (PULSE_DUTY << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | PULSE_LEVEL +SOUNDING_TRIANGLE: Final[int] = 0xFF +SOUNDING_NOISE: Final[int] = SUSTAINED_LEVEL | NOISE_LEVEL + +Write = Tuple[EmulatorUnit, int, int] + +ENABLING: Final[Tuple[Write, ...]] = ( + (EmulatorUnit.APU, APU_STATUS, PULSE_STATUS), + (EmulatorUnit.DMC, APU_STATUS, TRIANGLE_NOISE_STATUS), +) +PULSE_WRITES: Final[Tuple[Write, ...]] = ( + (EmulatorUnit.APU, PULSE1_CONTROL, SOUNDING_PULSE), + (EmulatorUnit.APU, PULSE1_TIMER_LOW, PULSE_TIMER & MAX_REGISTER_VALUE), + (EmulatorUnit.APU, PULSE1_TIMER_HIGH, LENGTH_INDEX_BITS | (PULSE_TIMER >> TIMER_HIGH_SHIFT)), +) +EVERY_CHANNEL_SOUNDING: Final[Tuple[Write, ...]] = ( + *ENABLING, + *PULSE_WRITES, + (EmulatorUnit.APU, PULSE2_CONTROL, SOUNDING_PULSE), + (EmulatorUnit.DMC, TRIANGLE_LINEAR_COUNTER, SOUNDING_TRIANGLE), + (EmulatorUnit.DMC, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER & MAX_REGISTER_VALUE), + (EmulatorUnit.DMC, TRIANGLE_TIMER_HIGH, LENGTH_INDEX_BITS | (TRIANGLE_TIMER >> TIMER_HIGH_SHIFT)), + (EmulatorUnit.DMC, NOISE_CONTROL, SOUNDING_NOISE), + (EmulatorUnit.DMC, NOISE_PERIOD, NOISE_REGISTER_PERIOD), +) + + +def _tick(*writes: Write, frame: int = 0, row: int = 0) -> Dict[str, Any]: + return { + "frame": frame, + "row": row, + "writes": [{"unit": unit.value, "address": address, "value": value} for unit, address, value in writes], + } + + +def _trace_text(ticks: Sequence[Dict[str, Any]]) -> str: + return json.dumps({"ticks": list(ticks)}) + + +def _audible(ticks: Sequence[Dict[str, Any]]) -> Dict[ChannelName, List[bool]]: + trace = read_engine_trace(_trace_text(ticks)) + return {channel: [sound.audible for sound in sounds] for channel, sounds in trace.channels.items()} + + +class TestUnitWrite(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + unit: EmulatorUnit + status: int + value: int + expected: int + + test_cases: Tuple[TestCase, ...] = ( + TestCase( + label="the pulse unit keeps the others' bits", unit=EmulatorUnit.APU, status=0x0C, value=0x1F, expected=0x0F + ), + TestCase( + label="the pulse unit clears its own bits", unit=EmulatorUnit.APU, status=0x0F, value=0x00, expected=0x0C + ), + TestCase( + label="the dmc unit keeps the pulse bits", unit=EmulatorUnit.DMC, status=0x03, value=0x03, expected=0x03 + ), + TestCase(label="the dmc unit sets its own bits", unit=EmulatorUnit.DMC, status=0x03, value=0x1C, expected=0x1F), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_status_write_reaches_the_bits_of_the_channels_its_unit_plays(self, test_case: TestCase) -> None: + write = UnitWrite(unit=test_case.unit, address=APU_STATUS, value=test_case.value) + + assert write.chip_write(test_case.status) == RegisterWrite(APU_STATUS, test_case.expected) + + def test_the_units_share_the_status_register_between_them(self) -> None: + assert UNIT_STATUS_BITS[EmulatorUnit.APU] | UNIT_STATUS_BITS[EmulatorUnit.DMC] == EVERY_STATUS_BIT + assert not UNIT_STATUS_BITS[EmulatorUnit.APU] & UNIT_STATUS_BITS[EmulatorUnit.DMC] + + @pytest.mark.parametrize("unit", tuple(EmulatorUnit), ids=str) + def test_a_channel_register_write_reaches_the_chip_as_it_is(self, unit: EmulatorUnit) -> None: + write = UnitWrite(unit=unit, address=NOISE_CONTROL, value=SOUNDING_NOISE) + + assert write.chip_write(EVERY_STATUS_BIT) == RegisterWrite(NOISE_CONTROL, SOUNDING_NOISE) + + +class TestReadEngineTrace: + def test_each_tick_keeps_the_frame_and_row_bitphase_played_it_at(self) -> None: + trace = read_engine_trace(_trace_text((_tick(frame=2, row=5),))) + + assert trace.positions == (TickPosition(frame=2, row=5),) + assert set(trace.channels) == set(ChannelName.items()) + + def test_every_channel_is_read_from_the_registers_the_engine_wrote(self) -> None: + trace = read_engine_trace(_trace_text((_tick(*EVERY_CHANNEL_SOUNDING),))) + + assert trace.channels[ChannelName.PULSE1] == ( + ChannelSound(audible=True, period=PULSE_TIMER, volume=PULSE_LEVEL, held=True, timbre=PULSE_DUTY), + ) + assert trace.channels[ChannelName.TRIANGLE] == ( + ChannelSound( + audible=True, period=TRIANGLE_TIMER, volume=ABSENT_REGISTER, held=True, timbre=ABSENT_REGISTER + ), + ) + assert trace.channels[ChannelName.NOISE] == ( + ChannelSound(audible=True, period=NOISE_REGISTER_PERIOD, volume=NOISE_LEVEL, held=True, timbre=0), + ) + + def test_a_register_written_on_one_tick_stands_on_the_ticks_after(self) -> None: + trace = read_engine_trace(_trace_text((_tick(*ENABLING, *PULSE_WRITES), _tick(), _tick()))) + + assert len(set(trace.channels[ChannelName.PULSE1])) == 1 + assert trace.channels[ChannelName.PULSE1][-1].audible + + def test_each_unit_enables_the_channels_it_plays(self) -> None: + audible = _audible( + ( + _tick(*EVERY_CHANNEL_SOUNDING), + _tick((EmulatorUnit.DMC, APU_STATUS, 0x00)), + _tick((EmulatorUnit.APU, APU_STATUS, 0x00), (EmulatorUnit.DMC, APU_STATUS, TRIANGLE_NOISE_STATUS)), + ) + ) + + assert audible == { + ChannelName.PULSE1: [True, True, False], + ChannelName.PULSE2: [True, True, False], + ChannelName.TRIANGLE: [True, False, True], + ChannelName.NOISE: [True, False, True], + } + + @pytest.mark.parametrize( + "write", + ( + {"unit": "apu", "address": 0x3FFF, "value": 0}, + {"unit": "apu", "address": APU_STATUS, "value": 0x100}, + {"unit": "mmc5", "address": APU_STATUS, "value": 0}, + ), + ids=("below the apu", "wider than a byte", "no unit of the emulator"), + ) + def test_a_write_the_script_never_records_is_refused(self, write: Dict[str, Any]) -> None: + with pytest.raises(ValidationError): + read_engine_trace(json.dumps({"ticks": [{"frame": 0, "row": 0, "writes": [write]}]})) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_factory.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_factory.py new file mode 100644 index 000000000..8b02f4ae8 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_factory.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program import wine +from sampletones_tools.tracker_playback.targets.famitracker.program.factory import located_program +from sampletones_tools.tracker_playback.targets.famitracker.program.native import NativeProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import WineProgram + + +@pytest.fixture(name="executable") +def executable_fixture(tmp_path: Path) -> Path: + path = tmp_path / "FamiTracker.exe" + path.write_bytes(b"MZ") + return path + + +class TestLocatedProgram: + def test_windows_runs_famitracker_directly(self, monkeypatch: pytest.MonkeyPatch, executable: Path) -> None: + monkeypatch.setattr(System, "current", classmethod(lambda cls: System.WINDOWS)) + + assert located_program(executable) == NativeProgram(executable=executable) + + @pytest.mark.parametrize("system", (System.LINUX, System.MACOS)) + def test_linux_and_macos_run_famitracker_through_wine( + self, + monkeypatch: pytest.MonkeyPatch, + executable: Path, + system: System, + ) -> None: + monkeypatch.setattr(System, "current", classmethod(lambda cls: system)) + monkeypatch.setattr(wine, "locate_program", lambda program: Path("/usr/bin") / program) + + assert located_program(executable) == WineProgram(wine=Path("/usr/bin/wine"), executable=executable) + + def test_a_missing_program_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(FamiTrackerError, match="FamiTracker.exe"): + located_program(tmp_path / "FamiTracker.exe") + + def test_a_launcher_script_is_refused(self, tmp_path: Path) -> None: + launcher = tmp_path / "famitracker" + launcher.write_text("#!/bin/bash\nexec wine FamiTracker.exe", encoding="utf-8") + + with pytest.raises(FamiTrackerError, match="FamiTracker.exe itself"): + located_program(launcher) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_native.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_native.py new file mode 100644 index 000000000..8c001382e --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_native.py @@ -0,0 +1,18 @@ +import os +from pathlib import Path + +from sampletones_tools.tracker_playback.targets.famitracker.program.native import NativeProgram +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH + + +class TestNativeProgram: + def test_famitracker_is_handed_every_path_absolute(self, tmp_path: Path) -> None: + program = NativeProgram(executable=tmp_path / "FamiTracker.exe") + + command = program.export_command(Path("song.ftm"), Path("song.nsf"), Path("song.log")) + + assert command[2] == EXPORT_SWITCH + assert all(Path(argument).is_absolute() for index, argument in enumerate(command) if index != 2) + + def test_the_export_runs_in_the_checks_own_environment(self, tmp_path: Path) -> None: + assert NativeProgram(executable=tmp_path / "FamiTracker.exe").environment() == dict(os.environ) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_wine.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_wine.py new file mode 100644 index 000000000..e7b01dbc1 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/program/test_wine.py @@ -0,0 +1,119 @@ +import subprocess +from pathlib import Path +from typing import Any, Final, List + +import pytest + +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.program import wine +from sampletones_tools.tracker_playback.targets.famitracker.program.protocol import EXPORT_SWITCH +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import ( + DISPLAY_VARIABLES, + INSTALL_HINTS, + WINE_DEBUG, + WINE_DEBUG_SILENT, + WINEPATH, + WineProgram, +) + +WINE: Final[Path] = Path("/usr/bin/wine") +EXECUTABLE: Final[Path] = Path("/apps/FamiTracker.exe") + + +def on_drive_z(path: str) -> str: + """A path as a default Wine prefix maps it: the file system's root is drive Z.""" + return "Z:" + path.replace("/", "\\") + + +class MappingWine: + """Wine's ``winepath`` standing in: it maps each path onto drive Z and records the commands it ran.""" + + def __init__(self) -> None: + self.commands: List[List[str]] = [] + + def run(self, command: List[str], **options: Any) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + mapped = "".join(f"{on_drive_z(path)}\n" for path in command[3:]) + return subprocess.CompletedProcess(command, 0, stdout=mapped, stderr="") + + +@pytest.fixture(name="mapping") +def mapping_fixture(monkeypatch: pytest.MonkeyPatch) -> MappingWine: + mapping = MappingWine() + monkeypatch.setattr(wine.subprocess, "run", mapping.run) + return mapping + + +class TestLocating: + def test_the_program_takes_the_wine_this_system_has(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wine, "locate_program", lambda program: Path("/opt/bin") / program) + + assert WineProgram.located(EXECUTABLE) == WineProgram(wine=Path("/opt/bin/wine"), executable=EXECUTABLE) + + def test_an_absent_wine_names_how_this_system_installs_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(wine, "locate_program", lambda program: None) + monkeypatch.setattr(System, "current", classmethod(lambda cls: System.LINUX)) + + with pytest.raises(FamiTrackerError, match=INSTALL_HINTS[System.LINUX]): + WineProgram.located(EXECUTABLE) + + +class TestTheExportCommand: + def test_famitracker_is_handed_every_path_as_wine_maps_it(self, mapping: MappingWine, tmp_path: Path) -> None: + program = WineProgram(wine=WINE, executable=EXECUTABLE) + + module, nsf, log = (tmp_path / name for name in ("song.ftm", "song.nsf", "song.log")) + + command = program.export_command(module, nsf, log) + + assert command == [ + str(WINE), + str(EXECUTABLE), + on_drive_z(str(module.resolve())), + EXPORT_SWITCH, + on_drive_z(str(nsf.resolve())), + on_drive_z(str(log.resolve())), + ] + + def test_wine_maps_every_path_in_one_call(self, mapping: MappingWine, tmp_path: Path) -> None: + WineProgram(wine=WINE, executable=EXECUTABLE).export_command(tmp_path / "a", tmp_path / "b", tmp_path / "c") + + (command,) = mapping.commands + assert command[:3] == [str(WINE), WINEPATH, "-w"] + + def test_a_failed_mapping_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def run(command: List[str], **options: Any) -> subprocess.CompletedProcess[str]: + raise subprocess.CalledProcessError(1, command, stderr="no prefix") + + monkeypatch.setattr(wine.subprocess, "run", run) + + with pytest.raises(FamiTrackerError, match="no prefix"): + WineProgram(wine=WINE, executable=EXECUTABLE).windows_paths((tmp_path,)) + + def test_a_mapping_missing_a_path_is_reported(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def run(command: List[str], **options: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 0, stdout="Z:\\one\n", stderr="") + + monkeypatch.setattr(wine.subprocess, "run", run) + + with pytest.raises(FamiTrackerError): + WineProgram(wine=WINE, executable=EXECUTABLE).windows_paths((tmp_path / "a", tmp_path / "b")) + + +class TestTheEnvironment: + def test_the_export_runs_with_no_display_and_wine_silenced(self, monkeypatch: pytest.MonkeyPatch) -> None: + for variable in DISPLAY_VARIABLES: + monkeypatch.setenv(variable, ":1") + + environment = WineProgram(wine=WINE, executable=EXECUTABLE).environment() + + assert not set(DISPLAY_VARIABLES) & set(environment) + assert environment[WINE_DEBUG] == WINE_DEBUG_SILENT + + def test_the_rest_of_the_environment_carries_over(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("WINEPREFIX", "/home/someone/.wine-famitracker") + + environment = WineProgram(wine=WINE, executable=EXECUTABLE).environment() + + assert environment["WINEPREFIX"] == "/home/someone/.wine-famitracker" diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/programs.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/programs.py new file mode 100644 index 000000000..d73ed7b26 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/programs.py @@ -0,0 +1,73 @@ +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +from sampletones_player.specification.nsf import PROGRAM_START +from sampletones_player.specification.registers import DMC_DIRECT_LOAD +from tests.unit.sampletones_tools.console.files import ( + INIT, + PLAY, + RETURN, + STORE_ACCUMULATOR, + NSFFile, + absolute, +) + +LOG_TEXT: Final[str] = "Opened the module.\nNSF export complete." +COUNTER: Final[int] = 0x00 +LOAD_ZERO_PAGE: Final[int] = 0xA5 +INCREMENT_ZERO_PAGE: Final[int] = 0xE6 +EXPORT_SCRIPT: Final[str] = """ +import shutil, sys +source, nsf, log, log_text = sys.argv[1:] +if source: + shutil.copyfile(source, nsf) +with open(log, "w", encoding="utf-8") as stream: + stream.write(log_text) +""" +MARKING_PLAY: Final[Tuple[int, ...]] = ( + LOAD_ZERO_PAGE, + COUNTER, + STORE_ACCUMULATOR, + *absolute(DMC_DIRECT_LOAD), + INCREMENT_ZERO_PAGE, + COUNTER, + RETURN, +) + + +def marking_nsf() -> bytes: + """An NSF whose every play call starts a row: it writes a marker carrying how many calls came before.""" + return NSFFile(program={INIT - PROGRAM_START: (RETURN,), PLAY - PROGRAM_START: MARKING_PLAY}).data + + +@dataclass(frozen=True) +class StandInProgram: + """A program standing in for FamiTracker: its export copies a prepared NSF into place and writes a log. + + It records every module it is asked to export, so a case reads what reached it. + + Attributes: + executable: The program file the report names. + source: The NSF the export leaves, or ``None`` for an export that writes none. + exported: The modules the export was asked for, in order. + """ + + executable: Path + source: Optional[Path] + exported: List[bytes] + + def export_command( + self, + module: Path, + nsf: Path, + log: Path, + ) -> List[str]: + self.exported.append(module.read_bytes()) + source = str(self.source) if self.source is not None else "" + return [sys.executable, "-c", EXPORT_SCRIPT, source, str(nsf), str(log), LOG_TEXT] + + def environment(self) -> Dict[str, str]: + return dict(os.environ) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_export.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_export.py new file mode 100644 index 000000000..96e446139 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_export.py @@ -0,0 +1,78 @@ +import subprocess +from pathlib import Path +from typing import Any, List + +import pytest + +from sampletones_tools.tracker_playback.targets.famitracker import export +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.export import NO_LOG, export_nsf, log_text +from tests.unit.sampletones_tools.tracker_playback.targets.famitracker.programs import ( + LOG_TEXT, + StandInProgram, + marking_nsf, +) + + +@pytest.fixture(name="source") +def source_fixture(tmp_path: Path) -> Path: + path = tmp_path / "prepared.nsf" + path.write_bytes(marking_nsf()) + return path + + +class TestExportNSF: + def test_the_nsf_the_export_writes_is_left_in_place(self, tmp_path: Path, source: Path) -> None: + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=source, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + assert (tmp_path / "song.nsf").read_bytes() == source.read_bytes() + assert program.exported == [b"module"] + + def test_an_export_writing_no_nsf_is_reported_with_its_log(self, tmp_path: Path) -> None: + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=None, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + + with pytest.raises(FamiTrackerError, match="NSF export complete"): + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + def test_an_nsf_left_by_an_earlier_run_counts_for_nothing(self, tmp_path: Path) -> None: + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=None, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + (tmp_path / "song.nsf").write_bytes(b"stale") + + with pytest.raises(FamiTrackerError): + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + assert not (tmp_path / "song.nsf").exists() + + def test_an_export_running_past_its_time_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + def run(*arguments: Any, **options: Any) -> List[str]: + raise subprocess.TimeoutExpired(cmd="FamiTracker.exe", timeout=options["timeout"]) + + monkeypatch.setattr(export.subprocess, "run", run) + program = StandInProgram(executable=tmp_path / "FamiTracker.exe", source=None, exported=[]) + module = tmp_path / "song.ftm" + module.write_bytes(b"module") + + with pytest.raises(FamiTrackerError, match="seconds"): + export_nsf(program, module, tmp_path / "song.nsf", tmp_path / "song.log") + + +class TestLogText: + def test_the_log_is_read_as_written(self, tmp_path: Path) -> None: + (tmp_path / "song.log").write_text(LOG_TEXT, encoding="utf-8") + + assert log_text(tmp_path / "song.log") == LOG_TEXT + + def test_a_missing_log_says_so(self, tmp_path: Path) -> None: + assert log_text(tmp_path / "song.log") == NO_LOG diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_markers.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_markers.py new file mode 100644 index 000000000..543164b25 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_markers.py @@ -0,0 +1,73 @@ +from typing import Final, List + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.model.module import FamiTrackerModule +from sampletones_core.formats.famitracker.specification.channels import ChannelId +from sampletones_core.formats.famitracker.specification.patterns import MAX_DAC_LEVEL, EffectId +from sampletones_core.project.settings import ProjectSettings +from sampletones_tools.tracker_playback.targets.famitracker.markers import MARKER_LEVELS, marked_module, row_marker +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample + +ROWS: Final[int] = 4 +FRAMES: Final[int] = 3 + + +@pytest.fixture(name="module") +def module_fixture() -> FamiTrackerModule: + """A module of three frames of four rows, a note on pulse 1 in the first.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=ROWS, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + project.song.order.extend(dict(project.song.order[0]) for _ in range(FRAMES - 1)) + return build_module(project).document + + +class TestRowMarker: + def test_a_marker_is_the_rows_place_wrapped_into_the_marker_levels(self) -> None: + last = MARKER_LEVELS - 1 + + assert [row_marker(index) for index in (0, 1, last, last + 1)] == [0, 1, last, 0] + + def test_every_marker_is_a_level_the_dmc_takes(self) -> None: + assert MARKER_LEVELS - 1 <= MAX_DAC_LEVEL + + def test_two_rows_in_a_row_carry_different_markers(self) -> None: + assert all(row_marker(index) != row_marker(index + 1) for index in range(1000)) + + +class TestMarkedModule: + def test_every_frame_plays_a_dpcm_pattern_of_its_own(self, module: FamiTrackerModule) -> None: + marked = marked_module(module) + + assert [entries[ChannelId.DPCM] for entries in marked.track.order] == list(range(FRAMES)) + + def test_every_row_loads_its_place_in_the_song(self, module: FamiTrackerModule) -> None: + marked = marked_module(module) + levels: List[int] = [] + for frame in range(FRAMES): + (pattern,) = ( + pattern + for pattern in marked.track.patterns + if pattern.channel == ChannelId.DPCM and pattern.index == frame + ) + levels.extend(level for cell in pattern.rows for effect, level in cell.effects if effect == EffectId.DAC) + + assert levels == [row_marker(index) for index in range(FRAMES * ROWS)] + + def test_the_other_channels_stay_as_the_export_built_them(self, module: FamiTrackerModule) -> None: + marked = marked_module(module) + + def tonal(document: FamiTrackerModule) -> object: + return ( + [entries[: ChannelId.DPCM] for entries in document.track.order], + [pattern for pattern in document.track.patterns if pattern.channel != ChannelId.DPCM], + document.instruments, + ) + + assert tonal(marked) == tonal(module) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_target.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_target.py new file mode 100644 index 000000000..6617445bb --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_target.py @@ -0,0 +1,135 @@ +from pathlib import Path +from typing import Final + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.formats.famitracker.builder import build_module +from sampletones_core.formats.famitracker.module import module_to_ftm_bytes +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.paths.extensions import EXT_FILE_JSON, EXT_FILE_MODULE, EXT_FILE_NSF +from sampletones_shared.utils.system.system import System +from sampletones_tools.tracker_playback.targets.famitracker import target as target_module +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.markers import marked_module +from sampletones_tools.tracker_playback.targets.famitracker.program import wine +from sampletones_tools.tracker_playback.targets.famitracker.program.wine import WineProgram +from sampletones_tools.tracker_playback.targets.famitracker.target import TITLE, FamiTrackerTarget +from sampletones_tools.tracker_playback.targets.famitracker.trace import DriverTrace +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample +from tests.unit.sampletones_tools.tracker_playback.targets.famitracker.programs import StandInProgram, marking_nsf + +NAME: Final[str] = "tone" +ROWS: Final[int] = 2 + + +@pytest.fixture(name="project") +def project_fixture() -> Project: + """A song of one frame of two rows, a note on pulse 1.""" + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=ROWS, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + return project + + +def _program(tmp_path: Path, nsf: bytes) -> StandInProgram: + source = tmp_path / "prepared.nsf" + source.write_bytes(nsf) + return StandInProgram(executable=tmp_path / "FamiTracker.exe", source=source, exported=[]) + + +@pytest.fixture(name="documents") +def documents_fixture(tmp_path: Path) -> Path: + path = tmp_path / "documents" + path.mkdir() + return path + + +class TestFamiTrackerTarget: + def test_the_report_names_famitracker_and_the_program_that_exported(self, tmp_path: Path) -> None: + target = FamiTrackerTarget(program=_program(tmp_path, marking_nsf())) + + assert target.title == TITLE + assert str(tmp_path / "FamiTracker.exe") in target.player + + def test_the_target_runs_famitracker_the_way_this_system_does( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + executable = tmp_path / "FamiTracker.exe" + executable.write_bytes(b"MZ") + monkeypatch.setattr(System, "current", classmethod(lambda cls: System.LINUX)) + monkeypatch.setattr(wine, "locate_program", lambda program: Path("/usr/bin") / program) + + assert FamiTrackerTarget.located(executable).program == WineProgram( + wine=Path("/usr/bin/wine"), + executable=executable, + ) + + +class TestPlayingAProject: + def test_the_module_is_kept_as_the_export_built_it( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + playback = FamiTrackerTarget(program=_program(tmp_path, marking_nsf())).play(project, documents, NAME) + + assert playback.document == documents / f"{NAME}{EXT_FILE_MODULE}" + assert playback.document.read_bytes() == module_to_ftm_bytes(build_module(project).document) + + def test_famitracker_exports_the_module_with_its_rows_marked( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + program = _program(tmp_path, marking_nsf()) + + FamiTrackerTarget(program=program).play(project, documents, NAME) + + assert program.exported == [module_to_ftm_bytes(marked_module(build_module(project).document))] + + def test_the_nsf_and_every_write_its_driver_made_are_kept_beside_the_module( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + playback = FamiTrackerTarget(program=_program(tmp_path, marking_nsf())).play(project, documents, NAME) + + recorded = DriverTrace.model_validate_json((documents / f"{NAME}{EXT_FILE_JSON}").read_text(encoding="utf-8")) + assert (documents / f"{NAME}{EXT_FILE_NSF}").read_bytes() == marking_nsf() + assert len(recorded.ticks) == playback.trace.ticks == ROWS + + def test_an_nsf_that_fails_to_play_is_reported( + self, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + target = FamiTrackerTarget(program=_program(tmp_path, b"not an NSF file at all")) + + with pytest.raises(FamiTrackerError, match="failed to play"): + target.play(project, documents, NAME) + + def test_a_project_a_module_has_no_room_for_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + documents: Path, + project: Project, + ) -> None: + def build_module(refused: Project) -> None: + raise ValueError("Order length 129 exceeds the FamiTracker limit of 128 frames") + + monkeypatch.setattr(target_module, "build_module", build_module) + + with pytest.raises(FamiTrackerError, match="129"): + FamiTrackerTarget(program=_program(tmp_path, marking_nsf())).play(project, documents, NAME) diff --git a/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_trace.py b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_trace.py new file mode 100644 index 000000000..b1cd9caf9 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/targets/famitracker/test_trace.py @@ -0,0 +1,109 @@ +from typing import Final, List, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.timing.bounds import MAX_TICKS_PER_ROW +from sampletones_player.specification.registers import ( + APU_STATUS, + DMC_DIRECT_LOAD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.markers import row_marker +from sampletones_tools.tracker_playback.targets.famitracker.trace import DriverWrite, recorded_trace +from sampletones_tools.tracker_playback.trace.sound import TickPosition + +ENABLED: Final[RegisterWrite] = RegisterWrite(APU_STATUS, 0x0F) +LOUD: Final[RegisterWrite] = RegisterWrite(PULSE1_CONTROL, 0xBF) +QUIET: Final[RegisterWrite] = RegisterWrite(PULSE1_CONTROL, 0xB4) +TIMER: Final[Tuple[RegisterWrite, RegisterWrite]] = ( + RegisterWrite(PULSE1_TIMER_LOW, 0xAB), + RegisterWrite(PULSE1_TIMER_HIGH, 0x01), +) + + +def marker(row_index: int) -> RegisterWrite: + return RegisterWrite(DMC_DIRECT_LOAD, row_marker(row_index)) + + +class ScriptedCalls: + """Routines answering with the writes a case scripts, one play call after another, then nothing.""" + + def __init__( + self, + initialization: Tuple[RegisterWrite, ...], + play_calls: Sequence[Tuple[RegisterWrite, ...]], + ) -> None: + self._initialization = initialization + self._play_calls: List[Tuple[RegisterWrite, ...]] = list(play_calls) + + def initialize(self) -> Tuple[RegisterWrite, ...]: + return self._initialization + + def play(self) -> Tuple[RegisterWrite, ...]: + return self._play_calls.pop(0) if self._play_calls else () + + +class TestSplittingThePlayCalls: + """A song of one frame and two rows, the first row lasting two ticks and the second one.""" + + @pytest.fixture(name="calls") + def calls_fixture(self) -> ScriptedCalls: + return ScriptedCalls( + (ENABLED,), + ( + (marker(0), *TIMER, LOUD), + (QUIET,), + (marker(1), LOUD), + (marker(2),), + ), + ) + + def test_a_tick_falls_on_the_row_its_last_marker_started(self, calls: ScriptedCalls) -> None: + trace = recorded_trace(calls, frames=1, rows=2) + + assert [(tick.frame, tick.row) for tick in trace.ticks] == [(0, 0), (0, 0), (0, 1)] + + def test_the_pass_ends_where_the_order_comes_back_round(self, calls: ScriptedCalls) -> None: + assert len(recorded_trace(calls, frames=1, rows=2).ticks) == 3 + + def test_each_tick_keeps_the_writes_its_call_made(self, calls: ScriptedCalls) -> None: + trace = recorded_trace(calls, frames=1, rows=2) + + assert trace.ticks[1].writes == (DriverWrite(address=QUIET.address, value=QUIET.value),) + assert trace.initialization == (DriverWrite(address=ENABLED.address, value=ENABLED.value),) + + def test_the_channels_sound_what_the_registers_hold_on_each_tick(self, calls: ScriptedCalls) -> None: + song = recorded_trace(calls, frames=1, rows=2).song_trace() + pulse = song.channels[ChannelName.PULSE1] + + assert song.positions == (TickPosition(0, 0), TickPosition(0, 0), TickPosition(0, 1)) + assert [sound.volume for sound in pulse] == [0x0F, 0x04, 0x0F] + assert {sound.period for sound in pulse} == {0x1AB} + assert all(sound.audible for sound in pulse) + + +class TestTheCallsAroundTheRows: + def test_calls_before_the_first_row_join_its_first_tick(self) -> None: + calls = ScriptedCalls((), ((LOUD,), (marker(0), QUIET), (marker(1),))) + + (tick,) = recorded_trace(calls, frames=1, rows=1).ticks + + assert [write.address for write in tick.writes] == [PULSE1_CONTROL, DMC_DIRECT_LOAD, PULSE1_CONTROL] + + def test_a_row_marked_out_of_place_is_reported(self) -> None: + skipped = RegisterWrite(DMC_DIRECT_LOAD, row_marker(2)) + calls = ScriptedCalls((), ((marker(0),), (skipped,))) + + with pytest.raises(FamiTrackerError, match="marked row 1"): + recorded_trace(calls, frames=1, rows=4) + + def test_a_row_lasting_longer_than_any_project_holds_one_is_reported(self) -> None: + calls = ScriptedCalls((), ((marker(0),),)) + + with pytest.raises(FamiTrackerError, match=str(MAX_TICKS_PER_ROW)): + recorded_trace(calls, frames=1, rows=2) diff --git a/tests/unit/sampletones_tools/tracker_playback/test_command.py b/tests/unit/sampletones_tools/tracker_playback/test_command.py new file mode 100644 index 000000000..14e6ecb48 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_command.py @@ -0,0 +1,196 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Final, List, Sequence + +import pytest + +from sampletones.commands.registry import COMMANDS +from sampletones.dispatcher import dispatch +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_tools.tracker_playback.comparison import TraceComparison +from sampletones_tools.tracker_playback.outcome import ProjectOutcome +from sampletones_tools.tracker_playback.projects import CheckedProject +from sampletones_tools.tracker_playback.report import MATCHES +from sampletones_tools.tracker_playback.session import PlaybackOutcome, PlaybackRun +from sampletones_tools.tracker_playback.targets.bitphase.engine import EngineError +from sampletones_tools.tracker_playback.targets.bitphase.target import BitphaseTarget +from sampletones_tools.tracker_playback.targets.famitracker.errors import FamiTrackerError +from sampletones_tools.tracker_playback.targets.famitracker.target import FamiTrackerTarget +from tests.suite.playback import ReplayingTarget + +COMMAND: Final[str] = "tracker-playback" +COMPARISON_CORPUS: Final[str] = "sampletones_tools.tracker_playback.corpus.build.comparison_corpus" +CHECK_PROJECTS: Final[str] = "sampletones_tools.tracker_playback.session.check_projects" +DEFAULT_OUTPUT: Final[str] = "sampletones_tools.tracker_playback.session.default_output" +CORPUS_PROJECT: Final[str] = "corpus-tone" + + +def _matching_outcome(name: str) -> ProjectOutcome: + return ProjectOutcome( + project=CheckedProject(name=name, purpose="A tone.", project=Project.create()), + comparison=TraceComparison(application_ticks=6, engine_ticks=6, timing=None, divergences=()), + skipped_rows=NO_SKIPPED_ROWS, + truncation=None, + ) + + +@dataclass +class StartedRuns: + """What the command started: each run, and the names of the projects it was handed.""" + + runs: List[PlaybackRun] = field(default_factory=list) + projects: List[List[str]] = field(default_factory=list) + + +@pytest.fixture(name="started") +def started_fixture( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + replaying_target: ReplayingTarget, +) -> StartedRuns: + """The runs the command starts, each answered with one matching project, the target located as a replay. + + The corpus is a single project named ``CORPUS_PROJECT``. + """ + started = StartedRuns() + + def check_projects(projects: Sequence[CheckedProject], run: PlaybackRun) -> PlaybackOutcome: + started.runs.append(run) + started.projects.append([project.name for project in projects]) + return PlaybackOutcome(outcomes=(_matching_outcome("tone"),), report=tmp_path / "report.md") + + monkeypatch.setattr(BitphaseTarget, "located", classmethod(lambda cls, root: replaying_target)) + monkeypatch.setattr(COMPARISON_CORPUS, lambda corpus: [_matching_outcome(CORPUS_PROJECT).project]) + monkeypatch.setattr(CHECK_PROJECTS, check_projects) + return started + + +class TestTrackerPlayback: + def test_each_verdict_and_the_report_link_are_printed( + self, + started: StartedRuns, + replaying_target: ReplayingTarget, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + arguments = [COMMAND, "bitphase", "--directory", str(tmp_path), "-o", str(tmp_path / "run")] + + assert dispatch(COMMANDS, arguments) == 0 + (run,) = started.runs + assert (run.target, run.output) == (replaying_target, tmp_path / "run") + assert run.settings.examples_per_difference >= 1 + assert capsys.readouterr().out.splitlines() == [ + f"tone: {MATCHES}", + f"Report: {(tmp_path / 'report.md').resolve().as_uri()}", + ] + + def test_a_run_given_no_output_writes_where_the_default_names( + self, + started: StartedRuns, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr(DEFAULT_OUTPUT, lambda: tmp_path / "stamped") + + assert dispatch(COMMANDS, [COMMAND, "bitphase", "--directory", str(tmp_path)]) == 0 + assert [run.output for run in started.runs] == [tmp_path / "stamped"] + + def test_a_run_given_no_project_checks_the_corpus(self, started: StartedRuns, tmp_path: Path) -> None: + assert dispatch(COMMANDS, [COMMAND, "bitphase", "--directory", str(tmp_path)]) == 0 + assert started.projects == [[CORPUS_PROJECT]] + + def test_a_run_given_projects_checks_those_alone_in_the_order_given( + self, + started: StartedRuns, + tmp_path: Path, + ) -> None: + for name in ("verse", "chorus"): + ProjectContainer.save(Project.create(), tmp_path / f"{name}.stp") + + arguments = [ + COMMAND, + "bitphase", + "--directory", + str(tmp_path), + "--project", + str(tmp_path / "chorus.stp"), + "--project", + str(tmp_path / "verse.stp"), + ] + + assert dispatch(COMMANDS, arguments) == 0 + assert started.projects == [["chorus", "verse"]] + + def test_a_project_that_fails_to_open_is_reported(self, started: StartedRuns, tmp_path: Path) -> None: + arguments = [COMMAND, "bitphase", "--directory", str(tmp_path), "--project", str(tmp_path / "absent.stp")] + + with pytest.raises(SystemExit, match="absent.stp"): + dispatch(COMMANDS, arguments) + + assert not started.runs + + def test_a_tracker_that_cannot_run_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + def located(cls: type, root: Path) -> BitphaseTarget: + raise EngineError("node is absent") + + monkeypatch.setattr(BitphaseTarget, "located", classmethod(located)) + + with pytest.raises(SystemExit, match="node is absent"): + dispatch(COMMANDS, [COMMAND, "bitphase", "--directory", str(tmp_path)]) + + def test_the_bitphase_directory_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, [COMMAND, "bitphase"]) + + assert leaving.value.code == 2 + + def test_the_famitracker_target_plays_through_the_program_given( + self, + started: StartedRuns, + monkeypatch: pytest.MonkeyPatch, + replaying_target: ReplayingTarget, + tmp_path: Path, + ) -> None: + located: List[Path] = [] + + def locate(cls: type, executable: Path) -> ReplayingTarget: + located.append(executable) + return replaying_target + + monkeypatch.setattr(FamiTrackerTarget, "located", classmethod(locate)) + executable = tmp_path / "FamiTracker.exe" + + assert dispatch(COMMANDS, [COMMAND, "famitracker", "--executable", str(executable)]) == 0 + assert located == [executable] + assert [run.target for run in started.runs] == [replaying_target] + + def test_a_famitracker_that_cannot_run_is_reported( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + def located(cls: type, executable: Path) -> FamiTrackerTarget: + raise FamiTrackerError("wine is missing") + + monkeypatch.setattr(FamiTrackerTarget, "located", classmethod(located)) + + with pytest.raises(SystemExit, match="wine is missing"): + dispatch(COMMANDS, [COMMAND, "famitracker", "--executable", str(tmp_path / "FamiTracker.exe")]) + + def test_the_famitracker_program_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, [COMMAND, "famitracker"]) + + assert leaving.value.code == 2 + + def test_a_target_is_required(self) -> None: + with pytest.raises(SystemExit) as leaving: + dispatch(COMMANDS, [COMMAND]) + + assert leaving.value.code == 2 diff --git a/tests/unit/sampletones_tools/tracker_playback/test_comparison.py b/tests/unit/sampletones_tools/tracker_playback/test_comparison.py new file mode 100644 index 000000000..802f832ca --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_comparison.py @@ -0,0 +1,169 @@ +from dataclasses import dataclass, replace +from typing import Dict, Final, Sequence, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_tools.tracker_playback.comparison import ( + SoundField, + compare_traces, + differing_fields, +) +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, SongTrace, TickPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TONE: Final[ChannelSound] = ChannelSound(audible=True, period=427, volume=15, held=True, timbre=2) +SILENT: Final[ChannelSound] = ChannelSound(audible=False, period=0, volume=0, held=True, timbre=0) +ROW_TICKS: Final[int] = 2 +EXAMPLES: Final[int] = 3 + + +def _trace(pulse: Sequence[ChannelSound], rows: Sequence[int]) -> SongTrace: + """A trace sounding ``pulse`` on the first pulse channel, the rest silent, a row every ``ROW_TICKS``.""" + positions = tuple(TickPosition(frame=0, row=row) for row in rows) + channels: Dict[ChannelName, Tuple[ChannelSound, ...]] = { + channel: tuple(pulse) if channel == ChannelName.PULSE1 else (SILENT,) * len(positions) + for channel in ChannelName.items() + } + return SongTrace(positions=positions, channels=channels) + + +def _rows(ticks: int) -> Tuple[int, ...]: + return tuple(tick // ROW_TICKS for tick in range(ticks)) + + +class TestDifferingFields(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + application: ChannelSound + engine: ChannelSound + expected: Tuple[SoundField, ...] + + test_cases: Tuple[TestCase, ...] = ( + TestCase(label="alike", application=TONE, engine=TONE, expected=()), + TestCase( + label="silent on both sides whatever the registers hold", + application=replace(SILENT, period=2047), + engine=replace(SILENT, period=5, volume=3, held=False), + expected=(), + ), + TestCase( + label="sounding on one side alone", + application=TONE, + engine=replace(TONE, audible=False, period=5), + expected=(SoundField.AUDIBLE,), + ), + TestCase( + label="every register", + application=TONE, + engine=ChannelSound(audible=True, period=428, volume=5, held=True, timbre=1), + expected=(SoundField.PERIOD, SoundField.VOLUME, SoundField.TIMBRE), + ), + TestCase( + label="the volume alone", + application=TONE, + engine=replace(TONE, volume=5), + expected=(SoundField.VOLUME,), + ), + TestCase( + label="a level the chip counts down against one it holds", + application=TONE, + engine=replace(TONE, held=False), + expected=(SoundField.VOLUME,), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_fields_that_differ(self, test_case: TestCase) -> None: + assert differing_fields(test_case.application, test_case.engine) == test_case.expected + + +class TestCompareTraces: + def test_alike_traces_match(self) -> None: + trace = _trace((TONE,) * 4, _rows(4)) + + comparison = compare_traces(trace, trace, examples=EXAMPLES) + + assert comparison.matches + assert (comparison.application_ticks, comparison.engine_ticks) == (4, 4) + + def test_ticks_differing_in_the_same_fields_share_a_divergence_counting_them(self) -> None: + application = _trace((TONE,) * 4, _rows(4)) + engine = _trace((TONE, replace(TONE, volume=5), replace(TONE, volume=5), replace(TONE, volume=4)), _rows(4)) + + comparison = compare_traces(application, engine, examples=EXAMPLES) + + assert not comparison.matches + (divergence,) = comparison.divergences + assert (divergence.channel, divergence.fields, divergence.ticks) == ( + ChannelName.PULSE1, + (SoundField.VOLUME,), + 3, + ) + assert divergence.first.tick == 1 + assert divergence.first.position == TickPosition(frame=0, row=0) + assert (divergence.first.application, divergence.first.engine) == (TONE, replace(TONE, volume=5)) + + def test_a_later_row_sounding_new_values_joins_the_examples(self) -> None: + application = _trace((TONE,) * 6, _rows(6)) + engine = _trace( + ( + replace(TONE, period=428), + replace(TONE, period=428), + replace(TONE, period=428), + replace(TONE, period=428), + replace(TONE, period=500), + replace(TONE, period=501), + ), + _rows(6), + ) + + (divergence,) = compare_traces(application, engine, examples=EXAMPLES).divergences + + assert [example.tick for example in divergence.examples] == [0, 4] + assert divergence.ticks == 6 + + def test_the_examples_stop_at_their_limit(self) -> None: + ticks = 2 * EXAMPLES * ROW_TICKS + application = _trace((TONE,) * ticks, _rows(ticks)) + engine = _trace(tuple(replace(TONE, period=tick) for tick in range(ticks)), _rows(ticks)) + + (divergence,) = compare_traces(application, engine, examples=EXAMPLES).divergences + + assert len(divergence.examples) == EXAMPLES + assert divergence.ticks == ticks + + def test_divergences_are_listed_in_the_order_they_first_show(self) -> None: + application = _trace((TONE,) * 4, _rows(4)) + engine = _trace((TONE, TONE, replace(TONE, volume=3), SILENT), _rows(4)) + + comparison = compare_traces(application, engine, examples=EXAMPLES) + + assert [divergence.fields for divergence in comparison.divergences] == [ + (SoundField.VOLUME,), + (SoundField.AUDIBLE,), + ] + + def test_a_song_bitphase_plays_longer_is_no_match(self) -> None: + comparison = compare_traces( + _trace((TONE,) * 2, _rows(2)), + _trace((TONE,) * 3, _rows(3)), + examples=EXAMPLES, + ) + + assert not comparison.divergences + assert comparison.timing is None + assert not comparison.matches + + def test_the_first_tick_the_rows_part_is_named(self) -> None: + application = _trace((TONE,) * 4, (0, 0, 1, 1)) + engine = _trace((TONE,) * 4, (0, 0, 0, 1)) + + comparison = compare_traces(application, engine, examples=EXAMPLES) + + assert comparison.timing is not None + assert comparison.timing.tick == 2 + assert comparison.timing.position == TickPosition(frame=0, row=1) + assert comparison.timing.engine_position == TickPosition(frame=0, row=0) + assert not comparison.matches diff --git a/tests/unit/sampletones_tools/tracker_playback/test_projects.py b/tests/unit/sampletones_tools/tracker_playback/test_projects.py new file mode 100644 index 000000000..03fa55a15 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_projects.py @@ -0,0 +1,74 @@ +from pathlib import Path +from typing import Final, Set + +import pytest + +from sampletones_core.project.container import ProjectContainer +from sampletones_core.project.project import Project +from sampletones_shared.exceptions.project import NotAValidArchiveError +from sampletones_tools.tracker_playback.projects import ( + COUNTED_NAME, + FIRST_COUNT, + LOADED_PURPOSE, + loaded_projects, + unique_name, +) + +FIRST_TITLE: Final[str] = "First song" +SECOND_TITLE: Final[str] = "Second song" + + +def _saved(path: Path, title: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + ProjectContainer.save(Project.create(title=title), path) + return path + + +class TestLoadedProjects: + def test_each_file_becomes_a_project_named_after_it_in_the_order_given(self, tmp_path: Path) -> None: + second = _saved(tmp_path / "second.stp", SECOND_TITLE) + first = _saved(tmp_path / "first.stp", FIRST_TITLE) + + projects = loaded_projects((second, first)) + + assert [project.name for project in projects] == ["second", "first"] + assert [project.project.info.title for project in projects] == [SECOND_TITLE, FIRST_TITLE] + + def test_a_project_says_which_file_it_came_from(self, tmp_path: Path) -> None: + path = _saved(tmp_path / "song.stp", FIRST_TITLE) + + (project,) = loaded_projects((path,)) + + assert project.purpose == LOADED_PURPOSE.format(path=path.resolve()) + + def test_files_sharing_a_name_write_apart(self, tmp_path: Path) -> None: + first = _saved(tmp_path / "one" / "song.stp", FIRST_TITLE) + second = _saved(tmp_path / "two" / "song.stp", SECOND_TITLE) + + projects = loaded_projects((first, second)) + + assert [project.name for project in projects] == [ + "song", + COUNTED_NAME.format(stem="song", count=FIRST_COUNT), + ] + + def test_a_file_holding_no_project_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "notes.stp" + path.write_text("not a project", encoding="utf-8") + + with pytest.raises(NotAValidArchiveError): + loaded_projects((path,)) + + def test_a_missing_file_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(OSError): + loaded_projects((tmp_path / "absent.stp",)) + + +class TestUniqueName: + def test_a_free_name_is_kept(self) -> None: + assert unique_name("song", {"other"}) == "song" + + def test_a_taken_name_takes_the_lowest_free_count(self) -> None: + taken: Set[str] = {"song", COUNTED_NAME.format(stem="song", count=FIRST_COUNT)} + + assert unique_name("song", taken) == COUNTED_NAME.format(stem="song", count=FIRST_COUNT + 1) diff --git a/tests/unit/sampletones_tools/tracker_playback/test_report.py b/tests/unit/sampletones_tools/tracker_playback/test_report.py new file mode 100644 index 000000000..da5757044 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_report.py @@ -0,0 +1,144 @@ +from dataclasses import replace +from typing import Final, Tuple + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason +from sampletones_core.exporters.truncation import EnvelopeTruncation +from sampletones_core.project.project import Project +from sampletones_tools.tracker_playback.comparison import TraceComparison, compare_traces +from sampletones_tools.tracker_playback.outcome import ProjectOutcome +from sampletones_tools.tracker_playback.projects import CheckedProject +from sampletones_tools.tracker_playback.report import ( + COUNTED_DOWN, + DIFFERS, + DIFFERS_ONCE, + LENGTHS_DIFFER, + LONG_MODE, + MATCHES, + NOISE_SOUND, + PULSE_SOUND, + SHORT_MODE, + SHORTENED, + SILENT, + SKIPPED_ROWS, + TIMING_DIFFERS, + TIMING_ONLY, + TRIANGLE_SOUND, + describe_sound, + report_text, + result_label, +) +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, SongTrace, TickPosition +from tests.suite.playback import ReplayingTarget + +EXAMPLES: Final[int] = 4 +TONE: Final[ChannelSound] = ChannelSound(audible=True, period=427, volume=15, held=True, timbre=2) +QUIET: Final[ChannelSound] = replace(TONE, volume=5) +SILENT_SOUND: Final[ChannelSound] = ChannelSound(audible=False, period=0, volume=0, held=True, timbre=0) + + +def _trace(pulse: Tuple[ChannelSound, ...]) -> SongTrace: + return SongTrace( + positions=tuple(TickPosition(frame=0, row=tick) for tick in range(len(pulse))), + channels={ + channel: pulse if channel == ChannelName.PULSE1 else (SILENT_SOUND,) * len(pulse) + for channel in ChannelName.items() + }, + ) + + +def _skipped(reason: SkipReason, row: int) -> SkippedRow: + return SkippedRow(voice_id="voice", channel=ChannelName.PULSE1, order_position=0, row_index=row, reason=reason) + + +def _outcome(name: str, comparison: TraceComparison) -> ProjectOutcome: + return ProjectOutcome( + project=CheckedProject(name=name, purpose=f"What {name} exercises.", project=Project.create()), + comparison=comparison, + skipped_rows=NO_SKIPPED_ROWS, + truncation=None, + ) + + +class TestDescribeSound: + def test_a_silent_channel_reads_silent_on_any_channel(self) -> None: + assert {describe_sound(channel, SILENT_SOUND) for channel in ChannelName.items()} == {SILENT} + + def test_each_channel_names_the_registers_it_has(self) -> None: + short = ChannelSound(audible=True, period=9, volume=12, held=True, timbre=1) + + assert describe_sound(ChannelName.PULSE2, TONE) == PULSE_SOUND.format(period=427, volume=15, timbre=2) + assert describe_sound(ChannelName.TRIANGLE, TONE) == TRIANGLE_SOUND.format(period=427) + assert describe_sound(ChannelName.NOISE, short) == NOISE_SOUND.format(period=9, volume=12, mode=SHORT_MODE) + assert describe_sound(ChannelName.NOISE, replace(short, timbre=0)) == NOISE_SOUND.format( + period=9, + volume=12, + mode=LONG_MODE, + ) + + def test_a_sound_the_chip_counts_down_says_so_on_every_channel(self) -> None: + counted_down = replace(TONE, held=False) + + for channel in ChannelName.items(): + assert describe_sound(channel, counted_down) == describe_sound(channel, TONE) + COUNTED_DOWN + + +class TestReportText: + def test_the_summary_names_every_project_with_its_verdict(self, replaying_target: ReplayingTarget) -> None: + alike = compare_traces(_trace((TONE, TONE)), _trace((TONE, TONE)), examples=EXAMPLES) + different = compare_traces(_trace((TONE, TONE)), _trace((TONE, QUIET)), examples=EXAMPLES) + + text = report_text(replaying_target, (_outcome("alike", alike), _outcome("quiet", different))) + + assert replaying_target.title in text + assert replaying_target.player in text + assert f"| alike | 2 | 2 | {MATCHES} |" in text + assert f"| quiet | 2 | 2 | {DIFFERS_ONCE} |" in text + assert "What quiet exercises." in text + + def test_a_divergence_lists_both_sides_where_it_first_shows(self, replaying_target: ReplayingTarget) -> None: + different = compare_traces(_trace((TONE, TONE)), _trace((TONE, QUIET)), examples=EXAMPLES) + + text = report_text(replaying_target, (_outcome("quiet", different),)) + + application = describe_sound(ChannelName.PULSE1, TONE) + engine = describe_sound(ChannelName.PULSE1, QUIET) + assert f"| pulse1 | volume | 1 | 0 | 1 | {application} | {engine} | 1 |" in text + + def test_the_lengths_the_timing_and_what_the_export_left_out_are_stated( + self, replaying_target: ReplayingTarget + ) -> None: + application = _trace((TONE, TONE)) + engine = SongTrace(positions=(TickPosition(frame=0, row=0),) * 3, channels=_trace((TONE,) * 3).channels) + outcome = replace( + _outcome("long", compare_traces(application, engine, examples=EXAMPLES)), + skipped_rows=( + _skipped(SkipReason.NO_INSTRUMENT, 0), + _skipped(SkipReason.UNREACHED_TRANSPOSE, 1), + _skipped(SkipReason.NO_INSTRUMENT, 2), + _skipped(SkipReason.CARRIED_PITCH, 3), + ), + truncation=EnvelopeTruncation(frames=512, source_frames=601, instruments=1), + ) + + text = report_text(replaying_target, (outcome,)) + + assert LENGTHS_DIFFER.format(application=2, engine=3) in text + assert TIMING_DIFFERS.format(tick=1, frame=0, row=1, engine_frame=0, engine_row=0) in text + assert SKIPPED_ROWS[SkipReason.NO_INSTRUMENT].format(count=2) in text + assert SKIPPED_ROWS[SkipReason.UNREACHED_TRANSPOSE].format(count=1) in text + assert SKIPPED_ROWS[SkipReason.CARRIED_PITCH].format(count=1) in text + assert SHORTENED.format(instruments=1, frames=512, source_frames=601) in text + + +class TestResultLabel: + def test_a_project_differing_several_ways_counts_them(self) -> None: + silent = replace(TONE, audible=False) + comparison = compare_traces(_trace((TONE, TONE)), _trace((QUIET, silent)), examples=EXAMPLES) + + assert result_label(comparison) == DIFFERS.format(count=2) + + def test_a_project_whose_rows_alone_differ_says_so(self) -> None: + comparison = compare_traces(_trace((TONE, TONE)), _trace((TONE, TONE, TONE)), examples=EXAMPLES) + + assert result_label(comparison) == TIMING_ONLY diff --git a/tests/unit/sampletones_tools/tracker_playback/test_session.py b/tests/unit/sampletones_tools/tracker_playback/test_session.py new file mode 100644 index 000000000..5e8de3d0b --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_session.py @@ -0,0 +1,53 @@ +from pathlib import Path +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.settings import ProjectSettings +from sampletones_tools.tracker_playback.paths import ( + DOCUMENTS_DIRECTORY_NAME, + OUTPUT_ROOT, + REPORT_FILENAME, +) +from sampletones_tools.tracker_playback.projects import CheckedProject +from sampletones_tools.tracker_playback.session import PlaybackRun, check_projects, default_output +from sampletones_tools.tracker_playback.settings import PlaybackSettings +from tests.suite.performance import make_pulse_reconstruction, place_instrument, project_with_sample +from tests.suite.playback import ReplayingTarget + +NAME: Final[str] = "tone" +SETTINGS: Final[PlaybackSettings] = PlaybackSettings(examples_per_difference=2) + + +def _checked_project() -> CheckedProject: + project, sample = project_with_sample( + make_pulse_reconstruction(count=4), + rows_per_pattern=2, + settings=ProjectSettings(tempo=150, speed=6, nes_frequency=60), + ) + place_instrument(project, channel_name=ChannelName.PULSE1, row_index=0, sample=sample, volume=15) + return CheckedProject(name=NAME, purpose="A tone.", project=project) + + +class TestCheckProjects: + def test_each_project_is_played_into_the_documents_and_the_report_is_written_beside_them( + self, + tmp_path: Path, + replaying_target: ReplayingTarget, + ) -> None: + outcome = check_projects( + (_checked_project(),), + PlaybackRun(target=replaying_target, output=tmp_path, settings=SETTINGS), + ) + + assert replaying_target.played == [(tmp_path / DOCUMENTS_DIRECTORY_NAME, NAME)] + assert outcome.report == tmp_path / REPORT_FILENAME + assert replaying_target.title in outcome.report.read_text(encoding="utf-8") + (project_outcome,) = outcome.outcomes + assert project_outcome.project.name == NAME + assert project_outcome.comparison.matches + assert (project_outcome.skipped_rows, project_outcome.truncation) == ((), None) + + +class TestDefaultOutput: + def test_a_run_given_no_output_lands_under_the_tools_documents_directory(self) -> None: + assert default_output().parent == OUTPUT_ROOT diff --git a/tests/unit/sampletones_tools/tracker_playback/test_settings.py b/tests/unit/sampletones_tools/tracker_playback/test_settings.py new file mode 100644 index 000000000..82573e1e7 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/test_settings.py @@ -0,0 +1,6 @@ +from sampletones_tools.tracker_playback.settings import PlaybackSettings + + +class TestPlaybackSettings: + def test_the_shipped_settings_load_from_the_package(self) -> None: + assert PlaybackSettings.load().examples_per_difference >= 1 diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/__init__.py b/tests/unit/sampletones_tools/tracker_playback/trace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py new file mode 100644 index 000000000..6d05aec9b --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_application.py @@ -0,0 +1,128 @@ +from fractions import Fraction +from typing import Final + +from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing import SONG_TICK_BOUNDS, Meter, RowRate, SongTiming +from sampletones_player.specification.registers import ( + APU_STATUS, + CHANNELS_ENABLED, + PULSE1_CONTROL, + PULSE1_SWEEP, + PULSE2_SWEEP, + SWEEP_DISABLED, +) +from sampletones_tools.tracker_playback.trace.application import ( + application_trace, + driver_registers, + song_positions, +) +from sampletones_tools.tracker_playback.trace.sound import ChannelSound, TickPosition +from tests.suite.performance import ( + make_pulse_reconstruction, + place_instrument, + project_with_sample, +) +from tests.suite.player import ( + PLAYER_FULL_VOLUME, + PLAYER_OCTAVE_UP_TIMER, + PLAYER_REFERENCE_TIMER, + PLAYER_SILENT_VOLUME, + PLAYER_TIMER_TABLE, + pulse_tick, + resting_streams, +) + +TICKS_PER_ROW: Final[int] = 6 +ROWS_PER_PATTERN: Final[int] = 2 +SETTINGS: Final[ProjectSettings] = ProjectSettings(tempo=150, speed=TICKS_PER_ROW, nes_frequency=60) +PITCH: Final[int] = 60 +FRAMES: Final[int] = 8 +ROW_VOLUME: Final[int] = 5 + + +class TestSongPositions: + def test_each_row_lasts_the_ticks_its_frame_gives_it(self) -> None: + """At 5/4 ticks a row a 2-row pattern lasts 2.5 ticks, so the first frame plays 3 and the second 2.""" + timing = SongTiming( + rate=RowRate(ticks_per_row=Fraction(5, 4)), + meter=Meter(rows=2, first_highlight=2, second_highlight=2), + bounds=SONG_TICK_BOUNDS, + ) + + positions = song_positions(timing, 2) + + assert positions == ( + TickPosition(frame=0, row=0), + TickPosition(frame=0, row=0), + TickPosition(frame=0, row=1), + TickPosition(frame=1, row=0), + TickPosition(frame=1, row=1), + ) + + +class TestDriverRegisters: + SOUNDING: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_REFERENCE_TIMER) + OCTAVE_UP: Final = pulse_tick(PLAYER_FULL_VOLUME, 0, PLAYER_OCTAVE_UP_TIMER) + RESTING: Final = pulse_tick(PLAYER_SILENT_VOLUME, 0, PLAYER_REFERENCE_TIMER) + + def test_the_console_stands_where_the_drivers_init_routine_leaves_it(self) -> None: + (registers,) = driver_registers(resting_streams((self.SOUNDING,)), 1) + + assert registers.value(APU_STATUS) == CHANNELS_ENABLED + assert (registers.value(PULSE1_SWEEP), registers.value(PULSE2_SWEEP)) == (SWEEP_DISABLED, SWEEP_DISABLED) + + def test_each_tick_holds_the_values_its_channels_write(self) -> None: + registers = driver_registers(resting_streams((self.SOUNDING, self.OCTAVE_UP)), 2) + + assert [tick.value(PULSE1_CONTROL) for tick in registers] == [self.SOUNDING.control, self.OCTAVE_UP.control] + + def test_a_channel_past_the_end_of_its_stream_holds_its_final_values(self) -> None: + registers = driver_registers(resting_streams((self.SOUNDING, self.RESTING)), 4) + + assert registers[-1] == registers[1] + + +class TestApplicationTrace: + def test_a_sample_sounds_its_frames_at_the_row_volume_and_then_rests(self) -> None: + project, sample = project_with_sample( + make_pulse_reconstruction(pitch=PITCH, count=FRAMES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + place_instrument( + project, + channel_name=ChannelName.PULSE1, + row_index=0, + sample=sample, + volume=ROW_VOLUME, + ) + + trace = application_trace(project) + + pulse = trace.channels[ChannelName.PULSE1] + assert trace.ticks == ROWS_PER_PATTERN * TICKS_PER_ROW + assert trace.positions[TICKS_PER_ROW] == TickPosition(frame=0, row=1) + assert pulse[0] == ChannelSound( + audible=True, + period=PLAYER_TIMER_TABLE[PITCH], + volume=ROW_VOLUME, + held=True, + timbre=0, + ) + assert all(sound.audible for sound in pulse[:FRAMES]) + assert not any(sound.audible for sound in pulse[FRAMES:]) + assert not any(sound.audible for sound in trace.channels[ChannelName.NOISE]) + + def test_every_channel_covers_every_tick_of_the_order(self) -> None: + project, _ = project_with_sample( + make_pulse_reconstruction(count=FRAMES), + rows_per_pattern=ROWS_PER_PATTERN, + settings=SETTINGS, + ) + project.song.append_frame() + + trace = application_trace(project) + + assert trace.ticks == 2 * ROWS_PER_PATTERN * TICKS_PER_ROW + assert {len(sounds) for sounds in trace.channels.values()} == {trace.ticks} diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_decode.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_decode.py new file mode 100644 index 000000000..98035a599 --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_decode.py @@ -0,0 +1,222 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.enums import ChannelName +from sampletones_player.specification.registers import ( + APU_STATUS, + CHANNELS_ENABLED, + CONSTANT_VOLUME, + DUTY_CYCLE_SHIFT, + LENGTH_COUNTER_HALT, + MAX_REGISTER_VALUE, + NOISE_CONTROL, + NOISE_MODE_SHIFT, + NOISE_PERIOD, + PULSE1_CONTROL, + PULSE1_TIMER_HIGH, + PULSE1_TIMER_LOW, + PULSE2_CONTROL, + PULSE2_TIMER_HIGH, + PULSE2_TIMER_LOW, + SUSTAINED_LEVEL, + TIMER_HIGH_SHIFT, + TRIANGLE_COUNTER_CONTROL, + TRIANGLE_LINEAR_COUNTER, + TRIANGLE_TIMER_HIGH, + TRIANGLE_TIMER_LOW, +) +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.trace.decode import ( + CHANNEL_STATUS_BITS, + LINEAR_COUNTER_RELOAD, + channel_sound, + song_trace, +) +from sampletones_tools.tracker_playback.trace.registers import ChipRegisters +from sampletones_tools.tracker_playback.trace.sound import ABSENT_REGISTER, ChannelSound, TickPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TIMER: Final[int] = 0x1AB +LENGTH_INDEX_BITS: Final[int] = 0xF8 +TIMER_LOW: Final[int] = TIMER & MAX_REGISTER_VALUE +TIMER_HIGH: Final[int] = LENGTH_INDEX_BITS | (TIMER >> TIMER_HIGH_SHIFT) +DUTY: Final[int] = 1 +LEVEL: Final[int] = 9 +NOISE_REGISTER_PERIOD: Final[int] = 6 +SOUNDING_PULSE: Final[int] = (DUTY << DUTY_CYCLE_SHIFT) | SUSTAINED_LEVEL | LEVEL +SOUNDING_TRIANGLE: Final[int] = TRIANGLE_COUNTER_CONTROL | LINEAR_COUNTER_RELOAD +SOUNDING_NOISE: Final[int] = SUSTAINED_LEVEL | LEVEL +SHORT_NOISE_PERIOD: Final[int] = (1 << NOISE_MODE_SHIFT) | NOISE_REGISTER_PERIOD + + +def _registers( + *writes: Tuple[int, int], + status: int = CHANNELS_ENABLED, +) -> ChipRegisters: + return ChipRegisters.power_up().written( + RegisterWrite(address, value) for address, value in ((APU_STATUS, status), *writes) + ) + + +def _every_channel_sounding(status: int) -> ChipRegisters: + return _registers( + (PULSE1_CONTROL, SOUNDING_PULSE), + (PULSE1_TIMER_LOW, TIMER_LOW), + (PULSE1_TIMER_HIGH, TIMER_HIGH), + (PULSE2_CONTROL, SOUNDING_PULSE), + (PULSE2_TIMER_LOW, TIMER_LOW), + (PULSE2_TIMER_HIGH, TIMER_HIGH), + (TRIANGLE_LINEAR_COUNTER, SOUNDING_TRIANGLE), + (TRIANGLE_TIMER_LOW, TIMER_LOW), + (TRIANGLE_TIMER_HIGH, TIMER_HIGH), + (NOISE_CONTROL, SOUNDING_NOISE), + (NOISE_PERIOD, SHORT_NOISE_PERIOD), + status=status, + ) + + +class TestPulseSound(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + channel: ChannelName + control: int + timer_low: int + timer_high: int + + test_cases: Tuple[TestCase, ...] = ( + TestCase( + label="pulse1", + channel=ChannelName.PULSE1, + control=PULSE1_CONTROL, + timer_low=PULSE1_TIMER_LOW, + timer_high=PULSE1_TIMER_HIGH, + ), + TestCase( + label="pulse2", + channel=ChannelName.PULSE2, + control=PULSE2_CONTROL, + timer_low=PULSE2_TIMER_LOW, + timer_high=PULSE2_TIMER_HIGH, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_pulse_reads_its_duty_its_level_and_its_timer_under_the_length_index(self, test_case: TestCase) -> None: + registers = _registers( + (test_case.control, SOUNDING_PULSE), + (test_case.timer_low, TIMER_LOW), + (test_case.timer_high, TIMER_HIGH), + ) + + assert channel_sound(test_case.channel, registers) == ChannelSound( + audible=True, + period=TIMER, + volume=LEVEL, + held=True, + timbre=DUTY, + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_pulse_at_level_zero_is_silent(self, test_case: TestCase) -> None: + registers = _registers((test_case.control, SUSTAINED_LEVEL)) + + assert not channel_sound(test_case.channel, registers).audible + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_pulse_running_its_envelope_sounds_counted_down_from_full(self, test_case: TestCase) -> None: + registers = _registers((test_case.control, LENGTH_COUNTER_HALT)) + + sound = channel_sound(test_case.channel, registers) + + assert sound.audible + assert not sound.held + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_constant_level_under_a_running_length_counter_is_counted_down(self, test_case: TestCase) -> None: + registers = _registers((test_case.control, CONSTANT_VOLUME | LEVEL)) + + sound = channel_sound(test_case.channel, registers) + + assert (sound.audible, sound.volume, sound.held) == (True, LEVEL, False) + + +class TestTriangleSound: + def test_the_triangle_sounds_its_timer_while_its_linear_counter_reloads(self) -> None: + registers = _registers( + (TRIANGLE_LINEAR_COUNTER, SOUNDING_TRIANGLE), + (TRIANGLE_TIMER_LOW, TIMER_LOW), + (TRIANGLE_TIMER_HIGH, TIMER_HIGH), + ) + + assert channel_sound(ChannelName.TRIANGLE, registers) == ChannelSound( + audible=True, + period=TIMER, + volume=ABSENT_REGISTER, + held=True, + timbre=ABSENT_REGISTER, + ) + + @pytest.mark.parametrize("control", (TRIANGLE_COUNTER_CONTROL, 0), ids=("held", "released")) + def test_a_reload_of_zero_is_silent(self, control: int) -> None: + registers = _registers((TRIANGLE_LINEAR_COUNTER, control)) + + assert not channel_sound(ChannelName.TRIANGLE, registers).audible + + def test_a_clear_control_flag_counts_the_triangle_down(self) -> None: + registers = _registers((TRIANGLE_LINEAR_COUNTER, LINEAR_COUNTER_RELOAD)) + + sound = channel_sound(ChannelName.TRIANGLE, registers) + + assert sound.audible + assert not sound.held + + +class TestNoiseSound: + def test_the_noise_reads_its_level_its_period_index_and_its_mode(self) -> None: + registers = _registers((NOISE_CONTROL, SOUNDING_NOISE), (NOISE_PERIOD, SHORT_NOISE_PERIOD)) + + assert channel_sound(ChannelName.NOISE, registers) == ChannelSound( + audible=True, + period=NOISE_REGISTER_PERIOD, + volume=LEVEL, + held=True, + timbre=1, + ) + + def test_the_noise_at_level_zero_is_silent(self) -> None: + registers = _registers((NOISE_CONTROL, SUSTAINED_LEVEL), (NOISE_PERIOD, SHORT_NOISE_PERIOD)) + + assert not channel_sound(ChannelName.NOISE, registers).audible + + +class TestStatusRegister: + @pytest.mark.parametrize("channel", ChannelName.items(), ids=str) + def test_a_channel_the_status_register_leaves_disabled_is_silent(self, channel: ChannelName) -> None: + registers = _every_channel_sounding(CHANNELS_ENABLED & ~CHANNEL_STATUS_BITS[channel]) + + assert {other: channel_sound(other, registers).audible for other in ChannelName.items()} == { + other: other != channel for other in ChannelName.items() + } + + def test_every_channel_is_silent_before_any_write(self) -> None: + assert not any(channel_sound(channel, ChipRegisters.power_up()).audible for channel in ChannelName.items()) + + +class TestSongTrace: + def test_every_channel_is_read_on_every_tick(self) -> None: + positions = (TickPosition(frame=0, row=0), TickPosition(frame=0, row=1)) + registers = (_every_channel_sounding(CHANNELS_ENABLED), ChipRegisters.power_up()) + + trace = song_trace(positions, registers) + + assert trace.positions == positions + assert {channel: [sound.audible for sound in sounds] for channel, sounds in trace.channels.items()} == { + channel: [True, False] for channel in ChannelName.items() + } + + def test_registers_for_another_number_of_ticks_are_refused(self) -> None: + with pytest.raises(ValueError, match="2 ticks have 1"): + song_trace((TickPosition(frame=0, row=0),) * 2, (ChipRegisters.power_up(),)) diff --git a/tests/unit/sampletones_tools/tracker_playback/trace/test_registers.py b/tests/unit/sampletones_tools/tracker_playback/trace/test_registers.py new file mode 100644 index 000000000..cde29362d --- /dev/null +++ b/tests/unit/sampletones_tools/tracker_playback/trace/test_registers.py @@ -0,0 +1,31 @@ +from typing import Final + +from sampletones_player.specification.registers import APU_STATUS, PULSE1_CONTROL +from sampletones_tools.player.trace.write import RegisterWrite +from sampletones_tools.tracker_playback.trace.registers import POWER_UP_VALUE, ChipRegisters + +FIRST_VALUE: Final[int] = 0x3F +SECOND_VALUE: Final[int] = 0x30 + + +class TestChipRegisters: + def test_a_register_never_written_stands_at_the_power_up_value(self) -> None: + assert ChipRegisters.power_up().value(APU_STATUS) == POWER_UP_VALUE + + def test_a_register_keeps_the_last_value_written_to_it(self) -> None: + registers = ChipRegisters.power_up().written( + ( + RegisterWrite(PULSE1_CONTROL, FIRST_VALUE), + RegisterWrite(PULSE1_CONTROL, SECOND_VALUE), + ) + ) + + assert registers.value(PULSE1_CONTROL) == SECOND_VALUE + + def test_writes_lay_over_what_earlier_writes_left(self) -> None: + earlier = ChipRegisters.power_up().written((RegisterWrite(PULSE1_CONTROL, FIRST_VALUE),)) + + later = earlier.written((RegisterWrite(APU_STATUS, SECOND_VALUE),)) + + assert (later.value(PULSE1_CONTROL), later.value(APU_STATUS)) == (FIRST_VALUE, SECOND_VALUE) + assert earlier.value(APU_STATUS) == POWER_UP_VALUE diff --git a/tests/unit/scripts/bootstrap/platforms/test_bundling.py b/tests/unit/scripts/bootstrap/platforms/test_bundling.py new file mode 100644 index 000000000..172169618 --- /dev/null +++ b/tests/unit/scripts/bootstrap/platforms/test_bundling.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from bootstrap.platforms.bundling import Bundling +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +NAME = "sampletones" + + +def _bundling(executable_suffix: str) -> Bundling: + return Bundling( + icon="icon.png", + executable_suffix=executable_suffix, + pyaudio_advice="", + tkinter_advice="", + tkinter_warning="", + ) + + +class TestLauncher(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + suffix: str + release: bool + expected: Path + + test_cases = ( + TestCase(label="a development bundle is one file", suffix="", release=False, expected=Path("bin", NAME)), + TestCase( + label="a release is a directory beside its launcher", + suffix="", + release=True, + expected=Path("bin", NAME, NAME), + ), + TestCase( + label="the suffix ends the launcher's name", + suffix=".exe", + release=True, + expected=Path("bin", NAME, f"{NAME}.exe"), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_launcher_lies_where_pyinstaller_writes_it(self, test_case: TestCase) -> None: + launcher = _bundling(test_case.suffix).launcher(Path("bin"), name=NAME, release=test_case.release) + + assert launcher == test_case.expected diff --git a/tests/unit/scripts/bootstrap/platforms/test_factory.py b/tests/unit/scripts/bootstrap/platforms/test_factory.py new file mode 100644 index 000000000..0f25c0cab --- /dev/null +++ b/tests/unit/scripts/bootstrap/platforms/test_factory.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass + +import pytest + +from bootstrap.platforms.factory import platform_named +from bootstrap.platforms.linux import LINUX +from bootstrap.platforms.macos import DARWIN +from bootstrap.platforms.windows import WINDOWS +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestPlatformNamed(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + system: str + + test_cases = ( + TestCase(label="Linux", system=LINUX), + TestCase(label="Windows", system=WINDOWS), + TestCase(label="macOS", system=DARWIN), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_system_name_selects_its_platform(self, test_case: TestCase) -> None: + assert platform_named(test_case.system).name == test_case.system + + def test_an_unknown_system_is_refused_by_name(self) -> None: + with pytest.raises(SystemExit, match="Plan 9"): + platform_named("Plan 9") diff --git a/tests/unit/scripts/bootstrap/platforms/test_linux.py b/tests/unit/scripts/bootstrap/platforms/test_linux.py new file mode 100644 index 000000000..c7af9df0d --- /dev/null +++ b/tests/unit/scripts/bootstrap/platforms/test_linux.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from bootstrap.platforms.linux import Linux + + +class TestLinux: + def test_a_virtual_environment_runs_bin_python(self) -> None: + assert Linux().interpreter(Path(".venv-build")) == Path(".venv-build", "bin", "python") + + def test_a_bundle_launcher_carries_no_extension(self) -> None: + assert Linux().bundling().launcher(Path("bin"), name="sampletones", release=False) == Path("bin", "sampletones") + + def test_apt_is_updated_before_the_packages_are_installed(self) -> None: + commands = Linux().system_packages() + + assert [command[:3] for command in commands] == [("sudo", "apt-get", "update"), ("sudo", "apt-get", "install")] + assert "portaudio19-dev" in commands[1] + assert "python3-tk" in commands[1] + assert Linux().missing_package_manager() is None + + def test_the_setup_and_the_build_take_the_variables_as_given(self) -> None: + assert Linux().setup_variables({"PATH": "/usr/bin"}, machine="x86_64") == {"PATH": "/usr/bin"} + assert Linux().build_flags(machine="x86_64") == () + + def test_the_driver_is_looked_up_on_the_path_alone(self) -> None: + assert Linux().cpu_backend_reason is None + assert Linux().nvidia_smi_locations({"SystemRoot": "C:/Windows"}) == () diff --git a/tests/unit/scripts/bootstrap/platforms/test_macos.py b/tests/unit/scripts/bootstrap/platforms/test_macos.py new file mode 100644 index 000000000..4a2ea00ae --- /dev/null +++ b/tests/unit/scripts/bootstrap/platforms/test_macos.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest + +from bootstrap.platforms import macos +from bootstrap.platforms.macos import ARCHFLAGS, CPU_BACKEND, HOMEBREW, HOMEBREW_SITE, PORTAUDIO, MacOS + +PORTAUDIO_PREFIX = "/opt/homebrew/opt/portaudio" + + +class TestMacOS: + def test_a_virtual_environment_runs_bin_python(self) -> None: + assert MacOS().interpreter(Path(".venv-build")) == Path(".venv-build", "bin", "python") + + def test_a_bundle_is_refused_with_the_way_to_run_from_source(self) -> None: + with pytest.raises(SystemExit, match="make setup"): + MacOS().bundling() + + def test_portaudio_is_installed_through_homebrew(self) -> None: + assert MacOS().system_packages() == ((HOMEBREW, "install", PORTAUDIO),) + + def test_with_homebrew_the_package_manager_is_present(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(macos.shutil, "which", lambda name: f"/opt/homebrew/bin/{name}") + + assert MacOS().missing_package_manager() is None + + def test_without_homebrew_the_refusal_names_where_to_get_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(macos.shutil, "which", lambda name: None) + + refusal = MacOS().missing_package_manager() + + assert refusal is not None + assert HOMEBREW_SITE in refusal + + def test_the_setup_pins_the_native_architecture(self) -> None: + variables = MacOS().setup_variables({"PATH": "/usr/bin"}, machine="arm64") + + assert variables == {"PATH": "/usr/bin", ARCHFLAGS: "-arch arm64"} + + def test_a_build_compiles_against_homebrew_s_portaudio(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(MacOS, "homebrew_prefix", staticmethod(lambda package: PORTAUDIO_PREFIX)) + + assert MacOS().build_flags(machine="arm64") == ( + f"CFLAGS=-I{PORTAUDIO_PREFIX}/include", + f"LDFLAGS=-L{PORTAUDIO_PREFIX}/lib", + f"{ARCHFLAGS}=-arch arm64", + ) + + def test_a_build_without_homebrew_s_portaudio_is_refused(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(MacOS, "homebrew_prefix", staticmethod(lambda package: "")) + + with pytest.raises(SystemExit, match="Homebrew"): + MacOS().build_flags(machine="arm64") + + def test_the_cpu_backend_is_the_one_it_runs(self) -> None: + assert MacOS().cpu_backend_reason == CPU_BACKEND + assert MacOS().nvidia_smi_locations({}) == () + + +class TestHomebrewPrefix: + def test_without_homebrew_the_prefix_is_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(macos.shutil, "which", lambda name: None) + + assert MacOS.homebrew_prefix(PORTAUDIO) == "" diff --git a/tests/unit/scripts/bootstrap/platforms/test_windows.py b/tests/unit/scripts/bootstrap/platforms/test_windows.py new file mode 100644 index 000000000..77fc8fb3a --- /dev/null +++ b/tests/unit/scripts/bootstrap/platforms/test_windows.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from bootstrap.platforms.windows import Windows + + +class TestWindows: + def test_a_virtual_environment_runs_scripts_python(self) -> None: + assert Windows().interpreter(Path(".venv-build")) == Path(".venv-build", "Scripts", "python.exe") + + def test_a_bundle_launcher_carries_the_executable_extension(self) -> None: + launcher = Windows().bundling().launcher(Path("bin"), name="sampletones", release=True) + + assert launcher == Path("bin", "sampletones", "sampletones.exe") + + def test_the_installer_carries_every_system_package(self) -> None: + assert Windows().system_packages() == () + assert Windows().missing_package_manager() is None + + def test_the_setup_and_the_build_take_the_variables_as_given(self) -> None: + assert Windows().setup_variables({"PATH": "C:/Python"}, machine="AMD64") == {"PATH": "C:/Python"} + assert Windows().build_flags(machine="AMD64") == () + + def test_the_driver_s_fixed_locations_are_read_from_the_variables_that_are_set(self) -> None: + locations = Windows().nvidia_smi_locations({"SystemRoot": "C:/Windows"}) + + assert Windows().cpu_backend_reason is None + assert locations == (Path("C:/Windows", "System32", "nvidia-smi.exe"),) diff --git a/tests/unit/scripts/bootstrap/test_cuda.py b/tests/unit/scripts/bootstrap/test_cuda.py new file mode 100644 index 000000000..cb565bfa9 --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_cuda.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Sequence, Tuple + +import pytest + +from bootstrap import cuda +from bootstrap.platforms.linux import Linux +from bootstrap.platforms.macos import MacOS +from bootstrap.platforms.windows import Windows +from bootstrap.project import GPU_CUDA11_EXTRA, GPU_EXTRA +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +def _completed( + output: str, + *, + returncode: int = 0, +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=["nvidia-smi"], returncode=returncode, stdout=output, stderr="") + + +TABLE_OUTPUT_CUDA12 = ( + "Thu Jul 21 10:00:00 2026\n" + "+-----------------------------------------------------------------------------+\n" + "| NVIDIA-SMI 550.54.14 Driver Version: 550.54.14 CUDA Version: 12.4 |\n" + "+-----------------------------------------------------------------------------+\n" +) +QUERY_OUTPUT_CUDA11 = "==============NVSMI LOG==============\n\nCUDA Version : 11.8\n" +NO_VERSION_OUTPUT = "GPU 0: NVIDIA GeForce RTX 4090 (UUID: GPU-abc)\n" + + +class TestSelectExtra(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + cuda_version: Optional[Tuple[int, int]] + expected: Optional[str] + + test_cases = ( + TestCase( + label="cuda_12_0_selects_gpu", + cuda_version=(12, 0), + expected=GPU_EXTRA, + ), + TestCase( + label="cuda_12_9_selects_gpu", + cuda_version=(12, 9), + expected=GPU_EXTRA, + ), + TestCase( + label="cuda_13_0_selects_gpu", + cuda_version=(13, 0), + expected=GPU_EXTRA, + ), + TestCase( + label="cuda_14_2_selects_gpu", + cuda_version=(14, 2), + expected=GPU_EXTRA, + ), + TestCase( + label="cuda_11_8_selects_legacy", + cuda_version=(11, 8), + expected=GPU_CUDA11_EXTRA, + ), + TestCase( + label="cuda_11_0_selects_legacy", + cuda_version=(11, 0), + expected=GPU_CUDA11_EXTRA, + ), + TestCase( + label="cuda_10_2_keeps_cpu", + cuda_version=(10, 2), + expected=None, + ), + TestCase( + label="absent_version_keeps_cpu", + cuda_version=None, + expected=None, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_select_extra(self, test_case: TestCase) -> None: + assert cuda.select_extra(test_case.cuda_version) == test_case.expected + + +class TestQueryDriverCudaVersion(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + output: str + expected: Optional[Tuple[int, int]] + + test_cases = ( + TestCase( + label="table_header", + output=TABLE_OUTPUT_CUDA12, + expected=(12, 4), + ), + TestCase( + label="query_block", + output=QUERY_OUTPUT_CUDA11, + expected=(11, 8), + ), + TestCase( + label="cuda_13", + output="CUDA Version: 13.0\n", + expected=(13, 0), + ), + TestCase( + label="no_version_present", + output=NO_VERSION_OUTPUT, + expected=None, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_parse( + self, + test_case: TestCase, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + def fake_run( + command: Sequence[str], + **_: Any, + ) -> subprocess.CompletedProcess[str]: + return _completed(test_case.output) + + monkeypatch.setattr(cuda.subprocess, "run", fake_run) + assert cuda.query_driver_cuda_version(Path("nvidia-smi")) == test_case.expected + + def test_falls_back_to_query_flag( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + def fake_run( + command: Sequence[str], + **_: Any, + ) -> subprocess.CompletedProcess[str]: + output = QUERY_OUTPUT_CUDA11 if "-q" in command else NO_VERSION_OUTPUT + return _completed(output) + + monkeypatch.setattr(cuda.subprocess, "run", fake_run) + assert cuda.query_driver_cuda_version(Path("nvidia-smi")) == (11, 8) + + def test_missing_executable_keeps_cpu( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + def fake_run( + command: Sequence[str], + **_: Any, + ) -> subprocess.CompletedProcess[str]: + raise OSError("nvidia-smi is not executable") + + monkeypatch.setattr(cuda.subprocess, "run", fake_run) + assert cuda.query_driver_cuda_version(Path("nvidia-smi")) is None + + def test_nonzero_return_code_keeps_cpu( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + def fake_run( + command: Sequence[str], + **_: Any, + ) -> subprocess.CompletedProcess[str]: + return _completed(TABLE_OUTPUT_CUDA12, returncode=9) + + monkeypatch.setattr(cuda.subprocess, "run", fake_run) + assert cuda.query_driver_cuda_version(Path("nvidia-smi")) is None + + +class TestFindNvidiaSmi: + def test_uses_path_when_present(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cuda.shutil, "which", lambda name: "/usr/bin/nvidia-smi") + + assert cuda.find_nvidia_smi(Linux(), {}) == Path("/usr/bin/nvidia-smi") + + def test_absent_on_linux(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cuda.shutil, "which", lambda name: None) + + assert cuda.find_nvidia_smi(Linux(), {}) is None + + def test_a_fixed_windows_location_stands_in_for_the_path( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + monkeypatch.setattr(cuda.shutil, "which", lambda name: None) + (tmp_path / "System32").mkdir() + (tmp_path / "System32" / "nvidia-smi.exe").write_bytes(b"") + + assert ( + cuda.find_nvidia_smi(Windows(), {"SystemRoot": str(tmp_path)}) == tmp_path / "System32" / "nvidia-smi.exe" + ) + + +class TestDetect: + def test_macos_keeps_cpu(self) -> None: + detection = cuda.detect(MacOS(), {}) + + assert detection.extra is None + assert detection.reason == MacOS().cpu_backend_reason + + def test_no_driver_keeps_cpu(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cuda.shutil, "which", lambda name: None) + + detection = cuda.detect(Linux(), {}) + + assert detection.extra is None + assert cuda.NVIDIA_SMI in detection.reason + + def test_selects_gpu_for_cuda12_driver(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cuda.shutil, "which", lambda name: "/usr/bin/nvidia-smi") + monkeypatch.setattr(cuda.subprocess, "run", lambda command, **_: _completed(TABLE_OUTPUT_CUDA12)) + + detection = cuda.detect(Linux(), {}) + + assert detection.extra == GPU_EXTRA + assert "12.4" in detection.reason diff --git a/tests/unit/scripts/bootstrap/test_files.py b/tests/unit/scripts/bootstrap/test_files.py new file mode 100644 index 000000000..d20fafc60 --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_files.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from bootstrap.files import remove_path + + +class TestRemovePath: + def test_a_directory_goes_with_everything_under_it(self, tmp_path: Path) -> None: + directory = tmp_path / "bin" + (directory / "nested").mkdir(parents=True) + (directory / "nested" / "file").write_text("") + + assert remove_path(directory) + assert not directory.exists() + + def test_a_file_goes(self, tmp_path: Path) -> None: + file = tmp_path / "sampletones.spec" + file.write_text("") + + assert remove_path(file) + assert not file.exists() + + def test_an_absent_path_is_reported_as_nothing_removed(self, tmp_path: Path) -> None: + assert not remove_path(tmp_path / "absent") diff --git a/tests/unit/scripts/bootstrap/test_interpreter.py b/tests/unit/scripts/bootstrap/test_interpreter.py new file mode 100644 index 000000000..687dd7f65 --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_interpreter.py @@ -0,0 +1,42 @@ +import subprocess +import sys +from typing import Final + +import pytest + +from bootstrap.interpreter import DOWNLOADS, REQUIRED_VERSION, require_python, running_version +from sampletones_tools.checks.paths import SCRIPTS_ROOT + +OLDER_INTERPRETER: Final[str] = "(3, 10, 0, 'final', 0)" + + +class TestRequirePython: + def test_an_interpreter_at_least_the_version_passes(self) -> None: + require_python((3, 8)) + + def test_an_older_interpreter_is_refused_with_the_download_site(self) -> None: + with pytest.raises(SystemExit) as refused: + require_python((99, 0)) + + assert DOWNLOADS in str(refused.value) + + def test_the_running_version_reads_major_minor_and_micro(self) -> None: + assert running_version() == ".".join(str(part) for part in sys.version_info[:3]) + + +class TestBootstrapGate: + def test_importing_a_helper_on_an_older_interpreter_names_the_required_version(self) -> None: + probe = f"import sys; sys.version_info = {OLDER_INTERPRETER}; import bootstrap.project" + + completed = subprocess.run( + [sys.executable, "-c", probe], + cwd=SCRIPTS_ROOT, + capture_output=True, + text=True, + check=False, + ) + + major, minor = REQUIRED_VERSION + assert completed.returncode == 1 + assert f"Python {major}.{minor} or newer is required" in completed.stderr + assert DOWNLOADS in completed.stderr diff --git a/tests/unit/scripts/bootstrap/test_layout.py b/tests/unit/scripts/bootstrap/test_layout.py new file mode 100644 index 000000000..a8308884e --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_layout.py @@ -0,0 +1,9 @@ +from bootstrap.layout import PROJECT_FILE, SOURCE_DIRECTORY, repository_root + + +class TestRepositoryRoot: + def test_the_root_holds_the_project_beside_the_sources(self) -> None: + root = repository_root() + + assert (root / PROJECT_FILE).is_file() + assert (root / SOURCE_DIRECTORY / "sampletones" / "__main__.py").is_file() diff --git a/tests/unit/scripts/bootstrap/test_passes.py b/tests/unit/scripts/bootstrap/test_passes.py new file mode 100644 index 000000000..0703b4e07 --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_passes.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from bootstrap.passes import Pass, run_pass, run_passes +from tests.suite.bootstrap import RecordingRunner + +PASSES = ( + Pass("first", "First...", ("first", "command")), + Pass("second", "Second...", ("second", "command")), + Pass("third", "Third...", ("third", "command")), +) + + +class TestRunPass: + def test_the_pass_is_announced_and_run_from_the_repository( + self, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + ) -> None: + runner = RecordingRunner({}, None) + + assert run_pass(PASSES[0], root=tmp_path, runner=runner, environment={}) == 0 + assert runner.lines == ["first command"] + assert runner.commands[0].cwd == tmp_path + assert capsys.readouterr().out == "First...\n" + + def test_the_status_is_the_command_s_own(self, tmp_path: Path) -> None: + assert run_pass(PASSES[1], root=tmp_path, runner=RecordingRunner({"second": 5}, None), environment={}) == 5 + + +class TestRunPasses: + def test_every_pass_runs_and_the_failed_ones_are_named(self, capsys: pytest.CaptureFixture[str]) -> None: + runner = RecordingRunner({"second": 1}, None) + + failed = run_passes(PASSES, root=Path("/repository"), runner=runner, environment={"PATH": "/usr/bin"}) + + assert failed == ["second"] + assert runner.lines == ["first command", "second command", "third command"] + assert all(recorded.cwd == Path("/repository") for recorded in runner.commands) + assert all(recorded.environment == {"PATH": "/usr/bin"} for recorded in runner.commands) + assert capsys.readouterr().out == "First...\nSecond...\nThird...\n" + + def test_a_clean_run_names_nothing(self) -> None: + runner = RecordingRunner({}, None) + + assert run_passes(PASSES, root=Path("/repository"), runner=runner, environment={}) == [] diff --git a/tests/unit/scripts/bootstrap/test_preflight.py b/tests/unit/scripts/bootstrap/test_preflight.py new file mode 100644 index 000000000..08f061205 --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_preflight.py @@ -0,0 +1,85 @@ +from pathlib import Path + +import pytest + +from bootstrap.platforms.linux import Linux +from bootstrap.preflight import can_import, check_build_interpreter +from tests.suite.bootstrap import RecordingRunner + + +@pytest.fixture +def python(tmp_path: Path) -> Path: + return tmp_path / "python" + + +class TestCanImport: + def test_the_probe_is_quiet_and_answers_the_status(self, python: Path, tmp_path: Path) -> None: + runner = RecordingRunner({"import tkinter": 1}, None) + + assert can_import(python, "pyaudio", runner=runner, cwd=tmp_path, environment={}) + assert not can_import(python, "tkinter", runner=runner, cwd=tmp_path, environment={}) + assert all(recorded.quiet for recorded in runner.commands) + + +class TestCheckBuildInterpreter: + def test_an_interpreter_without_audio_playback_is_refused_with_the_platform_s_advice( + self, + python: Path, + tmp_path: Path, + ) -> None: + with pytest.raises(SystemExit, match="make system-deps"): + check_build_interpreter( + python, + Linux().bundling(), + release=False, + runner=RecordingRunner({"import pyaudio": 1}, None), + cwd=tmp_path, + environment={}, + ) + + def test_a_release_without_tk_is_refused(self, python: Path, tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="tkinter"): + check_build_interpreter( + python, + Linux().bundling(), + release=True, + runner=RecordingRunner({"import tkinter": 1}, None), + cwd=tmp_path, + environment={}, + ) + + def test_a_development_bundle_without_tk_is_warned( + self, + python: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + check_build_interpreter( + python, + Linux().bundling(), + release=False, + runner=RecordingRunner({"import tkinter": 1}, None), + cwd=tmp_path, + environment={}, + ) + + assert "WARNING" in capsys.readouterr().out + + def test_an_interpreter_carrying_both_passes( + self, + python: Path, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + check_build_interpreter( + python, + Linux().bundling(), + release=True, + runner=RecordingRunner({}, None), + cwd=tmp_path, + environment={}, + ) + + output = capsys.readouterr().out + assert "pyaudio: available" in output + assert "tkinter: available" in output diff --git a/tests/unit/scripts/bootstrap/test_processes.py b/tests/unit/scripts/bootstrap/test_processes.py new file mode 100644 index 000000000..1267cadbe --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_processes.py @@ -0,0 +1,52 @@ +import os +import sys +from pathlib import Path + +import pytest + +from bootstrap.processes import expect_success, run +from tests.suite.bootstrap import RecordingRunner + + +class TestRun: + def test_the_status_is_the_command_s_own(self, tmp_path: Path) -> None: + status = run( + (sys.executable, "-c", "raise SystemExit(3)"), + cwd=tmp_path, + environment={}, + quiet=True, + ) + + assert status == 3 + + def test_what_the_script_printed_leads_the_command_s_output( + self, + tmp_path: Path, + capfd: pytest.CaptureFixture[str], + ) -> None: + print("before") + run( + (sys.executable, "-c", "print('inside')"), + cwd=tmp_path, + environment=os.environ, + quiet=False, + ) + + assert capfd.readouterr().out.splitlines() == ["before", "inside"] + + +class TestExpectSuccess: + def test_a_succeeding_command_passes(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + expect_success(runner, ("echo", "hello"), cwd=tmp_path, environment={"KEY": "value"}) + + assert runner.lines == ["echo hello"] + assert runner.commands[0].environment == {"KEY": "value"} + assert not runner.commands[0].quiet + + def test_a_failing_command_stops_the_script_naming_it(self, tmp_path: Path) -> None: + runner = RecordingRunner({"echo": 2}, None) + + with pytest.raises(SystemExit, match="echo hello exited with status 2"): + expect_success(runner, ("echo", "hello"), cwd=tmp_path, environment={}) diff --git a/tests/unit/scripts/bootstrap/test_project.py b/tests/unit/scripts/bootstrap/test_project.py new file mode 100644 index 000000000..482df6f7c --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_project.py @@ -0,0 +1,59 @@ +import tomllib +from pathlib import Path + +import pytest + +from bootstrap.layout import SOURCE_DIRECTORY, repository_root +from bootstrap.project import ( + DEVELOPMENT_GROUP, + GPU_CUDA11_EXTRA, + NAMED_EXTRAS, + NAMED_GROUPS, + TAG_PREFIX, + parse_project, + read_project, +) +from tests.suite.bootstrap import PROJECT_DOCUMENT, PROJECT_NAME, PROJECT_VERSION, write_project + + +class TestReadProject: + def test_the_repository_states_every_name_the_scripts_install(self) -> None: + project = read_project(repository_root()) + + assert set(NAMED_EXTRAS) <= set(project.extras) + assert set(NAMED_GROUPS) <= set(project.groups) + assert (repository_root() / project.entry_script).is_file() + assert all((repository_root() / SOURCE_DIRECTORY / package).is_dir() for package in project.packages) + + def test_the_facts_are_read_from_the_file(self, tmp_path: Path) -> None: + project = read_project(write_project(tmp_path)) + + assert project.name == PROJECT_NAME + assert project.version == PROJECT_VERSION + assert project.entry_module == f"{PROJECT_NAME}.__main__" + assert project.entry_script == f"{SOURCE_DIRECTORY}/{PROJECT_NAME}/__main__.py" + assert project.packages == (PROJECT_NAME, f"{PROJECT_NAME}_core") + assert project.tag == f"{TAG_PREFIX}{PROJECT_VERSION}" + + +class TestParseProject: + def test_a_missing_extra_is_refused_by_name(self) -> None: + document = tomllib.loads(PROJECT_DOCUMENT) + del document["project"]["optional-dependencies"][GPU_CUDA11_EXTRA] + + with pytest.raises(SystemExit, match=GPU_CUDA11_EXTRA): + parse_project(document) + + def test_a_missing_group_is_refused_by_name(self) -> None: + document = tomllib.loads(PROJECT_DOCUMENT) + del document["dependency-groups"][DEVELOPMENT_GROUP] + + with pytest.raises(SystemExit, match=DEVELOPMENT_GROUP): + parse_project(document) + + def test_a_project_without_its_command_is_refused(self) -> None: + document = tomllib.loads(PROJECT_DOCUMENT) + del document["project"]["scripts"] + + with pytest.raises(SystemExit, match=r"\[project.scripts\]"): + parse_project(document) diff --git a/tests/unit/scripts/bootstrap/test_venv_build.py b/tests/unit/scripts/bootstrap/test_venv_build.py new file mode 100644 index 000000000..ce8bdc9de --- /dev/null +++ b/tests/unit/scripts/bootstrap/test_venv_build.py @@ -0,0 +1,60 @@ +import sys +from pathlib import Path + +from bootstrap.layout import BUILD_ENVIRONMENT +from bootstrap.platforms.linux import Linux +from bootstrap.venv_build import ensure_build_venv, install +from tests.suite.bootstrap import RecordingRunner + + +class TestEnsureBuildVenv: + def test_a_missing_environment_is_created_by_the_running_interpreter(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + python = ensure_build_venv(tmp_path, Linux(), runner=runner, environment={}) + + assert python == Linux().interpreter(tmp_path / BUILD_ENVIRONMENT) + assert runner.lines == [f"{sys.executable} -m venv --clear {tmp_path / BUILD_ENVIRONMENT}"] + + def test_an_environment_with_its_interpreter_is_kept(self, tmp_path: Path) -> None: + python = Linux().interpreter(tmp_path / BUILD_ENVIRONMENT) + python.parent.mkdir(parents=True) + python.write_text("") + runner = RecordingRunner({}, None) + + assert ensure_build_venv(tmp_path, Linux(), runner=runner, environment={}) == python + assert runner.lines == [] + + def test_a_directory_an_interrupted_creation_left_is_created_again(self, tmp_path: Path) -> None: + (tmp_path / BUILD_ENVIRONMENT).mkdir() + runner = RecordingRunner({}, None) + + ensure_build_venv(tmp_path, Linux(), runner=runner, environment={}) + + assert runner.lines == [f"{sys.executable} -m venv --clear {tmp_path / BUILD_ENVIRONMENT}"] + + +class TestInstall: + def test_pip_is_upgraded_then_the_package_installed_with_its_extras(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + python = tmp_path / "python" + + install( + tmp_path, + python, + extras=("build", "gpu"), + runner=runner, + environment={"PATH": "/usr/bin"}, + ) + + assert runner.lines == [ + f"{python} -m pip install --upgrade pip", + f"{python} -m pip install .[build,gpu]", + ] + + def test_every_install_refuses_an_interpreter_outside_a_virtual_environment(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + install(tmp_path, tmp_path / "python", extras=("build",), runner=runner, environment={}) + + assert all(recorded.environment["PIP_REQUIRE_VIRTUALENV"] == "1" for recorded in runner.commands) diff --git a/tests/unit/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py deleted file mode 100644 index e2aa92fc7..000000000 --- a/tests/unit/scripts/checks/test_import_boundary.py +++ /dev/null @@ -1,157 +0,0 @@ -from pathlib import Path -from typing import Final, List - -import pytest - -from sampletones_shared.meta.source.modules import source_paths -from tests.suite.scripts import load_script - -check_import_boundary = load_script("checks/import_boundary.py") - -LOGIC_RULE: Final[str] = "logic/**/*.py" - -FORBIDDEN_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" -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: - assert check_import_boundary.main(["--all"]) == 0 - - def test_a_forbidden_import_is_reported_where_it_sits( - self, - tmp_path: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - path = write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT) - - exit_code = check_import_boundary.main(["--all", "--package", str(tmp_path)]) - - assert exit_code == 1 - error = capsys.readouterr().err - assert f"{path}:1" in error - assert "dearpygui" in error - - def test_named_files_narrow_the_run_to_themselves( - self, - 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) - - assert check_import_boundary.main([str(clean), "--package", 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 deleted file mode 100644 index 2056eb0d4..000000000 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ /dev/null @@ -1,155 +0,0 @@ -from pathlib import Path -from typing import Final, List, Tuple - -from pytest import fixture - -from sampletones_application.paths import PALETTES_DIRECTORY -from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths.resources import CONFIG_DIRECTORY -from scripts.checks.palette_colors import dpg_module_helper -from tests.suite.scripts import load_script -from tests.suite.source import parse_source - -check_palette_colors = load_script("checks/palette_colors.py") - -PANEL_MODULE: Final[Path] = Path("ui/panel.py") - -PANEL_SOURCE: Final[str] = """ -class GUIPanel: - def __init__(self, layout) -> None: - self._tint = layout.colors.accent.rgba - self._colors = layout.colors - cls._border: ColorRGBA = layout.colors.border.rgba - self._theme = create_theme(layout.colors.text.rgba) - local = layout.colors.text.rgba - - def draw(self) -> None: - dpg.add_text("value", color=self._colors.accent.rgba) -""" - -PALETTE_FILE: Final[str] = 'colors:\n accent: "#a97fe3"\n' -LAYOUT_FILE: Final[str] = 'label_color: .accent\nclip_color: "#ff5555"\n' - - -@fixture -def module_helpers() -> Tuple[Path, str]: - return dpg_module_helper() - - -def messages(source: str) -> List[str]: - module = SourceModule(path=PANEL_MODULE, tree=parse_source(source)) - return [finding.message for finding in check_palette_colors.stored_colors(module)] - - -def locations(source: str) -> List[str]: - module = SourceModule(path=PANEL_MODULE, tree=parse_source(source)) - return [finding.location for finding in check_palette_colors.stored_colors(module)] - - -def theme_color_messages( - source: str, - module_helpers: Tuple[Path, str], - path: Path = PANEL_MODULE, -) -> List[str]: - bindings_module, theme_color_helper = module_helpers - module = SourceModule(path=path, tree=parse_source(source)) - return [ - finding.message - for finding in check_palette_colors.unregistered_theme_colors( - module, - bindings_module=bindings_module, - theme_color_helper=theme_color_helper, - ) - ] - - -class TestStoredColors: - def test_an_attribute_assigned_the_resolved_value_is_reported(self) -> None: - assert len(messages(PANEL_SOURCE)) == 2 - - 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: - source = "class GUIPanel:\n def __init__(self, layout) -> None:\n self._c = layout.colors\n" - - assert not messages(source) - - def test_a_resolved_value_handed_straight_to_dearpygui_passes(self) -> None: - assert not messages('def draw(self) -> None:\n dpg.add_text("v", color=self._colors.accent.rgba)\n') - - def test_a_resolved_value_reaching_a_call_passes(self) -> None: - assert not messages("class P:\n def f(self, layout) -> None:\n self._t = build(layout.c.text.rgba)\n") - - def test_a_local_holding_the_resolved_value_passes(self) -> None: - assert not messages("def f(layout) -> None:\n local = layout.colors.text.rgba\n") - - -class TestUnregisteredThemeColors: - def test_a_theme_colour_filled_directly_is_reported( - self, - module_helpers: Tuple[Path, str], - ) -> None: - source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" - - assert len(theme_color_messages(source, module_helpers)) == 1 - - def test_the_helper_that_records_it_passes( - self, - module_helpers: Tuple[Path, str], - ) -> None: - source = "def build() -> None:\n dpg_add_palette_theme_color(dpg.mvThemeCol_Text, color)\n" - - assert not theme_color_messages(source, module_helpers) - - def test_the_bindings_module_may_fill_it( - self, - module_helpers: Tuple[Path, str], - ) -> None: - """The helper is where the call belongs, since it records the token in the same breath.""" - bindings_module, _ = module_helpers - source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" - - assert not theme_color_messages(source, module_helpers, bindings_module) - - def test_a_theme_style_passes( - self, - module_helpers: Tuple[Path, str], - ) -> None: - source = "def build() -> None:\n dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 0, 0)\n" - - assert not theme_color_messages(source, module_helpers) - - -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_palette_colors.APPLICATION_PACKAGE]) - - def test_the_configuration_package_holds_files_to_read(self) -> None: - assert list(CONFIG_DIRECTORY.rglob(check_palette_colors.CONFIG_PATTERN)) - - def test_the_palettes_sit_inside_the_configuration_package(self) -> None: - assert CONFIG_DIRECTORY in PALETTES_DIRECTORY.parents - - -class TestLiteralColors: - def test_a_hex_colour_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: - palettes = tmp_path / "palettes" - palettes.mkdir() - (palettes / "studio.yaml").write_text(PALETTE_FILE) - - assert check_palette_colors.find_literal_colors(tmp_path, palettes) == [] - - def test_a_token_reference_passes(self, tmp_path: Path) -> None: - (tmp_path / "settings.yaml").write_text("label_color: .accent\n") - - assert check_palette_colors.find_literal_colors(tmp_path, tmp_path / "palettes") == [] diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py deleted file mode 100644 index 06e339dcb..000000000 --- a/tests/unit/scripts/ci/checks/test_bundle.py +++ /dev/null @@ -1,198 +0,0 @@ -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Any, List, Sequence - -import pytest - -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase -from tests.suite.scripts import load_script - -check_bundle = load_script("ci/checks/bundle.py") - -NOTICES = ("LICENSE", "THIRD-PARTY-NOTICES.md", "THIRD-PARTY-LICENSES.txt") - - -@pytest.fixture -def bundle(tmp_path: Path) -> Path: - source = tmp_path / "bin" / "sampletones" - source.mkdir(parents=True) - for name in NOTICES: - (source / name).write_text(name) - - return source - - -def _install_launcher(bundle: Path) -> Path: - launcher: Path = check_bundle.launcher_path( - bundle, - system=check_bundle.platform.system(), - ) - launcher.write_bytes(b"launcher") - return launcher - - -def _stub_run( - monkeypatch: pytest.MonkeyPatch, - *, - returncode: int, -) -> List[Sequence[str]]: - commands: List[Sequence[str]] = [] - - def fake_run( - command: Sequence[str], - **_: Any, - ) -> subprocess.CompletedProcess[str]: - commands.append(command) - return subprocess.CompletedProcess( - args=list(command), - returncode=returncode, - ) - - monkeypatch.setattr(check_bundle.subprocess, "run", fake_run) - return commands - - -class TestLauncherPath(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - system: str - expected: str - - test_cases = ( - TestCase( - label="windows_launcher_carries_an_extension", - system="Windows", - expected="sampletones.exe", - ), - TestCase( - label="linux_launcher", - system="Linux", - expected="sampletones", - ), - TestCase( - label="macos_launcher", - system="Darwin", - expected="sampletones", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_launcher_path( - self, - test_case: "TestLauncherPath.TestCase", - tmp_path: Path, - ) -> None: - assert check_bundle.launcher_path(tmp_path, system=test_case.system).name == test_case.expected - - -class TestMissingNotices: - def test_a_complete_bundle_lacks_nothing(self, bundle: Path) -> None: - assert check_bundle.missing_notices(bundle) == [] - - def test_every_absent_notice_is_reported(self, bundle: Path) -> None: - (bundle / "LICENSE").unlink() - (bundle / "THIRD-PARTY-LICENSES.txt").unlink() - - assert check_bundle.missing_notices(bundle) == [ - "LICENSE", - "THIRD-PARTY-LICENSES.txt", - ] - - def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None: - (bundle / "LICENSE").unlink() - (bundle / "LICENSE").mkdir() - - assert check_bundle.missing_notices(bundle) == ["LICENSE"] - - -class TestCarriedBuildTools: - def test_an_application_bundle_holds_to_its_notices(self, bundle: Path) -> None: - assert check_bundle.carried_build_tools(bundle) == [] - - def test_a_build_tool_beside_the_application_is_reported(self, bundle: Path) -> None: - (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) - - assert check_bundle.carried_build_tools(bundle) == ["PIL"] - - def test_a_build_tool_beside_the_launcher_is_reported(self, bundle: Path) -> None: - (bundle / "PIL").mkdir() - - assert check_bundle.carried_build_tools(bundle) == ["PIL"] - - -class TestMain: - def test_a_complete_bundle_passes( - self, - bundle: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - launcher = _install_launcher(bundle) - commands = _stub_run(monkeypatch, returncode=0) - - assert check_bundle.main([str(bundle)]) == 0 - assert commands == [[str(launcher), "--version"]] - - def test_a_missing_notice_is_annotated_as_an_error( - self, - bundle: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - _install_launcher(bundle) - (bundle / "THIRD-PARTY-NOTICES.md").unlink() - - assert check_bundle.main([str(bundle)]) == 1 - - output = capsys.readouterr().out - assert output.startswith("::error::") - assert "THIRD-PARTY-NOTICES.md" in output - - def test_bundled_build_tooling_is_annotated_as_an_error( - self, - bundle: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - _install_launcher(bundle) - (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) - - assert check_bundle.main([str(bundle)]) == 1 - - output = capsys.readouterr().out - assert output.startswith("::error::") - assert "PIL" in output - - def test_a_missing_launcher_is_annotated_as_an_error( - self, - bundle: Path, - capsys: pytest.CaptureFixture[str], - ) -> None: - assert check_bundle.main([str(bundle)]) == 1 - assert capsys.readouterr().out.startswith("::error::") - - def test_a_launcher_that_fails_propagates_its_status( - self, - bundle: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - _install_launcher(bundle) - _stub_run(monkeypatch, returncode=3) - - assert check_bundle.main([str(bundle)]) == 3 - - def test_the_launcher_runs_only_once_the_notices_are_present( - self, - bundle: Path, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - _install_launcher(bundle) - (bundle / "LICENSE").unlink() - commands = _stub_run(monkeypatch, returncode=0) - - check_bundle.main([str(bundle)]) - - assert commands == [] diff --git a/tests/unit/scripts/ci/checks/test_version_tag.py b/tests/unit/scripts/ci/checks/test_version_tag.py deleted file mode 100644 index 77725fb10..000000000 --- a/tests/unit/scripts/ci/checks/test_version_tag.py +++ /dev/null @@ -1,139 +0,0 @@ -from dataclasses import dataclass - -import pytest - -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase -from tests.suite.scripts import load_script - -check_version_tag = load_script("ci/checks/version_tag.py") - - -class TestVersionFromTag(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - tag: str - expected: str - - test_cases = ( - TestCase( - label="release_tag", - tag="v0.3.0", - expected="0.3.0", - ), - TestCase( - label="prerelease_tag", - tag="v0.3.0.dev1", - expected="0.3.0.dev1", - ), - TestCase( - label="release_candidate", - tag="v1.0.0rc2", - expected="1.0.0rc2", - ), - TestCase( - label="bare_version", - tag="0.3.0", - expected="0.3.0", - ), - TestCase( - label="single_prefix_is_dropped", - tag="vv0.3.0", - expected="v0.3.0", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_version_from_tag(self, test_case: TestCase) -> None: - assert check_version_tag.version_from_tag(test_case.tag) == test_case.expected - - -class TestTagNamesVersion(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - tag: str - project_version: str - expected: bool - - test_cases = ( - TestCase( - label="tag_matches", - tag="v0.3.0", - project_version="0.3.0", - expected=True, - ), - TestCase( - label="prerelease_matches", - tag="v0.3.0.dev1", - project_version="0.3.0.dev1", - expected=True, - ), - TestCase( - label="patch_differs", - tag="v0.3.1", - project_version="0.3.0", - expected=False, - ), - TestCase( - label="project_ahead_of_tag", - tag="v0.3.0", - project_version="0.4.0", - expected=False, - ), - TestCase( - label="prerelease_against_release", - tag="v0.3.0", - project_version="0.3.0.dev1", - expected=False, - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_tag_names_version( - self, - test_case: TestCase, - ) -> None: - result = check_version_tag.tag_names_version( - tag=test_case.tag, - project_version=test_case.project_version, - ) - - assert result is test_case.expected - - -class TestMain: - def test_matching_version_succeeds( - self, - capsys: pytest.CaptureFixture[str], - ) -> None: - assert ( - check_version_tag.main( - ["--tag", "v0.3.0", "--project-version", "0.3.0"], - ) - == 0 - ) - assert "matches" in capsys.readouterr().out - - def test_mismatched_version_is_annotated_as_an_error( - self, - capsys: pytest.CaptureFixture[str], - ) -> None: - assert ( - check_version_tag.main( - ["--tag", "v0.3.1", "--project-version", "0.3.0"], - ) - == 1 - ) - - output = capsys.readouterr().out - assert output.startswith("::error::") - assert "v0.3.1" in output - assert "0.3.0" in output diff --git a/tests/unit/scripts/ci/test_zip_bundle.py b/tests/unit/scripts/ci/test_zip_bundle.py deleted file mode 100644 index a5c40633d..000000000 --- a/tests/unit/scripts/ci/test_zip_bundle.py +++ /dev/null @@ -1,115 +0,0 @@ -import zipfile -from pathlib import Path -from typing import List - -import pytest - -from tests.suite.scripts import load_script - -zip_bundle = load_script("ci/zip_bundle.py") - -ROOT = "sampletones-v0.3.0-windows-x86_64" - -LAUNCHER = "sampletones.exe" -LIBRARY = "_internal/python312.dll" -NOTICES = "THIRD-PARTY-NOTICES.md" - - -@pytest.fixture -def bundle(tmp_path: Path) -> Path: - source = tmp_path / "bin" / "sampletones" - (source / "_internal" / "empty").mkdir(parents=True) - (source / LAUNCHER).write_bytes(b"MZ") - (source / LIBRARY).write_bytes(b"library") - (source / NOTICES).write_text("notices") - return source - - -@pytest.fixture -def archive(tmp_path: Path) -> Path: - return tmp_path / "bundles" / f"{ROOT}.zip" - - -def _names(archive: Path) -> List[str]: - with zipfile.ZipFile(archive) as bundle: - return bundle.namelist() - - -class TestWriteArchive: - def test_every_entry_sits_under_the_root_directory(self, bundle: Path, archive: Path) -> None: - zip_bundle.write_archive(bundle, archive, root=ROOT) - - names = _names(archive) - assert names - assert all(name.startswith(f"{ROOT}/") for name in names) - - def test_bundle_contents_are_archived(self, bundle: Path, archive: Path) -> None: - zip_bundle.write_archive(bundle, archive, root=ROOT) - - names = _names(archive) - assert f"{ROOT}/{LAUNCHER}" in names - assert f"{ROOT}/{LIBRARY}" in names - assert f"{ROOT}/{NOTICES}" in names - - def test_empty_directories_are_kept(self, bundle: Path, archive: Path) -> None: - zip_bundle.write_archive(bundle, archive, root=ROOT) - - assert f"{ROOT}/_internal/empty/" in _names(archive) - - def test_file_contents_survive_the_round_trip(self, bundle: Path, archive: Path) -> None: - zip_bundle.write_archive(bundle, archive, root=ROOT) - - with zipfile.ZipFile(archive) as written: - assert written.read(f"{ROOT}/{LIBRARY}") == b"library" - - def test_missing_target_directory_is_created(self, bundle: Path, archive: Path) -> None: - zip_bundle.write_archive(bundle, archive, root=ROOT) - - assert archive.is_file() - - def test_repeated_runs_archive_the_same_order(self, bundle: Path, tmp_path: Path) -> None: - first = tmp_path / "first.zip" - second = tmp_path / "second.zip" - - zip_bundle.write_archive(bundle, first, root=ROOT) - zip_bundle.write_archive(bundle, second, root=ROOT) - - assert _names(first) == _names(second) - - def test_source_directory_is_left_in_place(self, bundle: Path, archive: Path) -> None: - """Archiving reads the bundle where it lies, so the build output keeps its own name.""" - zip_bundle.write_archive(bundle, archive, root=ROOT) - - assert bundle.is_dir() - assert (bundle / LAUNCHER).is_file() - - def test_archives_while_a_handle_is_held_inside_the_bundle(self, bundle: Path, archive: Path) -> None: - """A process that ran the executable, or a virus scanner reading it, leaves the archive reachable. - - Windows refuses to rename a directory holding an open file, which is the condition this - reproduces; reading each entry in place keeps the bundle archivable throughout. - """ - - with (bundle / LIBRARY).open("rb"): - zip_bundle.write_archive(bundle, archive, root=ROOT) - - assert f"{ROOT}/{LIBRARY}" in _names(archive) - - -class TestMain: - def test_reports_success_for_a_built_bundle(self, bundle: Path, archive: Path) -> None: - assert zip_bundle.main([str(bundle), str(archive), "--root", ROOT]) == 0 - assert archive.is_file() - - def test_reports_a_missing_bundle_directory(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - absent = tmp_path / "bin" / "sampletones" - - assert zip_bundle.main([str(absent), str(tmp_path / "out.zip"), "--root", ROOT]) == 1 - assert "::error::" in capsys.readouterr().out - - def test_a_missing_bundle_writes_no_archive(self, tmp_path: Path) -> None: - archive = tmp_path / "out.zip" - - zip_bundle.main([str(tmp_path / "absent"), str(archive), "--root", ROOT]) - - assert not archive.exists() diff --git a/tests/unit/scripts/test_archive_bundle.py b/tests/unit/scripts/test_archive_bundle.py new file mode 100644 index 000000000..47bfd7a46 --- /dev/null +++ b/tests/unit/scripts/test_archive_bundle.py @@ -0,0 +1,133 @@ +import zipfile +from pathlib import Path +from typing import List + +import pytest + +from bootstrap.layout import BUNDLES, DISTRIBUTION +from bootstrap.platforms.macos import MacOS +from bootstrap.platforms.windows import Windows +from bootstrap.project import TAG_PREFIX, read_project +from tests.suite.bootstrap import PROJECT_NAME, PROJECT_VERSION, write_project +from tests.suite.scripts import load_script + +archive_bundle = load_script("archive_bundle.py") + +LABEL = "windows-x86_64" +ROOT = f"{PROJECT_NAME}-{TAG_PREFIX}{PROJECT_VERSION}-{LABEL}" + +LAUNCHER = "sampletones.exe" +LIBRARY = "_internal/python312.dll" +NOTICES = "THIRD-PARTY-NOTICES.md" + + +@pytest.fixture +def bundle(tmp_path: Path) -> Path: + source = tmp_path / "bin" / "sampletones" + (source / "_internal" / "empty").mkdir(parents=True) + (source / LAUNCHER).write_bytes(b"MZ") + (source / LIBRARY).write_bytes(b"library") + (source / NOTICES).write_text("notices") + return source + + +@pytest.fixture +def archive(tmp_path: Path) -> Path: + return tmp_path / "bundles" / f"{ROOT}.zip" + + +def _names(archive: Path) -> List[str]: + with zipfile.ZipFile(archive) as bundle: + return bundle.namelist() + + +class TestWriteArchive: + def test_every_entry_sits_under_the_root_directory(self, bundle: Path, archive: Path) -> None: + archive_bundle.write_archive(bundle, archive, root=ROOT) + + names = _names(archive) + assert names + assert all(name.startswith(f"{ROOT}/") for name in names) + + def test_bundle_contents_are_archived(self, bundle: Path, archive: Path) -> None: + archive_bundle.write_archive(bundle, archive, root=ROOT) + + names = _names(archive) + assert f"{ROOT}/{LAUNCHER}" in names + assert f"{ROOT}/{LIBRARY}" in names + assert f"{ROOT}/{NOTICES}" in names + + def test_empty_directories_are_kept(self, bundle: Path, archive: Path) -> None: + archive_bundle.write_archive(bundle, archive, root=ROOT) + + assert f"{ROOT}/_internal/empty/" in _names(archive) + + def test_file_contents_survive_the_round_trip(self, bundle: Path, archive: Path) -> None: + archive_bundle.write_archive(bundle, archive, root=ROOT) + + with zipfile.ZipFile(archive) as written: + assert written.read(f"{ROOT}/{LIBRARY}") == b"library" + + def test_missing_target_directory_is_created(self, bundle: Path, archive: Path) -> None: + archive_bundle.write_archive(bundle, archive, root=ROOT) + + assert archive.is_file() + + def test_repeated_runs_archive_the_same_order(self, bundle: Path, tmp_path: Path) -> None: + first = tmp_path / "first.zip" + second = tmp_path / "second.zip" + + archive_bundle.write_archive(bundle, first, root=ROOT) + archive_bundle.write_archive(bundle, second, root=ROOT) + + assert _names(first) == _names(second) + + def test_source_directory_is_left_in_place(self, bundle: Path, archive: Path) -> None: + """Archiving reads the bundle where it lies, so the build output keeps its own name.""" + archive_bundle.write_archive(bundle, archive, root=ROOT) + + assert bundle.is_dir() + assert (bundle / LAUNCHER).is_file() + + def test_archives_while_a_handle_is_held_inside_the_bundle(self, bundle: Path, archive: Path) -> None: + """A process that ran the executable, or a virus scanner reading it, leaves the archive reachable. + + Windows refuses to rename a directory holding an open file, which is the condition this + reproduces; reading each entry in place keeps the bundle archivable throughout. + """ + + with (bundle / LIBRARY).open("rb"): + archive_bundle.write_archive(bundle, archive, root=ROOT) + + assert f"{ROOT}/{LIBRARY}" in _names(archive) + + +class TestArchiveRoot: + def test_the_root_names_the_project_its_version_and_the_platform(self, tmp_path: Path) -> None: + assert archive_bundle.archive_root(read_project(write_project(tmp_path)), LABEL) == ROOT + + +class TestArchiveRelease: + def test_the_release_bundle_is_archived_into_the_bundles_directory(self, tmp_path: Path) -> None: + root = write_project(tmp_path / "repository") + launcher = Windows().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, release=True) + launcher.parent.mkdir(parents=True) + launcher.write_bytes(b"MZ") + + assert archive_bundle.archive_release(root, Windows(), label=LABEL) == 0 + assert _names(root / BUNDLES / f"{ROOT}.zip") == [f"{ROOT}/{launcher.name}"] + + def test_a_missing_bundle_is_annotated_and_writes_no_archive( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + root = write_project(tmp_path) + + assert archive_bundle.archive_release(root, Windows(), label=LABEL) == 1 + assert "::error::" in capsys.readouterr().out + assert not (root / BUNDLES).exists() + + def test_a_system_without_bundles_is_refused(self, tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="make setup"): + archive_bundle.archive_release(write_project(tmp_path), MacOS(), label=LABEL) diff --git a/tests/unit/scripts/test_build_environment.py b/tests/unit/scripts/test_build_environment.py new file mode 100644 index 000000000..738d962a1 --- /dev/null +++ b/tests/unit/scripts/test_build_environment.py @@ -0,0 +1,33 @@ +import pytest + +from bootstrap.platforms.linux import Linux +from bootstrap.platforms.macos import MacOS +from tests.suite.scripts import load_script + +build_environment = load_script("build_environment.py") + +PORTAUDIO_PREFIX = "/opt/homebrew/opt/portaudio" + + +class TestMain: + def test_the_platform_s_flags_are_printed_one_per_line( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.setattr(build_environment, "current_platform", MacOS) + monkeypatch.setattr(MacOS, "homebrew_prefix", staticmethod(lambda package: PORTAUDIO_PREFIX)) + monkeypatch.setattr(build_environment.running, "machine", lambda: "arm64") + + assert build_environment.main([]) == 0 + assert capsys.readouterr().out.splitlines() == list(MacOS().build_flags(machine="arm64")) + + def test_linux_prints_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + monkeypatch.setattr(build_environment, "current_platform", Linux) + + assert build_environment.main([]) == 0 + assert capsys.readouterr().out == "" diff --git a/tests/unit/scripts/test_bundle.py b/tests/unit/scripts/test_bundle.py new file mode 100644 index 000000000..cb4422d80 --- /dev/null +++ b/tests/unit/scripts/test_bundle.py @@ -0,0 +1,157 @@ +from pathlib import Path +from typing import Callable, Sequence + +import pytest + +from bootstrap.layout import BUILD_ENVIRONMENT, BUILD_TOOLS, DISTRIBUTION, NOTICES, RELEASE_HOOK +from bootstrap.platforms.linux import Linux +from bootstrap.platforms.macos import MacOS +from bootstrap.platforms.windows import Windows +from bootstrap.project import BUILD_EXTRA, GPU_EXTRA, read_project +from tests.suite.bootstrap import PROJECT_NAME, RecordingRunner, write_project +from tests.suite.scripts import load_script + +bundle = load_script("bundle.py") + + +def _repository(tmp_path: Path) -> Path: + for notice in NOTICES: + (tmp_path / notice).write_text(notice) + + return write_project(tmp_path) + + +class TestPyInstallerCommand: + def test_a_release_is_a_directory_with_the_runtime_hook(self, tmp_path: Path) -> None: + project = read_project(_repository(tmp_path)) + options = bundle.BundleOptions(release=True, gpu=False) + + command = bundle.pyinstaller_command(Path("python"), Linux().bundling(), project, options) + + assert command[:3] == ["python", "-m", "PyInstaller"] + assert "--onedir" in command + assert command[command.index("--runtime-hook") + 1] == RELEASE_HOOK + assert command[-1] == project.entry_script + + def test_a_development_bundle_is_one_file_without_the_hook(self, tmp_path: Path) -> None: + project = read_project(_repository(tmp_path)) + + command = bundle.pyinstaller_command( + Path("python"), + Linux().bundling(), + project, + bundle.BundleOptions(release=False, gpu=False), + ) + + assert "--onefile" in command + assert "--runtime-hook" not in command + + def test_every_package_brings_its_data_and_the_build_tools_stay_out(self, tmp_path: Path) -> None: + project = read_project(_repository(tmp_path)) + + command = bundle.pyinstaller_command( + Path("python"), + Windows().bundling(), + project, + bundle.BundleOptions(release=False, gpu=False), + ) + + collected = [command[index + 1] for index, flag in enumerate(command) if flag == "--collect-data"] + excluded = [command[index + 1] for index, flag in enumerate(command) if flag == "--exclude-module"] + assert collected == list(project.packages) + assert excluded == list(BUILD_TOOLS) + assert command[command.index("--icon") + 1] == Windows().bundling().icon + assert command[command.index("--name") + 1] == project.name + + +class TestExtras: + def test_the_build_extra_is_always_installed_and_gpu_on_request(self) -> None: + assert bundle.extras(bundle.BundleOptions(release=False, gpu=False)) == (BUILD_EXTRA,) + assert bundle.extras(bundle.BundleOptions(release=False, gpu=True)) == (BUILD_EXTRA, GPU_EXTRA) + + +class TestRemovePrevious: + def test_a_previous_file_and_directory_are_removed(self, tmp_path: Path) -> None: + (tmp_path / PROJECT_NAME).mkdir() + (tmp_path / f"{PROJECT_NAME}.exe").write_text("") + + bundle.remove_previous(tmp_path, Windows().bundling(), PROJECT_NAME) + + assert list(tmp_path.iterdir()) == [] + + +def _leave_behind(root: Path, launcher: Path) -> Callable[[Sequence[str]], None]: + """What a build leaves on disk: the environment's interpreter, and the launcher where PyInstaller writes it.""" + + def on_run(command: Sequence[str]) -> None: + if "venv" in command: + python = Linux().interpreter(root / BUILD_ENVIRONMENT) + python.parent.mkdir(parents=True) + python.write_text("") + + if "PyInstaller" in command: + launcher.parent.mkdir(parents=True, exist_ok=True) + launcher.write_text("") + + return on_run + + +class TestBuildBundle: + def test_a_release_runs_every_step_in_order_and_places_the_notices(self, tmp_path: Path) -> None: + root = _repository(tmp_path) + launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, release=True) + runner = RecordingRunner({}, _leave_behind(root, launcher)) + + built = bundle.build_bundle( + root, Linux(), bundle.BundleOptions(release=True, gpu=False), runner=runner, environment={} + ) + + assert built == launcher + assert runner.lines[0].endswith(BUILD_ENVIRONMENT) + assert "pip install --upgrade pip" in runner.lines[1] + assert f".[{BUILD_EXTRA}]" in runner.lines[2] + assert "import pyaudio" in runner.lines[3] + assert "import tkinter" in runner.lines[4] + assert "PyInstaller" in runner.lines[5] + assert runner.lines[6] == f"{launcher} {bundle.SELF_CHECK}" + assert all((launcher.parent / notice).read_text() == notice for notice in NOTICES) + + def test_a_bundle_pyinstaller_never_wrote_is_reported(self, tmp_path: Path) -> None: + root = _repository(tmp_path) + elsewhere = tmp_path / "elsewhere" / PROJECT_NAME + + with pytest.raises(SystemExit, match="produced no executable"): + bundle.build_bundle( + root, + Linux(), + bundle.BundleOptions(release=False, gpu=False), + runner=RecordingRunner({}, _leave_behind(root, elsewhere)), + environment={}, + ) + + def test_a_launcher_failing_its_self_check_fails_the_build(self, tmp_path: Path) -> None: + root = _repository(tmp_path) + launcher = Linux().bundling().launcher(root / DISTRIBUTION, name=PROJECT_NAME, release=False) + + with pytest.raises(SystemExit, match=bundle.SELF_CHECK): + bundle.build_bundle( + root, + Linux(), + bundle.BundleOptions(release=False, gpu=False), + runner=RecordingRunner({bundle.SELF_CHECK: 1}, _leave_behind(root, launcher)), + environment={}, + ) + + def test_a_system_without_bundles_is_told_to_run_from_source_before_anything_runs(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + with pytest.raises(SystemExit, match="make setup"): + bundle.build_bundle( + _repository(tmp_path), + MacOS(), + bundle.BundleOptions(release=False, gpu=False), + runner=runner, + environment={}, + ) + + assert runner.lines == [] diff --git a/tests/unit/scripts/test_clean.py b/tests/unit/scripts/test_clean.py new file mode 100644 index 000000000..5d65aa15e --- /dev/null +++ b/tests/unit/scripts/test_clean.py @@ -0,0 +1,46 @@ +from pathlib import Path + +from tests.suite.scripts import load_script + +clean = load_script("clean.py") + + +def _tree(root: Path) -> None: + for directory in ( + "bin", + "build", + "dist", + "htmlcov", + "src/__pycache__", + "src/sampletones.egg-info", + ".venv/__pycache__", + ): + (root / directory).mkdir(parents=True) + + for file in (".coverage", "sampletones.spec", "src/module.pyc", "src/module.py", ".venv/cached.pyc"): + (root / file).write_text("") + + +class TestRemoveArtifacts: + def test_the_build_outputs_and_reports_go(self, tmp_path: Path) -> None: + _tree(tmp_path) + + clean.remove_artifacts(tmp_path) + + assert not any((tmp_path / name).exists() for name in ("bin", "build", "dist", "htmlcov", ".coverage")) + assert not (tmp_path / "sampletones.spec").exists() + assert (tmp_path / "src" / "module.py").exists() + + +class TestRemoveCaches: + def test_the_caches_go_and_the_environments_stay(self, tmp_path: Path) -> None: + _tree(tmp_path) + + clean.remove_caches(tmp_path) + + assert not (tmp_path / "src" / "__pycache__").exists() + assert not (tmp_path / "src" / "sampletones.egg-info").exists() + assert not (tmp_path / "src" / "module.pyc").exists() + assert (tmp_path / "src" / "module.py").exists() + assert (tmp_path / ".venv" / "__pycache__").exists() + assert (tmp_path / ".venv" / "cached.pyc").exists() diff --git a/tests/unit/scripts/test_detect_cuda.py b/tests/unit/scripts/test_detect_cuda.py deleted file mode 100644 index 60012eb6d..000000000 --- a/tests/unit/scripts/test_detect_cuda.py +++ /dev/null @@ -1,224 +0,0 @@ -from __future__ import annotations - -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Optional, Sequence, Tuple - -import pytest - -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase -from tests.suite.scripts import load_script - -detect_cuda = load_script("detect_cuda.py") - - -def _completed( - output: str, - *, - returncode: int = 0, -) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess(args=["nvidia-smi"], returncode=returncode, stdout=output, stderr="") - - -TABLE_OUTPUT_CUDA12 = ( - "Thu Jul 21 10:00:00 2026\n" - "+-----------------------------------------------------------------------------+\n" - "| NVIDIA-SMI 550.54.14 Driver Version: 550.54.14 CUDA Version: 12.4 |\n" - "+-----------------------------------------------------------------------------+\n" -) -QUERY_OUTPUT_CUDA11 = "==============NVSMI LOG==============\n\nCUDA Version : 11.8\n" -NO_VERSION_OUTPUT = "GPU 0: NVIDIA GeForce RTX 4090 (UUID: GPU-abc)\n" - - -class TestSelectExtra(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - cuda_version: Optional[Tuple[int, int]] - expected: Optional[str] - - test_cases = ( - TestCase( - label="cuda_12_0_selects_gpu", - cuda_version=(12, 0), - expected="gpu", - ), - TestCase( - label="cuda_12_9_selects_gpu", - cuda_version=(12, 9), - expected="gpu", - ), - TestCase( - label="cuda_13_0_selects_gpu", - cuda_version=(13, 0), - expected="gpu", - ), - TestCase( - label="cuda_14_2_selects_gpu", - cuda_version=(14, 2), - expected="gpu", - ), - TestCase( - label="cuda_11_8_selects_legacy", - cuda_version=(11, 8), - expected="gpu-cuda11", - ), - TestCase( - label="cuda_11_0_selects_legacy", - cuda_version=(11, 0), - expected="gpu-cuda11", - ), - TestCase( - label="cuda_10_2_keeps_cpu", - cuda_version=(10, 2), - expected=None, - ), - TestCase( - label="absent_version_keeps_cpu", - cuda_version=None, - expected=None, - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_select_extra(self, test_case: TestCase) -> None: - assert detect_cuda.select_extra(test_case.cuda_version) == test_case.expected - - -class TestQueryDriverCudaVersion(BaseTestSuite): - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - output: str - expected: Optional[Tuple[int, int]] - - test_cases = ( - TestCase( - label="table_header", - output=TABLE_OUTPUT_CUDA12, - expected=(12, 4), - ), - TestCase( - label="query_block", - output=QUERY_OUTPUT_CUDA11, - expected=(11, 8), - ), - TestCase( - label="cuda_13", - output="CUDA Version: 13.0\n", - expected=(13, 0), - ), - TestCase( - label="no_version_present", - output=NO_VERSION_OUTPUT, - expected=None, - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_parse( - self, - test_case: TestCase, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - def fake_run( - command: Sequence[str], - **_: Any, - ) -> subprocess.CompletedProcess[str]: - return _completed(test_case.output) - - monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) - assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) == test_case.expected - - def test_falls_back_to_query_flag( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - def fake_run( - command: Sequence[str], - **_: Any, - ) -> subprocess.CompletedProcess[str]: - output = QUERY_OUTPUT_CUDA11 if "-q" in command else NO_VERSION_OUTPUT - return _completed(output) - - monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) - assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) == (11, 8) - - def test_missing_executable_keeps_cpu( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - def fake_run( - command: Sequence[str], - **_: Any, - ) -> subprocess.CompletedProcess[str]: - raise OSError("nvidia-smi is not executable") - - monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) - assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) is None - - def test_nonzero_return_code_keeps_cpu( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - def fake_run( - command: Sequence[str], - **_: Any, - ) -> subprocess.CompletedProcess[str]: - return _completed(TABLE_OUTPUT_CUDA12, returncode=9) - - monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) - assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) is None - - -class TestFindNvidiaSmi: - def test_uses_path_when_present(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - detect_cuda.shutil, - "which", - lambda name: "/usr/bin/nvidia-smi", - ) - assert detect_cuda.find_nvidia_smi(system="Linux") == Path("/usr/bin/nvidia-smi") - - def test_absent_on_linux(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(detect_cuda.shutil, "which", lambda name: None) - assert detect_cuda.find_nvidia_smi(system="Linux") is None - - -class TestDetect: - def test_macos_keeps_cpu(self) -> None: - detection = detect_cuda.detect(system="Darwin") - assert detection.extra is None - assert detection.cuda_version is None - - def test_no_driver_keeps_cpu(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(detect_cuda.shutil, "which", lambda name: None) - detection = detect_cuda.detect(system="Linux") - assert detection.extra is None - assert detection.nvidia_smi is None - - def test_selects_gpu_for_cuda12_driver( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setattr( - detect_cuda.shutil, - "which", - lambda name: "/usr/bin/nvidia-smi", - ) - monkeypatch.setattr( - detect_cuda.subprocess, - "run", - lambda command, **_: _completed(TABLE_OUTPUT_CUDA12), - ) - detection = detect_cuda.detect(system="Linux") - assert detection.cuda_version == (12, 4) - assert detection.extra == "gpu" diff --git a/tests/unit/scripts/test_formatting.py b/tests/unit/scripts/test_formatting.py new file mode 100644 index 000000000..691ee31b1 --- /dev/null +++ b/tests/unit/scripts/test_formatting.py @@ -0,0 +1,36 @@ +from pathlib import Path + +import pytest + +from tests.suite.bootstrap import RecordingRunner +from tests.suite.scripts import load_script + +formatting = load_script("formatting.py") + + +class TestFormattedPaths: + def test_the_three_trees_are_formatted_by_default(self) -> None: + assert formatting.formatted_paths(()) == formatting.FORMATTED_TREES + + def test_named_paths_replace_the_trees(self) -> None: + assert formatting.formatted_paths(("scripts/lint.py",)) == ("scripts/lint.py",) + + +class TestFormatCode: + def test_isort_runs_before_black(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + formatting.format_code(tmp_path, formatting.FORMATTED_TREES, runner=runner, environment={}) + + assert runner.lines == [ + " ".join((*formatting.ISORT, *formatting.FORMATTED_TREES)), + " ".join((*formatting.BLACK, *formatting.FORMATTED_TREES)), + ] + + def test_a_failing_formatter_stops_the_run(self, tmp_path: Path) -> None: + runner = RecordingRunner({"isort": 1}, None) + + with pytest.raises(SystemExit, match="isort"): + formatting.format_code(tmp_path, formatting.FORMATTED_TREES, runner=runner, environment={}) + + assert len(runner.lines) == 1 diff --git a/tests/unit/scripts/test_hooks.py b/tests/unit/scripts/test_hooks.py new file mode 100644 index 000000000..1364dcdbb --- /dev/null +++ b/tests/unit/scripts/test_hooks.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from tests.suite.bootstrap import RecordingRunner +from tests.suite.scripts import load_script + +hooks = load_script("hooks.py") + + +class TestInstallHooks: + def test_both_hook_stages_are_installed_from_the_repository(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + hooks.install_hooks(tmp_path, runner=runner, environment={}) + + assert runner.lines == [" ".join(hooks.INSTALL_HOOKS)] + assert "--hook-type pre-commit --hook-type pre-push" in runner.lines[0] + assert runner.commands[0].cwd == tmp_path diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py new file mode 100644 index 000000000..5f517e2de --- /dev/null +++ b/tests/unit/scripts/test_lint.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import pytest + +from tests.suite.bootstrap import RecordingRunner +from tests.suite.scripts import load_script + +lint = load_script("lint.py") + + +class TestLinters: + def test_mypy_reads_its_configuration_and_pylint_sweeps_the_trees(self) -> None: + mypy, pylint = lint.linters(()) + + assert mypy.command == ("uv", "run", "python", "-m", lint.MYPY) + assert pylint.command == ("uv", "run", "python", "-m", lint.PYLINT, *lint.LINTED_TREES) + + def test_named_paths_reach_both(self) -> None: + mypy, pylint = lint.linters(("scripts/lint.py",)) + + assert mypy.command[-1] == "scripts/lint.py" + assert pylint.command[-1] == "scripts/lint.py" + + +class TestChosenLinters: + def test_both_linters_run_by_default(self) -> None: + assert [linter.name for linter in lint.chosen_linters((), mypy=False, pylint=False)] == [lint.MYPY, lint.PYLINT] + + def test_a_flag_picks_one_linter(self) -> None: + assert [linter.name for linter in lint.chosen_linters((), mypy=False, pylint=True)] == [lint.PYLINT] + + +class TestLintCode: + def test_every_linter_passing_is_reported(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + runner = RecordingRunner({}, None) + + assert lint.lint_code(tmp_path, lint.linters(()), runner=runner, environment={}) == 0 + assert len(runner.lines) == 2 + assert "All linting checks passed." in capsys.readouterr().out + + def test_a_failing_linter_stops_nothing_and_is_named( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + runner = RecordingRunner({lint.MYPY: 1}, None) + + assert lint.lint_code(tmp_path, lint.linters(()), runner=runner, environment={}) == 1 + assert len(runner.lines) == 2 + assert f"Linting failed: {lint.MYPY}." in capsys.readouterr().out diff --git a/tests/unit/scripts/test_run_tests.py b/tests/unit/scripts/test_run_tests.py new file mode 100644 index 000000000..e8d717990 --- /dev/null +++ b/tests/unit/scripts/test_run_tests.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from bootstrap.layout import BENCHMARKS_DIRECTORY, SCREENS_DIRECTORY +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.scripts import load_script + +run_tests = load_script("run_tests.py") + + +class TestPlannedPasses: + def test_every_pass_is_found_under_its_name(self) -> None: + passes = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS) + + assert all(name == current.name for name, current in passes.items()) + assert set(passes) == {run_tests.SUITE, run_tests.DOCTESTS, run_tests.BENCHMARKS, run_tests.SCREENS} + + def test_the_suite_is_covered_across_the_workers_and_leaves_the_other_passes_out(self) -> None: + command = run_tests.planned_passes("auto", run_tests.SCREEN_WORKERS)[run_tests.SUITE].command + + assert command[-5:] == ( + "-n", + "auto", + "--cov", + f"--ignore={BENCHMARKS_DIRECTORY}", + f"--ignore={SCREENS_DIRECTORY}", + ) + + def test_the_screen_scenarios_run_across_their_own_workers_uncovered(self) -> None: + command = run_tests.planned_passes("auto", "2")[run_tests.SCREENS].command + + assert SCREENS_DIRECTORY in command + assert command[-3:] == ("-n", "2", "--no-cov") + + def test_the_doctests_read_the_sources_uncovered(self) -> None: + command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS)[ + run_tests.DOCTESTS + ].command + + assert "--doctest-modules" in command + assert "--no-cov" in command + + def test_the_benchmarks_run_serial_uncovered_and_show_their_readings(self) -> None: + command = run_tests.planned_passes(run_tests.DEFAULT_WORKERS, run_tests.SCREEN_WORKERS)[ + run_tests.BENCHMARKS + ].command + + assert BENCHMARKS_DIRECTORY in command + assert "--no-cov" in command + assert "-s" in command + assert "-n" not in command + + +class TestRunEnvironment: + def test_a_shard_reaches_the_screen_scenarios_plugin(self) -> None: + environment = run_tests.run_environment({"DISPLAY": ":1"}, shard="2/3") + + assert environment[run_tests.SHARD_VARIABLE] == "2/3" + assert environment["DISPLAY"] == ":1" + + def test_without_a_shard_the_environment_travels_as_it_is(self) -> None: + assert run_tests.run_environment({"DISPLAY": ":1"}, shard=None) == {"DISPLAY": ":1"} + + +class TestRefusedPasses(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + argv: Tuple[str, ...] + + test_cases = ( + TestCase(label="a missing pass", argv=()), + TestCase(label="an unknown pass", argv=("everything",)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_pass_outside_the_planned_ones_is_refused(self, test_case: TestCase) -> None: + with pytest.raises(SystemExit) as exit_info: + run_tests.main(test_case.argv) + + assert exit_info.value.code == 2 diff --git a/tests/unit/scripts/test_setup_environment.py b/tests/unit/scripts/test_setup_environment.py new file mode 100644 index 000000000..7de7fdf3a --- /dev/null +++ b/tests/unit/scripts/test_setup_environment.py @@ -0,0 +1,82 @@ +from pathlib import Path + +import pytest + +from bootstrap.platforms.linux import Linux +from bootstrap.platforms.macos import ARCHFLAGS, MacOS +from bootstrap.project import DEVELOPMENT_GROUP, GPU_CUDA11_EXTRA, GPU_EXTRA +from tests.suite.bootstrap import RecordingRunner +from tests.suite.scripts import load_script + +setup_environment = load_script("setup_environment.py") + + +class TestGpuExtra: + def test_zero_keeps_the_cpu_backend(self) -> None: + assert setup_environment.gpu_extra(setup_environment.GPU_OFF, Linux(), {}) is None + + def test_a_named_extra_is_taken_as_given(self) -> None: + assert setup_environment.gpu_extra(GPU_CUDA11_EXTRA, Linux(), {}) == GPU_CUDA11_EXTRA + + def test_auto_on_macos_keeps_the_cpu_backend(self) -> None: + assert setup_environment.gpu_extra(setup_environment.GPU_AUTO, MacOS(), {}) is None + + +class TestMain: + def test_a_gpu_choice_outside_the_extras_is_refused_before_anything_runs( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + with pytest.raises(SystemExit) as exit_info: + setup_environment.main(["--gpu", "1"]) + + refusal = capsys.readouterr().err + assert exit_info.value.code == 2 + assert all(choice in refusal for choice in setup_environment.GPU_CHOICES) + + +class TestSetupCommands: + def test_the_cpu_backend_synchronizes_and_installs_the_bare_package(self) -> None: + commands = setup_environment.setup_commands(None) + + assert commands == [ + ["uv", "sync", "--group", DEVELOPMENT_GROUP], + ["uv", "tool", "install", "--force", "."], + ] + + def test_a_gpu_extra_reaches_both_installs(self) -> None: + commands = setup_environment.setup_commands(GPU_EXTRA) + + assert commands[0] == ["uv", "sync", "--group", DEVELOPMENT_GROUP, "--extra", GPU_EXTRA] + assert commands[1] == ["uv", "tool", "install", "--force", f".[{GPU_EXTRA}]"] + + +class TestSetUpEnvironment: + def test_macos_runs_the_commands_on_its_native_architecture(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + setup_environment.set_up_environment( + tmp_path, + MacOS(), + None, + machine="arm64", + runner=runner, + environment={"PATH": "/usr/bin"}, + ) + + assert runner.lines == [" ".join(command) for command in setup_environment.setup_commands(None)] + assert all(recorded.environment[ARCHFLAGS] == "-arch arm64" for recorded in runner.commands) + + def test_linux_passes_the_variables_through(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + setup_environment.set_up_environment( + tmp_path, + Linux(), + GPU_EXTRA, + machine="x86_64", + runner=runner, + environment={"PATH": "/usr/bin"}, + ) + + assert all(recorded.environment == {"PATH": "/usr/bin"} for recorded in runner.commands) diff --git a/tests/unit/scripts/test_system_dependencies.py b/tests/unit/scripts/test_system_dependencies.py new file mode 100644 index 000000000..d4194a8ce --- /dev/null +++ b/tests/unit/scripts/test_system_dependencies.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import pytest + +from bootstrap.platforms import macos +from bootstrap.platforms.linux import Linux +from bootstrap.platforms.macos import MacOS +from bootstrap.platforms.windows import Windows +from tests.suite.bootstrap import RecordingRunner +from tests.suite.scripts import load_script + +system_dependencies = load_script("system_dependencies.py") + + +class TestInstallSystemPackages: + def test_windows_has_nothing_to_install(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + runner = RecordingRunner({}, None) + + assert system_dependencies.install_system_packages(tmp_path, Windows(), runner=runner, environment={}) == 0 + assert runner.lines == [] + assert "Nothing to install" in capsys.readouterr().out + + def test_linux_runs_the_apt_commands_in_order(self, tmp_path: Path) -> None: + runner = RecordingRunner({}, None) + + assert system_dependencies.install_system_packages(tmp_path, Linux(), runner=runner, environment={}) == 0 + assert runner.lines == [" ".join(command) for command in Linux().system_packages()] + + def test_macos_with_homebrew_installs_portaudio(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + runner = RecordingRunner({}, None) + monkeypatch.setattr(macos.shutil, "which", lambda name: f"/opt/homebrew/bin/{name}") + + assert system_dependencies.install_system_packages(tmp_path, MacOS(), runner=runner, environment={}) == 0 + assert runner.lines == [" ".join(command) for command in MacOS().system_packages()] + + def test_macos_without_homebrew_is_refused( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + runner = RecordingRunner({}, None) + monkeypatch.setattr(macos.shutil, "which", lambda name: None) + + assert system_dependencies.install_system_packages(tmp_path, MacOS(), runner=runner, environment={}) == 1 + assert runner.lines == [] + assert "Homebrew" in capsys.readouterr().err + + def test_a_failing_install_stops_the_script(self, tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="apt-get update"): + system_dependencies.install_system_packages( + tmp_path, + Linux(), + runner=RecordingRunner({"update": 100}, None), + environment={}, + ) diff --git a/tests/unit/scripts/test_verify_bundle.py b/tests/unit/scripts/test_verify_bundle.py new file mode 100644 index 000000000..5bc6d90fe --- /dev/null +++ b/tests/unit/scripts/test_verify_bundle.py @@ -0,0 +1,105 @@ +from pathlib import Path + +import pytest + +from bootstrap.layout import BUILD_TOOLS, DISTRIBUTION, NOTICES +from bootstrap.platforms.linux import Linux +from bootstrap.platforms.macos import MacOS +from tests.suite.bootstrap import PROJECT_NAME, RecordingRunner, write_project +from tests.suite.scripts import load_script + +verify_bundle = load_script("verify_bundle.py") + + +@pytest.fixture(name="root") +def root_fixture(tmp_path: Path) -> Path: + """A repository holding a release bundle with its notices and its launcher.""" + launcher = Linux().bundling().launcher(tmp_path / DISTRIBUTION, name=PROJECT_NAME, release=True) + launcher.parent.mkdir(parents=True) + launcher.write_bytes(b"launcher") + for name in NOTICES: + (launcher.parent / name).write_text(name) + + return write_project(tmp_path) + + +def _bundle(root: Path) -> Path: + return root / DISTRIBUTION / PROJECT_NAME + + +class TestMissingNotices: + def test_a_complete_bundle_lacks_nothing(self, root: Path) -> None: + assert verify_bundle.missing_notices(_bundle(root)) == [] + + def test_every_absent_notice_is_reported(self, root: Path) -> None: + for name in NOTICES[1:]: + (_bundle(root) / name).unlink() + + assert verify_bundle.missing_notices(_bundle(root)) == list(NOTICES[1:]) + + def test_a_notice_directory_counts_as_absent(self, root: Path) -> None: + (_bundle(root) / NOTICES[0]).unlink() + (_bundle(root) / NOTICES[0]).mkdir() + + assert verify_bundle.missing_notices(_bundle(root)) == [NOTICES[0]] + + +class TestCarriedBuildTools: + def test_an_application_bundle_holds_to_its_notices(self, root: Path) -> None: + assert verify_bundle.carried_build_tools(_bundle(root)) == [] + + def test_a_build_tool_beside_the_application_is_reported(self, root: Path) -> None: + (_bundle(root) / verify_bundle.INTERNAL_DIRECTORY / BUILD_TOOLS[0]).mkdir(parents=True) + + assert verify_bundle.carried_build_tools(_bundle(root)) == [BUILD_TOOLS[0]] + + def test_a_build_tool_beside_the_launcher_is_reported(self, root: Path) -> None: + (_bundle(root) / BUILD_TOOLS[0]).mkdir() + + assert verify_bundle.carried_build_tools(_bundle(root)) == [BUILD_TOOLS[0]] + + +class TestBundleFailures: + def test_a_complete_bundle_passes_once_its_launcher_starts(self, root: Path) -> None: + runner = RecordingRunner({}, None) + + assert verify_bundle.bundle_failures(root, Linux(), runner=runner, environment={}) == [] + assert runner.lines == [f"{_bundle(root) / PROJECT_NAME} {verify_bundle.VERSION_FLAG}"] + + def test_a_missing_notice_is_named(self, root: Path) -> None: + (_bundle(root) / NOTICES[1]).unlink() + + failures = verify_bundle.bundle_failures(root, Linux(), runner=RecordingRunner({}, None), environment={}) + + assert len(failures) == 1 + assert NOTICES[1] in failures[0] + + def test_bundled_build_tooling_is_named(self, root: Path) -> None: + (_bundle(root) / verify_bundle.INTERNAL_DIRECTORY / BUILD_TOOLS[0]).mkdir(parents=True) + + failures = verify_bundle.bundle_failures(root, Linux(), runner=RecordingRunner({}, None), environment={}) + + assert [BUILD_TOOLS[0] in failure for failure in failures] == [True] + + def test_a_missing_launcher_is_named_and_nothing_runs(self, root: Path) -> None: + (_bundle(root) / PROJECT_NAME).unlink() + runner = RecordingRunner({}, None) + + failures = verify_bundle.bundle_failures(root, Linux(), runner=runner, environment={}) + + assert [("offers no launcher" in failure) for failure in failures] == [True] + assert runner.lines == [] + + def test_a_launcher_that_fails_is_named_with_its_status(self, root: Path) -> None: + failures = verify_bundle.bundle_failures( + root, + Linux(), + runner=RecordingRunner({verify_bundle.VERSION_FLAG: 3}, None), + environment={}, + ) + + assert [("status 3" in failure) for failure in failures] == [True] + + def test_a_system_without_bundles_is_refused(self, root: Path) -> None: + with pytest.raises(SystemExit, match="make setup"): + verify_bundle.bundle_failures(root, MacOS(), runner=RecordingRunner({}, None), environment={}) diff --git a/tests/unit/scripts/test_verify_version_tag.py b/tests/unit/scripts/test_verify_version_tag.py new file mode 100644 index 000000000..351a7baa7 --- /dev/null +++ b/tests/unit/scripts/test_verify_version_tag.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from bootstrap.layout import repository_root +from bootstrap.project import TAG_PREFIX, read_project +from tests.suite.base import BaseTestSuite +from tests.suite.bootstrap import write_project +from tests.suite.case import BaseRegularTestCase +from tests.suite.scripts import load_script + +verify_version_tag = load_script("verify_version_tag.py") + + +class TestVersionFromTag(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + tag: str + expected: str + + test_cases = ( + TestCase(label="release_tag", tag="v0.3.0", expected="0.3.0"), + TestCase(label="prerelease_tag", tag="v0.3.0.dev1", expected="0.3.0.dev1"), + TestCase(label="release_candidate", tag="v1.0.0rc2", expected="1.0.0rc2"), + TestCase(label="bare_version", tag="0.3.0", expected="0.3.0"), + TestCase(label="single_prefix_is_dropped", tag="vv0.3.0", expected="v0.3.0"), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_version_from_tag(self, test_case: TestCase) -> None: + assert verify_version_tag.version_from_tag(test_case.tag) == test_case.expected + + +class TestTagFailure: + def test_the_tag_naming_the_project_version_passes(self, tmp_path: Path) -> None: + project = read_project(write_project(tmp_path)) + + assert verify_version_tag.tag_failure(project.tag, project) is None + + def test_a_tag_naming_another_version_names_both(self, tmp_path: Path) -> None: + project = read_project(write_project(tmp_path)) + tag = f"{project.tag}.post9" + + failure = verify_version_tag.tag_failure(tag, project) + + assert failure is not None + assert tag in failure + assert project.version in failure + + +class TestMain: + def test_the_tag_naming_the_project_version_passes(self) -> None: + version = read_project(repository_root()).version + + assert verify_version_tag.main(["--tag", f"{TAG_PREFIX}{version}"]) == 0 + + def test_a_tag_naming_another_version_is_annotated_as_an_error(self, capsys: pytest.CaptureFixture[str]) -> None: + version = read_project(repository_root()).version + + assert verify_version_tag.main(["--tag", f"{TAG_PREFIX}{version}.post9"]) == 1 + assert capsys.readouterr().out.startswith("::error::") diff --git a/uv.lock b/uv.lock index 4699d98d5..af1c18c49 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" @@ -1655,6 +1664,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -1684,6 +1705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "pyvirtualdisplay" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/9f/23e5a82987c26d225139948a224a93318d7a7c8b166d4dbe4de7426dc4e4/PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159", size = 18560, upload-time = "2022-02-13T07:57:05.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/eb/c3b8deb661cb3846db63288c99bbb39f217b7807fc8acb2fd058db41e2e6/PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e", size = 15258, upload-time = "2022-02-13T07:57:04.051Z" }, +] + [[package]] name = "pywin32" version = "312" @@ -1788,7 +1818,7 @@ wheels = [ [[package]] name = "sampletones" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "anytree" }, @@ -1798,6 +1828,7 @@ dependencies = [ { name = "msgpack" }, { name = "numpy" }, { name = "pebble" }, + { name = "py65" }, { name = "pyaudio" }, { name = "pydantic" }, { name = "pytaskbar", marker = "sys_platform == 'win32' or (extra == 'extra-11-sampletones-gpu' and extra == 'extra-11-sampletones-gpu-cuda11')" }, @@ -1828,6 +1859,9 @@ gpu-cuda11 = [ assets = [ { name = "pillow" }, ] +calibration = [ + { name = "zimtohrli" }, +] dev = [ { name = "black" }, { name = "isort" }, @@ -1839,6 +1873,9 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "python-xlib" }, + { name = "pyvirtualdisplay" }, + { name = "types-python-xlib" }, { name = "types-pyyaml" }, ] @@ -1856,6 +1893,7 @@ requires-dist = [ { name = "nvidia-cuda-nvrtc-cu11", marker = "sys_platform == 'linux' and extra == 'gpu-cuda11'" }, { name = "nvidia-cuda-runtime-cu11", marker = "sys_platform == 'linux' and extra == 'gpu-cuda11'" }, { name = "pebble", specifier = ">=5.0,<6" }, + { name = "py65", specifier = ">=1.2,<2" }, { name = "pyaudio", specifier = ">=0.2.14,<0.3" }, { name = "pydantic", specifier = ">=2.9,<3" }, { name = "pyinstaller", marker = "extra == 'build'", specifier = ">=6.21,<7" }, @@ -1872,6 +1910,7 @@ provides-extras = ["build", "gpu", "gpu-cuda11"] [package.metadata.requires-dev] assets = [{ name = "pillow", specifier = ">=11,<13" }] +calibration = [{ name = "zimtohrli", specifier = ">=0.2.1,<0.3" }] dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, @@ -1883,6 +1922,9 @@ dev = [ { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "python-xlib", specifier = "==0.33" }, + { name = "pyvirtualdisplay", specifier = "==3.0" }, + { name = "types-python-xlib", specifier = "==0.33.0.20260724" }, { name = "types-pyyaml", specifier = "==6.0.12.20260518" }, ] @@ -2003,6 +2045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "soundfile" version = "0.13.1" @@ -2107,6 +2158,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "types-python-xlib" +version = "0.33.0.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a3/5edce1508c62748b37d103e682abe4933184998a302a45e6e17e4a9510d1/types_python_xlib-0.33.0.20260724.tar.gz", hash = "sha256:81999c1d390f8fde0113286c0c45441c37fd404c03aa67c06997ab929dbab177", size = 54664, upload-time = "2026-07-24T04:59:01.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/a4/635ae88bae98d313c44adb8c36c970a3c19094541cb8b5c65f5d9d37cd57/types_python_xlib-0.33.0.20260724-py3-none-any.whl", hash = "sha256:1d47c3d7c783c5890ad255054c8a37aa766a6d9b7e3c12f1841ea3b1721375d7", size = 67523, upload-time = "2026-07-24T04:59:00.43Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518" @@ -2160,3 +2220,34 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be0 wheels = [ { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] + +[[package]] +name = "zimtohrli" +version = "0.2.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/91/da58ac8f4bf82dda1261e9b9f04983bd8cef4327fea67d68c01cf702d76d/zimtohrli-0.2.1.post1.tar.gz", hash = "sha256:0463fa5eb8614f46408b463a7f2a3595a2bd699a8b0ae48c0751ac2b37d6095b", size = 21518, upload-time = "2025-09-30T08:12:26.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/2d/31c2c6d043873e6a8b14ca546220a1aeda061375e897855410de5aecbe8f/zimtohrli-0.2.1.post1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:63ed6ce7d49b2215987161df9d512c43d98d9cf2e5edd5ad48994adab0073c65", size = 28273, upload-time = "2025-09-30T08:12:00.718Z" }, + { url = "https://files.pythonhosted.org/packages/00/4d/7c91c2dedd94f66b415e4487f1b9d1488aaffe1cb3a8252606a492edad42/zimtohrli-0.2.1.post1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9a384d4d66525082e3fb8471f19501cb954a0fd13dc8dcc629b4f793f7c5ff1", size = 161899, upload-time = "2025-09-30T08:12:01.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/639f364dcbfdf35f0e9d734f69ae62bb098f0ed4a56ee110b237f126dd3d/zimtohrli-0.2.1.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0d2f4fd76015f254601afaa20d64762ce7c5ca6ee70c07649b97a2fd46165d77", size = 1190952, upload-time = "2025-09-30T08:12:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c1/ad8562e9d8171fed7e96d1259f09b17784044d37af2be7fd2de98adc02cd/zimtohrli-0.2.1.post1-cp312-cp312-win32.whl", hash = "sha256:736b889938a561ff7f50f441a199d0181d3c8e56c10b36f17b16c52a6445ee43", size = 28070, upload-time = "2025-09-30T08:12:04.473Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ae/bf4ed2d43e8fe33fdcaa9debbf98529bc1862d03ee66bcf3bd4a16bab95f/zimtohrli-0.2.1.post1-cp312-cp312-win_amd64.whl", hash = "sha256:9946d3f5ab2c6e57248ae4012d014c31d1812efb7f349b1a218355cf60970be8", size = 32527, upload-time = "2025-09-30T08:12:05.548Z" }, + { url = "https://files.pythonhosted.org/packages/f2/62/380f40e530bd8112e30d78fab52e19bb47e342c7bfc0fdae08d2024f4389/zimtohrli-0.2.1.post1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cac384b9a8268577df0c8f60b35a71e5de7be49f490f83733b52f6eb51941b91", size = 28276, upload-time = "2025-09-30T08:12:06.611Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/169441e6be2b1981df321f539684bac2b7d3af1bded312ad4c4bec725a1f/zimtohrli-0.2.1.post1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be6fbb52bc5967970a0dd5159b32812c9dbf7e8d6c9ef85b1b05afc0cf1db05b", size = 161846, upload-time = "2025-09-30T08:12:08Z" }, + { url = "https://files.pythonhosted.org/packages/56/c1/d3424134b7d683d446a1ee65a8d00fb0b9968bbdffddef573ebc70b699f8/zimtohrli-0.2.1.post1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ce472c22698aea34f3b16743afb1c02c32d2c6539729861f44f2e82124e45163", size = 1190999, upload-time = "2025-09-30T08:12:09.672Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fd/2b2ae939c0ff84bff7ccb5fd51ef37b51c1e40e35dd9a708455d6140bc6a/zimtohrli-0.2.1.post1-cp313-cp313-win32.whl", hash = "sha256:9352ce9732e929e0f981301ed457bdbd70668008df949ddf3265785f71ef11fc", size = 28071, upload-time = "2025-09-30T08:12:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5f/59751aecd76f171a9bf7f9a6625c6cac39a1973230251149a25705ad2c85/zimtohrli-0.2.1.post1-cp313-cp313-win_amd64.whl", hash = "sha256:8a9d5c64f3176aff909113728cfcd524dc81cb0dbee0c363abec8b1f220b7073", size = 32527, upload-time = "2025-09-30T08:12:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/5d6a2940729154c36a55edf6815ac8a416ef6b061754c537b959653c14ee/zimtohrli-0.2.1.post1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:612440ab80e841788adcf6874404a17a4bed4e59e257a8f4ed5212fb948f946a", size = 28269, upload-time = "2025-09-30T08:12:13.692Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/41f683611c3e7c041bfe78d3636024296e2e330437b49bd7c0d63630892f/zimtohrli-0.2.1.post1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241c09c20b3c0f76f0503c2dbcb5e489fd7b2c1cc3866e05ae46c2953d93786b", size = 161936, upload-time = "2025-09-30T08:12:14.777Z" }, + { url = "https://files.pythonhosted.org/packages/c7/12/2dd3bdb4307dd05515e791e4f67bac8349bc91c08cd3e9baab58dcfa7cac/zimtohrli-0.2.1.post1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d16924660bcfe6c570ad147fdaa82e5787ce289510e11b5943918b2f447f7caa", size = 1191112, upload-time = "2025-09-30T08:12:16.188Z" }, + { url = "https://files.pythonhosted.org/packages/13/04/2417c40ab44b77c238fcbc360718da605e1e9e3424a1d2d1bc0c735ef0d5/zimtohrli-0.2.1.post1-cp314-cp314-win32.whl", hash = "sha256:67b654efaf3865639bde41a90819f39da12fd441b79f526196fd78150782aaa7", size = 28735, upload-time = "2025-09-30T08:12:17.864Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4c/21b8aacde85637d07b3534b31fa9f0584f4dadf364a348d313228570731f/zimtohrli-0.2.1.post1-cp314-cp314-win_amd64.whl", hash = "sha256:08f4bc952cef7681da11e28ab3b01006129082a03393fae2a0fc6de2332dda31", size = 33079, upload-time = "2025-09-30T08:12:18.947Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/6e8ffe92575511455a652a90c335db567a09702c780651151b68cdc0f859/zimtohrli-0.2.1.post1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5fd4336b70cedc8f8b19b69e6f1331a1258f1dc3d6b1bd14ef138d18c777ce0c", size = 28397, upload-time = "2025-09-30T08:12:20.085Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/88e61b9461521a62d9cbcc1bc54feb8a9eacb1f8c7b41d459a69f5a0851b/zimtohrli-0.2.1.post1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:341a6c618f9390877a7b2005b16dff128708315b402ae2df4ad044b04949ad8a", size = 162517, upload-time = "2025-09-30T08:12:21.159Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/7329479a9dfbe0452ed285fdfea9c6bd94d5856ade05809b0888c62c2944/zimtohrli-0.2.1.post1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0fe5d4974a19a96536e78d3dcd5c0ed506c9ab288d30f85d5bd44466301715be", size = 1191666, upload-time = "2025-09-30T08:12:22.539Z" }, + { url = "https://files.pythonhosted.org/packages/82/08/874263294b1e2cc72cf3e235597e02af405153119e2d5433ea172c83f745/zimtohrli-0.2.1.post1-cp314-cp314t-win32.whl", hash = "sha256:5ad1790ee4c2a3a9220d43c5b06ee6123feda3b079929b77591c10730713c8b1", size = 28860, upload-time = "2025-09-30T08:12:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/de75945f3083ddfa66d165bc55b28308799e104e954ee43508bb1043c67a/zimtohrli-0.2.1.post1-cp314-cp314t-win_amd64.whl", hash = "sha256:43ce58509922ccdcbd527e664bc2ddddc85fb239abbda1d0b964e874736c9afc", size = 33211, upload-time = "2025-09-30T08:12:25.417Z" }, +]